Docs

Rule Schema v1

Detection rules are strict JSON bundles. The built-in bundle is deploy/rules/default.json, embedded in the binary. Operators add or override rules with more bundles in rules.dir (default /etc/pisentry/rules.d, env PISENTRY_RULES_DIR).

The agent runs as root and parses these files, so the loader is attack surface. It is encoding/json with unknown fields rejected, hard size limits, and RE2 regexes compiled at load time. There is no YAML parser in the agent.

Bundle

{
  "schema_version": 1,
  "catalog": { "stratum_ports": [3333, 4444] },
  "rules": [ { "id": "...", "...": "..." } ]
}
FieldRequiredNotes
schema_versionyesMust be 1.
rulesone of the twoUp to 500 rules per file.
catalogone of the twoTable-driven lists for detectors whose logic stays in Go: stratum_ports, shell_names, backdoor_ports ({port, note}), fim_watches, gtfo_suid, pkg_managers, priv_groups. A list in rules.d replaces the built-in list of the same name.

Rule

Field names follow Sigma where they map cleanly, so SigmaHQ Linux rules can be converted later.

{
  "id": "PS04-SUDO-SETCAP",
  "title": "Sudo assignment of Linux file capabilities",
  "status": "stable",
  "category": "PS04",
  "level": "medium",
  "tags": ["attack.t1548.001"],
  "source": "log",
  "match": {
    "all": [
      { "field": "log.ident", "equals": ["sudo"] },
      { "field": "process.cmdline", "regex": ["\\bsetcap\\b"] }
    ]
  },
  "exceptions": [ { "users": ["ansible"] } ],
  "falsepositives": ["Configuration management granting cap_net_bind_service"],
  "response": "none"
}
FieldRequiredNotes
idyes[A-Za-z0-9_.-], at most 128 bytes, unique across all loaded files.
titleyesAt most 256 bytes. Becomes rule.title on the event.
categoryyesPS01..PS10, see pisentry categories.
levelnoinfo (default), low, medium, high, critical. Becomes the event severity.
sourceyesTelemetry stream: process, network, file, log, k3s.
matchyesSee below.
mitre / tagsnoTechnique IDs (T1548.001). Sigma tags of the form attack.t1548.001 are added to mitre.
statusnostable, test, experimental, deprecated.
description, author, references, falsepositivesnoDocumentation only.
exceptionsnoUp to 32. See below.
responsenonone (default), kill_tree, quarantine. Recorded only; no response actions run in v0.2.

Match

all conditions must all hold. If any is present, at least one of its conditions must also hold. A condition tests one field and is satisfied by any one of its patterns:

OperatorKind
equals, contains, startswith, endswithLiteral. Kept apart from regex so a literal prefilter can be added without a schema change.
regexRE2 (Go regexp), no backtracking. At most 512 bytes.
case_insensitiveApplies to every pattern in the condition.

Limits per rule: 32 conditions, 64 patterns. Exceeding a limit rejects the file; nothing is truncated.

Fields

Rules match on Event Schema v1 names: process.pid, process.ppid, process.uid, process.comm, process.exe, process.cmdline, process.parent_comm, source.ip, source.port, source.proto, user.name, user.uid, file.path, file.change, file.content, container.id, container.pod, container.namespace, container.image, log.ident, log.msg, k3s.kind, k3s.spec. Any other name is a load error.

Which fields a detector fills depends on the source. log rules on sudo see log.ident, log.msg, user.name and the command as process.cmdline. process rules see the process.* fields; process.uid and process.parent_comm cost extra /proc reads, which only happen for every process when some rule matches on them.

Exceptions

An exception suppresses a match when every non-empty key in it matches. Several exceptions on a rule are ORed. Keys: exe, exe_prefix, comm, parent_comm, uids, users, container_image (glob), cidrs (CIDR or bare IP), paths (glob), cmdline_contains. An empty exception is rejected because it would silence the rule.

To add an exception to a built-in rule, copy the rule into rules.d with the same id and add exceptions. A rule in rules.d with a built-in id replaces the built-in rule. Two files in rules.d defining the same id is an error.

Validating

pisentry check                       # built-in rules plus rules.dir from the config, as the agent loads them
pisentry check -rules /path/to/dir   # a file or directory on its own

Both exit 1 on the first error and name the file, rule and field. At startup, a broken rules.d is logged and the agent runs on the built-in rules rather than with none.

Reloading

The running agent reloads rules.d without a restart:

  • on SIGHUP (sudo systemctl reload pisentry, or kill -HUP $(pidof pisentry)), and
  • when the contents of rules.d change, checked every rules.reload_interval (default 10s, 0s turns the watch off).

The directory is watched, not single files, and files are read through symlinks, so a Kubernetes ConfigMap update that swaps the ..data symlink is picked up. A change has to be stable for 2 seconds before it is loaded, so copying several files is one reload.

The new set is compiled and validated in the background and swapped in atomically. If it fails, the previous set stays active, pisentry_rules_reload_failures_total goes up, the error is logged, and a High PS00 event names the file and field (the alert PisentryRuleReloadFailed fires on it). On success pisentry_rules_loaded and pisentry_rules_last_reload_timestamp_seconds change.

Rules and most catalog lists take effect on the next scan. fim_watches is read when the FIM detector starts, so a change to it needs a restart.

Fixtures

Every built-in rule has recorded inputs under internal/rules/testdata/rules/<rule-id>/:

  • positive/*.json: the rule must fire.
  • negative/*.json: the rule must stay quiet. These come from normal activity on a Pi running k3s: apt upgrades, k3s restarts, backups, cron.

Each file is one normalized input with the same shape the detectors build:

{
  "source": "log",
  "log_ident": "sudo",
  "log_msg": "    pi : TTY=pts/0 ; PWD=/home/pi ; USER=root ; COMMAND=/usr/sbin/setcap cap_setuid+ep /usr/bin/python3.13",
  "user": { "name": "pi" },
  "process": { "cmdline": "/usr/sbin/setcap cap_setuid+ep /usr/bin/python3.13" }
}

Input keys: source, process (pid, ppid, uid, comm, exe, cmdline), parent_comm, network (ip, port), proto, user (name, uid), file (path), file_change, file_content, container (id, pod, namespace), container_image, log_ident, log_msg, k3s_kind, k3s_spec. Unknown keys fail the test, so a typo cannot turn a positive fixture into a no-op.

go test ./internal/rules/... replays them with no root and no network. It fails when a rule lacks either kind, when a fixture directory names no rule, or when any fixture gives the wrong verdict. make sim stays the end-to-end check.

Source: docs/rule-schema.md