Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .clusterfuzzlite/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
FROM gcr.io/oss-fuzz-base/base-builder-python

COPY . $SRC/examples
COPY .clusterfuzzlite/build.sh $SRC/build.sh
WORKDIR $SRC/examples
31 changes: 31 additions & 0 deletions .clusterfuzzlite/build.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
#!/bin/bash -eu
# Build the fuzz targets for ClusterFuzzLite.
#
# The targets import the example modules straight from their directories, the
# way a reader runs them. cryptography is the only third-party dependency, and
# it comes from the same hashed lock the receipts CI job uses.

cd "$SRC/examples"
pip3 install --no-cache-dir --require-hashes -r requirements/receipts-tests.txt

# compile_python_fuzzer bundles each target with PyInstaller, which follows
# static imports only. The cryptography stack reaches email.mime lazily, so
# without this the bundled target dies at runtime with
# "ModuleNotFoundError: No module named 'email.mime'" and libFuzzer reports it
# as a crash in the target.
PYI_ARGS=(
--collect-submodules=email
--paths="$SRC/examples/embodied-action-receipts"
--paths="$SRC/examples/agentic-commerce-accountability"
--paths="$SRC/examples/industrial-embodied-ai"
)

for target in "$SRC"/examples/.clusterfuzzlite/fuzz_*.py; do
compile_python_fuzzer "$target" "${PYI_ARGS[@]}"
done

# Seed each JSON target with the committed fixtures, so the fuzzer starts from
# documents that already get past parsing and, for receipts, past signature
# verification.
zip -j "$OUT/fuzz_receipts_seed_corpus.zip" embodied-action-receipts/fixtures/*.json
zip -j "$OUT/fuzz_purchase_seed_corpus.zip" agentic-commerce-accountability/fixtures/*.json
93 changes: 93 additions & 0 deletions .clusterfuzzlite/fuzz_controller.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
#!/usr/bin/python3
"""Fuzz the industrial example's mock safety controller.

request_motion() receives the motion request an agent sends through the cMCP
gateway, arriving as JSON over HTTP. It must either accept a motion inside the
controller's envelope or raise SafetyRejected. The target mints a genuine state
token most of the time, so inputs get past the HMAC and reach the envelope
checks, then fuzzes the rest of the request.

Two bugs sat here before the target: a NaN speed compared false against both
bounds and was accepted, and an integer too large for a float raised
OverflowError instead of SafetyRejected.
"""
import math
import sys
from pathlib import Path

import atheris

sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "industrial-embodied-ai"))

with atheris.instrument_imports():
from controller import (
ALLOWED_TARGETS,
MAX_SPEED_MPS,
IndependentSafetyController,
SafetyRejected,
)

_TARGETS = sorted(ALLOWED_TARGETS)


class _Clock:
value = 1_781_179_200.0

def __call__(self) -> float:
return self.value


def _speed(fdp: atheris.FuzzedDataProvider) -> object:
kind = fdp.ConsumeIntInRange(0, 6)
if kind == 0:
return fdp.ConsumeRegularFloat()
if kind == 1:
return fdp.ConsumeFloat() # includes NaN and the infinities
if kind == 2:
return fdp.ConsumeInt(fdp.ConsumeIntInRange(1, 256))
if kind == 3:
return fdp.ConsumeUnicodeNoSurrogates(24)
if kind == 4:
return fdp.ConsumeBool()
if kind == 5:
return None
return [fdp.ConsumeRegularFloat()]


def TestOneInput(data: bytes) -> None:
fdp = atheris.FuzzedDataProvider(data)
clock = _Clock()
controller = IndependentSafetyController(clock=clock, token_key=b"fuzz-only-controller-key")
token = controller.read_safety_state()["state_token"]
if fdp.ConsumeIntInRange(0, 7) == 0:
token = fdp.ConsumeUnicodeNoSurrogates(256)
clock.value += fdp.ConsumeIntInRange(0, 6000) / 1000
request: dict[str, object] = {"safety_state_token": token}
if fdp.ConsumeBool():
request["motion_id"] = fdp.ConsumeUnicodeNoSurrogates(16)
if fdp.ConsumeBool():
request["target"] = (
_TARGETS[fdp.ConsumeIntInRange(0, len(_TARGETS) - 1)]
if fdp.ConsumeBool()
else fdp.ConsumeUnicodeNoSurrogates(24)
)
if fdp.ConsumeIntInRange(0, 9):
request["max_speed_mps"] = _speed(fdp)

try:
result = controller.request_motion(request)
except SafetyRejected:
return
speed = float(request["max_speed_mps"])
assert math.isfinite(speed) and 0 <= speed <= MAX_SPEED_MPS, request
assert request["target"] in ALLOWED_TARGETS, request
assert result["controller_decision"] == "accepted", result


def main() -> None:
atheris.Setup(sys.argv, TestOneInput)
atheris.Fuzz()


if __name__ == "__main__":
main()
99 changes: 99 additions & 0 deletions .clusterfuzzlite/fuzz_purchase.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
#!/usr/bin/python3
"""Fuzz the agentic-commerce purchase verifier.

verify() decides whether a purchase bundle (authority grant, request, policy
decision, runtime evidence, merchant receipt) hangs together. An empty error
list is an acceptance, so the property checked is the one that matters: when it
accepts, every constraint it claims to check really holds. Malformed shapes may
raise KeyError, TypeError, AttributeError or RecursionError, which the CLI turns
into a non-zero exit; that is a refusal, not an acceptance.

A NaN spending ceiling, a negative amount and a string allow-list (which turns
membership into a substring test) each used to be accepted.
"""
import json
import sys
from pathlib import Path

import atheris

sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "agentic-commerce-accountability"))

with atheris.instrument_imports():
from verify_purchase import digest, verify

_REFUSALS = (KeyError, TypeError, AttributeError, RecursionError)


def _check_acceptance(bundle: dict) -> None:
grant = bundle["authority_grant"]
request = bundle["purchase_request"]
decision = bundle["policy_decision"]
evidence = bundle["runtime_evidence"]
receipt = bundle["purchase_receipt"]
amount, ceiling = request["amount_minor"], grant["max_amount_minor"]
assert type(amount) is int and type(ceiling) is int, (amount, ceiling)
assert 0 < amount <= ceiling, (amount, ceiling)
assert isinstance(grant["allowed_operations"], list)
assert isinstance(grant["allowed_merchants"], list)
assert request["operation"] in grant["allowed_operations"]
assert request["merchant_id"] in grant["allowed_merchants"]
assert request["currency"] == grant["currency"]
assert evidence["runtime_identity"] == grant["delegate"]
assert decision["outcome"] == "allow"
assert decision["request_digest"] == digest(request)
assert decision["authority_digest"] == digest(grant)
assert evidence["policy_decision_digest"] == digest(decision)
assert receipt["request_digest"] == digest(request)
assert receipt["runtime_evidence_digest"] == digest(evidence)


def _rebind(bundle: dict) -> None:
"""Re-derive every digest link from the fuzzed content.

A fuzzer cannot find SHA-256 preimages, so without this nearly every
mutation dies on a digest mismatch and the constraint checks behind it are
never reached with a consistent chain.
"""
request, grant = bundle["purchase_request"], bundle["authority_grant"]
decision, evidence = bundle["policy_decision"], bundle["runtime_evidence"]
decision["request_digest"] = digest(request)
decision["authority_digest"] = digest(grant)
evidence["policy_decision_digest"] = digest(decision)
receipt = bundle["purchase_receipt"]
receipt["request_digest"] = digest(request)
receipt["runtime_evidence_digest"] = digest(evidence)


def _check(bundle: dict) -> None:
try:
errors = verify(bundle)
except _REFUSALS:
return
assert isinstance(errors, list), errors
if not errors:
_check_acceptance(bundle)


def TestOneInput(data: bytes) -> None:
try:
text = data.decode("utf-8")
_check(json.loads(text))
except (UnicodeDecodeError, ValueError, RecursionError):
return
# The same document again with its digest chain made consistent.
rebound = json.loads(text)
try:
_rebind(rebound)
except _REFUSALS:
return
_check(rebound)


def main() -> None:
atheris.Setup(sys.argv, TestOneInput)
atheris.Fuzz()


if __name__ == "__main__":
main()
55 changes: 55 additions & 0 deletions .clusterfuzzlite/fuzz_receipts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
#!/usr/bin/python3
"""Fuzz the embodied-action receipt verifier.

verify_text() reads a fixture an auditor was handed: a TRACE reference, an
action and a chain of controller-signed receipts. Every byte is attacker-chosen
until the Ed25519 check on each receipt passes.

The property is the documented contract: it always returns a verdict, with
"valid" or "invalid" as the result, and never raises. Before this target
existed a wrong shape escaped as KeyError, TypeError, AttributeError or
binascii.Error, and a signature with stray characters in its base64 still
verified. The seed corpus is the committed fixtures, so mutations start from a
chain that already verifies.
"""
import sys
from pathlib import Path

import atheris

sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "embodied-action-receipts"))

with atheris.instrument_imports():
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from verify_receipts import TrustedSigner, b64url_decode, verify_text

# The public test key from embodied-action-receipts/trusted-keys.json, inlined
# because PyInstaller bundles code, not the data files beside it.
_TRUSTED = {
"robot-cell-7-controller": TrustedSigner(
issuer="spiffe://factory.example/controller/robot-cell-7",
public_key=Ed25519PublicKey.from_public_bytes(
b64url_decode("A6EHv_POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg")
),
)
}


def TestOneInput(data: bytes) -> None:
try:
text = data.decode("utf-8")
except UnicodeDecodeError:
return
result = verify_text(text, _TRUSTED)
assert isinstance(result, dict), result
assert result.get("result") in {"valid", "invalid"}, result
assert isinstance(result.get("receipt_state"), str), result


def main() -> None:
atheris.Setup(sys.argv, TestOneInput)
atheris.Fuzz()


if __name__ == "__main__":
main()
37 changes: 37 additions & 0 deletions .github/workflows/cflite_batch.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: ClusterFuzzLite batch

on:
schedule:
# 04:40 UTC daily, off the hour so it does not queue behind everything else
# that runs at midnight.
- cron: '40 4 * * *'
workflow_dispatch:

permissions: read-all

concurrency:
group: cflite-batch
cancel-in-progress: false

jobs:
fuzz:
name: Batch fuzz
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Build fuzzers
uses: google/clusterfuzzlite/actions/build_fuzzers@884713a6c30a92e5e8544c39945cd7cb630abcd1 # v1
with:
language: python
github-token: ${{ secrets.GITHUB_TOKEN }}
sanitizer: address

- name: Run fuzzers
uses: google/clusterfuzzlite/actions/run_fuzzers@884713a6c30a92e5e8544c39945cd7cb630abcd1 # v1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
# Every target, not just what changed. The budget is shared across
# the three targets and leaves the build room inside the job timeout.
fuzz-seconds: 1800
mode: batch
sanitizer: address
45 changes: 45 additions & 0 deletions .github/workflows/cflite_pr.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
name: ClusterFuzzLite PR

on:
pull_request:
paths:
- 'embodied-action-receipts/**'
- 'agentic-commerce-accountability/*.py'
- 'agentic-commerce-accountability/fixtures/**'
- 'industrial-embodied-ai/controller.py'
- 'requirements/receipts-tests.*'
- '.clusterfuzzlite/**'
- '.github/workflows/cflite_pr.yml'

# Read-only. Findings surface in the job log and the uploaded crash artifact
# rather than as code-scanning alerts, so no security-events: write is needed.
permissions: read-all

concurrency:
group: cflite-pr-${{ github.ref }}
cancel-in-progress: true

jobs:
fuzz:
name: Fuzz changed code
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
# Only the address sanitizer. The targets are pure Python under Atheris,
# where undefined-behaviour instrumentation has nothing to instrument.
- name: Build fuzzers
uses: google/clusterfuzzlite/actions/build_fuzzers@884713a6c30a92e5e8544c39945cd7cb630abcd1 # v1
with:
language: python
github-token: ${{ secrets.GITHUB_TOKEN }}
sanitizer: address

- name: Run fuzzers
uses: google/clusterfuzzlite/actions/run_fuzzers@884713a6c30a92e5e8544c39945cd7cb630abcd1 # v1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
fuzz-seconds: 300
# code-change fuzzes only what the PR touched, which is what keeps
# this inside a PR's time budget. The nightly batch covers the rest.
mode: code-change
sanitizer: address
6 changes: 4 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -144,10 +144,12 @@ jobs:
with:
python-version: "3.11"
cache: "pip"
cache-dependency-path: embodied-action-receipts/requirements.txt
cache-dependency-path: requirements/receipts-tests.txt

- name: Install dependencies
run: python -m pip install -r requirements.txt
# The job cwd is embodied-action-receipts (defaults above), so the
# lock is one level up.
run: python -m pip install --require-hashes -r ../requirements/receipts-tests.txt

- name: Verify embodied-action receipt fixtures
run: python -m unittest discover -s tests -v
Expand Down
Loading
Loading