--- name: write-cve-rule description: Write, debug, or validate a CVEhound detection rule (.cocci or .grep) for a Linux kernel CVE. Use when adding a rule under cvehound/cve/, when a rule's slow tests fail, or when asked why a rule does or doesn't fire on a kernel tree. Not for general Coccinelle work outside this repository. --- # Writing a CVEhound detection rule A rule is **one file** in `cvehound/cve/CVE-YYYY-NNNNN.cocci` (or `.grep`). That is the entire change. Do not add, edit, or parametrize tests — `tests/conftest.py` discovers every rule and generates the whole suite from the file's metadata headers. Syntax questions go to `docs/COCCINELLE_CHEATSHEET.md`. Everything below is workflow and the things about *this repository* that will otherwise trip you up. ## 1. Gather the inputs You cannot start without these: - CVE ID - the mainline **fix** commit hash - the commit that **introduced** the bug, if it is known - the affected file paths, relative to the kernel root ```bash git -C tests/linux show # the diff is the specification git -C tests/linux log -1 --format=%H ``` Look for a `Fixes:` trailer in the fix commit message — that is usually the introducing commit. If there is none and you can only guess, you will use `Detect-To:` instead. ## 2. Decide what to match Read the diff and take the first branch that applies: | The fix… | Approach | Match | | --- | --- | --- | | **adds** code (a check, an init) | Missing Fix Detection | the absence of it, via `... when != ` | | **changes** a value or flag | Unfixed Code Detection | the old value | | **removes** code | Unfixed Code Detection | the presence of the removed code | | **refactors** logic | Unfixed Code Detection / hybrid | the distinctive vulnerable shape | **Match the invariant, not the era.** The rule runs on every commit in `Fixes..Fix` and on old stable branches — the code's exact shape drifts across that range, and a pattern that transcribes today's spelling (the precise condition, per-era disjunction branches) silently misses the eras nobody transcribed. Prefer a stable anchor that existed the whole time (the call or computation that *is* the bug — that line gets the star) plus a `@fixed@` helper matching what the fix added, with the error rule `depends on !fixed`. `cvehound/cve/CVE-2017-1000112.cocci` is the worked example; `docs/WRITING_RULES.md` → "Rule 7: Match the invariant, not the era" has the full treatment and the loosening toolbox. Then read three existing rules of the same shape before writing yours — they are the real house style, and they encode workarounds no prose captures: ```bash cd cvehound/cve grep -l "memset" *.cocci # initialization bugs grep -l "copy_to_user" *.cocci # information leaks grep -l "when != if" *.cocci # missing checks grep -l 'depends on' *.cocci # inter-rule dependencies grep -l 'depends on .*&&' *.cocci # conjunctions: several conditions must hold ``` ## 3. Write it Start from `contrib/blank.cocci`. The skeleton: ```cocci /// Files: /// Fix: /// Fixes: // or Detect-To: @err@ @@ vulnerable_function(...) { ... * ... } ``` A rule is match rules only. The report is the `*`: on a match spatch prints a unified diff of the starred lines, and CVEhound reads that. No output means no detection. There is no script rule and no `position`/`@p` *for reporting* — the rules must run under a spatch built without Python. (A `position` used as a match constraint is still fair game; see `cvehound/cve/CVE-2020-27777.cocci`.) Non-negotiables: - **Anchor the pattern in a named function.** A bare `return -1;` or `kfree(x);` with no enclosing context is the single largest source of false positives here. - **Never declare a `virtual` rule.** CVEhound passes no `-D`, so a rule gated on one is dropped before translation and reports nothing, silently. To turn a rule off, comment it out and say why. - **`*` is not decoration.** It switches the patch into match mode, which flips the default quantification of un-annotated `...` from `forall` to `exists`. Adding or removing it changes what matches. Every rule stars a line, so that flip is always in force: **never write `exists` in a rule header — it is a no-op.** To make one ellipsis hold on all paths (a `when !=` guard otherwise only constrains a single witness path), annotate that ellipsis with `when forall`. - **Star the deciding rule only, and only its identifying line.** A starred rule prints whenever *it* matches, whatever the other rules did — so a `*` on a helper (classically a `@fix@` rule that recognises the fix) reports on **fixed** trees, and starring several lines across `...` reports a partial match. - **Star a line spatch can delete — in every version of the range.** A star on a lone `}` is *silently dropped* (the branch matches and reports nothing); a star on a token an older version builds via a macro aborts spatch with "try to delete an expanded token". - **Independent sites are separate starred rules (OR); a conjunction needs `depends on`.** Chain the rules so the last one depends on all the others — `@err_c depends on err_a && err_b@` — and star only that last rule. See `cvehound/cve/CVE-2021-3347.cocci` and `cvehound/cve/CVE-2021-3609.cocci` for the AND, `cvehound/cve/CVE-2016-5195.cocci` for the OR. - **Filter by function with the pattern, not after the fact.** "Only in `foo()`" is written by anchoring inside the definition — `foo(...) { ... when any ... when any }`, with `\(foo1\|foo2\)` for several. Never wrap a statement body in `<+... ...+>`: same meaning, orders of magnitude slower. See `cvehound/cve/CVE-2021-28971.cocci`, `cvehound/cve/CVE-2017-1000112.cocci`. - **The rule's literal tokens decide how much of the tree spatch parses.** A name left literal where a metavariable would do, or more than five rules that star something in one file, and spatch stops skipping files it cannot match. `validate-rule.sh` measures it; `docs/WRITING_RULES.md` → "Rule 8: Keep the grep query selective" explains it. `docs/WRITING_RULES.md` → "Rule 2: Star discipline" has the full treatment of the star rules — the corpus exceptions, the failure symptoms, and the measured costs. When the rule is a judgement call, **prefer a false positive** — a missed CVE is worse than a noisy one. ## 4. Validate ```bash .agents/skills/write-cve-rule/scripts/validate-rule.sh cvehound/cve/CVE-YYYY-NNNNN.cocci ``` It checks the filename, the metadata block, that the rule parses, that no star sits on a lone brace, that the `Files:` paths resolve at both ends of the range -- the fix commit and `Fixes:`/`Detect-To:` -- and that the rule fires at `Fix~` **and at the old end of the range**, and is silent at `Fix`. A spatch failure at any of those (the expanded-token abort, typically) is reported as its own verdict; `.grep` rules get the same three detection verdicts through grep. It does this by extracting just the `Files:` paths at each commit, so it does not touch or check out the kernel working tree. Then the real thing, which is the authority: ```bash uv run pytest --runslow --cve=CVE-YYYY-NNNNN ``` ## 5. Repository gotchas **A `Files:` path that resolves nowhere is silent.** If none of the paths exist, `check_cve` skips the rule unless the caller explicitly requests `all_files=True`, which turns a bad path into a false negative. A rename does this as surely as a typo: the tests run the rule across the whole `Fixes..Fix` range and on old stable branches, so list every name the file has had there (`drivers/tty/n_hdlc.c drivers/char/n_hdlc.c`). The validator checks both ends and prints the historical name when the older end has none. **The hashes are test inputs.** `test_03_on_fix` checks out `Fix:` and `Fix~`; `test_04_on_fixes` does the same around `Fixes:`/`Detect-To:`; `test_05_between_fixes_fix` checks every commit in between. A wrong hash is a failing test, not a cosmetic error. `Fixes:` and `Detect-To:` populate the *same* field — set exactly one. **Register legitimate failures as data, never as `xfail`.** If a fix was never backported to a stable branch, add the `(cve, branch)` pair to `missing_backports` in `tests/conftest.py` — and delete it once the backport lands: the xfail is strict, so a backported pair fails the suite. If the upstream `Fixes:` tag is wrong, add `(cve, reason)` to `ownfixes` in `tests/test_00_metadata.py`. **Disputed CVEs go in `cvehound/cve/disputed/`.** Directory placement is the only thing that drives the `all` / `assigned` / `disputed` groups; the default `--cve assigned` skips that directory. **Headers are never resolved.** CVEhound runs spatch with `--no-includes`, so match what is written in the `.c` file, not what a macro expands to after preprocessing. **A content overlay never shadows your work here** — in a dev checkout cvehound uses the repo's own `cvehound/cve/` unless `CVEHOUND_CONTENT` is set, and the test suite pins `CVEHOUND_CONTENT=none`. `cvehound update` writes only to `~/.local/share/cvehound/`. ## 6. `.grep` rules Use these only when Coccinelle cannot express the pattern (assembly, tracepoint macros). Format: the same `///` metadata block, then **one regex per line**. All patterns must match for the CVE to be reported; the file is consumed by `grep -rPzle`, so the regexes are PCRE with `\s`, `\w`, and cross-line matching available. See `cvehound/cve/CVE-2017-1000255.grep`. ## Reference - `docs/WRITING_RULES.md` — the complete guide: metadata semantics, worked examples from five real CVEs, advanced techniques, troubleshooting - `docs/COCCINELLE_CHEATSHEET.md` — syntax and the vulnerability-pattern catalog - `contrib/blank.cocci`, `contrib/template.cocci` — starting points - `AGENTS.md` — repository conventions (style, tests, architecture)