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:
- Emit the finding from the analysis module that owns the question, with an
id one past the current highest
H. - Add the id to
RULE_CATEGORIESinsrc/trustsight/categories.py, which is what makes it a documented rule and what reserves the id against arules.tomlentry claiming it. - Add its message template to
src/trustsight/findings.py. - 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:
Give the rule a permanent uid, which the registry refuses to load without:
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:
Add the anchor to the page's list in docs_src/layout.json, and a stub to
docs/reference/rules/system.md:
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:
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:
- 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.
- Subject (category). The single closed category it belongs to, which
owns its reference page (
trustsight.categories). - 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:
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:
- Run the rule against the full benign corpus (
tests/fixtures/benign-corpus/). - Compute the fire rate:
hits / n_diffs. - If fire rate < 30% → rule passes, keep its severity.
- 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.