diff --git a/.github/workflows/catalog-release.yml b/.github/workflows/catalog-release.yml index c7f5653..fd453be 100644 --- a/.github/workflows/catalog-release.yml +++ b/.github/workflows/catalog-release.yml @@ -1,8 +1,9 @@ name: catalog-release # Publishes a catalog release: a GitHub Release tagged v carrying -# ave-records.json, ave-records.manifest.json and SHA256SUMS, with a build -# provenance attestation for the two data files. Run by hand from +# ave-records.json, ave-records.manifest.json, ave-taxonomy.json (the SARIF +# taxonomy of exactly those records) and SHA256SUMS, with a build provenance +# attestation for the three data files. Run by hand from # Actions > catalog-release; it builds a commit already on main and never # pushes to the repository. Policy: docs/specs/scaling-and-governance.md, # section 6. @@ -126,19 +127,26 @@ jobs: --manifest-out release/ave-records.manifest.json jq --arg release "v$VERSION" '{release: $release} + .' release/ave-records.manifest.json > manifest.tmp mv manifest.tmp release/ave-records.manifest.json - (cd release && sha256sum ave-records.json ave-records.manifest.json > SHA256SUMS) + python3 scripts/build_taxonomy.py \ + --records release/ave-records.json \ + --release "v$VERSION" \ + --release-date "$(jq -r '.generated_at[:10]' release/ave-records.manifest.json)" \ + --download-uri "https://github.com/$GITHUB_REPOSITORY/releases/download/v$VERSION/ave-taxonomy.json" \ + --out release/ave-taxonomy.json + (cd release && sha256sum ave-records.json ave-records.manifest.json ave-taxonomy.json > SHA256SUMS) cat release/ave-records.manifest.json release/SHA256SUMS # the tag does not exist yet, so source_commit is checked against this run's commit - name: Verify the release files - run: python scripts/check_release_assets.py release --tag "v$VERSION" --commit "$(git rev-parse HEAD)" + run: python scripts/check_release_assets.py release --tag "v$VERSION" --commit "$(git rev-parse HEAD)" --repo "$GITHUB_REPOSITORY" - - name: Attest build provenance for both data files + - name: Attest build provenance for the three data files uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 with: subject-path: | release/ave-records.json release/ave-records.manifest.json + release/ave-taxonomy.json - name: Create the release env: @@ -157,4 +165,4 @@ jobs: --title "AVE v$VERSION" \ --notes-file release-notes.md \ "${draft_flag[@]}" \ - release/ave-records.json release/ave-records.manifest.json release/SHA256SUMS + release/ave-records.json release/ave-records.manifest.json release/ave-taxonomy.json release/SHA256SUMS diff --git a/CHANGELOG.md b/CHANGELOG.md index ecb9fd0..31803ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ Format: [Semantic Versioning](https://semver.org). Schema versions and record se ## [Unreleased] ### SARIF +- **The taxonomy as a verified catalog-release asset** (#442). The `catalog-release` workflow builds + `ave-taxonomy.json` with `scripts/build_taxonomy.py` from the release's own `ave-records.json`, stamped with the + release's version, the manifest's date and its own download URL, lists it in `SHA256SUMS` and attests it with the + other data files. `scripts/check_release_assets.py` regenerates it from the release's records and demands a byte + match, naming the field or taxon that differs first, and takes a new `--repo owner/name` (default `aveproject/ave`) + so a fork's release is verified against the fork. Releases from v1.3.0 must carry the taxonomy; v1.0.0 to v1.2.0 + verify as before. That makes the order mechanical: this has to merge before v1.3.0 is cut. - **The AVE taxonomy as a canonical file, form C and taxonomy checks** (#420). `dist/ave-taxonomy-latest.json` is the AVE taxonomy as a SARIF 2.1.0 `toolComponent` with a frozen `guid` (`5110b285-34d7-5334-94db-0308b24cfb49`, the UUIDv5 of `https://aveproject.org/taxonomy/AVE`) and one taxon per record, including records whose status is not `active`. diff --git a/README.md b/README.md index e15576e..d78ca04 100644 --- a/README.md +++ b/README.md @@ -512,12 +512,16 @@ watching and the API. A catalog release is a [GitHub Release](https://github.com/aveproject/ave/releases) tagged `vX.Y.Z`: a fixed copy of the record set that does not change after it is published. Each one carries three files and a build provenance attestation for -the first two: +the first two; from v1.3.0 it carries a fourth, the SARIF taxonomy, which the +attestation covers too: - `ave-records.json`: every published record. - `ave-records.manifest.json`: the release tag, `schema_version`, `record_count`, the SHA-256 of `ave-records.json` and `source_commit`, the commit it was built from. -- `SHA256SUMS`: checksums of both files. +- `ave-taxonomy.json` (from v1.3.0): the [SARIF taxonomy](docs/specs/ave-in-sarif.md) + of exactly these records, stamped with the release's own version, date and + download link, ready to embed in a tool's SARIF output. +- `SHA256SUMS`: checksums of the data files (three of them from v1.3.0). Pin a tag, then check what you downloaded: @@ -535,7 +539,24 @@ gh attestation verify ave-records.manifest.json --repo aveproject/ave ``` `check_release_assets.py` confirms the checksums, that the manifest matches the -records file, and that `source_commit` is the commit the tag points at. +records file, and that `source_commit` is the commit the tag points at. From +v1.3.0 it also regenerates `ave-taxonomy.json` from the records file and requires +a byte for byte match, and refuses a release without it. + +From v1.3.0 on, add the taxonomy to the same steps. `--repo` is the repository the +release is published under, so verify a fork's release with `--repo owner/name`: + +```bash +TAG=v1.3.0 +curl -fsSLO "https://github.com/aveproject/ave/releases/download/$TAG/ave-taxonomy.json" +sha256sum -c SHA256SUMS # now covers three files +python ave-src/scripts/check_release_assets.py . --tag "$TAG" --git-dir ave-src --repo aveproject/ave +gh attestation verify ave-taxonomy.json --repo aveproject/ave +``` + +The taxonomy has stable URLs: `https://github.com/aveproject/ave/releases/download/vX.Y.Z/ave-taxonomy.json` +for a release, and `https://github.com/aveproject/ave/releases/latest/download/ave-taxonomy.json` +for the newest. Three kinds of record file, and which is which: diff --git a/crosswalks/cfgaudit-to-ave.json b/crosswalks/cfgaudit-to-ave.json index da22cc8..3130281 100644 --- a/crosswalks/cfgaudit-to-ave.json +++ b/crosswalks/cfgaudit-to-ave.json @@ -3,24 +3,24 @@ "source": { "tool": "cfgaudit", "vendor": "cfgaudit", - "version": "1.16.0", + "version": "1.18.1", "url": "https://github.com/cfgaudit/cfgaudit", "license": "Apache-2.0", "tool_class": "static configuration auditor", - "rules_total": 114, + "rules_total": 116, "rules_mapped": 66, - "commit": "7868c87ff576de8e497cdc419a0095808b88a4dd" + "commit": "d490362f1889e84c2704ae304a0e6bb1863697fa" }, "target": { "standard": "AVE", "version": "1.1.0", "url": "https://aveproject.org", - "record_count": 87, + "record_count": 89, "static_record_count": 61, - "commit": "7d32d20f0256e21725c8796ea7698befe3c3f10c" + "commit": "8e08eaaf2a6a870f44172c4504f46325d6ecde8c" }, - "generated": "2026-10-12", - "note": "cfgaudit is a static auditor of committable AI-agent CONFIGURATION files. It does not connect to running servers or observe runtime, so it maps only to AVE's static_detection records. Each cfgaudit rule emits its primary AVE id in JSON/SARIF output (see github.com/cfgaudit/cfgaudit/blob/main/docs/cfgaudit-to-ave.md). Mappings are class-level behavioral equivalence, not asserted identity. cfgaudit now maps 66 config-surface rules onto 28 AVE behavioral classes, up from 64 onto 27 at v1.13.0. This revision spans three cfgaudit releases (v1.14.0, v1.15.0, v1.16.0), which added eight rules, CFG107 through CFG114. Two map: CFG113 to AVE-2026-00001, a class this file did not cover before, and CFG114 to AVE-2026-00064. The other six are left unmapped on purpose and listed under config_surfaces_beyond_ave with the reason. The record set grew from 80 to 87; of the seven new records, AVE-2026-00082, 00084 and 00085 are static_detection and are listed under gaps, each with the detail that keeps it from mapping. Both sides are pinned by commit: cfgaudit at the v1.16.0 tag, AVE at the commit read.", + "generated": "2026-10-24", + "note": "cfgaudit is a static auditor of committable AI-agent CONFIGURATION files. It does not connect to running servers or observe runtime, so it maps only to AVE's static_detection records. Each cfgaudit rule emits its primary AVE id in JSON/SARIF output (see github.com/cfgaudit/cfgaudit/blob/main/docs/cfgaudit-to-ave.md). Mappings are class-level behavioral equivalence, not asserted identity. cfgaudit maps 66 config-surface rules onto 28 AVE behavioral classes, the same as at v1.16.0. This revision spans cfgaudit v1.17.0 to v1.18.1, which added two rules, CFG115 and CFG116. Both are left unmapped on purpose and listed under config_surfaces_beyond_ave with the reason: each makes the agent read a file on the reader's machine, which no record covers. The record set grew from 87 to 89; AVE-2026-00088 and 00089 are both runtime_observed, so the static count stays at 61 and no gap is added. Both sides are pinned by commit: cfgaudit at the v1.18.1 tag, AVE at the commit read.", "mappings": [ { "ave_id": "AVE-2026-00001", @@ -302,10 +302,10 @@ ], "coverage": { "ave_static_records": 61, - "cfgaudit_rules_total": 114, + "cfgaudit_rules_total": 116, "cfgaudit_rules_mapped": 66, "ave_classes_covered": 28, - "cfgaudit_rules_unmapped": 48 + "cfgaudit_rules_unmapped": 50 }, "validation": { "against": { @@ -379,14 +379,14 @@ "CFG107", "CFG110" ], - "note": "New. Codex shell_environment_policy.set (CFG107) and the Claude Code settings env block (CFG110) setting BASH_ENV, LD_PRELOAD, NODE_OPTIONS and similar, so code runs in every shell command and hook the agent starts. The mechanism is the one AVE-2026-00055 describes, but that record is bound to an untrusted MCP launch config; stretching it onto the agent's own process environment would change what it asserts." + "note": "Added at v1.14.0 (CFG107) and v1.15.0 (CFG110). Codex shell_environment_policy.set (CFG107) and the Claude Code settings env block (CFG110) setting BASH_ENV, LD_PRELOAD, NODE_OPTIONS and similar, so code runs in every shell command and hook the agent starts. The mechanism is the one AVE-2026-00055 describes, but that record is bound to an untrusted MCP launch config; stretching it onto the agent's own process environment would change what it asserts." }, { "surface": "a committed scheduler that fires prompts on a timer", "example_rules": [ "CFG108" ], - "note": "New. A committed .claude/scheduled_tasks.json: its presence self-enables Claude Code's otherwise-off scheduler and enqueues a repository-authored prompt on a schedule. AVE-2026-00063 is a bypassed human approval gate; this bypasses no gate, it opens a new autonomous channel." + "note": "Added at v1.14.0. A committed .claude/scheduled_tasks.json: its presence self-enables Claude Code's otherwise-off scheduler and enqueues a repository-authored prompt on a schedule. AVE-2026-00063 is a bypassed human approval gate; this bypasses no gate, it opens a new autonomous channel." }, { "surface": "a declared configuration that never loads", @@ -394,14 +394,22 @@ "CFG109", "CFG012" ], - "note": "New. The repository states guardrails that are not in force because the agent does not load the file. Measured in both directions: Codex logs 'Invalid configuration; using defaults' and runs, and Claude Code silently discards a whole settings file, every deny rule and hook with it, on a top-level type error or on one unloadable PreToolUse or PermissionRequest hook entry. AVE-2026-00083 is the nearest in effect but is runtime_observed and scoped to a mechanism's own comparison logic." + "note": "CFG109 added at v1.15.0; CFG012 is older. The repository states guardrails that are not in force because the agent does not load the file. Measured in both directions: Codex logs 'Invalid configuration; using defaults' and runs, and Claude Code silently discards a whole settings file, every deny rule and hook with it, on a top-level type error or on one unloadable PreToolUse or PermissionRequest hook entry. AVE-2026-00083 is the nearest in effect but is runtime_observed and scoped to a mechanism's own comparison logic." }, { "surface": "a committed credential source sent to a committed endpoint", "example_rules": [ "CFG112" ], - "note": "New. An MCP server's bearer_token_file names a file on the reader's machine whose content is sent as a bearer token to the server URL in the same committed file. AVE-2026-00047 is a credential written into the component; here the file names where to read one and where to send it, which no record covers." + "note": "Added at v1.15.0. An MCP server's bearer_token_file names a file on the reader's machine whose content is sent as a bearer token to the server URL in the same committed file. AVE-2026-00047 is a credential written into the component; here the file names where to read one and where to send it, which no record covers." + }, + { + "surface": "a committed path that makes the agent read a file on the reader's machine", + "example_rules": [ + "CFG115", + "CFG116" + ], + "note": "New. CFG115: a committed config path is a symbolic link that resolves outside the repository, so the agent loads its settings, MCP config or instructions from a file on the machine that checked the repository out. CFG116: an @path import in a committed CLAUDE.md, AGENTS.md or .claude/rules file resolves outside the repository (~/..., an absolute path, ../ past the root); Claude Code loads it after a once-per-project approval dialog. A reviewer sees the link or the import line, but not the content of the file it reaches. AVE-2026-00001 is a remote fetch, AVE-2026-00047 a credential written into the component, AVE-2026-00053 a path traversal in an MCP tool's own path validation and AVE-2026-00089 a runtime_observed sandbox scope taken from a tool parameter; none covers a repository choosing which local file the agent reads." } ] } diff --git a/crosswalks/cfgaudit-to-ave.md b/crosswalks/cfgaudit-to-ave.md index 8fa4843..4eba3fa 100644 --- a/crosswalks/cfgaudit-to-ave.md +++ b/crosswalks/cfgaudit-to-ave.md @@ -8,26 +8,21 @@ cfgaudit emits each rule's primary AVE id in its JSON and SARIF output (`AVEID` | | Version | |---|---| -| cfgaudit | 1.16.0, read at the tag (`7868c87`) | -| AVE record set | 87 records (AVE-2026-00001 .. 00087), 61 of them `static_detection`, read at `7d32d20` | +| cfgaudit | 1.18.1, read at the tag (`d490362`) | +| AVE record set | 89 records (AVE-2026-00001 .. 00089), 61 of them `static_detection`, read at `8e08eaa` | | Bawbel Scanner (validation, below) | 1.3.0 | ## Coverage -cfgaudit has **114 rules** in total. **66 of them map onto 28 AVE behavioral classes**, up from the 64 onto 27 this file carried at v1.13.0. It is a many-to-one mapping: several cfgaudit rules land on the same AVE class, because cfgaudit slices threats by config surface where AVE slices by behavior. For example, cfgaudit has five distinct secret-detection rules (a secret in `settings.json` env, in an MCP `env`/`headers` block, an entropy fallback, a Continue inline `apiKey`, a crypto signing key), and all five map to the single AVE class `AVE-2026-00047` (hardcoded credentials in component). +cfgaudit has **116 rules** in total. **66 of them map onto 28 AVE behavioral classes**, the same as at v1.16.0. It is a many-to-one mapping: several cfgaudit rules land on the same AVE class, because cfgaudit slices threats by config surface where AVE slices by behavior. For example, cfgaudit has five distinct secret-detection rules (a secret in `settings.json` env, in an MCP `env`/`headers` block, an entropy fallback, a Continue inline `apiKey`, a crypto signing key), and all five map to the single AVE class `AVE-2026-00047` (hardcoded credentials in component). -The other 48 rules have no AVE class: they check config surfaces AVE's skill and MCP-server records do not enumerate (see "Config surfaces beyond AVE's model" below). +The other 50 rules have no AVE class: they check config surfaces AVE's skill and MCP-server records do not enumerate (see "Config surfaces beyond AVE's model" below). -**This revision spans three cfgaudit releases,** v1.14.0, v1.15.0 and v1.16.0, which added eight rules, `CFG107` through `CFG114`. Two of them map: +**This revision spans cfgaudit v1.17.0 to v1.18.1**, which added two rules, `CFG115` and `CFG116`. No mapping changes. **Both new rules are left unmapped on purpose:** each makes the agent read a file on the reader's machine, which no record covers; the reason is under "Config surfaces beyond AVE's model". (v1.18.0 has no published binaries, its release job hung before upload; v1.18.1 is the same code.) -- `CFG113` (an instruction file that tells the agent to fetch remote text and follow it) maps to `AVE-2026-00001`, metamorphic payload via external config fetch. The class is new to this file. -- `CFG114` (committed OpenCode plugin or tool code) maps to `AVE-2026-00064`. Measured on opencode 1.18.18, the loader runs plugin files when OpenCode starts in the repository and tool files when it loads an agent, both with no prompt. +**The record set grew from 87 to 89.** `AVE-2026-00088` (an agent-written workspace file later run by a trusted component outside the sandbox) and `AVE-2026-00089` (a sandbox whose writable scope comes from a tool parameter the model sets) are both `runtime_observed`, so the static count stays at 61 and no gap is added. -**Six of the eight are left unmapped on purpose:** `CFG107`, `CFG108`, `CFG109`, `CFG110`, `CFG111` and `CFG112`. Four of them open new surfaces and two extend existing ones; reasons are under "Config surfaces beyond AVE's model". - -**The record set grew from 80 to 87.** `AVE-2026-00081`, `00083`, `00086` and `00087` are runtime records. `00082`, `00084` and `00085` are `static_detection` and are listed under "Gaps", each with the detail that keeps it from mapping; `00082` comes close to `CFG102`. - -**Both sides are pinned by commit**, so the counts can be re-derived. The cfgaudit tree read is the `v1.16.0` tag itself. +**Both sides are pinned by commit**, so the counts can be re-derived. The cfgaudit tree read is the `v1.18.1` tag itself. ## Rule mapping @@ -80,10 +75,11 @@ Seven of the eight surfaces listed here at v1.10.0 have since been closed by AVE | Two committed skills claiming one name | two `SKILL.md` files, load order decides | CFG102 | narrowed by `AVE-2026-00082`, not closed | | An **automated** security reviewer switched off or blunted | Codex `[features.guardianv2]` `enabled`, `review_threshold` | CFG103 | `AVE-2026-00063` is a bypassed **human**-approval step | | Repository grants browser or desktop-application access | Codex `[browser_use]` `full_cdp_access`, `[computer_use]` app access | CFG106 | not filed under `AVE-2026-00063` on purpose | -| A configuration-declared environment that loads code into every shell | Codex `shell_environment_policy.set`, Claude Code settings `env` with `BASH_ENV`, `LD_PRELOAD`, `NODE_OPTIONS` | CFG107, CFG110 | new | -| A committed scheduler that fires prompts on a timer | `.claude/scheduled_tasks.json` | CFG108 | new | -| A declared configuration that never loads | a Codex config that fails to parse, a Claude Code settings file the agent discards | CFG109, CFG012 | new | -| A committed credential source sent to a committed endpoint | MCP `bearer_token_file` | CFG112 | new | +| A configuration-declared environment that loads code into every shell | Codex `shell_environment_policy.set`, Claude Code settings `env` with `BASH_ENV`, `LD_PRELOAD`, `NODE_OPTIONS` | CFG107, CFG110 | added at v1.14.0 (CFG107) and v1.15.0 (CFG110) | +| A committed scheduler that fires prompts on a timer | `.claude/scheduled_tasks.json` | CFG108 | added at v1.14.0 | +| A declared configuration that never loads | a Codex config that fails to parse, a Claude Code settings file the agent discards | CFG109, CFG012 | added at v1.15.0 (CFG109; CFG012 is older) | +| A committed credential source sent to a committed endpoint | MCP `bearer_token_file` | CFG112 | added at v1.15.0 | +| A committed path that makes the agent read a file on the reader's machine | a config path that is a symbolic link leaving the repository; an `@~/...` or `../` import in `CLAUDE.md` | CFG115, CFG116 | new | ### Sandbox weakening, the one surface still open @@ -104,18 +100,22 @@ This is the last of the eight, and the reason it is still open is that the field **A repository granting browser or desktop access.** Codex's `[browser_use]` grants per-origin `access`, `downloads`, `uploads` and `full_cdp_access` (full Chrome DevTools Protocol, so script execution plus cookie and storage access inside the browser session), and `[computer_use]` grants control of desktop applications by bundle id, AUMID or executable. The value type is an enum of exactly `allow` and `deny`, so there is no ask state and `allow` is unambiguously the weakening direction. This is deliberately **not** filed under `AVE-2026-00063`: whether a prompt is skipped is unverified, because the value is observable in the effective config but the tools themselves live in the client app. Filing it there would assert more than the detection does. -### The four surfaces new in this revision +### Four surfaces added in the previous revision (v1.14.0 to v1.16.0) -**A configuration-declared environment that loads code into every shell (CFG107, CFG110).** Codex `shell_environment_policy.set` and the Claude Code settings `env` block both set the process environment of every shell command and hook the agent starts, so `BASH_ENV`, `LD_PRELOAD`, `NODE_OPTIONS` and similar run repository-chosen code in each of them. Measured for Claude Code: a project-scope `env` value reaches the hook process. The mechanism is the one `AVE-2026-00055` describes, but that record is bound to an untrusted MCP launch config. Stretching it onto the agent's own process environment would change what it asserts, so this is reported as a gap. +**A configuration-declared environment that loads code into every shell (CFG107 at v1.14.0, CFG110 at v1.15.0).** Codex `shell_environment_policy.set` and the Claude Code settings `env` block both set the process environment of every shell command and hook the agent starts, so `BASH_ENV`, `LD_PRELOAD`, `NODE_OPTIONS` and similar run repository-chosen code in each of them. Measured for Claude Code: a project-scope `env` value reaches the hook process. The mechanism is the one `AVE-2026-00055` describes, but that record is bound to an untrusted MCP launch config. Stretching it onto the agent's own process environment would change what it asserts, so this is reported as a gap. -**A committed scheduler that fires prompts on a timer (CFG108).** A committed `.claude/scheduled_tasks.json` self-enables Claude Code's otherwise-off cron scheduler and enqueues a repository-authored prompt on a schedule. `AVE-2026-00063` is a bypassed human-approval gate; this bypasses no gate, it opens a new autonomous channel. +**A committed scheduler that fires prompts on a timer (CFG108, v1.14.0).** A committed `.claude/scheduled_tasks.json` self-enables Claude Code's otherwise-off cron scheduler and enqueues a repository-authored prompt on a schedule. `AVE-2026-00063` is a bypassed human-approval gate; this bypasses no gate, it opens a new autonomous channel. -**A declared configuration that never loads (CFG109, and the discarded-file case of CFG012).** The repository states guardrails that are not in force, because the agent does not load the file. Measured in both directions: Codex logs `Invalid configuration; using defaults` and runs, and Claude Code silently discards a whole settings file, every deny rule and hook with it, on a top-level type error or on one unloadable `PreToolUse` or `PermissionRequest` hook entry. `AVE-2026-00083` is the nearest in effect, a protection that executes but never evaluates, but it is `runtime_observed` and scoped to a mechanism's own comparison logic. +**A declared configuration that never loads (CFG109 at v1.15.0, and the discarded-file case of the older CFG012).** The repository states guardrails that are not in force, because the agent does not load the file. Measured in both directions: Codex logs `Invalid configuration; using defaults` and runs, and Claude Code silently discards a whole settings file, every deny rule and hook with it, on a top-level type error or on one unloadable `PreToolUse` or `PermissionRequest` hook entry. `AVE-2026-00083` is the nearest in effect, a protection that executes but never evaluates, but it is `runtime_observed` and scoped to a mechanism's own comparison logic. -**A committed credential source sent to a committed endpoint (CFG112).** An MCP server's `bearer_token_file` names a file on the reader's machine whose content is sent as a bearer token to the server URL in the same committed file. `AVE-2026-00047` is a credential written into the component; here the file names where to read one and where to send it. +**A committed credential source sent to a committed endpoint (CFG112, v1.15.0).** An MCP server's `bearer_token_file` names a file on the reader's machine whose content is sent as a bearer token to the server URL in the same committed file. `AVE-2026-00047` is a credential written into the component; here the file names where to read one and where to send it. `CFG111` extends the "guardrail that does not hold" row: an invisible character such as a zero-width space inside a permission rule or hook matcher leaves the rule present and matching nothing. Like `CFG101`, it is an ineffective control rather than an attacker behaviour. +### The surface new in this revision + +**A committed path that makes the agent read a file on the reader's machine (CFG115, CFG116).** `CFG115`: a committed config path (`.claude/settings.json`, `.mcp.json`, `CLAUDE.md`, a skill) is a symbolic link, or runs through a linked directory, that resolves outside the repository, so the agent loads a file from the machine that checked the repository out. `CFG116`: an `@path` import in a committed `CLAUDE.md`, `AGENTS.md` or `.claude/rules` file resolves outside the repository (`@~/...`, an absolute path elsewhere, `../` past the root). Measured with Claude Code 2.1.310, such an import is not loaded in print mode, and an interactive session loads it only after a once-per-project dialog that lists the files. In both cases the repository chooses which local file the agent reads. A reviewer sees the link or the import line, but not the content of the file it reaches. The nearest records do not cover this: `AVE-2026-00001` is a fetch from a remote source, `AVE-2026-00047` a credential written into the component, `AVE-2026-00053` a path traversal in an MCP tool's own path validation, and `AVE-2026-00089` a sandbox whose writable scope comes from a tool parameter (`runtime_observed`). + ### The three deliberately unmapped rules - **CFG100**, Grok `[plugins]`. `AVE-2026-00064` would be the class, but it requires the loader to run plugin code at project load with no prompt. That is not verified against the shipped Grok build, so mapping it would assert a mechanism nobody measured. diff --git a/dist/ave-records-latest.json b/dist/ave-records-latest.json index 612bd83..84fc17c 100644 --- a/dist/ave-records-latest.json +++ b/dist/ave-records-latest.json @@ -11613,6 +11613,13 @@ "external_comms" ] }, + "security_boundary": "single server's trust boundary → multiple, unrelated actual origins within it", + "missing_control": "no per-origin trust segmentation within one server's own declared manifest", + "vulnerability_rationale": { + "capability": "A single MCP server's manifest can declare tools/resources whose URLs span multiple distinct domains.", + "vulnerability": "No per-origin trust segmentation exists within one server's declared surface.", + "impact": "A minority-domain tool or resource can hijack context intended for the trusted majority origin, with no deception required." + }, "mitigation": { "strategy": [ "isolate_scope", @@ -11639,7 +11646,7 @@ "researcher": "Akash Krishna (akashjavelin)", "researcher_url": "https://github.com/akashjavelin", "published": "2026-08-10T00:00:00Z", - "last_updated": "2026-08-10T00:00:00Z", + "last_updated": "2026-10-31T00:00:00Z", "references": [ { "tag": "Ramparts cross-origin scanner", @@ -11756,6 +11763,13 @@ "untrusted_content" ] }, + "security_boundary": "sub-agent result (unverified) → orchestrator's accepted ground truth (propagated downstream as though verified)", + "missing_control": "no quorum vote, redundancy comparison, or independent corroboration before committing to a single source's claim", + "vulnerability_rationale": { + "capability": "An orchestrator can dispatch sub-tasks to parallel sub-agents or accept results from a delegation chain.", + "vulnerability": "No quorum or redundancy check exists at the result-aggregation step of a multi-agent pipeline; a single compromised or adversarially-influenced source unilaterally determines the accepted output.", + "impact": "A single compromised or adversarially-influenced sub-agent unilaterally determines the pipeline's accepted output, which propagates downstream as though it had been verified." + }, "mitigation": { "strategy": [ "validate_input", @@ -11782,7 +11796,7 @@ "researcher": "Faisal Haque Bappy, Tahrim Hossain, Tarannum Shaila Zaman, Raiful Hasan, Kamrul Hasan, Tariqul Islam", "researcher_url": "https://arxiv.org/abs/2608.00718", "published": "2026-08-14T00:00:00Z", - "last_updated": "2026-08-14T00:00:00Z", + "last_updated": "2026-10-31T00:00:00Z", "references": [ { "tag": "arXiv:2608.00718", @@ -11891,6 +11905,13 @@ "untrusted_content" ] }, + "security_boundary": "declared plan (N steps) → actual executed trace (fewer steps, unverified)", + "missing_control": "no plan-to-execution-trace verification gates a self-reported completion signal", + "vulnerability_rationale": { + "capability": "An orchestrator tracks a declared plan and invokes a final step based on agent-reported status.", + "vulnerability": "No verification compares the actual executed step count against the declared plan before accepting a self-reported completion signal.", + "impact": "Remaining planned steps, including verification steps, are silently skipped." + }, "mitigation": { "strategy": [ "validate_input", @@ -11917,7 +11938,7 @@ "researcher": "Faisal Haque Bappy, Tahrim Hossain, Tarannum Shaila Zaman, Raiful Hasan, Kamrul Hasan, Tariqul Islam", "researcher_url": "https://arxiv.org/abs/2608.00718", "published": "2026-08-14T00:00:00Z", - "last_updated": "2026-08-14T00:00:00Z", + "last_updated": "2026-10-31T00:00:00Z", "references": [ { "tag": "arXiv:2608.00718", @@ -12027,6 +12048,13 @@ "untrusted_content" ] }, + "security_boundary": "routing position (observable, spoofable) → agent identity (should be credential-bound, is not)", + "missing_control": "no attestation or session-token check confirming a post-retry response originates from the same agent instance as before the failure", + "vulnerability_rationale": { + "capability": "An agent's identity in a multi-agent pipeline is inferred from its routing position rather than a bound credential.", + "vulnerability": "No persistent, verifiable credential binds identity to a routing position; identity is positional, not cryptographic, so anything answering at the expected slot inherits its full existing trust.", + "impact": "An adversary-controlled process that answers at the expected routing position is silently accepted as the legitimate agent, and its adversarial payload propagates downstream with the full trust the legitimate agent would have received." + }, "mitigation": { "strategy": [ "verify_identity", @@ -12053,7 +12081,7 @@ "researcher": "Faisal Haque Bappy, Tahrim Hossain, Tarannum Shaila Zaman, Raiful Hasan, Kamrul Hasan, Tariqul Islam", "researcher_url": "https://arxiv.org/abs/2608.00718", "published": "2026-08-14T00:00:00Z", - "last_updated": "2026-08-14T00:00:00Z", + "last_updated": "2026-10-31T00:00:00Z", "references": [ { "tag": "arXiv:2608.00718", diff --git a/dist/ave-records-latest.manifest.json b/dist/ave-records-latest.manifest.json index 7504000..60d533d 100644 --- a/dist/ave-records-latest.manifest.json +++ b/dist/ave-records-latest.manifest.json @@ -1,7 +1,7 @@ { "schema_version": "1.3.0", "record_count": 89, - "generated_at": "2026-10-31T14:48:01.286Z", + "generated_at": "2026-11-04T13:46:15.467Z", "source": "https://github.com/aveproject/ave", - "sha256": "2ddd54c181615e2e4fe07fb902b5169b9396ec10a61fe7bc05cface2c411549c" + "sha256": "ef927adae8c9deca9361808f60869d1bfa6e65622de60c5c6c9e4bbcb82cb4ab" } diff --git a/docs/specs/ave-implementer-guide.md b/docs/specs/ave-implementer-guide.md index 98237ac..edc833a 100644 --- a/docs/specs/ave-implementer-guide.md +++ b/docs/specs/ave-implementer-guide.md @@ -35,13 +35,15 @@ comes first. ## Pin and verify a release A catalog release is a [GitHub Release](https://github.com/aveproject/ave/releases) -tagged `vX.Y.Z`. It never changes after it is published, and carries three files: +tagged `vX.Y.Z`. It never changes after it is published, and carries three files, +and from v1.3.0 a fourth: - `ave-records.json`: every published record, whatever its status, except drafts. - `ave-records.manifest.json`: what the release is (below). -- `SHA256SUMS`: checksums of both files. +- `ave-taxonomy.json` (from v1.3.0): the SARIF taxonomy of exactly those records. +- `SHA256SUMS`: checksums of the data files. -The first two also carry a build provenance attestation, signed by the workflow +The data files also carry a build provenance attestation, signed by the workflow that built them. `v1.2.0` is the first release with these files. Download a release and verify it. This needs network access, `curl`, `git`, @@ -78,6 +80,19 @@ What each check proves: `aveproject/ave`. This is the check that does not trust where you downloaded from. A file that differs by a single byte has no attestation and fails. +From v1.3.0 the release also carries `ave-taxonomy.json`, the AVE taxonomy for the SARIF you +emit ([AVE-in-SARIF](ave-in-sarif.md)), already stamped with the release's version, date and +download link. Download it with the other files; `sha256sum -c SHA256SUMS` then covers three +files, `check_release_assets.py` regenerates the taxonomy from the release's records and refuses a +release whose copy differs by a byte (pass `--repo owner/name` to verify a fork's release), and +the attestation check takes it too: + +```bash +curl -fsSL -o "ave-$TAG/ave-taxonomy.json" "https://github.com/aveproject/ave/releases/download/$TAG/ave-taxonomy.json" +gh attestation verify "ave-$TAG/ave-taxonomy.json" --repo aveproject/ave \ + --signer-workflow aveproject/ave/.github/workflows/catalog-release.yml +``` + The manifest of `v1.2.0`: ```json diff --git a/docs/specs/ave-in-sarif.md b/docs/specs/ave-in-sarif.md index 98f8918..c664c45 100644 --- a/docs/specs/ave-in-sarif.md +++ b/docs/specs/ave-in-sarif.md @@ -179,7 +179,7 @@ Add one taxon per AVE record that appears in the scan results. Scanners may incl python3 scripts/build_taxonomy.py --only AVE-2026-00001,AVE-2026-00075 --out - ``` -The release-stamped form, for a scanner pinned to a catalog release, takes that release's records file and its facts: `python3 scripts/build_taxonomy.py --records ave-records.json --release v1.2.0 --release-date 2026-10-29 --download-uri --out -`. The file is not yet a catalog-release asset. A taxon's `id` and `name` are the record's `ave_id` and `title` exactly, and the checker compares them with the catalog (`taxon_unknown`, `taxon_name_differs`). +The release-stamped form, for a scanner pinned to a catalog release, takes that release's records file and its facts: `python3 scripts/build_taxonomy.py --records ave-records.json --release v1.2.0 --release-date 2026-10-29 --download-uri --out -`. From v1.3.0 every catalog release carries the same file as an asset, `ave-taxonomy.json`: the pinned copy a tool should embed, already stamped with the release's `version`, `semanticVersion`, `releaseDateUtc` and `downloadUri` (`https://github.com/aveproject/ave/releases/download/vX.Y.Z/ave-taxonomy.json`, or `https://github.com/aveproject/ave/releases/latest/download/ave-taxonomy.json` for the newest), listed in the release's `SHA256SUMS`, attested, and checked byte for byte against the release's own records by `scripts/check_release_assets.py`. `dist/ave-taxonomy-latest.json` is the moving copy, and `latest` has no `version`. A taxon's `id` and `name` are the record's `ave_id` and `title` exactly, and the checker compares them with the catalog (`taxon_unknown`, `taxon_name_differs`). --- diff --git a/docs/specs/scaling-and-governance.md b/docs/specs/scaling-and-governance.md index 34cb089..9381980 100644 --- a/docs/specs/scaling-and-governance.md +++ b/docs/specs/scaling-and-governance.md @@ -293,8 +293,12 @@ The two are numbered separately. - `ave-records.manifest.json`: `release`, `schema_version`, `record_count`, `generated_at`, `source`, `sha256` (of `ave-records.json`) and `source_commit`, the tagged commit. -- `SHA256SUMS`: both files, in `sha256sum` format. -- A build provenance attestation for both files, checked with +- `ave-taxonomy.json` (from v1.3.0): the SARIF taxonomy (see + `docs/specs/ave-in-sarif.md`) of exactly this release's records, built by + `scripts/build_taxonomy.py` from `ave-records.json`, with the release's own + version, the manifest's `generated_at` date and its own download URL. +- `SHA256SUMS`: the data files, in `sha256sum` format (all three from v1.3.0). +- A build provenance attestation for each data file, checked with `gh attestation verify --repo aveproject/ave`. Asset names carry no version; the tag does. `scripts/check_release_assets.py` @@ -302,6 +306,20 @@ checks the files against each other, against the tag and against the tagged comm The release workflow runs it before publishing, and an implementer can run it after downloading (README, "Releases and pinning"). +**The taxonomy asset.** The verifier regenerates `ave-taxonomy.json` from the +release's own `ave-records.json` and demands a byte match, so no field, taxon or +character can differ from what the generator produces; a failure names the field +or taxon first. Releases from v1.3.0 must carry it and a release without it fails +verification; v1.0.0 to v1.2.0 shipped before it existed and verify without it. +That makes the order mechanical: a release cut by a workflow that does not build +the taxonomy fails its own verification, so the change that builds it has to merge +before v1.3.0 is cut. Its `downloadUri` must point at the repository it is +published under (`--repo owner/name`, default `aveproject/ave`), so a fork's +release is verified against the fork. Stable URLs: +`https://github.com/aveproject/ave/releases/download/vX.Y.Z/ave-taxonomy.json` +and `https://github.com/aveproject/ave/releases/latest/download/ave-taxonomy.json`. +`dist/ave-taxonomy-latest.json` is the moving copy and has no `version`. + **Cadence:** a release when records have changed, at most monthly unless a correction is urgent. This is the maintainer's policy, and the maintainer can change it. diff --git a/scripts/check_release_assets.py b/scripts/check_release_assets.py index acc09fa..fd97530 100644 --- a/scripts/check_release_assets.py +++ b/scripts/check_release_assets.py @@ -1,5 +1,5 @@ -# What: verifies the three files of an AVE catalog release (ave-records.json, -# ave-records.manifest.json, SHA256SUMS) against each other, against the +# What: verifies the files of an AVE catalog release (ave-records.json, +# ave-records.manifest.json, ave-taxonomy.json, SHA256SUMS) against each other, against the # release tag and against the commit the release was built from. # Why: a catalog release is what implementers pin to, so each file must # describe the others exactly: a checksum that no longer matches, a @@ -8,7 +8,10 @@ # catalog-release workflow before anything is published and on an # implementer's machine after download. # How: python scripts/check_release_assets.py --tag vX.Y.Z -# [--git-dir | --commit ] +# [--git-dir | --commit ] [--repo owner/name] +# ave-taxonomy.json is regenerated from ave-records.json with +# scripts/build_taxonomy.py and must match it byte for byte; releases from +# v1.3.0 on must carry it, earlier releases need not. # --git-dir checks source_commit against the commit points at in # that repository; --commit checks it against a given commit instead (the # workflow, which runs before the tag exists). Exit 0 when every check @@ -23,9 +26,19 @@ import sys from pathlib import Path +try: # imported as scripts.check_release_assets (tests) or run as a script from scripts/ + from scripts import build_taxonomy as bt +except ImportError: + import build_taxonomy as bt + RECORDS_FILE = "ave-records.json" MANIFEST_FILE = "ave-records.manifest.json" SUMS_FILE = "SHA256SUMS" +TAXONOMY_FILE = "ave-taxonomy.json" +# The first release that carries the SARIF taxonomy. A release from this tag on without it is refused; +# an earlier one (v1.0.0 to v1.2.0 shipped before it existed) may omit it. +TAXONOMY_FROM = (1, 3, 0) +DEFAULT_REPO = "aveproject/ave" TAG_PATTERN = re.compile(r"v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)") COMMIT_PATTERN = re.compile(r"[0-9a-f]{40}") # sha256sum's own output: digest, a space, then " " (text) or "*" (binary), then the name @@ -44,7 +57,7 @@ def sha256_of(path: Path) -> str: # must cover both release files and match each byte for byte # How: parse each line in sha256sum format; refuse malformed lines and names # that leave the directory; compare each digest; require both files listed -def check_sums(directory: Path) -> list[str]: +def check_sums(directory: Path, extra_required=()) -> list[str]: sums = directory / SUMS_FILE if not sums.is_file(): return [f"{SUMS_FILE} is missing"] @@ -67,7 +80,7 @@ def check_sums(directory: Path) -> list[str]: errors.append(f"{SUMS_FILE} lists {name}, which is missing") elif sha256_of(target) != digest: errors.append(f"{SUMS_FILE}: {name} does not match its checksum") - for required in (RECORDS_FILE, MANIFEST_FILE): + for required in (RECORDS_FILE, MANIFEST_FILE, *extra_required): if required not in listed: errors.append(f"{SUMS_FILE} does not list {required}") return errors @@ -140,18 +153,69 @@ def check_manifest(directory: Path, tag: str, git_dir: Path | None, commit: str return errors +# What: whether a release must carry the taxonomy, from its tag +# Why: releases before the taxonomy existed stay valid; from TAXONOMY_FROM on it is part of the release +# How: compare the tag's (major, minor, patch) with TAXONOMY_FROM; a malformed tag requires nothing here (the tag check reports it) +def taxonomy_required(tag: str) -> bool: + match = TAG_PATTERN.fullmatch(tag) + return bool(match) and tuple(int(g) for g in match.groups()) >= TAXONOMY_FROM + + +# What: every way ave-taxonomy.json disagrees with the release it ships in +# Why: tools copy this file into their SARIF output, so a wrong guid, version, link or taxon would be copied +# with it; the only trustworthy file is the one the generator produces from the release's own records +# How: regenerate it from ave-records.json with the tag, the manifest's date and the asset's own URL, and +# compare byte for byte; when it differs, name the fields and taxa that differ before the generic message +def check_taxonomy(directory: Path, tag: str, repo: str = DEFAULT_REPO) -> list[str]: + path = directory / TAXONOMY_FILE + if not path.is_file(): + return [f"{TAXONOMY_FILE} is missing (releases from v{'.'.join(map(str, TAXONOMY_FROM))} carry it)"] if taxonomy_required(tag) else [] + try: + records = json.loads((directory / RECORDS_FILE).read_text(encoding="utf-8")) + manifest = json.loads((directory / MANIFEST_FILE).read_text(encoding="utf-8")) + actual_text = path.read_text(encoding="utf-8") + actual = json.loads(actual_text) + except (OSError, ValueError) as exc: + return [f"{TAXONOMY_FILE} cannot be checked, a release file is unreadable or not JSON: {exc}"] + if not (isinstance(records, list) and isinstance(manifest, dict) and isinstance(actual, dict) and TAG_PATTERN.fullmatch(tag)): + return [f"{TAXONOMY_FILE} cannot be checked against malformed records, manifest or tag"] + date = str(manifest.get("generated_at", ""))[:10] + uri = f"https://github.com/{repo}/releases/download/{tag}/{TAXONOMY_FILE}" + try: + expected = bt.build(records, release=tag, release_date=date, download_uri=uri) + except (ValueError, KeyError, TypeError) as exc: + return [f"{TAXONOMY_FILE} cannot be regenerated from {RECORDS_FILE}: {exc}"] + if actual_text == bt.dumps(expected): + return [] + errors = [] + for key in ("guid", "name", "version", "semanticVersion", "releaseDateUtc", "downloadUri", "isComprehensive"): + if actual.get(key) != expected.get(key): + errors.append(f"{TAXONOMY_FILE} {key} is {actual.get(key)!r}, expected {expected.get(key)!r}") + have = {t.get("id"): t for t in actual.get("taxa", []) if isinstance(t, dict)} + want = {t["id"]: t for t in expected["taxa"]} + for missing in sorted(set(want) - set(have)): + errors.append(f"{TAXONOMY_FILE} has no taxon for {missing}, which is in {RECORDS_FILE}") + for extra in sorted(set(have) - set(want)): + errors.append(f"{TAXONOMY_FILE} has a taxon for {extra}, which is not in {RECORDS_FILE}") + for common in sorted(set(have) & set(want)): + if have[common] != want[common]: + errors.append(f"{TAXONOMY_FILE} taxon {common} is not what {RECORDS_FILE} produces") + return errors or [f"{TAXONOMY_FILE} is not what scripts/build_taxonomy.py produces from {RECORDS_FILE} (same content, different bytes)"] + + # What: every check on one release directory, in the order they are reported # Why: main() and the tests both call this, so a check dropped from the # pipeline fails a test instead of passing silently -# How: the tag's form, then SHA256SUMS, then the manifest -def release_errors(directory: Path, tag: str, git_dir: Path | None = None, commit: str | None = None) -> list[str]: +# How: the tag's form, then SHA256SUMS, then the manifest, then the taxonomy +def release_errors(directory: Path, tag: str, git_dir: Path | None = None, commit: str | None = None, repo: str = DEFAULT_REPO) -> list[str]: errors = [] if not TAG_PATTERN.fullmatch(tag): errors.append(f"tag {tag!r} is not vX.Y.Z") if commit is not None and not COMMIT_PATTERN.fullmatch(commit): errors.append(f"--commit {commit!r} is not a 40-character commit SHA") commit = None - return errors + check_sums(directory) + check_manifest(directory, tag, git_dir, commit) + needs = (TAXONOMY_FILE,) if (directory / TAXONOMY_FILE).is_file() or taxonomy_required(tag) else () + return errors + check_sums(directory, needs) + check_manifest(directory, tag, git_dir, commit) + check_taxonomy(directory, tag, repo) def main(argv=None) -> int: @@ -163,9 +227,10 @@ def main(argv=None) -> int: source = parser.add_mutually_exclusive_group() source.add_argument("--git-dir", type=Path, help="a clone of aveproject/ave; source_commit must be the tag's commit there") source.add_argument("--commit", help="source_commit must be this full commit SHA (before the tag exists)") + parser.add_argument("--repo", default=DEFAULT_REPO, help="owner/name the release is published under; the taxonomy's downloadUri must point there") args = parser.parse_args(argv) - errors = release_errors(args.directory, args.tag, args.git_dir, args.commit) + errors = release_errors(args.directory, args.tag, args.git_dir, args.commit, args.repo) if errors: for error in errors: print(error) @@ -174,9 +239,14 @@ def main(argv=None) -> int: manifest = json.loads((args.directory / MANIFEST_FILE).read_text(encoding="utf-8")) checked = "the tag's commit" if args.git_dir else ("the given commit" if args.commit else "its form only") + taxonomy = ( + f" ave-taxonomy.json regenerated from the records and matches ({manifest['record_count']} taxa)." + if (args.directory / TAXONOMY_FILE).is_file() + else "" + ) print( f"AVE {args.tag}: {manifest['record_count']} records on schema {manifest['schema_version']}, " - f"checksums match, source_commit {manifest['source_commit']} (checked against {checked})." + f"checksums match, source_commit {manifest['source_commit']} (checked against {checked})." + taxonomy ) return 0 diff --git a/tests/test_catalog_release_workflow.py b/tests/test_catalog_release_workflow.py new file mode 100644 index 0000000..823dfe6 --- /dev/null +++ b/tests/test_catalog_release_workflow.py @@ -0,0 +1,60 @@ +"""The catalog-release workflow builds, verifies, attests and uploads the taxonomy with the other release files.""" + +import re +from pathlib import Path + +WORKFLOW = Path(".github/workflows/catalog-release.yml") + + +def steps(): + text = WORKFLOW.read_text(encoding="utf-8") + parts = re.split(r"(?m)^ - name: ", text)[1:] + return {p.split("\n", 1)[0].strip(): p for p in parts} + + +def step(prefix): + found = [body for name, body in steps().items() if name.startswith(prefix)] + assert len(found) == 1, (prefix, len(found)) + return found[0] + + +def test_the_header_says_the_release_carries_the_taxonomy(): + head = WORKFLOW.read_text(encoding="utf-8").split("\non:")[0] + assert "ave-taxonomy.json" in head and "three data files" in head + + +def test_the_taxonomy_is_built_from_the_release_records_with_the_tag_the_date_and_the_assets_own_url(): + body = step("Build the release files") + assert "python3 scripts/build_taxonomy.py" in body + for needle in ('--records release/ave-records.json', '--release "v$VERSION"', "jq -r '.generated_at[:10]' release/ave-records.manifest.json", + '--download-uri "https://github.com/$GITHUB_REPOSITORY/releases/download/v$VERSION/ave-taxonomy.json"', "--out release/ave-taxonomy.json"): + assert needle in body, needle + + +def test_the_taxonomy_is_built_before_the_checksums_and_the_checksums_cover_it(): + body = step("Build the release files") + assert body.index("build_taxonomy.py") < body.index("sha256sum") + assert "sha256sum ave-records.json ave-records.manifest.json ave-taxonomy.json > SHA256SUMS" in body + + +def test_the_verifier_is_told_which_repository_the_release_is_published_under(): + body = step("Verify the release files") + assert "scripts/check_release_assets.py release" in body and '--repo "$GITHUB_REPOSITORY"' in body and '--tag "v$VERSION"' in body + + +def test_the_taxonomy_is_attested_with_the_other_data_files(): + body = step("Attest build provenance") + for f in ("release/ave-records.json", "release/ave-records.manifest.json", "release/ave-taxonomy.json"): + assert f in body, f + + +def test_the_taxonomy_and_the_checksums_are_uploaded_with_the_release(): + body = step("Create the release") + assert "release/ave-taxonomy.json" in body and "release/SHA256SUMS" in body and "release/ave-records.json" in body + + +def test_no_run_step_interpolates_an_input_directly(): + # inputs reach the shell through env (VERSION, DRAFT) so a crafted input cannot inject commands + text = WORKFLOW.read_text(encoding="utf-8") + runs = re.findall(r"(?ms)^\s+run: (.*?)(?=^\s+- name:|\Z)", text) + assert runs and not any("${{ inputs." in r for r in runs) diff --git a/tests/test_check_release_assets.py b/tests/test_check_release_assets.py index 41ca37b..82f27aa 100644 --- a/tests/test_check_release_assets.py +++ b/tests/test_check_release_assets.py @@ -260,17 +260,17 @@ def test_source_commit_from_another_commit_is_refused_against_the_tag(tmp_path, def test_a_tag_missing_from_the_repository_is_refused(tmp_path, tagged_repo): repo, tagged, _, _ = tagged_repo - release = _release(tmp_path / "release", commit=tagged, release="v9.9.9") + release = _release(tmp_path / "release", commit=tagged, release="v1.2.9") - assert cra.release_errors(release, "v9.9.9", git_dir=repo) == [f"tag v9.9.9 was not found in {repo}"] + assert cra.release_errors(release, "v1.2.9", git_dir=repo) == [f"tag v1.2.9 was not found in {repo}"] def test_a_branch_named_like_the_tag_does_not_stand_in_for_it(tmp_path, tagged_repo): repo, tagged, later, _ = tagged_repo - _git(repo, "branch", "v9.9.9") # a branch, at the later commit, with a tag's name - release = _release(tmp_path / "release", commit=later, release="v9.9.9") + _git(repo, "branch", "v1.2.9") # a branch, at the later commit, with a tag's name + release = _release(tmp_path / "release", commit=later, release="v1.2.9") - assert cra.release_errors(release, "v9.9.9", git_dir=repo) == [f"tag v9.9.9 was not found in {repo}"] + assert cra.release_errors(release, "v1.2.9", git_dir=repo) == [f"tag v1.2.9 was not found in {repo}"] # --- the command line --------------------------------------------------------------- diff --git a/tests/test_release_taxonomy.py b/tests/test_release_taxonomy.py new file mode 100644 index 0000000..2983e25 --- /dev/null +++ b/tests/test_release_taxonomy.py @@ -0,0 +1,182 @@ +"""scripts/check_release_assets.py and the taxonomy asset: a release carries the file the generator produces from its own records.""" + +import hashlib +import json +from pathlib import Path + +import pytest + +from scripts import build_taxonomy as bt +from scripts import check_release_assets as cra + +COMMIT = "b" * 40 +REPO = "aveproject/ave" + + +def _sha(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _records(): + return [ + {"ave_id": "AVE-2026-00001", "title": "Class one", "status": "active", "severity": "HIGH", "schema_version": "1.3.0", + "detection_stage": "static_detection", "attack_class": "A - one", "behavioral_fingerprint": "One thing.", "aivss": {"aivss_score": 7.5}}, + {"ave_id": "AVE-2026-00002", "title": "Class two", "status": "rejected", "severity": "MEDIUM", "schema_version": "1.3.0", + "detection_stage": "runtime_observed", "attack_class": "A - two", "behavioral_fingerprint": "Two things.", "aivss": {"aivss_score": 5.0}, + "rejection_reason": "https://example.org/decision"}, + ] + + +def release(directory: Path, tag="v1.3.0", taxonomy=True, sums=True, records=None, **override): + """A consistent release built the way the workflow builds one; `override` replaces fields of the taxonomy.""" + directory.mkdir(parents=True, exist_ok=True) + records = _records() if records is None else records + rpath = directory / "ave-records.json" + rpath.write_text(json.dumps(records, indent=2) + "\n", encoding="utf-8") + manifest = {"release": tag, "schema_version": "1.3.0", "record_count": len(records), "generated_at": "2026-11-05T09:30:00.000Z", + "source": "https://github.com/aveproject/ave", "sha256": _sha(rpath), "source_commit": COMMIT} + (directory / "ave-manifest.tmp").write_text("", encoding="utf-8") + (directory / "ave-manifest.tmp").unlink() + (directory / "ave-records.manifest.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + names = ["ave-records.json", "ave-records.manifest.json"] + if taxonomy: + comp = bt.build(records, release=tag, release_date="2026-11-05", download_uri=f"https://github.com/{REPO}/releases/download/{tag}/ave-taxonomy.json") + comp.update(override) + (directory / "ave-taxonomy.json").write_text(bt.dumps(comp), encoding="utf-8") + names.append("ave-taxonomy.json") + if sums: + (directory / "SHA256SUMS").write_text("".join(f"{_sha(directory / n)} {n}\n" for n in names), encoding="utf-8") + return directory + + +def errors(directory, tag="v1.3.0", **kw): + return cra.release_errors(directory, tag, commit=COMMIT, **kw) + + +def test_a_consistent_release_with_its_taxonomy_passes(tmp_path): + assert errors(release(tmp_path)) == [] + + +def test_a_release_from_v1_3_0_without_the_taxonomy_is_refused(tmp_path): + out = errors(release(tmp_path, taxonomy=False)) + assert any("ave-taxonomy.json is missing" in e for e in out) + + +@pytest.mark.parametrize("tag,required", [("v1.2.9", False), ("v1.3.0", True), ("v1.3.1", True), ("v2.0.0", True), ("v1.10.0", True), ("v0.9.9", False)]) +def test_the_taxonomy_is_required_from_the_first_taxonomy_release_on(tmp_path, tag, required): + out = errors(release(tmp_path, tag=tag, taxonomy=False), tag=tag) + assert any("ave-taxonomy.json is missing" in e for e in out) is required + + +def test_an_earlier_release_with_no_taxonomy_still_passes(tmp_path): + assert errors(release(tmp_path, tag="v1.2.0", taxonomy=False), tag="v1.2.0") == [] + + +def test_an_earlier_release_that_has_a_taxonomy_is_checked_like_any_other(tmp_path): + assert errors(release(tmp_path, tag="v1.2.0"), tag="v1.2.0") == [] + out = errors(release(tmp_path / "bad", tag="v1.2.0", version="9.9.9"), tag="v1.2.0") + assert any("version" in e for e in out) + + +def test_a_taxonomy_missing_from_sha256sums_is_refused(tmp_path): + d = release(tmp_path) + (d / "SHA256SUMS").write_text("".join(f"{_sha(d / n)} {n}\n" for n in ("ave-records.json", "ave-records.manifest.json")), encoding="utf-8") + assert "SHA256SUMS does not list ave-taxonomy.json" in errors(d) + + +def test_a_taxonomy_changed_after_its_checksum_is_refused(tmp_path): + d = release(tmp_path) + (d / "ave-taxonomy.json").write_text((d / "ave-taxonomy.json").read_text(encoding="utf-8") + " ", encoding="utf-8") + assert "SHA256SUMS: ave-taxonomy.json does not match its checksum" in errors(d) + + +@pytest.mark.parametrize("field,value", [("version", "9.9.9"), ("semanticVersion", "9.9.9"), ("releaseDateUtc", "2020-01-01"), ("guid", "00000000-0000-4000-8000-000000000000"), + ("downloadUri", "https://example.org/ave-taxonomy.json"), ("isComprehensive", False), ("name", "Other")]) +def test_a_taxonomy_header_field_that_is_not_the_releases_is_refused(tmp_path, field, value): + out = errors(release(tmp_path, **{field: value})) + assert any(e.startswith(f"ave-taxonomy.json {field} is {value!r}") for e in out), out + + +def test_a_taxonomy_that_points_at_another_repository_is_refused(tmp_path): + out = errors(release(tmp_path), repo="someone/fork") + assert any("downloadUri" in e for e in out) + assert errors(release(tmp_path / "ok", ), repo=REPO) == [] + + +def test_a_missing_taxon_is_refused_and_named(tmp_path): + d = release(tmp_path) + comp = json.loads((d / "ave-taxonomy.json").read_text(encoding="utf-8")); comp["taxa"].pop() + (d / "ave-taxonomy.json").write_text(bt.dumps(comp), encoding="utf-8") + (d / "SHA256SUMS").write_text("".join(f"{_sha(d / n)} {n}\n" for n in ("ave-records.json", "ave-records.manifest.json", "ave-taxonomy.json")), encoding="utf-8") + assert "ave-taxonomy.json has no taxon for AVE-2026-00002, which is in ave-records.json" in errors(d) + + +def test_an_extra_taxon_is_refused_and_named(tmp_path): + d = release(tmp_path) + comp = json.loads((d / "ave-taxonomy.json").read_text(encoding="utf-8")); comp["taxa"].append({"id": "AVE-2026-00099", "name": "Invented"}) + (d / "ave-taxonomy.json").write_text(bt.dumps(comp), encoding="utf-8") + (d / "SHA256SUMS").write_text("".join(f"{_sha(d / n)} {n}\n" for n in ("ave-records.json", "ave-records.manifest.json", "ave-taxonomy.json")), encoding="utf-8") + assert "ave-taxonomy.json has a taxon for AVE-2026-00099, which is not in ave-records.json" in errors(d) + + +def test_a_taxon_that_is_not_what_its_record_produces_is_refused(tmp_path): + d = release(tmp_path) + comp = json.loads((d / "ave-taxonomy.json").read_text(encoding="utf-8")); comp["taxa"][0]["name"] = "Renamed" + (d / "ave-taxonomy.json").write_text(bt.dumps(comp), encoding="utf-8") + (d / "SHA256SUMS").write_text("".join(f"{_sha(d / n)} {n}\n" for n in ("ave-records.json", "ave-records.manifest.json", "ave-taxonomy.json")), encoding="utf-8") + assert "ave-taxonomy.json taxon AVE-2026-00001 is not what ave-records.json produces" in errors(d) + + +def test_the_same_content_in_other_bytes_is_refused(tmp_path): + d = release(tmp_path) + comp = json.loads((d / "ave-taxonomy.json").read_text(encoding="utf-8")) + (d / "ave-taxonomy.json").write_text(json.dumps(comp, indent=4), encoding="utf-8") + (d / "SHA256SUMS").write_text("".join(f"{_sha(d / n)} {n}\n" for n in ("ave-records.json", "ave-records.manifest.json", "ave-taxonomy.json")), encoding="utf-8") + assert any("same content, different bytes" in e for e in errors(d)) + + +def test_a_taxonomy_that_is_not_json_is_refused_not_crashed_on(tmp_path): + d = release(tmp_path) + (d / "ave-taxonomy.json").write_text("{not json", encoding="utf-8") + (d / "SHA256SUMS").write_text("".join(f"{_sha(d / n)} {n}\n" for n in ("ave-records.json", "ave-records.manifest.json", "ave-taxonomy.json")), encoding="utf-8") + assert any("cannot be checked" in e for e in errors(d)) + + +def _with_stand_in_taxonomy(directory: Path, records): + """A release whose taxonomy was written by some other tool, because the generator refuses these records.""" + release(directory, taxonomy=False, records=records) + (directory / "ave-taxonomy.json").write_text("{}\n", encoding="utf-8") + (directory / "SHA256SUMS").write_text("".join(f"{_sha(directory / n)} {n}\n" for n in ("ave-records.json", "ave-records.manifest.json", "ave-taxonomy.json")), encoding="utf-8") + return directory + + +def test_records_the_generator_cannot_use_are_reported_not_crashed_on(tmp_path): + out = errors(_with_stand_in_taxonomy(tmp_path / "dup", _records() + [_records()[0]])) + assert any("cannot be regenerated" in e and "duplicate" in e for e in out) + out = errors(_with_stand_in_taxonomy(tmp_path / "bad", [{"schema_version": "1.3.0"}])) + assert any("cannot be regenerated" in e for e in out) + + +def test_main_passes_a_consistent_release_and_names_what_it_checked(tmp_path, capsys): + d = release(tmp_path) + assert cra.main([str(d), "--tag", "v1.3.0", "--commit", COMMIT]) == 0 + out = capsys.readouterr().out + assert "AVE v1.3.0: 2 records" in out and "ave-taxonomy.json regenerated from the records and matches (2 taxa)" in out + + +def test_main_does_not_claim_a_taxonomy_check_for_a_release_without_one(tmp_path, capsys): + d = release(tmp_path, tag="v1.2.0", taxonomy=False) + assert cra.main([str(d), "--tag", "v1.2.0", "--commit", COMMIT]) == 0 + assert "ave-taxonomy.json" not in capsys.readouterr().out + + +def test_main_fails_a_release_without_its_taxonomy(tmp_path, capsys): + d = release(tmp_path, taxonomy=False) + assert cra.main([str(d), "--tag", "v1.3.0", "--commit", COMMIT]) == 1 + assert "ave-taxonomy.json is missing" in capsys.readouterr().out + + +def test_main_takes_the_repository_the_release_is_published_under(tmp_path, capsys): + d = release(tmp_path) + assert cra.main([str(d), "--tag", "v1.3.0", "--commit", COMMIT, "--repo", "someone/fork"]) == 1 + assert "downloadUri" in capsys.readouterr().out