Implement one planned rule
Goal: turn one planned entry from docs/planned-rules.md into a working, tested detection and a
reviewable PR. One rule, one PR — no Go code change is needed to add a detection.
Guardrails
All sg-maintain global guardrails apply. Attack strings you add to fixtures are inert test data.
1. Pick the entry
Read docs/planned-rules.md. Choose the highest-priority planned row (P0 before P1 before
P2; break ties by table order). If a caller named a specific SG-ID, use that. Set its status to
in-progress in the doc as you start (so a concurrent cycle doesn't grab it).
2. Design the detection
- Read the entry's threat description and source, plus its section (if any) in
docs/rule-verification.mdanddocs/surfaceguard-design.md §5. - Decide the layer (
contentvscode), targets (body/manifest/scripts/configs/refs), severity, base confidence, and the match tree: composite (any/all/not) over leaf primitives (regex,substring,unicode_category,bidi_control,tag_block,url_host). - Regex is RE2 — no lookaround/backreferences. Model the shape on an existing rule in the same
pack. Pick real signals; plan the false-positive
suppresscarve-outs up front.
3. Add the rule to a pack
Add the rule block to the pack that matches its family (core-injection, core-network,
core-exec, core-secret, core-metadata) — or create a new pkg/rules/packs/<name>.yaml
(auto-loaded via the packs/*.yaml embed glob). Required fields: id, title, ast, severity,
engine: static, layer, confidence, targets, match, rationale, fix. Add suppress
patterns for known benign phrasings.
Bump the pack's version: (line 3) in the same commit — a new rule id is a minor bump
(1.4.0 → 1.5.0); a brand-new pack file starts at 1.0.0. Rules for the other levels:
docs/surfaceguard-design.md §8.1.
4. Test it
- Rule-level table test in
pkg/rules/rules_test.go(model:TestInjectionOverrideCoversParaphrase): fetch the new rule by ID fromBuiltin(), assert{malicious → true}and{benign near-miss → false}rows viarule.Evaluate(...). - Fixture — add a representative malicious snippet to
testdata/malicious/SKILL.mdand (if it should stay clean) a near-miss totestdata/benign/SKILL.md; assert inpkg/scan/scan_test.goas needed. Respect the line-offset invariant — findings report trueSKILL.mdline numbers.
go test ./pkg/rules/ ./pkg/scan/ -v
5. Verify end-to-end
Standard preflight is sg-maintain §Ship it step 1. Beyond it, this cycle owes one thing:
- Regenerate evaluation and confirm the new rule's corpus hits are ones you can defend, with no
movement in the other rules' counts (git-ignored, local sanity check):
go build -o surfaceguard ./cmd/surfaceguard && evaluation/scripts/run_scans.sh && python3 evaluation/scripts/aggregate.py— mind the parallelism cap inCLAUDE.md.
6. Update docs and backlog
- Add the rule's detection notes to
docs/rule-verification.md(Signals / carve-outs / fixtures). - Update the AST coverage note in
docs/owasp-ast-taxonomy.mdif this rule changes a row's status. - In
docs/planned-rules.md, set the entry's status toimplementedand add the PR link. Do not delete the row — the history stays auditable.
7. Open the PR
Ship per sg-maintain §Ship it, with:
- branch
rule/<SG-ID>-<slug>· labelrule-implement(+researchwhen the backlog row was filed bysg-threat-research) - paths
pkg/rules/ testdata/ docs/ - commit
feat(rules): add <SG-ID> — <threat> (AST0X) - evidence for the body: what the rule detects and why that shape, the new tests, and the corpus result — hits, and a defence of each one, or an explicit "0 hits on the corpus".
Close the tracking issue. If the backlog entry came from a GitHub issue, Closes #<n> in the
body is mandatory — it auto-closes the tracking issue on merge. Report the PR link and mark the
cycle done.