Writing a Rule¶
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 | rules.toml |
Yes | Regex-detectable patterns |
| H-series | H001-H097 | analysis/*.py |
No | Heuristics: code-emitted detection |
| D-series | D001-D004 | analysis/*.py |
No | Dependency-graph rules |
| C-series | C001-C009 | analysis/*.py |
No | Structural / multi-condition |
| S-series | S001-S008 | analysis/sabotage.py |
No | Sabotage: payloads aimed at the machine |
| X-series | X001-X025 | analysis/crossfire.py |
No | Crossfire: the evasion technique itself |
| P-series | P001-P008 (P004 skipped) | analysis/*.py |
No | Declared practice, reported at weight 0 |
| W-series | W001-W006 | 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 |
Example:
[rules.R001]
id = "R001"
name = "curl-pipe-bash"
pattern = "curl .* \\| bash"
severity = "CRITICAL"
category = "network"
match_target = "raw_line"
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:
Then write the ### H086: Name {#h086} section on that category's page,
and add a stub to docs/reference/rules/system.md:
Then regenerate the index, whose legend and quick-reference table are both derived rather than hand-maintained:
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.
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 |
| Rule must always run (cannot be disabled) | C-series |
Fixtures¶
Every new scored rule needs two fixture pairs:
Benign fixture¶
Place under tests/fixtures/benign/:
The .diff must be a real or plausible benign change. The expected.json must contain a score of 0 for this rule.
Malicious fixture¶
Place under tests/fixtures/malicious/synthetic/:
The .diff must trigger the rule. The expected.json must contain a non-zero score for this rule.
expected.json schema¶
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):
python scripts/build_corpus.py --from-manifest \
--manifest tests/fixtures/corpus.lock \
--out tests/fixtures/benign-corpus
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()})"
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:
pytest tests/test_rules.py::test_r001_curl_bash -v
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.