---
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 <fix_commit>          # the diff is the specification
git -C tests/linux log -1 --format=%H <fix_commit>
```

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 != <the new code>` |
| **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: <paths from kernel root, space-separated, one line>
/// Fix: <full hash of the fixing commit>
/// Fixes: <full hash of the introducing commit>   // or Detect-To: <hash>

@err@
@@

vulnerable_function(...)
{
	...
*	<the line that identifies the vulnerability>
	...
}
```

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 <pattern> ... 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)
