Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

CEL conditions

condition is written in Common Expression Language (CEL).

rule_sets:
  - ruleset_id: acme/process
    rules:
      - rule_id: shell_download
        event_type: process_exec
        condition: |
          process.exec_path.endsWith("/bash") &&
          process.argv.exists(arg, arg == "-c") &&
          process.argv.exists(arg, arg.contains("curl"))
        action: detect

Basic operators

operatorExampleMeaning
==protocol == "tcp"equal
!=process.exec_path != "/usr/bin/git"not equal
&&is_read && path.endsWith("/.npmrc")and
``
!!is_foldernot
<, <=, >, >=remote_port >= 1024numeric comparison

Use parentheses to make compound conditions explicit.

condition: |
  protocol == "tcp" &&
  (
    remote_port == 80 ||
    remote_port == 443
  )

String matching

Use startsWith, endsWith, and contains for strings.

condition: process.exec_path.endsWith("/curl")
condition: path.startsWith("/home/runner/work/")
condition: domain.endsWith(".example.com")

String literals and event values are lowercased and NFC-normalized. Regex matches() is not supported.

Lists

Define values under RuleSet lists, then use them with list.<name>.exists(...).

rule_sets:
  - ruleset_id: acme/files
    lists:
      credential_paths:
        - /.npmrc
        - /.pypirc
        - /.docker/config.json
    rules:
      - rule_id: credential_file_read
        event_type: file_open
        condition: |
          is_read &&
          list.credential_paths.exists(s, path.endsWith(s))
        action: collect

list.<name> must be defined in lists within the same RuleSet. An undefined list is a validation error.

Process arguments

process.argv is a list(string). Use exists to check whether a value is present.

condition: process.argv.exists(arg, arg == "--publish")
condition: process.argv.exists(arg, arg.startsWith("--registry="))
condition: |
  process.argv.exists(arg, arg.contains("curl")) &&
  process.argv.exists(arg, arg.contains("|")) &&
  process.argv.exists(arg, arg.contains("bash"))

Index access is not supported. Expressions such as process.argv[0] are rejected by the validator.

Process ancestors

process.ancestors is the ancestor snapshot list attached to the event process context. It is ordered from the current process outward: parent, grandparent, and so on. Use exists to search across the ancestors visible within the job, not only the immediate parent.

Example: process started through a shell.

condition: |
  process.ancestors.exists(parent,
    parent.exec_path.endsWith("/sh") ||
    parent.exec_path.endsWith("/bash")
  )

You can also inspect ancestor argv.

condition: |
  process.ancestors.exists(parent,
    parent.exec_path.endsWith("/bash") &&
    parent.argv.exists(arg, arg == "-c")
  )

Example: process started by npm install. Multiple checks on the same ancestor go inside one exists predicate.

condition: |
  process.ancestors.exists(parent,
    parent.exec_path.endsWith("/npm") &&
    parent.argv.exists(arg, arg == "install")
  )

Each ancestor also exposes descendants. It contains only the processes forked below that ancestor on the path to the current process. The list is ordered from that ancestor toward the current process: parent -> child. The current process itself is not included.

descendants is useful in many process-chain rules. For example, a suspicious npm post-install script starts a shell, and that shell starts the process that triggers the event:

flowchart LR
    RUNNER["Runner"]
    NPM["npm"]
    SH["sh"]
    CAT["cat<br/>current process"]

    RUNNER --> NPM --> SH --> CAT

When the current process is cat, the ancestors and descendants look like this. The process tree runs left to right, while process.ancestors is listed from the current process outward:

process.ancestors = [sh, npm, Runner]

sh.descendants     = []
npm.descendants    = [sh]
Runner.descendants = [npm, sh]

If the same executable appears more than once in the chain, descendants depends on which ancestor matched. It is not grouped by executable name; it is only the path from the selected ancestor toward the current process.

flowchart LR
    OUTER_NODE["node<br/>outer ancestor"]
    BASH["bash"]
    INNER_NODE["node<br/>inner ancestor"]
    CAT["cat<br/>current process"]

    OUTER_NODE --> BASH --> INNER_NODE --> CAT

For that event, the repeated node processes are separate ancestor nodes:

process.ancestors = [node, bash, node]

inner node.descendants = []
bash.descendants       = [node]
outer node.descendants = [bash, node]

For detections, avoid anchoring only on a basename such as node. Combine exec_path with argv or package-manager lifecycle context so the rule matches the intended ancestor.

Example: detect an event that happened under a shell launched by an npm post-install script. The event process can be cat, curl, node, or another child of that shell; the rule only requires that a shell exists below the npm ancestor.

condition: |
  process.ancestors.exists(parent,
    parent.exec_path.endsWith("/npm") &&
    parent.argv.exists(arg, arg == "install") &&
    parent.descendants.exists(child,
      child.exec_path.endsWith("/sh") ||
      child.exec_path.endsWith("/bash")
    )
  )

Ancestors are important for security rules. In CI/CD jobs, the same binary can mean different things depending on whether a developer explicitly ran it or it was launched indirectly by a package install script or build script.

Network and IP

Use inIpRange(ip, cidr) for CIDR checks. The CIDR must be written as a literal string.

condition: inIpRange(remote_ip, "10.0.0.0/8")

Example: connection to private addresses.

condition: |
  family == "ipv4" &&
  (
    inIpRange(remote_ip, "10.0.0.0/8") ||
    inIpRange(remote_ip, "172.16.0.0/12") ||
    inIpRange(remote_ip, "192.168.0.0/16")
  )

inIpRange does not match hostname-like values. Invalid CIDR strings are validation errors.

Credential access patterns

Example: collect credential file reads.

condition: |
  is_read &&
  (
    path.endsWith("/.npmrc") ||
    path.endsWith("/.pypirc") ||
    path.endsWith("/.docker/config.json")
  )

Example: access to environment files.

condition: |
  is_read &&
  (
    path.endsWith("/.env") ||
    path.contains("/secrets/")
  )

Unsupported CEL features

cicd-sensor rule CEL is intentionally limited to the surface that can be evaluated predictably as runtime security rules.

UnsupportedExample
regexpath.matches(".*secret.*")
sizesize(process.argv) > 3
index accessprocess.argv[0] == "bash"
arithmeticremote_port + 1 == 444
has()has(process.exec_path)
all, filter, map, exists_oneprocess.argv.all(arg, arg != "")

Use exists when searching lists or argv values.