Skip to content

Commit b21b263

Browse files
chore(fuzz): add ClusterFuzzLite targets for the untrusted input surface
Mirrors agentrust-io/agent-manifest#405, which is merged and where the same approach found a real bug in the COSE decoders on its first run. Scorecard's Fuzzing check does not recognise Hypothesis or Atheris on their own, so Python property tests would not have moved it. ClusterFuzzLite is what it detects, and it runs real coverage-guided fuzzing rather than only satisfying the check. Two targets, on what arrives before anything about it has been verified: fuzz_attestation_parsers.py parse_event_log walks a TCG event log, whose Spec ID header declares digest algorithms and lengths and whose every event declares its own digest count and data length. parse_nv_certify reads a size-prefixed TPM NV certification. fuzz_canonical_json.py the bytes a catalog approval signature covers. The parser target asserts each function fails closed: it returns, or raises the ValueError its module documents. The canonicalizer target asserts a round trip rather than absence of a crash, because the three RFC 8785 bugs found in the sibling agent-manifest canonicalizer were all silent. Standing when added: both parsers already fail closed under a local probe of 12,000 mutated inputs, and the canonicalizer round-tripped 17,918 generated documents while refusing 12,080 as declared, with no invariant violation. These are regression guards, not holes being closed. Carries both fixes agent-manifest paid for: --collect-submodules=email, without which PyInstaller misses the lazy import and libFuzzer reports the runtime ModuleNotFoundError as a crash in the target; and a 45 minute budget, since the oss-fuzz base image build dominates. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XbDBXDWWvMFa7c2jGgyq9t
1 parent 424bd78 commit b21b263

7 files changed

Lines changed: 322 additions & 0 deletions

File tree

‎.clusterfuzzlite/Dockerfile‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
FROM gcr.io/oss-fuzz-base/base-builder-python
2+
3+
COPY . $SRC/cmcp
4+
COPY .clusterfuzzlite/build.sh $SRC/build.sh
5+
WORKDIR $SRC/cmcp

‎.clusterfuzzlite/README.md‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# Fuzzing
2+
3+
Coverage-guided fuzzing via [ClusterFuzzLite](https://google.github.io/clusterfuzzlite/),
4+
running Atheris against the untrusted input surface. Mirrors the setup in
5+
agentrust-io/agent-manifest, where the same targets found a real bug.
6+
7+
## What is fuzzed
8+
9+
| Target | Surface |
10+
| --- | --- |
11+
| `fuzz_attestation_parsers.py` | `parse_event_log` walks a TCG event log: a Spec ID header declaring which digest algorithms are present and how long each is, then events each carrying their own declared digest count and data length. `parse_nv_certify` reads a bare or size-prefixed TPM NV certification. Every one of those lengths comes from the blob, which arrives from the platform before anything about it is verified. |
12+
| `fuzz_canonical_json.py` | The bytes a catalog approval signature covers. |
13+
14+
## The properties
15+
16+
The parser target asserts each function fails closed: it returns, or raises the
17+
`ValueError` its module documents (`EventLogError` is a `ValueError`). A
18+
`struct.error`, `IndexError`, `MemoryError` or `OverflowError` reaching the
19+
caller means a declared length was believed.
20+
21+
`fuzz_canonical_json.py` asserts a round trip: parsing the canonical output must
22+
reproduce the input. That is stronger than checking for a crash, deliberately.
23+
The three RFC 8785 bugs found in the sibling agent-manifest canonicalizer in
24+
September 2026 were all silent; the sharpest normalized two distinct object keys
25+
into one, so the output carried that key twice and a field disappeared from a
26+
document whose signature claimed to cover it. Nothing raised. cmcp does not have
27+
that bug, and this is what keeps it that way.
28+
29+
Equality is asserted up to JSON's number model, since JSON has one number type
30+
and a large float legitimately re-parses as an int.
31+
32+
## Standing when added
33+
34+
Both parsers already fail closed: a local probe of 12,000 mutated inputs found
35+
no undeclared exception escaping either. The canonicalizer is clean too: 17,918
36+
generated documents round-tripped and 12,080 were refused as declared, with no
37+
invariant violation. These are regression guards.
38+
39+
## Bundling gotcha
40+
41+
`compile_python_fuzzer` bundles each target with PyInstaller, which follows
42+
static imports only. The cryptography and pydantic stacks reach `email.mime`
43+
lazily, so without help the bundled target dies at runtime with
44+
`ModuleNotFoundError: No module named 'email.mime'`, and libFuzzer reports that
45+
as a crash in the target rather than a build problem. `build.sh` passes
46+
`--collect-submodules=email`. A new dependency with a lazy import can need the
47+
same treatment.
48+
49+
## Running locally
50+
51+
```
52+
git clone https://github.com/google/clusterfuzzlite --depth 1 /tmp/clusterfuzzlite
53+
python /tmp/clusterfuzzlite/infra/helper.py build_image --external $PWD
54+
python /tmp/clusterfuzzlite/infra/helper.py build_fuzzers --external --sanitizer address $PWD
55+
python /tmp/clusterfuzzlite/infra/helper.py run_fuzzer --external $PWD fuzz_canonical_json
56+
```
57+
58+
## In CI
59+
60+
`cflite_pr.yml` fuzzes only code the pull request touched, for five minutes.
61+
`cflite_batch.yml` runs every target for an hour, nightly. Both are read-only.
62+
Budget 45 minutes for the PR job: the oss-fuzz base image build dominates.

‎.clusterfuzzlite/build.sh‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
#!/bin/bash -eu
2+
# Build the fuzz targets for ClusterFuzzLite.
3+
#
4+
# Installed rather than put on the path so the targets exercise the same import
5+
# surface a consumer gets.
6+
7+
cd "$SRC/cmcp"
8+
pip3 install --no-cache-dir .
9+
10+
# compile_python_fuzzer bundles each target with PyInstaller, which follows
11+
# static imports only. The cryptography and pydantic stacks reach email.mime
12+
# lazily, so without this the bundled target dies at runtime with
13+
# "ModuleNotFoundError: No module named 'email.mime'" and libFuzzer reports it
14+
# as a crash in the target.
15+
PYI_ARGS=(--collect-submodules=email)
16+
17+
for target in "$SRC"/cmcp/.clusterfuzzlite/fuzz_*.py; do
18+
compile_python_fuzzer "$target" "${PYI_ARGS[@]}"
19+
done
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
#!/usr/bin/python3
2+
"""Fuzz the attestation blob parsers in cmcp_verify.
3+
4+
parse_event_log walks a TCG event log: a Spec ID header that declares which
5+
digest algorithms are present and how long each one is, then a run of events
6+
each carrying its own declared digest count and data length. Every one of those
7+
numbers comes from the blob being parsed, and the log arrives from the platform
8+
before anything about it has been verified.
9+
10+
parse_nv_certify reads a bare or size-prefixed TPM NV certification, where a
11+
length prefix decides how much of the rest is structure.
12+
13+
The property is that each parser fails closed: it returns, or raises the
14+
ValueError its module documents (EventLogError is a ValueError). A struct.error,
15+
IndexError, MemoryError or OverflowError reaching the caller means a declared
16+
length was believed, and callers written against the documented exception will
17+
not catch it.
18+
"""
19+
import sys
20+
21+
import atheris
22+
23+
with atheris.instrument_imports():
24+
from cmcp_verify.nv_certify import parse_nv_certify
25+
from cmcp_verify.tcg_event_log import parse_event_log
26+
27+
_TARGETS = [parse_event_log, parse_nv_certify]
28+
29+
30+
def TestOneInput(data: bytes) -> None:
31+
if not data:
32+
return
33+
fdp = atheris.FuzzedDataProvider(data)
34+
parser = _TARGETS[fdp.ConsumeIntInRange(0, len(_TARGETS) - 1)]
35+
blob = fdp.ConsumeBytes(fdp.remaining_bytes())
36+
try:
37+
parser(blob)
38+
except ValueError:
39+
pass
40+
41+
42+
def main() -> None:
43+
atheris.Setup(sys.argv, TestOneInput)
44+
atheris.Fuzz()
45+
46+
47+
if __name__ == "__main__":
48+
main()
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
#!/usr/bin/python3
2+
"""Fuzz the canonicalizer that catalog approvals are signed over.
3+
4+
canonical_json() produces the bytes an approval signature covers, so those bytes
5+
have to be a faithful, lossless encoding of the input. The property asserted
6+
here is a round trip: parsing the canonical output must reproduce the input.
7+
8+
That is stronger than checking for a crash, deliberately. The three RFC 8785
9+
bugs found in the sibling agent-manifest canonicalizer in September 2026 were
10+
all silent. The sharpest was NFC normalization of object keys: two distinct keys
11+
normalized to one, the output carried that key twice, json.loads kept one of the
12+
pair, and a field disappeared from a document whose signature claimed to cover
13+
it. Nothing raised. cmcp's implementation does not have that bug, and this is
14+
what keeps it that way.
15+
16+
Structure is built from the fuzz data rather than by mutating JSON text, so the
17+
budget goes on key and value shapes (combining marks, surrogate pairs, control
18+
characters, integer boundaries) instead of on producing syntactically valid JSON.
19+
"""
20+
import json
21+
import math
22+
import sys
23+
24+
import atheris
25+
26+
with atheris.instrument_imports():
27+
from cmcp_runtime.catalog.approval import canonical_json
28+
29+
_MAX_DEPTH = 4
30+
_MAX_ITEMS = 6
31+
32+
33+
def _build(fdp: atheris.FuzzedDataProvider, depth: int = 0):
34+
if depth >= _MAX_DEPTH or fdp.remaining_bytes() == 0:
35+
return fdp.ConsumeUnicodeNoSurrogates(16)
36+
kind = fdp.ConsumeIntInRange(0, 7)
37+
if kind == 0:
38+
return None
39+
if kind == 1:
40+
return fdp.ConsumeBool()
41+
if kind == 2:
42+
# Straddle the safe-integer boundary on purpose: past it, RFC 8785 maps
43+
# two distinct integers to the same digits, so the canonicalizer has to
44+
# refuse rather than emit them.
45+
return fdp.ConsumeIntInRange(-(2**54), 2**54)
46+
if kind == 3:
47+
return fdp.ConsumeFloat()
48+
if kind == 4:
49+
return fdp.ConsumeUnicodeNoSurrogates(64)
50+
if kind == 5:
51+
return [_build(fdp, depth + 1) for _ in range(fdp.ConsumeIntInRange(0, _MAX_ITEMS))]
52+
return {
53+
fdp.ConsumeUnicodeNoSurrogates(24): _build(fdp, depth + 1)
54+
for _ in range(fdp.ConsumeIntInRange(0, _MAX_ITEMS))
55+
}
56+
57+
58+
def _json_equal(a, b) -> bool:
59+
"""Equality up to JSON's number model.
60+
61+
JSON has one number type, so a float whose shortest form has no fractional
62+
part re-parses as a Python int and will not compare equal to the float it
63+
came from. That is correct output, not a defect, so the round trip is
64+
asserted up to numeric type.
65+
"""
66+
if isinstance(a, bool) or isinstance(b, bool):
67+
return a is b
68+
if isinstance(a, (int, float)) and isinstance(b, (int, float)):
69+
return float(a) == float(b)
70+
if isinstance(a, dict) and isinstance(b, dict):
71+
return a.keys() == b.keys() and all(_json_equal(a[k], b[k]) for k in a)
72+
if isinstance(a, list) and isinstance(b, list):
73+
return len(a) == len(b) and all(_json_equal(x, y) for x, y in zip(a, b))
74+
return type(a) is type(b) and a == b
75+
76+
77+
def _has_nonfinite(value) -> bool:
78+
"""NaN and Infinity have no JSON form; the canonicalizer rejects them."""
79+
if isinstance(value, float):
80+
return not math.isfinite(value)
81+
if isinstance(value, dict):
82+
return any(_has_nonfinite(v) for v in value.values())
83+
if isinstance(value, list):
84+
return any(_has_nonfinite(v) for v in value)
85+
return False
86+
87+
88+
def TestOneInput(data: bytes) -> None:
89+
fdp = atheris.FuzzedDataProvider(data)
90+
value = _build(fdp)
91+
if _has_nonfinite(value):
92+
return
93+
try:
94+
out = canonical_json(value)
95+
except ValueError:
96+
# Declared: CatalogApprovalError is a ValueError, and covers floats,
97+
# integers outside the RFC 8785 safe domain, and unsupported types.
98+
return
99+
100+
assert _json_equal(json.loads(out), value), f"canonical bytes did not round-trip: {out!r}"
101+
assert canonical_json(value) == out, "canonicalization is not deterministic"
102+
103+
104+
def main() -> None:
105+
atheris.Setup(sys.argv, TestOneInput)
106+
atheris.Fuzz()
107+
108+
109+
if __name__ == "__main__":
110+
main()

‎.github/workflows/cflite_batch.yml‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
name: ClusterFuzzLite batch
2+
3+
on:
4+
schedule:
5+
# 04:20 UTC daily, off the hour so it does not queue behind everything else
6+
# that runs at midnight.
7+
- cron: '20 4 * * *'
8+
workflow_dispatch:
9+
10+
permissions: read-all
11+
12+
concurrency:
13+
group: cflite-batch
14+
cancel-in-progress: false
15+
16+
jobs:
17+
fuzz:
18+
name: Batch fuzz
19+
runs-on: ubuntu-latest
20+
timeout-minutes: 90
21+
steps:
22+
- name: Build fuzzers
23+
uses: google/clusterfuzzlite/actions/build_fuzzers@884713a6c30a92e5e8544c39945cd7cb630abcd1 # v1
24+
with:
25+
language: python
26+
github-token: ${{ secrets.GITHUB_TOKEN }}
27+
sanitizer: address
28+
29+
- name: Run fuzzers
30+
uses: google/clusterfuzzlite/actions/run_fuzzers@884713a6c30a92e5e8544c39945cd7cb630abcd1 # v1
31+
with:
32+
github-token: ${{ secrets.GITHUB_TOKEN }}
33+
# Every target, not just what changed, and long enough for the
34+
# attestation parsers to get past their length and tag fields.
35+
fuzz-seconds: 3600
36+
mode: batch
37+
sanitizer: address

‎.github/workflows/cflite_pr.yml‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
name: ClusterFuzzLite PR
2+
3+
on:
4+
pull_request:
5+
paths:
6+
- 'src/**'
7+
- '.clusterfuzzlite/**'
8+
- '.github/workflows/cflite_pr.yml'
9+
10+
# Read-only. Findings surface in the job log and the uploaded crash artifact
11+
# rather than as code-scanning alerts, so no security-events: write is needed.
12+
permissions: read-all
13+
14+
concurrency:
15+
group: cflite-pr-${{ github.ref }}
16+
cancel-in-progress: true
17+
18+
jobs:
19+
fuzz:
20+
name: Fuzz changed code
21+
runs-on: ubuntu-latest
22+
timeout-minutes: 45
23+
steps:
24+
# Only the address sanitizer. The targets are pure Python under Atheris,
25+
# where undefined-behaviour instrumentation has nothing to instrument.
26+
- name: Build fuzzers
27+
uses: google/clusterfuzzlite/actions/build_fuzzers@884713a6c30a92e5e8544c39945cd7cb630abcd1 # v1
28+
with:
29+
language: python
30+
github-token: ${{ secrets.GITHUB_TOKEN }}
31+
sanitizer: address
32+
33+
- name: Run fuzzers
34+
uses: google/clusterfuzzlite/actions/run_fuzzers@884713a6c30a92e5e8544c39945cd7cb630abcd1 # v1
35+
with:
36+
github-token: ${{ secrets.GITHUB_TOKEN }}
37+
fuzz-seconds: 300
38+
# code-change fuzzes only what the PR touched, which is what keeps
39+
# this inside a PR's time budget. The nightly batch covers the rest.
40+
mode: code-change
41+
sanitizer: address

0 commit comments

Comments
 (0)