Skip to content

Writing a Rule

The normative standards for a rule change are in Standards; this page is the practical guide. TrustSight has two rule namespaces to avoid identifier collision:

Namespace IDs Defined in Editable by users Purpose
R-series R001-R003, R007-R008, R010-R013, R017, R039-R059, R078, R091, R099, R104, R144, R152 rules.toml Yes Regex-detectable patterns
H-series H001-H103 analysis/*.py No Heuristics: code-emitted detection
D-series D001-D005 analysis/*.py No Dependency-graph rules
C-series C001-C032 analysis/*.py No Structural / multi-condition
S-series S001-S008 analysis/sabotage.py No Sabotage: payloads aimed at the machine
X-series X001-X034 analysis/crossfire.py No Crossfire: the evasion technique itself
P-series P001-P009 (P004 skipped) scoring.py No Declared practice, reported at weight 0
W-series W001-W012 analysis/*.py No Unverifiable: what this run could not read, weight 0

R-series rules (TOML)

Every R-series rule lives in rules.toml under ~/.config/trustsight/; that is what the prefix means. A detection that cannot be expressed as a regex over diff lines is an H-series heuristic instead. Each R rule has:

Field Description
id Unique rule identifier, e.g. R001
name Human-readable name
pattern Regex pattern to match
severity CRITICAL, HIGH, MEDIUM, LOW, or INFO
category Risk category, e.g. network, integrity
match_target Where to match: raw_line or resolved
scope (Optional, raw_line only) Line contexts (function_body, message, other) or a named PKGBUILD function (pkgver) to restrict matching to.

Example:

[[rules]]
id = "R001"
name = "Remote Script Execution"
pattern = 'curl.*(?<!\\\\)\\|\\s*(?:@@EXEC@@)'
severity = "CRITICAL"
category = "network_execution"
match_target = "resolved"

What a rule sees

A pattern is matched against logical lines: a trailing backslash joins a command split across physical lines, so curl \ + https://… | bash is one line to the regex. Only lines carrying the same diff marker are joined, so an addition is never spliced onto a removal.

scope (optional, raw_line only) restricts which lines a rule checks. Scope classification is shell-file aware: lines from a companion file whose extension is not a shell one are blanked first, so a build() { in a .desktop file or a patch cannot place the rest of that file inside a function. Function detection reads both spellings - build() { and function build {, with or without parentheses - and hyphenated names. The rule system reference covers the call graph, the message rules and the linter warnings.

H-series rules (code)

H-series rules live in the analysis/ package and are emitted from Python rather than matched from rules.toml. Reach for one when the signal is not a property of a line but of a change: what a value was before, whether a checksum moved with the URL it covers, how the package sits against the corpus. A regex sees one line at a time and cannot answer any of those.

Adding one means:

  1. Emit the finding from the analysis module that owns the question, with an id one past the current highest H.
  2. Add the id to RULE_CATEGORIES in src/trustsight/categories.py, which is what makes it a documented rule and what reserves the id against a rules.toml entry claiming it.
  3. Add its message template to src/trustsight/findings.py.
  4. Document it on the category page under docs/reference/rules/, as ### H0NN:, so the index builder picks it up.

An H rule has no enabled or weight_override control, because it has no rules.toml entry to attach one to. If a rule should be operator-tunable, that is a reason to write it as a regex instead - see when to use each.

Never reuse a retired id. The R/H split freed ninety-five R numbers and the linter reserves every one of them; stored reports and published baselines still name them.

S-series rules (code)

S-series rules live in analysis/sabotage.py and describe a payload aimed at the operator's machine rather than at getting something out of it: resource exhaustion, deletion, permission sabotage, service disruption, resource theft. They are code-emitted and cannot be disabled.

The series exists separately because the family's calibration problem is unlike the rest of the ruleset's. Its commands are not rare in a PKGBUILD - rm -rf appears in most recipes - so each rule is written against a distinction rather than a command: the build sandbox is not the system, a mention is not an invocation, and a package's own service is not the system's. See the sabotage reference.

C-series rules (code)

C-series rules are defined as Python code in the analysis/ package. They express multi-condition invariants that cannot be captured by a single regex; for example "checksum changed AND URLs unchanged AND pkgver unchanged".

Users cannot disable C-series rules.

Categorising and documenting it

Two different things are called a category, and a new rule needs both.

The category field above names the capability a match touched (network, persistence, obfuscation). It is fine-grained, it is set per-rule, and it is what H027 counts when it looks for a diff whose hits span three or more capabilities. Reuse an existing value where one fits.

RuleCategory, in src/trustsight/categories.py, names the kind of claim the rule makes. The set is closed, every rule has exactly one, and it decides which page under docs/reference/rules/ carries the rule's definition. Add the id to RULE_CATEGORIES in the same change that adds the rule:

RULE_CATEGORIES: dict[str, RuleCategory] = {
    ...
    "H086": _C.MAINTAINER_AND_METADATA,
}

Give the rule a permanent uid, which the registry refuses to load without:

uv run python scripts/build_registry_uids.py

Add the rule's display metadata to src/trustsight/registry_meta.json, keyed by the section's anchor:

"h086": {
  "uid": "TS-0999",
  "name": "Name",
  "canonical": true,
  "severity_text": "HIGH (weight 25)",
  "severity": "HIGH",
  "capability": "maintainer",
  "target": "programmatic"
}

The reference page writes the ### heading and the Target / Severity / Category bullets from that record, so they cannot disagree with the name or severity a finding carries. docs_src/sections/<anchor>.md then holds only the identity and the prose:

<!-- uid: TS-0999 -->
<!-- anchor: h086 -->

- **Condition:** ...

Add the anchor to the page's list in docs_src/layout.json, and a stub to docs/reference/rules/system.md:

### H086 {#h086}

See [H086: Name](../reference/rules/maintainer-and-metadata.md#h086).

Each category also has a page on the Rule Nature tab (docs/reference/nature/<category>.md) describing where the category's ideas come from and what a finding means. Those pages are prose, not per-rule definitions, so a new rule rarely changes one; update it only when the rule introduces a new kind of claim the page does not yet cover. The two tabs are kept in step by the navigation check in tests/test_docs.py, which requires every page on disk to appear in the nav.

Then rebuild the pages and the index from docs_src and the registry:

uv run python scripts/build_rules_index.py

tests/test_docs.py fails if any of those is missing, if the section lands on a page the category does not own, if a quoted pattern has drifted from the shipped one, or if the id is absent from the quick-reference table. tests/test_generated_docs.py fails if a page or the index was not rebuilt. tests/test_rule_metadata.py fails if the documented name or severity is not one the code actually emits.

When to use each

Scenario Use
A single regex matches a pattern in diff lines R-series
A single regex matches a resolved string R-series
The signal needs diff context a line cannot show H-series
Logic spans multiple fields / conditions C-series
Needs a structural or multi-condition invariant C-series

The three axes: mechanism, subject, layer

A proposed rule names three things, and the intake review checks all three:

  1. Mechanism (series). What kind of thing detects it - regex (R), heuristic (H), invariant (C), refusal (X), statistics (E), history (T), correlation (G), indicator (I), or meta (M). This is the series axis.
  2. Subject (category). The single closed category it belongs to, which owns its reference page (trustsight.categories).
  3. Trajectory hole (layer). Which assurance layer the attack it targets would otherwise pass: L1 structure, L2 readability, L3 payload, L4 evasion, L5 behaviour, L6 ecosystem, L7 indicators, L8 campaign. The series-to-layer defaults live in trustsight.layers.

If a mechanism in a different evidence category already covers the same attack, the PR must argue why this one adds complementary coverage rather than redundant coverage - or file it as an instance of that existing mechanism instead. The intake review also records the rule's shared dependencies, because different mechanisms that rely on the same tokenizer or history database can fail together even though their logic differs.

Fixtures

Every new scored rule needs a benign case and a malicious case. Fixtures are grouped by theme, with one expected.json per group, and each entry is keyed by the diff's filename.

Benign fixture

Add the diff under the matching tests/fixtures/benign/<group>/ directory (dependency-changes, recipe-only-changes, sabotage-lookalikes or crossfire-lookalikes) and add an entry to that group's expected.json. The .diff must be a real or plausible benign change; the entry asserts that it does not fire and does not score:

{
  "<file>.diff": {
    "must_fire": [],
    "max_score": 0
  }
}

Malicious fixture

Add the diff under tests/fixtures/malicious/synthetic/ (or the historical, holdout, evasion or campaign group) and add an entry to that category's expected.json. The .diff must trigger the rule; the entry asserts a minimum score and the ids that must fire:

{
  "<file>.diff": {
    "min_score": 25,
    "must_fire": ["H001"],
    "relabelled": "optional note when a rule id or expectation changed"
  }
}

scripts/verify_fixtures.py checks that every record has a .diff body and that every .diff is referenced by a record, so neither half can drift silently.

Fire-rate gate

Any new scored rule (severity other than INFO) must pass the benign-corpus fire-rate check:

  1. Run the rule against the full benign corpus (tests/fixtures/benign-corpus/).
  2. Compute the fire rate: hits / n_diffs.
  3. If fire rate < 30% → rule passes, keep its severity.
  4. If fire rate ≥ 30% → demote to INFO/severity 0 (cannot affect scoring).

To check the fire rate, re-baseline and read the per-rule rates it records. Rebuild the corpus first, as it is gitignored (see Re-baselining):

uv run python scripts/build_corpus.py --from-manifest \
  --manifest tests/fixtures/corpus.lock \
  --out tests/fixtures/benign-corpus
uv run python scripts/rebaseline.py --baseline /tmp/baseline-check.json

Each stratum's rules map in the output holds that rule's fire rate:

python -c "import json; d=json.load(open('/tmp/baseline-check.json')); \
  print({s: v['rules'].get('R0XX') for s, v in d['strata'].items()})"

Scan the corpus in parallel, never in a serial loop

To check a rule or a regex change across the whole corpus, use the parallel paths: uv run python scripts/scan_benign.py --rule R0XX runs the whole benign corpus in one pass over the calibration worker pool with the shipped config, and scripts/calibration_gates.py --jobs N runs the gate set the same way. Do not hand-roll a for path: analyze_diff(...) / scan_diff(...) loop: it is serial, reloads state per call, and turns a sub-minute pass into tens of minutes. When checking many campaign copies, deduplicate by diff hash first - the same diff recurs across copies of a campaign tree.

Tests

Add test cases in tests/test_rules.py. Each rule must have at least two tests:

def test_r001_curl_bash():
    """Malicious fixture must fire."""
    ...

def test_r001_no_false_positive():
    """Benign fixture must NOT fire."""
    ...

Run them with:

uv run --with pytest python -m pytest tests/test_rules.py::test_r001_curl_bash -v
uv run --with pytest python -m pytest tests/test_rules.py::test_r001_no_false_positive -v

Common mistakes

ID collision

Every prefix names a mechanism, and the linter enforces it. R is a regex in rules.toml; H is a heuristic in analysis/*.py; C is a structural rule. Do not give an R id to a code rule: rules.toml is where an operator looks for an R, and an id that is not there is an id they cannot act on. The reserved-id check refuses a rules.toml entry that reuses a code-emitted id.

Delta vs. end-state

Verification evidence is computed over the resolved PKGBUILD end-state, not the diff delta. A rule that checks whether source contains an http:// URL should inspect the resolved PKGBUILD after the diff is applied, not just the lines that changed.