diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0e2b71345..be6135824 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -352,7 +352,7 @@ repos: name: check-family-plugins (marketplace plugins vs frontmatter) language: system entry: python3 tools/dev/check-family-plugins.py --fix - files: ^(tools/(agent-guard/src/.*|vetted-ops/(src/.*|hooks/.*|pyproject\.toml|README\.md))|tools/(jira|local-tracker|security-management|skill-lock|pr-management|typed-decision|release-config|release-management|release-verify|reproducible-archive|contributor-metrics|permission-audit|privacy-llm/redactor|github-body-field|github-rollup|vcs|cve-tool-vulnogram/(generate-cve-json|oauth-api)|privacy-llm/checker|asf-security-dash|gmail/oauth-draft)/(src/.*|hooks/.*|pyproject\.toml|README\.md)|tools/forwarder-relay/README\.md|tools/gmail/asf-relay\.md|tools/security-tracker-stats-dashboard/[^/]+|tools/security-tracker-stats-dashboard/src/.*|tools/dev/check-family-plugins\.py|\.pre-commit-config\.yaml|skills/.*/SKILL\.md|plugins/.*|\.claude-plugin/(marketplace|plugin)\.json|\.codex-plugin/plugin\.json|\.agents/plugins/marketplace\.json|(plugin|marketplace)\.json|apm\.yml|pyproject\.toml)$ + files: ^(tools/(agent-guard/src/.*|vetted-ops/(src/.*|hooks/.*|pyproject\.toml|README\.md))|tools/(jira|local-tracker|security-management|skill-lock|pr-management|typed-decision|release-config|release-management|release-verify|reproducible-archive|contributor-metrics|permission-audit|privacy-llm/redactor|github-body-field|github-rollup|vcs|cve-tool|cve-tool-vulnogram/(generate-cve-json|oauth-api)|privacy-llm/checker|asf-security-dash|gmail/oauth-draft)/(src/.*|hooks/.*|pyproject\.toml|README\.md)|tools/forwarder-relay/README\.md|tools/gmail/asf-relay\.md|tools/security-tracker-stats-dashboard/[^/]+|tools/security-tracker-stats-dashboard/src/.*|tools/dev/check-family-plugins\.py|\.pre-commit-config\.yaml|skills/.*/SKILL\.md|plugins/.*|\.claude-plugin/(marketplace|plugin)\.json|\.codex-plugin/plugin\.json|\.agents/plugins/marketplace\.json|(plugin|marketplace)\.json|apm\.yml|pyproject\.toml)$ pass_filenames: false # Claude Code mods — `claude plugin validate --strict` and `claude plugin # test` on every plugin whose `hooks/hooks.json` declares `modules` diff --git a/docs/labels-and-capabilities.md b/docs/labels-and-capabilities.md index 37e85e1b9..03eb6e354 100644 --- a/docs/labels-and-capabilities.md +++ b/docs/labels-and-capabilities.md @@ -331,7 +331,7 @@ or a contract-free mix of substrates (e.g. `tools/spec-inventory` is | [`tools/asf-svn`](../tools/asf-svn/) | `contract:source-control` | ASF SVN tool adapter: source-control binding for `svn.apache.org` working copies (centralized model), `svn` CLI operation catalogue, `dist.apache.org` release-distribution helpers (stage/promote/prune), ASF committer/PMC authorization, and optional svnpubsub site publishing. The SVN counterpart to `tools/github/` for ASF projects. Also the `land` delegate for the `jira-patch` and `mail-patch` change-request backends (`svn patch` + `svn commit`) | | [`tools/change-request`](../tools/change-request/) | `contract:change-request` | Adapter contract for the proposed-change review + merge gate (pull request / merge request / patch). Pure interface spec; no executable code — backends under `tools/github/` (PR), `tools/jira-patch/`, and `tools/mail-patch/` implement it. The seam that lets `pr-management-*` skills run on non-GitHub backends | | [`tools/cve-org`](../tools/cve-org/) | `contract:cve-authority` | CVE.org services adapter: publishes records to CVE.org and reads back the resulting CVE state. Implements the `tools/cve-tool/` contract for the CVE.org-direct backend | -| [`tools/cve-tool`](../tools/cve-tool/) | `contract:cve-authority` | Adapter contract for CNA backends (Vulnogram, MITRE form, CVE.org direct, GHSA). Pure interface spec; no executable code — adapters under sibling `tools/cve-tool-*/` directories implement it. | +| [`tools/cve-tool`](../tools/cve-tool/) | `contract:cve-authority` | Adapter contract for CNA backends (Vulnogram, MITRE form, CVE.org direct, GHSA), and the `cve-tool` command the skills run: it reads `cve_authority.tool` and dispatches to that backend's adapter, over the sibling `tools/cve-tool-*/` directories. | | [`tools/cve-tool-vulnogram`](../tools/cve-tool-vulnogram/) | `contract:cve-authority` | ASF Vulnogram CVE-allocation adapter. Implements the `tools/cve-tool/` contract. Previously named `tools/vulnogram/`. | | [`tools/dashboard-generator`](../tools/dashboard-generator/) | `substrate:analytics` | Self-contained HTML dashboard generator | | [`tools/dev`](../tools/dev/) | `substrate:framework-dev` | Framework dev-loop helpers | diff --git a/docs/security/process.md b/docs/security/process.md index 65594e64e..400481d14 100644 --- a/docs/security/process.md +++ b/docs/security/process.md @@ -474,8 +474,8 @@ one pass sync: signal that the advisory has shipped. The exact wire mechanism depends on the adapter (`cve_authority.publication_propagation` declares whether sync `poll`s for the result, awaits a - `webhook`, or treats the move as `manual`); for the - Vulnogram adapter the implementation is + `webhook`, or treats the move as `manual`); sync runs + `cve-tool publish`, which for the Vulnogram adapter calls [`vulnogram-api-record-publish`](../../tools/cve-tool-vulnogram/oauth-api/README.md) under `poll`. 7. Moves the project-board column to `Announced`. diff --git a/plugins/magpie-security-tools/tools/cve-tool/README.md b/plugins/magpie-security-tools/tools/cve-tool/README.md new file mode 100644 index 000000000..cf108a23e --- /dev/null +++ b/plugins/magpie-security-tools/tools/cve-tool/README.md @@ -0,0 +1,472 @@ + + + + +**Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)* + +- [`tools/cve-tool/`](#toolscve-tool) + - [Prerequisites](#prerequisites) + - [What this is](#what-this-is) + - [Today's adapters](#todays-adapters) + - [The `cve-tool` command](#the-cve-tool-command) + - [Operations](#operations) + - [Exit codes](#exit-codes) + - [Adding an adapter](#adding-an-adapter) + - [Interface](#interface) + - [`allocate(scope, fields) to cve_id`](#allocatescope-fields-to-cve_id) + - [`fetch_current_state(cve_id) to {state, fields}`](#fetch_current_statecve_id-to-state-fields) + - [`push_update(cve_id, fields, state_transition=None) to diff`](#push_updatecve_id-fields-state_transitionnone-to-diff) + - [`publish(cve_id) to ok`](#publishcve_id-to-ok) + - [`retract(cve_id, reason) to ok`](#retractcve_id-reason-to-ok) + - [Generic state verbs](#generic-state-verbs) + - [Skills that consume this contract](#skills-that-consume-this-contract) + - [ASF default — Vulnogram](#asf-default--vulnogram) + - [Configuration](#configuration) + + + + + +# `tools/cve-tool/` + +**Capability:** contract:cve-authority + +**Kind:** interface + +**Vendor:** agnostic + +## Prerequisites + +- **Runtime:** Python 3.11+ and [`uv`](https://docs.astral.sh/uv/). The `cve-tool` command (`uv run --project /tools/cve-tool cve-tool `) is stdlib-only; it reads `cve_authority.tool` and runs that tool's adapter. +- **CLIs / credentials / network:** Provided entirely by the resolved adapter — the sibling `tools/cve-tool-/` directory named by `cve_authority.tool` in `project.md` (the ASF default is Vulnogram at `tools/cve-tool-vulnogram/`). See that adapter for its concrete prerequisites. + +## What this is + +The framework's CVE-tooling adapter contract, and the `cve-tool` command that implements it. +A project running the ASF's Vulnogram, CVE.org's direct submission portal, the MITRE form for non-CNAs, GHSA's own CNA flow, +or no CVE allocation at all needs the security skills to handle a CVE record the same way. +This contract is the seam that makes that possible. +It defines the operations every CVE-tool backend implements, the lifecycle moments at which the skills run each one, +and the generic state verbs the skills use to talk about a CVE record without baking in any one tool's vocabulary. + +The skills and the [`security-management`](https://github.com/apache/magpie/blob/main/tools/security-management) helpers never run a CVE tool's own commands: +they run [`cve-tool `](#the-cve-tool-command), which reads `cve_authority.tool` and hands the call to that tool's adapter. +Skills consume the contract; the adapter consumes the wire protocol of whichever CNA tool the adopter actually uses. +Adopters who run Vulnogram inherit the ASF defaults verbatim. + +## Today's adapters + +Only one CVE-tool adapter ships today: + +| Adapter | Directory | Status | Notes | +|---|---|---|---| +| Vulnogram (ASF) | `tools/cve-tool-vulnogram/` | Shipping | The reference implementation. `cve-tool` runs it through [`src/cve_tool/adapters/vulnogram.py`](src/cve_tool/adapters/vulnogram.py), which calls the [`oauth-api`](../cve-tool-vulnogram/oauth-api/README.md) commands. | +| CVE.org direct submission | `tools/cve-tool-cve-org-direct/` *(planned)* | Placeholder | For adopters who are themselves a CNA and submit records straight to `cve.org` via the CVE Services API rather than through an intermediate CNA tool. | +| MITRE form | `tools/cve-tool-mitre-form/` *(planned)* | Placeholder | For adopters who are not a CNA and request CVE IDs via the [MITRE CVE Request form](https://cveform.mitre.org/). Allocation is asynchronous and operator-mediated; `fetch_current_state` will frequently return `unknown` until the form's email reply arrives. | +| GHSA-as-CNA | `tools/cve-tool-ghsa/` *(planned)* | Placeholder | For adopters who already drive their advisory flow through GitHub Security Advisories (GHSA) and use GitHub as the CNA. `allocate` becomes a GHSA draft create; `publish` becomes a GHSA publish. | +| None | `tools/cve-tool-none/` *(planned)* | Placeholder | The disable-CVE-allocation backend. All methods are no-ops returning a synthetic `not-applicable` result. For adopters who triage security issues but do not allocate CVEs (e.g. an internal product with no public distribution). | + +The four placeholder adapters do not exist on disk yet; they exist +in this table so the contract can describe the surface they will fit +into when an adopter actually needs one. +They land when an adopter implements them; +until then `cve-tool` refuses them with exit `21` (see [Exit codes](#exit-codes)). + +## The `cve-tool` command + +```bash +uv run --project /tools/cve-tool cve-tool [--tool NAME] [--project-root DIR] [--config-dir DIR] [args ...] +``` + +The command resolves `cve_authority.tool`, first hit wins: +`--tool`, then `/project.md`, then the project's organization manifest +(`organizations//organization.md`, in-tree, then adopter-local), the chain AGENTS.md describes for every dotted config key. +`--project-root` (default: the current directory) is the adopter repository the configuration is read from. +`cve-tool which` prints, as JSON, the tool it resolved, where the value came from, and the operations its adapter implements. + +Everything after the operation goes to the adapter unchanged, +so the flags, output and exit codes are the adapter's own (for Vulnogram, see the [`oauth-api` README](../cve-tool-vulnogram/oauth-api/README.md)). +That makes the order matter: `cve-tool`'s own options (`--tool`, `--project-root`, `--config-dir`) go **before** the operation, +and the operation's own arguments go after it. + +```bash +cve-tool --project-root /path/to/adopter fetch --cve-id CVE-2026-12345 --state-only # right +cve-tool fetch --project-root /path/to/adopter --cve-id CVE-2026-12345 --state-only # refused, exit 2 +``` + +`cve-tool` refuses one of its own options after the operation with a usage error naming it, +before the adapter runs, rather than letting the adapter reject an argument it does not know. + +### Operations + +| Operation | Contract method | Vulnogram adapter runs | +|---|---|---| +| `allocate` | [`allocate`](#allocatescope-fields-to-cve_id) | `vulnogram-api-allocate` | +| `fetch` | [`fetch_current_state`](#fetch_current_statecve_id-to-state-fields) | `vulnogram-api-record-fetch` | +| `push` | [`push_update`](#push_updatecve_id-fields-state_transitionnone-to-diff) | `vulnogram-api-record-update` | +| `publish` | [`publish`](#publishcve_id-to-ok) | `vulnogram-api-record-publish` | +| `retract` | [`retract`](#retractcve_id-reason-to-ok) | *not implemented*: reject the record in the Vulnogram UI | +| `check` | session probe | `vulnogram-api-check` | +| `setup` | one-time session setup | `vulnogram-api-setup` | + +An adapter translates its tool's states at the edge, so callers see only the [generic state verbs](#generic-state-verbs): +`cve-tool fetch --state-only` prints `review-ready`, not Vulnogram's `REVIEW`, +and `cve-tool publish --allow-state` takes a generic state as well as a native one. + +### Exit codes + +An adapter's own exit codes pass through. +The interface adds three, clear of every adapter's own `0`–`7`: + +| Exit | Meaning | What the skill does | +|---|---|---| +| `20` | No `cve_authority.tool` is declared anywhere in the chain. | Declare one in `project.md`, or pass `--tool`. | +| `21` | The tool has no adapter (a [planned](#todays-adapters) one, or an unknown name). | Follow the skill's manual path for the step. | +| `22` | The adapter does not implement the operation. | Follow the hint in the message (for Vulnogram's `retract`, the UI). | + +A usage error in `cve-tool`'s own arguments (an unknown operation, or one of its options after the operation) +exits `2`, as any `argparse` command does, and names the problem on stderr. + +### Adding an adapter + +An adapter is one module under [`src/cve_tool/adapters/`](src/cve_tool/adapters/) and one entry in `ADAPTERS` there. +The module exposes `OPERATIONS` (the operation names it implements), `run(operation, argv) -> int`, +and optionally `UNSUPPORTED`, a hint per operation it cannot do. +Its commands must not define an option named `--tool`, `--project-root` or `--config-dir`: +`cve-tool` reserves those names (`OWN_OPTIONS` in [`cli.py`](src/cve_tool/cli.py)) and refuses them after the operation. +The skills and helpers do not change: they already run `cve-tool `. + +## Interface + +Every CVE-tool adapter exposes five methods. The names here are the +generic verbs the skills use; an adapter is free to name its internal +CLIs whatever fits its tool's vocabulary, as long as the skill-facing +surface uses these names. + +### `allocate(scope, fields) to cve_id` + +Reserve a CVE ID and create the initial record. + +- **Lifecycle moment.** Fires once per tracking issue, inside + `security-cve-allocate` after the security team has agreed the + report is valid and a CVE should be allocated. The pre-allocation + consensus discussion happens on the tracker; `allocate` is the + step that turns that consensus into a reserved ID. +- **Inputs.** + - `scope` — a string identifying the project scope under which + the CVE belongs. Concretely for ASF: the scope label + (`affects: airflow` / `affects: providers` / `affects: chart`) + drives which `vendor` / `product` / `packageName` triad the + adapter writes into the initial record. For other adopters this + is whatever string their CVE tool needs to route the allocation + to the right CNA pool. + - `fields` — a dict of the tracker's body fields at the moment + allocation fires: `title`, `description`, `affected_versions`, + `cwe`, `severity`, `credits`, `references`. The adapter is free + to ignore fields its tool cannot accept at allocation time and + fill them in later via `push_update`. The Vulnogram adapter + accepts the full set at allocation; the MITRE-form adapter + only sends `title` + `affected_versions` + a short description + in the initial form submission. +- **Output.** The allocated CVE ID as a string in canonical + `CVE-YYYY-NNNN+` form (`CVE-2026-12345`). If the adapter cannot + allocate synchronously (the MITRE-form case), it returns a + provisional placeholder string and the skill stores that on the + tracker as `cve allocation pending`; the next `fetch_current_state` + call upgrades it once the real ID arrives. +- **No-op case.** The `none` adapter raises `NotApplicable` and + the skill skips allocation entirely. The `security-cve-allocate` + skill detects this and posts a "this adopter does not allocate + CVEs; closing without an ID" comment on the tracker instead. + +### `fetch_current_state(cve_id) to {state, fields}` + +Read the current record state and the fields the tool stores. + +- **Lifecycle moment.** Fires inside `security-issue-sync` at + Step 1e (the reviewer-comment sync) and Step 5b (the + state-progression gate before the release-manager hand-off). + Also fires from `security-issue-invalidate`'s Step 0 gate, to + refuse closure when a record exists and is past `review-ready`. +- **Inputs.** The CVE ID. Nothing else — the adapter is responsible + for resolving the tool-specific URL or API call from the ID. +- **Output.** A two-field dict: + - `state` — one of the generic state verbs defined below + (`allocated`, `review-ready`, `publish-ready`, `public`, + `retracted`, `unknown`). The adapter is responsible for + mapping its tool's native state vocabulary onto these verbs. + - `fields` — a dict of the fields the tool currently stores + for the record. Shape matches the `allocate` input dict; the + skills diff this against the tracker body to detect drift. +- **No-op case.** The `none` adapter returns `{state: "unknown", + fields: {}}`. Skills treat `unknown` as "do not gate progression + on tool state"; they fall back to the tracker body's view. + +### `push_update(cve_id, fields, state_transition=None) to diff` + +Write field updates to the record, optionally with a state move. + +- **Lifecycle moment.** Fires inside `security-issue-sync` at + Step 5c, whenever the tracker body has drifted from what + `fetch_current_state` returned. Also fires from + `security-issue-deduplicate` when the kept tracker absorbs + credits or references from the merged-in tracker. +- **Inputs.** + - `cve_id` — as for `fetch_current_state`. + - `fields` — the new desired fields. The adapter is responsible + for translating these into whatever wire format its tool + accepts (JSON record for Vulnogram, web-form fields for the + MITRE form, GraphQL mutation for GHSA). + - `state_transition` *(optional)* — one of the generic state + verbs. When supplied, the adapter performs the state move as + part of the same write; when omitted, the adapter only writes + fields and leaves state untouched. The Vulnogram adapter today + embeds the state inside the JSON body and so does both in one + PUT; other adapters may need a separate API call after the + field write. The contract does not constrain how — only that + the call appear atomic from the skill's point of view. +- **Output.** A diff dict — `{added: [...], removed: [...], + changed: [...]}` — that the skill can show the user as part of + the sync confirmation. Empty diff means the record was already + up to date; the skill suppresses the confirmation prompt in + that case. +- **No-op case.** The `none` adapter returns `{added: [], removed: + [], changed: []}` and writes nothing. The `unknown`-state + return from `fetch_current_state` also causes the skill to skip + `push_update` — the adapter has nowhere to push to. + +### `publish(cve_id) to ok` + +Move the record to its terminal public state. + +- **Lifecycle moment.** Fires inside `security-issue-sync` at + the publication step, **after** the public advisory archive URL + has been captured on the tracker. The captured archive URL is + the real-world signal that the advisory has actually shipped to + the users-list; before that point the adapter would be + publishing a record before the world has heard about the issue, + which is the opposite of the desired ordering. +- **Inputs.** The CVE ID. +- **Output.** `ok` (a sentinel; the skill does not inspect the + return body — failure raises). The skill follows up with + `fetch_current_state` to confirm the state landed at `public` + and posts a confirmation comment on the tracker. +- **No-op case.** The `none` adapter raises `NotApplicable`. The + skill treats this as "this adopter does not publish CVEs; the + tracker close is the only terminal action" and proceeds to the + archive-from-board step directly. The `ghsa` adapter implements + this as a GraphQL `publishSecurityAdvisory` mutation. + +### `retract(cve_id, reason) to ok` + +Mark an allocated-but-not-public record as rejected. + +- **Lifecycle moment.** Fires inside `security-issue-invalidate` + for trackers that already carry a CVE ID. CVE retraction is + governance-sensitive; the skill requires explicit confirmation + from a release-vote-gating role (`governance.cve_allocation_gate` + from `project.md`) before invoking `retract`. The retracted + state is terminal — once a record has been retracted, no + subsequent `push_update` or `publish` is valid. +- **Inputs.** + - `cve_id` — as above. + - `reason` — a short string captured from the tracker + discussion explaining the retraction. The adapter is + responsible for writing this into the appropriate field of + its tool's record (Vulnogram's `CNA_private.justification`, + CVE.org's `rejectedReason`, etc.). +- **Output.** `ok` sentinel as for `publish`. +- **No-op case.** The `none` adapter raises `NotApplicable`. The + skill falls back to "close the tracker as invalid; there is no + CVE record to retract." For adapters whose tool does not + support retraction of `public` records (Vulnogram refuses this; + CVE.org has a separate REJECT flow), the adapter must raise a + distinguishable `AlreadyPublic` error and the skill escalates + to the configured governance contact. + +## Generic state verbs + +The skills speak in generic verbs about a CVE record's lifecycle. +The adapter is responsible for mapping its tool's native states +onto these verbs. The verbs are: + +| Verb | Meaning | Vulnogram-native state | +|---|---|---| +| `allocated` | Record exists, ID is reserved, content is being filled in. Not visible publicly. | `DRAFT` | +| `review-ready` | Record content is complete and ready for CNA review. Reviewer comments may arrive at this state. Not visible publicly. | `REVIEW` | +| `publish-ready` | Record content is final, reviewer comments addressed, staged for the advisory-send step. The advisory emails are dispatched from the CVE tool while in this state. Not visible publicly. | `READY` | +| `public` | Record pushed to `cve.org` and world-readable. Terminal in the success path. | `PUBLIC` | +| `retracted` | Record marked rejected post-allocation. Terminal in the failure path. | `REJECTED` (in `CNA_private.state`) | +| `unknown` | The adapter cannot determine the state (network failure, asynchronous tool that hasn't replied yet, `none` backend). Skills treat this as "fall back to the tracker body's view." | n/a | + +The map is **adapter-internal**. Skills never write `DRAFT` or +`REVIEW` — they write `allocated` and `review-ready`. An adapter +that needs a more granular internal model is free to introduce +sub-states inside its `tool/` directory, as long as the +contract-facing methods normalise on the verbs above. + +The `DRAFT` to `REVIEW` transition is sync-driven (the tracker body +fields determine readiness) in the Vulnogram adapter; the +`REVIEW` to `READY` transition is release-manager-driven because the +adapter cannot tell from the tracker body alone whether reviewer +comments are still pending. Other adapters may collapse these two +transitions into one (the GHSA adapter has no separate +review/publish-ready distinction) or split them further (a +hypothetical multi-stage CNA tool with `REVIEW` to `ADDRESSING` → +`READY`). The contract does not constrain how the adapter maps +its internal states onto the four generic verbs — only that the +verbs are what the skills see. + +## Skills that consume this contract + +The CVE-tool contract is consumed by four skills today. +Each runs the operations as `cve-tool `, mostly through the commands the +[`security-management`](https://github.com/apache/magpie/blob/main/tools/security-management) helpers print: + +- **`security-cve-allocate`** — calls `allocate` to reserve the + CVE ID and create the initial record, then writes the ID back + onto the tracker's *CVE tool link* body field. The skill is + PMC-gated (or `governance.cve_allocation_gate`-gated for + non-ASF adopters); the adapter does not enforce that gate + itself — the skill does. +- **`security-issue-sync`** — calls `fetch_current_state` at + Step 1e (to surface reviewer-comment signals) and at Step 5b + (to gate the release-manager hand-off on the post-push state + being `review-ready`). Calls `push_update` at Step 5b on every run + that regenerates the CVE JSON, so the record never drifts from the body. Calls + `publish` at the publication step after the public advisory + archive URL has been captured. +- **`security-issue-invalidate`** — uses `fetch_current_state` at + its Step 0 gate. A record in `allocated` or `review-ready` stops + the skill until it is retracted at the CVE tool (the retraction is + a separate, governance-gated flow); `publish-ready` or `public` + escalates to `governance.escalation_contact`, since an invalid + close there has public consequences. +- **`security-issue-deduplicate`** — calls `push_update` on the + kept tracker's CVE record after merging in credits and + references from the duplicate tracker. The duplicate tracker's + CVE record (if any) is the subject of a separate `retract` + call once the dedup decision has been confirmed. + +These four skills are the only consumers in the current shape of +the framework. Future skills that need to touch a CVE record +(e.g. a hypothetical `security-cve-rotate-credit` action) will +extend the contract rather than reaching past it into a specific +adapter. + +## ASF default — Vulnogram + +The ASF reference adapter lives at [`tools/cve-tool-vulnogram/`](https://github.com/apache/magpie/blob/main/tools/cve-tool-vulnogram). +It is the only adapter shipping today, and the only adapter the +skills are tested against. Key properties of the ASF default: + +- **URL prefix:** `https://cveprocess.apache.org/cve5/`. + The record page, `#source` tab, `#json` tab, and `#email` tab + are all rooted there. See [`tools/cve-tool-vulnogram/record.md`](https://github.com/apache/magpie/blob/main/tools/cve-tool-vulnogram/record.md#record-urls) + for the canonical URL table. +- **Email preview URL:** `https://cveprocess.apache.org/cve5/#email`. + Renders the advisory exactly as Vulnogram will dispatch it to + the users-list and announce-list — same subject, same body, + same recipient list. The release-manager checklist calls this + out as a load-bearing checkpoint before the advisory-send step. +- **Source tab URL:** `https://cveprocess.apache.org/cve5/#source`. + The copy-paste fallback target for the JSON record. The default + write path is the OAuth-authenticated API (`cve-tool push`, which runs `vulnogram-api-record-update`); + copy-paste is the documented fallback when the OAuth session + has expired or the operator has opted out. +- **State machine:** `DRAFT` to `REVIEW` to `READY` to `PUBLIC`, + carried inside `CNA_private.state` on the CVE 5.x record. The + adapter maps these onto the generic verbs as shown in the + state-verb table above. +- **Reviewer-comment channel:** mailing-list (the ASF CNA + reviewers email the project's `security_list` rather than + surfacing comments on the tracking issue directly). This is + the `cve_authority.reviewer_channel: mailing-list` setting in + `project.md`; an adapter could equally well declare + `reviewer_channel: github-pr` (for a CNA tool that uses pull + requests as its review surface) or `reviewer_channel: none` + (for a CNA tool with no separate review step). +- **Publication propagation:** poll (the skills check the public + advisory archive on every sync rather than waiting for a + webhook). This is the `cve_authority.publication_propagation: + poll` setting in `project.md`. + +## Configuration + +A project inherits its CVE-tool choice from its organization's `cve_authority` block +(the ASF's names `vulnogram`, the `independent` baseline's names `mitre-form`), +and declares the block in `/project.md` only to differ from it. +The shape is: + +```yaml +cve_authority: + tool: vulnogram # vulnogram | cve-org-direct | mitre-form | ghsa | none + allocate_url: "https://cveprocess.apache.org/cve5/new" + record_url_template: "https://cveprocess.apache.org/cve5/{cve_id}" + source_tab_url_template: "https://cveprocess.apache.org/cve5/{cve_id}#source" + email_preview_url_template: "https://cveprocess.apache.org/cve5/{cve_id}#email" + states: [allocated, review-ready, publish-ready, public] + publication_propagation: poll # poll | webhook | manual + emits_allocation_email: true + reviewer_channel: mailing-list # mailing-list | github-pr | none +``` + +Field-by-field: + +- **`tool`** — names the adapter `cve-tool` runs. + The ASF default is `vulnogram` (the adapter in `src/cve_tool/adapters/vulnogram.py`, over `tools/cve-tool-vulnogram/`). + Adopters using a different CVE-tool backend pick one of the other four + enumerated values, each of which is expected to resolve to a + sibling `tools/cve-tool-/` directory and an adapter module (see [Adding an adapter](#adding-an-adapter)). +- **`allocate_url`** — the URL the human operator opens to begin + the allocation flow (for tool-mediated allocation paths) or + the API endpoint the adapter POSTs to (for fully automated + paths). The Vulnogram default is the `/cve5/new` form; the + MITRE-form default would be `https://cveform.mitre.org/`. +- **`record_url_template`** — the per-record URL pattern. The + `{cve_id}` placeholder is the only token the skills substitute. + Skills use this for the "open the CVE record" links they post + on the tracker. +- **`source_tab_url_template`** — the copy-paste fallback target. + Optional for adapters that have no copy-paste fallback (e.g. + GHSA — there is no JSON form to paste into). When the field is + null, the skills suppress the copy-paste fallback proposal. +- **`email_preview_url_template`** — the advisory-email preview + URL. Optional for adapters whose CVE tool does not dispatch + the advisory itself; when null, the release-manager checklist + omits the preview step. The ASF default points at the `#email` + tab. +- **`states`** — the ordered list of generic state verbs the + adapter exposes. Adapters with fewer states (the GHSA adapter + collapses `review-ready` and `publish-ready` into a single + pre-publish state; the `none` adapter has only `allocated` and + `unknown`) declare a shorter list. Skills branch on the + declared list when deciding which lifecycle steps apply. +- **`publication_propagation`** — how the skills learn that a + record has reached `public`. `poll` (the ASF default) means + the skills check the public archive on every sync; `webhook` + means an external hook updates the tracker directly; `manual` + means a human flips the tracker label and the skills trust it. +- **`emits_allocation_email`** — whether the CVE tool sends an + allocation-confirmation email at `allocate` time. The ASF + default is `true` (Vulnogram emails the project's + `security_list` on every allocation); the MITRE-form default + is also `true` (the form replies with the allocated ID); + GHSA's default is `false` (no email — the allocation result is + the API response). Skills that wait on this email surface a + "do not close this tracker until the allocation email lands" + hint when the flag is `true`. +- **`reviewer_channel`** — where reviewer comments arrive. + `mailing-list` (ASF default) means `security-issue-sync` reads + reviewer comments off the security mailing list; `github-pr` + means it reads them off a backing PR; `none` means there is + no separate review step. + +The contract does not constrain how the adapter implements any of +these settings — only that the settings are present and that the +adapter respects them. Adapters are free to add their own +tool-specific sub-keys under `cve_authority.:` (e.g. +`cve_authority.vulnogram.asf_org_id`, `cve_authority.vulnogram.cna_private_owner`) +for fields the contract does not surface. diff --git a/plugins/magpie-security-tools/tools/cve-tool/pyproject.toml b/plugins/magpie-security-tools/tools/cve-tool/pyproject.toml new file mode 100644 index 000000000..0f89df7c0 --- /dev/null +++ b/plugins/magpie-security-tools/tools/cve-tool/pyproject.toml @@ -0,0 +1,90 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "cve-tool" +version = "0.1.0" +description = "The CVE-tool interface: one tool-neutral command that runs fetch, push, publish and allocate through the adapter `cve_authority.tool` names." +readme = "README.md" +requires-python = ">=3.11" +license = { text = "Apache-2.0" } +# stdlib-only, like the members it uses: release-config reads +# `cve_authority.tool` through the project -> organization chain, and +# vulnogram-api is the ASF adapter's implementation. +dependencies = ["release-config", "vulnogram-api"] + +[project.scripts] +cve-tool = "cve_tool.cli:main" + +[tool.hatch.build.targets.wheel] +packages = ["src/cve_tool"] + +[tool.ruff] +line-length = 110 +target-version = "py311" +src = ["src", "tests"] + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "UP", # pyupgrade + "SIM", # flake8-simplify + "C4", # flake8-comprehensions + "RUF", # ruff-specific +] +ignore = [ + "E501", # line-too-long — the 110-char limit above is already generous +] + +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["B", "SIM"] # test clarity beats these + +[tool.mypy] +python_version = "3.11" +files = ["src", "tests"] +warn_unused_ignores = true +warn_redundant_casts = true +warn_unreachable = true +check_untyped_defs = true +no_implicit_optional = true +disallow_untyped_defs = true +disallow_incomplete_defs = true + +[[tool.mypy.overrides]] +module = ["release_config.*"] +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "tests.*" +disallow_untyped_defs = false +disallow_incomplete_defs = false + +[tool.pytest.ini_options] +minversion = "8.0" +addopts = "-ra -q" +testpaths = ["tests"] + +[tool.uv.sources] +release-config = { path = "../release-config" } +vulnogram-api = { path = "../cve-tool-vulnogram/oauth-api" } diff --git a/plugins/magpie-security-tools/tools/cve-tool/src/cve_tool/__init__.py b/plugins/magpie-security-tools/tools/cve-tool/src/cve_tool/__init__.py new file mode 100644 index 000000000..b83481608 --- /dev/null +++ b/plugins/magpie-security-tools/tools/cve-tool/src/cve_tool/__init__.py @@ -0,0 +1,37 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""The CVE-tool interface of `tools/cve-tool/README.md`, as a command. + +Skills and helpers run `cve-tool ` instead of a tool's own +commands; the command resolves `cve_authority.tool` and hands the call to +that tool's adapter under :mod:`cve_tool.adapters`. +""" + +from __future__ import annotations + +# The operations the contract defines (`allocate`, `fetch_current_state`, +# `push_update`, `publish`, `retract`), by their command names, and the two +# session helpers every authenticated adapter needs. +OPERATIONS = ("allocate", "fetch", "push", "publish", "retract", "check", "setup") + +# The generic record states the skills speak in (README: Generic state verbs). +STATES = ("allocated", "review-ready", "publish-ready", "public", "retracted", "unknown") + +# Exit codes of the interface itself, clear of every adapter's own 0-7. +EXIT_NOT_DECLARED = 20 +EXIT_NO_ADAPTER = 21 +EXIT_UNSUPPORTED = 22 diff --git a/plugins/magpie-security-tools/tools/cve-tool/src/cve_tool/adapters/__init__.py b/plugins/magpie-security-tools/tools/cve-tool/src/cve_tool/adapters/__init__.py new file mode 100644 index 000000000..2dd21e399 --- /dev/null +++ b/plugins/magpie-security-tools/tools/cve-tool/src/cve_tool/adapters/__init__.py @@ -0,0 +1,39 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""The adapter registry: `cve_authority.tool` value -> adapter module. + +An adapter module exposes `OPERATIONS` (the operation names it implements) +and `run(operation, argv) -> int`. Adding a CVE tool is one module here and +one entry in `ADAPTERS`; the skills and helpers do not change. +""" + +from __future__ import annotations + +import importlib +from types import ModuleType + +ADAPTERS = { + "vulnogram": "cve_tool.adapters.vulnogram", +} + +# Tools the contract names (README: Today's adapters) that have no adapter yet. +PLANNED = ("cve-org-direct", "mitre-form", "ghsa", "none") + + +def load(tool: str) -> ModuleType | None: + module = ADAPTERS.get(tool) + return importlib.import_module(module) if module else None diff --git a/plugins/magpie-security-tools/tools/cve-tool/src/cve_tool/adapters/vulnogram.py b/plugins/magpie-security-tools/tools/cve-tool/src/cve_tool/adapters/vulnogram.py new file mode 100644 index 000000000..d609f4689 --- /dev/null +++ b/plugins/magpie-security-tools/tools/cve-tool/src/cve_tool/adapters/vulnogram.py @@ -0,0 +1,111 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""The Vulnogram adapter (ASF): the operations run `tools/cve-tool-vulnogram/oauth-api`. + +Each operation calls the matching `vulnogram_api` command's `main()` with the +arguments as given, so flags, output and exit codes are Vulnogram's own, with +two translations at the edge so callers only see the generic states: + +- `fetch --state-only` prints the generic state (`review-ready`), not + `CNA_private.state` (`REVIEW`); +- `publish --allow-state` takes a generic state as well as a native one. +""" + +from __future__ import annotations + +import contextlib +import importlib +import io +import sys +from collections.abc import Callable + +# operation -> the vulnogram_api module whose main() implements it +OPERATIONS = { + "check": "check", + "setup": "setup_session", + "allocate": "allocate", + "fetch": "record_fetch", + "push": "record_update", + "publish": "record_publish", +} + +# CNA_private.state -> generic state (README: Generic state verbs) +NATIVE_TO_GENERIC = { + "DRAFT": "allocated", + "REVIEW": "review-ready", + "READY": "publish-ready", + "PUBLIC": "public", + "REJECTED": "retracted", +} +GENERIC_TO_NATIVE = {generic: native for native, generic in NATIVE_TO_GENERIC.items()} + +UNSUPPORTED = { + "retract": "reject the record in the Vulnogram UI; the API helpers have no reject call", +} + + +def generic_state(native: str) -> str: + return NATIVE_TO_GENERIC.get(native.strip().upper(), "unknown") + + +def _entry(operation: str) -> Callable[[list[str] | None], int]: + main: Callable[[list[str] | None], int] = importlib.import_module( + f"vulnogram_api.{OPERATIONS[operation]}" + ).main + return main + + +def _native_allow_states(argv: list[str]) -> list[str]: + out: list[str] = [] + expect_state = False + for arg in argv: + if expect_state: + out.append(GENERIC_TO_NATIVE.get(arg.lower(), arg)) + expect_state = False + elif arg == "--allow-state": + out.append(arg) + expect_state = True + elif arg.startswith("--allow-state="): + value = arg.split("=", 1)[1] + out.append(f"--allow-state={GENERIC_TO_NATIVE.get(value.lower(), value)}") + else: + out.append(arg) + return out + + +def _fetch_state(main: Callable[[list[str] | None], int], argv: list[str]) -> int: + captured = io.StringIO() + code = 1 + try: + with contextlib.redirect_stdout(captured): + code = main(argv) + finally: + text = captured.getvalue() + if code == 0 and text.strip(): + print(generic_state(text.strip())) + else: + sys.stdout.write(text) + return code + + +def run(operation: str, argv: list[str]) -> int: + main = _entry(operation) + if operation == "publish": + return main(_native_allow_states(argv)) + if operation == "fetch" and "--state-only" in argv: + return _fetch_state(main, argv) + return main(argv) diff --git a/plugins/magpie-security-tools/tools/cve-tool/src/cve_tool/cli.py b/plugins/magpie-security-tools/tools/cve-tool/src/cve_tool/cli.py new file mode 100644 index 000000000..8ef6c8821 --- /dev/null +++ b/plugins/magpie-security-tools/tools/cve-tool/src/cve_tool/cli.py @@ -0,0 +1,141 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""cve-tool: run a CVE-record operation through the configured CVE tool. + + cve-tool [--tool NAME] [--project-root DIR] [--config-dir DIR] [args ...] + cve-tool which + +Everything after the operation goes to the adapter unchanged, so its flags, +output and exit codes are the adapter's own. cve-tool's own options go before +the operation; one placed after it is refused as a usage error (exit 2). The interface adds three exit +codes of its own: 20 (no `cve_authority.tool` declared), 21 (the tool has no +adapter), 22 (the adapter does not implement the operation). +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +from cve_tool import EXIT_NO_ADAPTER, EXIT_NOT_DECLARED, EXIT_UNSUPPORTED, OPERATIONS, adapters +from cve_tool.config import Resolved, resolve + + +def parse_args(argv: list[str] | None) -> argparse.Namespace: + ap = argparse.ArgumentParser( + prog="cve-tool", + description=(__doc__ or "").split("\n\n", 1)[0], + ) + ap.add_argument( + "--tool", + default=None, + help="The CVE tool to use, overriding cve_authority.tool from the project configuration.", + ) + ap.add_argument( + "--project-root", + type=Path, + default=Path.cwd(), + help="The adopter repository the configuration is read from. Default: the current directory.", + ) + ap.add_argument( + "--config-dir", + type=Path, + default=None, + help="Read project.md from this directory instead of the configuration layers.", + ) + ap.add_argument("operation", choices=(*OPERATIONS, "which")) + ap.add_argument("args", nargs=argparse.REMAINDER, help="Passed to the adapter unchanged.") + args = ap.parse_args(argv) + misplaced = _misplaced_option(args.args) + if misplaced: + ap.error( + f"{misplaced} is a cve-tool option, not a {args.operation} argument: " + f"put it before the operation (cve-tool {misplaced} ... {args.operation} ...)" + ) + return args + + +# cve-tool's own options. After the operation they would reach the adapter, +# so they are refused there, and no adapter may define an option of the same name. +OWN_OPTIONS = ("--tool", "--project-root", "--config-dir") + + +def _misplaced_option(adapter_args: list[str]) -> str | None: + """The first of cve-tool's own options among the adapter's arguments, as `--name` or `--name=value`.""" + for arg in adapter_args: + name = arg.split("=", 1)[0] + if name in OWN_OPTIONS: + return name + return None + + +def _fail(code: int, message: str) -> int: + print(f"✗ cve-tool: {message}", file=sys.stderr) + return code + + +def _describe(resolved: Resolved) -> str: + return f"cve_authority.tool is {resolved.tool!r} (from {resolved.source})" + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + resolved = resolve(args.tool, args.project_root, args.config_dir) + adapter = adapters.load(resolved.tool) if resolved.tool else None + + if args.operation == "which": + operations = sorted(adapter.OPERATIONS) if adapter else [] + json.dump( + { + "tool": resolved.tool, + "source": resolved.source, + "adapter": bool(adapter), + "operations": operations, + }, + sys.stdout, + ) + sys.stdout.write("\n") + return 0 + + if not resolved.tool: + return _fail( + EXIT_NOT_DECLARED, + "no cve_authority.tool in project.md or its organization manifest; declare one or pass --tool", + ) + if adapter is None: + if resolved.tool in adapters.PLANNED: + why = "the contract names this tool, but no adapter ships for it yet" + else: + why = f"no adapter is registered for it (known: {', '.join(sorted(adapters.ADAPTERS))})" + return _fail( + EXIT_NO_ADAPTER, f"{_describe(resolved)}: {why}; follow the skill's manual path for this step" + ) + if args.operation not in adapter.OPERATIONS: + hint = getattr(adapter, "UNSUPPORTED", {}).get(args.operation) + return _fail( + EXIT_UNSUPPORTED, + f"the {resolved.tool} adapter does not implement {args.operation}" + + (f": {hint}" if hint else ""), + ) + code: int = adapter.run(args.operation, list(args.args)) + return code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/magpie-security-tools/tools/cve-tool/src/cve_tool/config.py b/plugins/magpie-security-tools/tools/cve-tool/src/cve_tool/config.py new file mode 100644 index 000000000..077acbc64 --- /dev/null +++ b/plugins/magpie-security-tools/tools/cve-tool/src/cve_tool/config.py @@ -0,0 +1,63 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""Which CVE tool a project uses: `cve_authority.tool`, first hit wins. + +`--tool` on the command line, then `/project.md`, then the +project's organization manifest (in-tree, then adopter-local), the same +chain AGENTS.md describes for every dotted config key. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from release_config import mdconfig as md +from release_config.config import org_manifest_chain + +KEY = "cve_authority.tool" + + +@dataclass(frozen=True) +class Resolved: + """The tool's name (lower-case), or None, and where it was read from.""" + + tool: str | None + source: str + + +def _declared(text: str | None) -> str | None: + value = md.dotted_value(text, KEY) + if not value or md.is_placeholder(value): + return None + return value.strip().lower() + + +def resolve(explicit: str | None, project_root: Path, config_dir: Path | None = None) -> Resolved: + if explicit and explicit.strip(): + return Resolved(explicit.strip().lower(), "--tool") + resolver = md.Resolver(project_root, config_dir) + project_path = resolver.find("project.md") + project_text = md.read_text(project_path) + tool = _declared(project_text) + if tool: + return Resolved(tool, resolver.display(project_path) or "project.md") + for path, label in org_manifest_chain(resolver, md.organization(project_text)): + tool = _declared(md.read_text(path)) + if tool: + return Resolved(tool, label) + return Resolved(None, "not declared") diff --git a/plugins/magpie-security-tools/tools/security-management/README.md b/plugins/magpie-security-tools/tools/security-management/README.md index 99a7985d4..b630a0fb2 100644 --- a/plugins/magpie-security-tools/tools/security-management/README.md +++ b/plugins/magpie-security-tools/tools/security-management/README.md @@ -212,7 +212,7 @@ Every command takes `--input F`, a JSON file. | `blockers` | `verdict` (`proceed`, `blocked`), `blockers`, `warnings` (`needs triage`, only when no hard blocker fired), `existing_cve`, `already_wired`, `scope_label`; empty `scope_labels` exits `2`. | | `preflight` | `path` (`api`, `recipe`, or `null` with the next check `command`), `reason`, `offer_setup`, `setup_command`. | | `title` | `original_title`, `normalized_title`, `word_count`, `changed`, `over_strip_warning` (fewer than 3 words). | -| `recipe` | `path` (`api`, `recipe`, `relay`), `text`, the `vulnogram-api-allocate` `command` with the title shell-quoted, `bare_refs`; an unanswered authorisation question exits `2`. | +| `recipe` | `path` (`api`, `recipe`, `relay`), `text`, the `cve-tool allocate` `command` with the title shell-quoted, `bare_refs`; an unanswered authorisation question exits `2`. | | `allocate-result` | `next` (`step-4`, `stop`, `setup`, `check-records`, `recipe`), `cve_id`, `message`, `rerun_allocate` (true only after an expired token). | | `cve` | `cve_id` and `record_url` from the user's paste-back; none, a malformed token or two IDs exit `2`. | | `draft-plan` | `action` (`skip`, `ask`, `surface_existing`, `blocked`, `create`), `notification` (`omitted`, `team_member`, `notified`, `draft_pending`) and for a draft `to`, `cc`, `subject`, `backend`, `anchor`, `secondary_threads`. | diff --git a/plugins/magpie-security-tools/tools/security-management/src/security_management/cve_allocate/cli.py b/plugins/magpie-security-tools/tools/security-management/src/security_management/cve_allocate/cli.py index 07791297e..0c4b6d9df 100644 --- a/plugins/magpie-security-tools/tools/security-management/src/security_management/cve_allocate/cli.py +++ b/plugins/magpie-security-tools/tools/security-management/src/security_management/cve_allocate/cli.py @@ -38,7 +38,7 @@ "preflight": "Step 0 item 5: the API pre-flight exits as the Step 3 path", "title": "Step 2: the CVE-ready title and the over-strip warning (JSON: title, project_names)", "recipe": "Step 3: the API proposal, the self-service recipe or the relay message", - "allocate-result": "Step 3: what a vulnogram-api-allocate exit means next", + "allocate-result": "Step 3: what a cve-tool allocate exit means next", "cve": "Step 3: the one CVE ID in the user's paste-back (JSON: text)", "draft-plan": "Step 4 item 5: whether the reporter gets a draft, to whom and how", "check-draft": "Step 4 item 5: grep a composed draft for the CVE-tool URL and other leaks", diff --git a/plugins/magpie-security-tools/tools/security-management/src/security_management/cve_allocate/gate.py b/plugins/magpie-security-tools/tools/security-management/src/security_management/cve_allocate/gate.py index b7b55450e..cd940efca 100644 --- a/plugins/magpie-security-tools/tools/security-management/src/security_management/cve_allocate/gate.py +++ b/plugins/magpie-security-tools/tools/security-management/src/security_management/cve_allocate/gate.py @@ -22,9 +22,9 @@ - `blockers`: the Step 1 checks as a function of state, labels and the *CVE tool link* value `body-field-get` printed. The `needs triage` warning only fires when no hard blocker did. -- `preflight`: the two `vulnogram-api-check` exits as the Step 3 path. +- `preflight`: the two `cve-tool check` exits as the Step 3 path. - `cve`: the one `CVE-YYYY-NNNNN` in text the user pasted back. -- `allocate_result`: what a `vulnogram-api-allocate` exit means next. The +- `allocate_result`: what a `cve-tool allocate` exit means next. The allocation is never re-run automatically. """ @@ -34,7 +34,7 @@ from collections.abc import Mapping, Sequence from typing import Any -from security_management.shared import selectors +from security_management.shared import cve_tool, selectors from security_management.shared.rollup import one_line from security_management.shell import dq, w @@ -42,7 +42,6 @@ CVE_ALLOCATED = "cve allocated" DUPLICATE = "duplicate" NEEDS_TRIAGE = "needs triage" -OAUTH_API = "/tools/cve-tool-vulnogram/oauth-api" READ = "uv run --project ~/.claude/magpie/vetted-ops vetted-op-read --caller security-cve-allocate" _CVE_IN_FIELD = re.compile(r"CVE-\d{4}-\d+") @@ -183,15 +182,14 @@ def blockers(data: Mapping[str, Any]) -> dict[str, Any]: } -def _check(scope: str, oauth_api: str) -> str: - flags = "--server --quiet" if scope == "server" else "--allocate" - return f"uv run --project {w(oauth_api)} vulnogram-api-check {flags}" +def _check(scope: str, data: Mapping[str, Any]) -> str: + flags = ("--server", "--quiet") if scope == "server" else ("--allocate",) + return cve_tool.command(data, "check", *flags) def preflight(data: Mapping[str, Any]) -> dict[str, Any]: - oauth_api = str(data.get("oauth_api") or OAUTH_API) pmc = one_line(data.get("pmc")) - setup = f"uv run --project {w(oauth_api)} vulnogram-api-setup --pmc {w(pmc or '')}" + setup = cve_tool.command(data, "setup", "--pmc", pmc or "") def out( path: str | None, why: str, *, offer_setup: bool = False, command: str | None = None @@ -207,7 +205,7 @@ def out( if data.get("governance_member") is not True: return out("recipe", "API allocation is only for a governance-authorised user") if data.get("server_exit") is None: - return out(None, "run the server check first", command=_check("server", oauth_api)) + return out(None, "run the server check first", command=_check("server", data)) server = int(data["server_exit"]) if server == 4: return out( @@ -218,9 +216,7 @@ def out( if server != 0: return out("recipe", f"unexpected server-check exit {server}") if data.get("allocate_exit") is None: - return out( - None, "the server supports it: check the allocate token", command=_check("allocate", oauth_api) - ) + return out(None, "the server supports it: check the allocate token", command=_check("allocate", data)) code = int(data["allocate_exit"]) if code == 0: lines = [one_line(line) for line in str(data.get("allocate_stdout") or "").splitlines()] @@ -274,7 +270,7 @@ def allocate_result(data: Mapping[str, Any]) -> dict[str, Any]: first = next((one_line(line) for line in str(data.get("stdout") or "").splitlines() if line.strip()), "") stderr = str(data.get("stderr") or "") pmc = one_line(data.get("pmc")) or "" - check = f"ask the user to check {pmc}'s records in Vulnogram before anything else" + check = f"ask the user to check {pmc}'s records in the CVE tool before anything else" def out(next_step: str, why: str, cve_id: str | None = None) -> dict[str, Any]: return {"next": next_step, "cve_id": cve_id, "message": why, "rerun_allocate": False} @@ -289,13 +285,13 @@ def out(next_step: str, why: str, cve_id: str | None = None) -> dict[str, Any]: return out("step-4", "allocated; no paste-back needed", first) return out( "step-4", - "the ID is reserved but Vulnogram did not save its record; the sync push creates it", + "the ID is reserved but the CVE tool did not save its record; the sync push creates it", first, ) if code == 5: return out( "stop", - "the PMC cannot allocate directly and Vulnogram mailed the security team: resume later with the ID as an override", + "the PMC cannot allocate directly and the CVE tool mailed the security team: resume later with the ID as an override", ) if code == 1: result = out( @@ -307,7 +303,7 @@ def out(next_step: str, why: str, cve_id: str | None = None) -> dict[str, Any]: if code == 4: return out( "check-records", - f"Vulnogram answered HTML with no ID: {check} (it may have allocated), then use the recipe", + f"the CVE tool answered HTML with no ID: {check} (it may have allocated), then use the recipe", ) if "may still have gone through" in stderr: return out("check-records", f"the request may still have gone through: {check}, then use the recipe") diff --git a/plugins/magpie-security-tools/tools/security-management/src/security_management/cve_allocate/recipe.py b/plugins/magpie-security-tools/tools/security-management/src/security_management/cve_allocate/recipe.py index f958a9868..bb45de778 100644 --- a/plugins/magpie-security-tools/tools/security-management/src/security_management/cve_allocate/recipe.py +++ b/plugins/magpie-security-tools/tools/security-management/src/security_management/cve_allocate/recipe.py @@ -18,7 +18,7 @@ - `api` (a governance-authorised user, pre-flight `api`): one proposal to allocate through the CVE tool's API, with the form URL as the fallback and - the one `vulnogram-api-allocate` command to run on a yes. + the one `cve-tool allocate` command to run on a yes. - `recipe` (authorised, any other pre-flight outcome): open the allocation URL, paste the title, submit, paste the ID back. - `relay` (not authorised): a terse request for an authorised member — the @@ -33,14 +33,11 @@ from __future__ import annotations import re -import shlex from collections.abc import Mapping, Sequence from typing import Any -from security_management.cve_allocate.gate import OAUTH_API -from security_management.shared import refs +from security_management.shared import cve_tool, refs from security_management.shared.rollup import one_line -from security_management.shell import w PASTE_BACK = "Paste the allocated `CVE-YYYY-NNNNN` back here when done." _HANDLE = re.compile(r"^@?([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))$") @@ -85,15 +82,14 @@ def render(data: Mapping[str, Any]) -> dict[str, Any]: pmc = one_line(data.get("pmc")) if not pmc: return {"ok": False, "refused": "the API path needs cna_private_owner (the PMC) from project.md"} - oauth_api = str(data.get("oauth_api") or OAUTH_API) path = "api" text = ( - f"**Allocate a CVE for {link}** through the Vulnogram API, for `{pmc}`, with this title:\n\n" + f"**Allocate a CVE for {link}** through the CVE tool's API, for `{pmc}`, with this title:\n\n" f"{block}\n\n" "This reserves a CVE ID immediately and cannot be undone.\n" f"(Fallback, if the API path fails: the form at {url}.)\n" ) - command = f"uv run --project {w(oauth_api)} vulnogram-api-allocate --pmc {w(pmc)} --title {shlex.quote(title)}" + command = cve_tool.command(data, "allocate", "--pmc", pmc, "--title", title) elif member: path = "recipe" indented = "\n".join(" " + line for line in block.split("\n")) diff --git a/plugins/magpie-security-tools/tools/security-management/src/security_management/cve_allocate/render.py b/plugins/magpie-security-tools/tools/security-management/src/security_management/cve_allocate/render.py index 7e58dc047..9f06e10a1 100644 --- a/plugins/magpie-security-tools/tools/security-management/src/security_management/cve_allocate/render.py +++ b/plugins/magpie-security-tools/tools/security-management/src/security_management/cve_allocate/render.py @@ -167,15 +167,10 @@ def rollup_entry(data: Mapping[str, Any]) -> dict[str, Any]: except ValueError as exc: return {"ok": False, "refused": str(exc)} allocated = str(data.get("cve_allocated_label") or CVE_ALLOCATED) - vulnogram = "vulnogram" in str(data.get("cve_tool") or "tools/cve-tool-vulnogram") source = one_line(data.get("source_tab_url_template")) - push = "push it to the record via the adapter's `push_update` method" - if vulnogram: - push += f" (for Vulnogram: `vulnogram-api-record-update --cve-id {cve_id} --json-file `)" - if source: - push += ( - f" or, as a fallback, paste it into the record's [`source` tab]({record_url(source, cve_id)})" - ) + push = f"push it to the record with `cve-tool push --cve-id {cve_id} --json-file `" + if source: + push += f" or, as a fallback, paste it into the record's [`source` tab]({record_url(source, cve_id)})" scope = one_line(data.get("scope_label")) product = one_line(data.get("product")) package = one_line(data.get("package_name")) diff --git a/plugins/magpie-security-tools/tools/security-management/src/security_management/issue_sync/push.py b/plugins/magpie-security-tools/tools/security-management/src/security_management/issue_sync/push.py index ec77ed69a..234250881 100644 --- a/plugins/magpie-security-tools/tools/security-management/src/security_management/issue_sync/push.py +++ b/plugins/magpie-security-tools/tools/security-management/src/security_management/issue_sync/push.py @@ -45,9 +45,8 @@ from typing import Any from security_management.cve_allocate.title import TRACKER_PREFIXES, strip -from security_management.shared import selectors +from security_management.shared import cve_tool, selectors from security_management.shared.rollup import one_line -from security_management.shell import w GATES = ( "title_strip_cascade", @@ -64,9 +63,11 @@ "anonymise_private_finder", "conservative_affected_range", ) +# The regenerated JSON is Vulnogram-shaped (generate-cve-json), so its +# CNA_private.state is native; `cve-tool fetch --state-only` already prints the +# generic state. `_state` reads either. NATIVE = {"DRAFT": "allocated", "REVIEW": "review-ready", "READY": "publish-ready", "PUBLIC": "public"} LATER = ("review-ready", "publish-ready", "public") -OAUTH_API = "/tools/cve-tool-vulnogram/oauth-api" _UPGRADE = re.compile(r"upgrade[^.\n]{0,80}?\b[A-Za-z][\w.-]{0,60} v?\d+\.\d+(?:\.\d+)?\b", re.IGNORECASE) _CWE_LONG = re.compile(r"^CWE-\d+: \S") @@ -94,7 +95,6 @@ def _state(value: object) -> str | None: def decide(data: Mapping[str, Any]) -> dict[str, Any]: cve_id = one_line(data.get("cve_id")) - oauth_api = w(str(data.get("oauth_api") or OAUTH_API)) def out(action: str, why: str, **extra: Any) -> dict[str, Any]: return { @@ -144,12 +144,10 @@ def out(action: str, why: str, **extra: Any) -> dict[str, Any]: stuck = transition and before == "allocated" probe = one_line(data.get("session")).lower() or None if probe is None: - return out( - "pending", "probe the CVE tool session", next=f"uv run --project {oauth_api} vulnogram-api-check" - ) + return out("pending", "probe the CVE tool session", next=cve_tool.command(data, "check")) if probe in ("expired", "not-configured"): why = ( - "the session expired: remind the operator to re-run vulnogram-api-setup --pmc ; manual-paste hand-off this run" + "the session expired: remind the operator to re-run cve-tool setup --pmc ; manual-paste hand-off this run" if probe == "expired" else "no session is configured: manual-paste hand-off through the source tab" ) @@ -163,7 +161,7 @@ def out(action: str, why: str, **extra: Any) -> dict[str, Any]: return out( "pending", "push the regenerated JSON", - next=f"uv run --project {oauth_api} vulnogram-api-record-update --cve-id {w(cve_id)} --json-file {w(json_file)}", + next=cve_tool.command(data, "push", "--cve-id", cve_id, "--json-file", json_file), ) if int(data["push_exit"]) != 0: return out( @@ -178,7 +176,7 @@ def out(action: str, why: str, **extra: Any) -> dict[str, Any]: return out( "pending", "verify the state advance", - next=f"uv run --project {oauth_api} vulnogram-api-record-fetch --cve-id {w(cve_id)} --state-only", + next=cve_tool.command(data, "fetch", "--cve-id", cve_id, "--state-only"), ) if fetched in LATER: return out( diff --git a/plugins/magpie-security-tools/tools/security-management/src/security_management/shared/cve_tool.py b/plugins/magpie-security-tools/tools/security-management/src/security_management/shared/cve_tool.py new file mode 100644 index 000000000..5507f8077 --- /dev/null +++ b/plugins/magpie-security-tools/tools/security-management/src/security_management/shared/cve_tool.py @@ -0,0 +1,45 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""The CVE-record commands the renderers print, through the CVE-tool interface. + +The skills never name a CVE tool's own commands: they run `cve-tool +` (tools/cve-tool), which resolves `cve_authority.tool` and runs +that tool's adapter. Arguments after the operation are the adapter's own; +`command` shell-quotes each one, so callers pass raw values. +""" + +from __future__ import annotations + +import shlex +from collections.abc import Mapping +from typing import Any + +from security_management.shell import w + +PROJECT = "/tools/cve-tool" + + +def command(data: Mapping[str, Any], operation: str, *args: object) -> str: + """`uv run --project cve-tool `; `data["cve_tool_project"]` overrides the path. + + Each argument is shell-quoted here, so pass raw values and never quote them first: + quoting twice breaks a value. `shlex.quote`, not `w`, because an argument can come + from the tracker (an allocation title), and `w` leaves a ``-shaped value bare. + """ + project = str(data.get("cve_tool_project") or PROJECT) + quoted = (shlex.quote(str(arg)) for arg in args) + return " ".join(("uv run --project", w(project), "cve-tool", operation, *quoted)) diff --git a/plugins/magpie-security/skills/cve-allocate/SKILL.md b/plugins/magpie-security/skills/cve-allocate/SKILL.md index 832c9209b..52319ccbf 100644 --- a/plugins/magpie-security/skills/cve-allocate/SKILL.md +++ b/plugins/magpie-security/skills/cve-allocate/SKILL.md @@ -21,7 +21,7 @@ argument-hint: "[issue-number] [CVE-YYYY-NNNNN]" capability: capability:resolve surface_hash: sha256:c96f5623aa6c4212 license: Apache-2.0 -measured_tokens: 7050 +measured_tokens: 7047 --- Project security pages updated with CVE_ID`, which records the security-pages step as done. A failed command posts nothing; report it in the recap with the command's output. -- **Vulnogram state transition (`REVIEW → PUBLIC`):** invoke the - [`vulnogram-api-record-publish`](../../../../tools/cve-tool-vulnogram/oauth-api/README.md) - CLI to flip the record's `CNA_private.state` over the OAuth API. - It refuses the transition unless the current state is `REVIEW` and the record already lists the archived advisory URL as a `vendor-advisory` reference (re-push the CVE JSON after capturing the URL, before this step); - `--allow-state REVIEW --allow-state READY` also accepts a record moved to `READY` manually, and no earlier state can be published: +- **CVE record state transition (`review-ready → public`):** run the CVE-tool interface's `publish` operation + ([`tools/cve-tool/README.md`](../../../../tools/cve-tool/README.md); for Vulnogram it flips the record's `CNA_private.state` from `REVIEW` to `PUBLIC` over the OAuth API). + It refuses the transition unless the current state is `review-ready` and the record already lists the archived advisory URL as a `vendor-advisory` reference (re-push the CVE JSON after capturing the URL, before this step); + `--allow-state review-ready --allow-state publish-ready` also accepts a record moved to `publish-ready` manually, and no earlier state can be published: ```bash - uv run --project /tools/cve-tool-vulnogram/oauth-api \ - vulnogram-api-record-publish --cve-id + uv run --project /tools/cve-tool cve-tool publish --cve-id ``` Use it only as part of the *Advisory archived on ``* combined apply in [Step 2b](SKILL.md#step-2--build-a-proposal-do-not-apply-anything-yet): the trigger is *"the advisory has provably shipped on ``"*, the signal a human would use before clicking the Vulnogram `REVIEW → PUBLIC` button. Outside that trigger, the state transition stays manual. - Idempotent: re-running on a record already in `PUBLIC` exits 0 with an informational message. + Idempotent: re-running on a record already `public` exits 0 with an informational message. Exit codes match `record-update` (2 = token expired, 3 = unexpected state, 5 = save failed, 6 = other API error, 7 = unexpected envelope), plus 4 = no `vendor-advisory` reference. On a non-zero exit, the combined apply stops and the failure surfaces in the recap; the partial state (URL captured, labels flipped, JSON re-pushed, tracker NOT yet closed) is the recovery starting point for the next sync. @@ -276,23 +274,22 @@ When any of the report's `normalisation_changes`, `missing_required` or `needs_d ### A record that is already public -When the *Public advisory URL* field is set, read the record's state before regenerating, so a regeneration never rewrites published content the team did not choose to change. -For Vulnogram: +When the *Public advisory URL* field is set, read the record's state before regenerating, so a regeneration never rewrites published content the team did not choose to change: ```bash -uv run --project /tools/cve-tool-vulnogram/oauth-api vulnogram-api-record-fetch --cve-id --state-only +uv run --project /tools/cve-tool cve-tool fetch --cve-id --state-only ``` -If the state is `PUBLIC`, save the live record and pass it as the baseline: +If the state is `public`, save the live record and pass it as the baseline: ```bash -uv run --project /tools/cve-tool-vulnogram/oauth-api vulnogram-api-record-fetch --cve-id > /cve-live-.json +uv run --project /tools/cve-tool cve-tool fetch --cve-id > /cve-live-.json uv run --project /tools/cve-tool-vulnogram/generate-cve-json generate-cve-json --attach --report-json /cve-report-.json --baseline /cve-live-.json ``` The body still wins for every value it owns; everything else keeps its published value. If the state cannot be read, or the fetch fails, skip the regeneration for this run and say why in the Step 6 recap. -Never regenerate a `PUBLIC` record without its baseline. +Never regenerate a `public` record without its baseline. ### Remediation-developer credit comes from the body field @@ -343,8 +340,8 @@ Capture the printed URL — it deep-links to the `## CVE JSON — paste-ready fo ## Step 5b — Push the regenerated JSON to the CVE tool via the adapter **When the operator's machine has a valid authenticated session** for the adapter named in `cve_authority.tool` -(one-time setup per `tools//README.md`; for Vulnogram, -`uv run --project /tools/cve-tool-vulnogram/oauth-api vulnogram-api-setup --pmc `, see +(one-time setup: `uv run --project /tools/cve-tool cve-tool setup …` with the adapter's own flags, per [`tools/cve-tool/README.md`](../../../../tools/cve-tool/README.md); +for Vulnogram, `cve-tool setup --pmc `, see [`tools/cve-tool-vulnogram/oauth-api/README.md`](../../../../tools/cve-tool-vulnogram/oauth-api/README.md)), **sync pushes the JSON to the record directly** through the adapter's `push_update(cve_id, fields, state_transition=None)` method (per [`tools/cve-tool/README.md`](../../../../tools/cve-tool/README.md)) @@ -473,20 +470,20 @@ When the state fetch fails, pass `fetched_state: "unknown"`. 3. **Push the update through the adapter's `push_update` method** (`push_update(cve_id, fields, state_transition=None)` per [`tools/cve-tool/README.md`](../../../../tools/cve-tool/README.md)). - For Vulnogram the entrypoint is `vulnogram-api-record-update`: + The `cve-tool push` operation runs it through the configured adapter: ```bash - uv run --project /tools/cve-tool-vulnogram/oauth-api vulnogram-api-record-update \ + uv run --project /tools/cve-tool cve-tool push \ --cve-id --json-file /cve--.json ``` - The push merges into the live record and refuses to rename an affected product or package. + For Vulnogram, the push merges into the live record and refuses to rename an affected product or package. Two explicit overrides exist; use them only after confirming with the user: - `--replace-references` — replace `references[]` instead of keeping existing URLs. Needed to drop a reference that must no longer appear (a private mailing-list thread, a reverted PR), since merge mode keeps them. - `--allow-product-change` — allow a product/package rename. - Right when the project's generator config intentionally defines the name (for example a Helm chart with a `pkg:helm` purl) and the live record is still `DRAFT`; + Right when the project's generator config intentionally defines the name (for example a Helm chart with a `pkg:helm` purl) and the live record is still `allocated`; never on a published record. `state_transition` is omitted: the JSON already carries the generator-computed state, @@ -500,7 +497,7 @@ When the state fetch fails, pass `fetched_state: "unknown"`. so do not short-circuit "already pushed this JSON": every successful run that re-regenerated the JSON re-pushes, keeping the record byte-identical to the tracker body. -5. **The state fetch.** If the fetch entrypoint `push-decision` names is not yet installed on the operator's machine (see [`tools/cve-tool-vulnogram/oauth-api/README.md`](../../../../tools/cve-tool-vulnogram/oauth-api/README.md)), +5. **The state fetch.** If the adapter does not implement the `fetch` operation `push-decision` names (`cve-tool` exits `22`, see [`tools/cve-tool/README.md`](../../../../tools/cve-tool/README.md)), read the state from the `push_update` response envelope, which the contract requires to include the saved state. On `state_advance`, the Step 6 recap notes *"CVE record state auto-promoted to `review-ready` at `PUSH_TIMESTAMP`."* (for Vulnogram, add *"i.e. `DRAFT` → `REVIEW` in the underlying record"*). diff --git a/plugins/magpie-security/skills/issue-sync/closed-tracker-check.md b/plugins/magpie-security/skills/issue-sync/closed-tracker-check.md index 9ab6a4dde..dd08d5e66 100644 --- a/plugins/magpie-security/skills/issue-sync/closed-tracker-check.md +++ b/plugins/magpie-security/skills/issue-sync/closed-tracker-check.md @@ -6,7 +6,7 @@ Part of Step 1 in [`gather.md`](gather.md), which says when to read this file. For **closed** trackers carrying the `announced` label (which `sync all` includes alongside open issues), -the CNA-tool record is `PUBLIC` and the issue closed at Step 15, but propagation to `cve.org` takes minutes to days. +the CNA-tool record is `public` and the issue closed at Step 15, but propagation to `cve.org` takes minutes to days. Once cve.org shows it published, the reporter is owed a final *"CVE is live"* email. The check is read-only and uses the MITRE CVE Services API v2 diff --git a/plugins/magpie-security/skills/issue-sync/cve-record-comments.md b/plugins/magpie-security/skills/issue-sync/cve-record-comments.md index 32decf966..c4c3de12b 100644 --- a/plugins/magpie-security/skills/issue-sync/cve-record-comments.md +++ b/plugins/magpie-security/skills/issue-sync/cve-record-comments.md @@ -8,14 +8,13 @@ Part of Step 1 in [`gather.md`](gather.md), which says when to read this file. Whenever the tracking issue has a CVE ID allocated (the *CVE tool link* body field is populated, or the `cve allocated` label is set), check whether a CVE reviewer has left comments on the record. -**Read the record's comments directly.** When the CVE-tool adapter exposes an authenticated record read — -for the ASF default, [`vulnogram-api-record-fetch`](../../../../tools/cve-tool-vulnogram/oauth-api/README.md), -on the Bearer token stored by `vulnogram-api-setup --pmc ` — +**Read the record's comments directly.** When the CVE-tool adapter exposes an authenticated record read +(the `fetch` operation of [`tools/cve-tool/README.md`](../../../../tools/cve-tool/README.md); +for the ASF default, Vulnogram, on the Bearer token stored by `cve-tool setup --pmc `), the reviewer comments are a **first-class field on the record** and that read is the primary signal path: ```bash -uv run --project /tools//oauth-api \ - vulnogram-api-record-fetch --cve-id --comments-only +uv run --project /tools/cve-tool cve-tool fetch --cve-id --comments-only ``` It prints a JSON array — `[]` when the record has no comments, else one object per comment: @@ -27,11 +26,11 @@ It prints a JSON array — `[]` when the record has no comments, else one object ``` This is the record's own state: every comment, whichever inbox the notification reached, with `hypertext` verbatim. -Run it for **every** tracker with an allocated CVE, not only those in `REVIEW` — -reviewers comment on records that have moved on, and a comment on a `PUBLIC` record still needs an answer. +Run it for **every** tracker with an allocated CVE, not only those in `review-ready` — +reviewers comment on records that have moved on, and a comment on a `public` record still needs an answer. > **Which records to poll.** One cheap, read-only fetch per tracker with an allocated CVE; it needs a live token. -> On an expired-token error, re-run `vulnogram-api-setup --pmc ` before continuing; if that is not possible this run, +> On an expired-token error, re-run `cve-tool setup --pmc ` before continuing; if that is not possible this run, > fall back to the mail path below and flag the gap in Step 2c rather than reporting *"no reviewer comments"* — > a failed read is not an empty result. @@ -56,7 +55,7 @@ and say so in the proposal (*"reviewer edited this comment after we processed it **Acknowledgement-only comments still get ledgered.** A bare *"LGTM"* / *"looks good"* / *"approved"* needs no body change and no reply, but unledgered it re-surfaces on every sync. Propose the ledger line with no other action, and note in Step 2a that the reviewer signed off -(the signal the release manager needs for the `REVIEW` → `READY` move). +(the signal the release manager needs for the `review-ready` → `publish-ready` move). **The mail path is the complement, not the duplicate.** The CVE tool also emails `` when a reviewer comments. That mail is the **only** path for an adopter whose CVE-tool adapter has no authenticated read, @@ -113,7 +112,7 @@ to extract the comment bodies verbatim — here the body text is the signal. Every other `get_thread` call in this skill stays `MINIMAL`, per the [get-thread default rule](../../../../tools/gmail/operations.md#get-thread). -**Absence of signal is the common case.** Most CVEs go through REVIEW and PUBLISHED with no reviewer pushback. +**Absence of signal is the common case.** Most CVEs go through review and publication with no reviewer pushback. When the record read returns `[]` and the mail search is empty, record `cve_review_comments: []` and move on — **only** when a read actually succeeded (see *Which records to poll* above). @@ -127,7 +126,7 @@ Then map **each** open review comment to a concrete proposal on the **tracking i and surface it as a numbered item in Step 2b. The body is the single source of truth for the CVE JSON: *reviewer asks → body field updated → CVE JSON regenerated (Step 5, automatic after apply) → record rewritten via -[`vulnogram-api-record-update`](../../../../tools/cve-tool-vulnogram/oauth-api/README.md) (fallback: the `#source` paste)*, +`cve-tool push` (fallback: the `#source` paste)*, so nobody hand-edits the record. Map common review comments to body fields like this: @@ -172,8 +171,7 @@ Comments that need human judgement (severity/CVSS, out-of-scope challenges, free their resolution is a security-team conversation, not a *"please re-review"* ping. **Answer a reviewer through the tracker body, never by hand-editing the record.** -Sync writes the record in this run (Step 5b, via -[`vulnogram-api-record-update`](../../../../tools/cve-tool-vulnogram/oauth-api/README.md)) +Sync writes the record in this run (Step 5b, via `cve-tool push`) only as the *output* of regenerating from the body; a field changed only on the record is silently reverted by the next regeneration. diff --git a/plugins/magpie-security/skills/issue-sync/gather.md b/plugins/magpie-security/skills/issue-sync/gather.md index fd2eaf6e0..da1054213 100644 --- a/plugins/magpie-security/skills/issue-sync/gather.md +++ b/plugins/magpie-security/skills/issue-sync/gather.md @@ -275,7 +275,7 @@ each becomes a proposed body-field update, label change, or next-step recommenda | Advisory message sent to `` / `` but archive URL not yet visible | No-op transition; **do not** flip the `fix released → announced` labels here. The flip belongs to the combined apply above and fires only once the archive URL is confirmed live on ``: a `[VOTE]/[ANNOUNCE]` thread in flight without an archived URL is ambiguous. | | Project-board column drifted from the issue's label-derived state (e.g. a tracker carries `pr merged` but is still in the `PR created` column on Project 2 (``), or `announced` + *Public advisory URL* body field populated but the column is still `Fix released`) | Propose moving the project item to the correct column per the mapping table in Step 2b; the board is the team's primary overview surface. | | `announced` label set and CVE record on `` now reports state PUBLISHED (checked via `curl -s /cve5/.json` / the CVE tool API, or an explicit release-manager comment on the issue stating the CVE-tool push is done) | Propose closing the issue. Do not update any labels. This is the terminal transition. | -| CVE record has open **review comments / reviewer proposals** — detected in Step 1e from the record's own `comments[]` via the CVE-tool adapter's authenticated record fetch (ASF default: `vulnogram-api-record-fetch --comments-only`), with the `` notification as the fallback signal source and the thread the courtesy reply lands on. Which comments are *open* is computed by slug against the processed-ledger markers in the status rollup (Step 1e). | Surface each open review comment in Step 2a, verbatim-quoted, with **clickable links** to the Gmail thread and to the CVE record on `` (the reader can authenticate in-browser to see live state). For each one that maps cleanly to a tracking-issue body field (CWE, Affected versions, Reporter credited as, Public advisory URL, Short public summary), **propose the matching body-field update** as a numbered item in Step 2b: the body is the source of truth for the CVE JSON, so the Step 5 regen carries the update and the release manager is left with the Vulnogram paste + comment-resolution click. Comments that map to no body field (severity/CVSS, out-of-scope challenges, free-form rewrites) are surfaced verbatim and flagged for human decision. Search recipe, mapping table and courtesy reply: Step 1e. | +| CVE record has open **review comments / reviewer proposals** — detected in Step 1e from the record's own `comments[]` via the CVE-tool adapter's authenticated record fetch (`cve-tool fetch --comments-only`), with the `` notification as the fallback signal source and the thread the courtesy reply lands on. Which comments are *open* is computed by slug against the processed-ledger markers in the status rollup (Step 1e). | Surface each open review comment in Step 2a, verbatim-quoted, with **clickable links** to the Gmail thread and to the CVE record on `` (the reader can authenticate in-browser to see live state). For each one that maps cleanly to a tracking-issue body field (CWE, Affected versions, Reporter credited as, Public advisory URL, Short public summary), **propose the matching body-field update** as a numbered item in Step 2b: the body is the source of truth for the CVE JSON, so the Step 5 regen carries the update and the release manager is left with the Vulnogram paste + comment-resolution click. Comments that map to no body field (severity/CVSS, out-of-scope challenges, free-form rewrites) are surfaced verbatim and flagged for human decision. Search recipe, mapping table and courtesy reply: Step 1e. | | The referenced `` PR has been opened but is still in `open` state | Propose `pr created` label; update the *"PR with the fix"* body field with the PR URL. | | The referenced `` PR moved to `merged` | Propose swapping `pr created` → `pr merged`; update the milestone to the shipping release if now known. **Also** check that all six mandatory CVE body fields are populated (*CWE*, *Affected versions*, *Severity*, *Reporter credited as*, *Short public summary for publish*, *PR with the fix*). *Reporter credited as* also counts as populated when it is empty / `_No response_` **and** the status rollup records a no-finder decision under the [finder-credit policy](../../../../tools/cve-tool-vulnogram/finder-credit-policy.md) — the reporter declined credit, the report earned none (Rule 1), or an internal / scanner finding has no public credit name (Rule 2); the other five are populated only when filled. If any is empty / `_No response_` without that exception, propose posting (or PATCH-updating) the *Remediation-developer fill-fields comment* per [the dedicated bullet in Step 2b](SKILL.md#step-2--build-a-proposal-do-not-apply-anything-yet); the tracker stays assigned to the remediation developer until the fields are complete. This is the **first** of two firing points for the fill-fields comment; the second is the `pr merged` → `fix released` row below. | | The *"PR with the fix"* body field has at least one PR URL **and** the *"Remediation developer"* body field is missing the PR author's name (or is `_No response_`) | Propose appending the PR author's display name (`author.name`, else `author.login`, from the Step 1b PR fetch — no extra call) to the *"Remediation developer"* body field. **Append, never overwrite**: manual edits (co-authors added by the triager, name spelling corrections, "Anonymous" overrides) must survive later syncs. Run once per fresh PR URL added to the field; skip if the resolved name is already present (case-insensitive substring match). **Apply the [bot/AI credit policy](../../../../tools/cve-tool-vulnogram/bot-credits-policy.md) to the resolved name + handle first** — if the PR author matches the bot detection rule (`*[bot]` suffix, known-bot list, `*-bot`/`*-ai`/`*-agent`/`*-gpt` suffix patterns), do **not** propose the append; surface *"skipped credit: `` (matches bot policy — ``)"* in Step 2 instead. The user can override per the policy doc. The CVE JSON generator emits one `type: "remediation developer"` credit per line of the field on its next regeneration; see the *"Auto-resolve --remediation-developer"* note in Step 5 for the historical CLI-flag fallback. | @@ -289,7 +289,7 @@ each becomes a proposed body-field update, label change, or next-step recommenda | The *"CWE"* body field is populated with a bare `CWE-NNN` token (no description text) — e.g. `CWE-22` or `CWE-502` alone, without the canonical short description that follows in the format `CWE-NNN: ` | Propose expanding the field to `CWE-NNN: <Canonical Title>` per the MITRE CWE catalog (e.g. `CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')`, `CWE-502: Deserialization of Untrusted Data`, `CWE-601: URL Redirection to Untrusted Site ('Open Redirect')`). **Prefer a CWE from the project's *advised CWEs* list** when one is declared in [`<project-config>/scope-labels.md`](../../../../<project-config>/scope-labels.md) or the project's CVE-tool config. The published `problemTypes[].descriptions[].description` is the text readers see, and a bare number tells them nothing. Apply on every sync run that surfaces a bare CWE token; then regenerate the CVE JSON attachment so `problemTypes[]` carries the expanded form. | | The tracker's *Security mailing list thread* body field references a **private scanner product** (declared in [`<project-config>/scanner-products.md`](../../../../<project-config>/scanner-products.md) — e.g. internal SAST, partner-shared scan, unpublished bug-bounty pipeline) **AND** the *Reporter credited as* body field names a person rather than `anonymous` / a public handle, **AND** there is no signal the finder consented to public credit (no inbound `security@` message from them under their own name, no public HackerOne / huntr.dev report URL on the thread, no explicit *"please credit me as `<name>`"* line). | Propose rewriting the *Reporter credited as* field to the scanner's **public credit name** (the `Public credit name` column in [`<project-config>/scanner-products.md`](../../../../<project-config>/scanner-products.md)), which the CVE-JSON generator emits with `type: "tool"` per [`bot-credits-policy.md`](../../../../tools/cve-tool-vulnogram/bot-credits-policy.md) — and, when the scanner declares no public credit name, **omitting the `finder` credit** rather than writing a placeholder, per [Rule 2 of the finder-credit policy](../../../../tools/cve-tool-vulnogram/finder-credit-policy.md) — and stripping the scanner product name from the *Short public summary for publish* body field text (e.g. *"Mythos scan flagged that…"* → drop the scanner-product clause; *"Imported from internal SAST"* → drop). **Audit-trail surfaces stay untouched**: the *Security mailing list thread* body field, the status-rollup comment, and the Gmail thread keep the original scanner-product + person-name references for security-team auditing; only the CVE-record-bound surfaces (summary, credit) get the scrub. Scanner product names are commercial / IP-sensitive, privately sourced finders have not consented to public credit, and the combination leaks the discovery channel. Apply on every sync run that surfaces the signal; the generator reads *Reporter credited as* verbatim into `credits[]` and the summary into `descriptions[].value`. The same scrub re-runs as the sixth pre-push hygiene gate in [Step 5b 1b](apply-and-push.md#decision-flow), for a body update missed at proposal time. **Exempt cases**: when the finder already self-credited under a public name (HackerOne report URL, huntr.dev public report URL, the reporter's own `security@` message naming themselves), keep the named credit. | | The **issue title** contains adopter-specific or internal noise that would otherwise ship to the public CVE record — leading or trailing project-name tokens (e.g. ``<vendor> <product>:`` / ``in <vendor> <product>`` / ``(<vendor> <product> X.Y)``), internal split markers (``(split from #NNN)`` / ``(split for scope clarity from #NNN)``), report-form classifiers (``[ Security Report ]`` / ``[Security Issue]``), external-tracker IDs in parentheses or brackets (``[GHSA-xxxx-xxxx-xxxx]``, ``(ZDRES-NNNNN)``, ``(HUNTR-NNNNN)``, ``(GHSL-NNNN-NNN)``), version-noise suffixes (``(v3.2.1)``, ``(3.x)``), prior-CVE-relationship parentheticals anywhere in the title (``(CVE-YYYY-NNNNN)`` / ``(possible CVE-YYYY-NNNNN variant)`` / ``(incomplete fix for CVE-YYYY-NNNNN)`` / ``(fix-bypass of CVE-YYYY-NNNNN)`` — the cross-CVE relationship belongs in the public summary's Gate #3 clause, never in the title), or trailing reporter-name attribution parentheticals (``(Evan Ricafort follow-up)`` / ``(<name> follow-up)`` — reporter attribution belongs in the credits field, never in the public title). The check applies on every sync pass, including trackers whose title was previously clean but has drifted since allocation. | Propose updating the title via `gh issue edit <N> --title "<cleaned>"`. **Reuse the [`security-cve-allocate` Step 2 title-strip cascade](../cve-allocate/title-normalize.md#step-2--compute-the-cve-ready-title)** — both the leading-pattern set (project-name tokens, `Security (Report\|Issue\|Vulnerability\|Bug)` prefixes) and the trailing-pattern set (`in (<vendor> )?<product>`, GHSA/ZDRES/HUNTR/GHSL trailing IDs, `(split from #N)` parentheticals). Re-run it on every pass even though `security-cve-allocate` strips at allocation time: the CVE-JSON generator reads the title **verbatim** into `containers.cna.title`, and titles drift after allocation (manual edits, sibling-tracker splits, GHSA-relay imports that append the GHSA ID). **Preserve stripped context as audit trail** in the issue body (a `### Related references` section near the bottom) or in the rollup — internal pointers like *"split from [#NNN](https://github.com/<tracker>/issues/<N>)"* move off the title, not out of the tracker. After updating the title, regenerate the CVE JSON attachment so the published `title` reflects the cleaned value. If the strip would leave fewer than 3 words, **flag the ambiguity** in the proposal (as `security-cve-allocate` does) and let the user override — over-stripping is worse than one redundant word. | -| A release carrying the fix has shipped. Detection is **scope-dependent**: each scope label can ride its own release train, with its own *"is it released?"* signal (which artifact registry to consult, what to query, how to map a tracker's milestone to it, partial-release edge cases), per [`<project-config>/scope-labels.md` — *Detecting that a fix release has shipped*](../../../../<project-config>/scope-labels.md#detecting-that-a-fix-release-has-shipped). An explicit *fix shipped in X.Y.Z* comment counts for every scope. **Read the release signal from an authoritative source:** the project's release **announcement** (mailing-list `[ANNOUNCE]` / registry release feed) and, where available, the package **changelog naming the fix PR** and/or the **git-ancestry of the fix commit under the release tag** (e.g. a `compare <release-tag>...<merge-commit>` that reports the commit as an ancestor). **Do not infer release state from a prose-summarizing fetch of a large package-index JSON API**: an LLM `WebFetch`-style summary can mangle the version / upload date into a *false "not released"*, stranding a shippable advisory at `pr merged` with no RM hand-off; use a structured read or the announcement instead. A false negative withholds a hand-off, so when the signals disagree, trust the announcement + changelog over any summarized fetch. | **Two-stage gate: every mandatory CVE field must be populated AND the CVE record state in Vulnogram must be `REVIEW`.** Run both checks before proposing the label swap or the assignee swap. **Stage 1 — body fields**: all six populated (not empty, not `_No response_`): *CWE*, *Affected versions*, *Severity*, *Reporter credited as*, *Short public summary for publish*, *PR with the fix* — with the same rollup-recorded no-finder exception for *Reporter credited as* as the `pr merged` row above. If any is missing, **do NOT propose the hand-off**; propose posting (or PATCH-updating) the *Remediation-developer fill-fields comment* per the dedicated bullet in Step 2b — the issue stays assigned to the remediation developer; no label swap, no assignee swap, no RM hand-off. **Stage 2 — CVE state**: with Stage 1 clear, Step 5b's `vulnogram-api-record-update` push includes `body.CNA_private.state = "REVIEW"` (auto-promote; see Step 5b). After the push, verify the record state is `REVIEW` (via `vulnogram-api-record-fetch` / the equivalent state probe). If it is still `DRAFT` (push failed, CNA-schema validation rejected the JSON, transient error), **re-fire the fill-fields comment** with the refreshed blocker description, and **do NOT propose the hand-off / label swap / assignee swap on this pass**: the RM never receives a hand-off while the record is in `DRAFT`. **When both stages are clear (state == REVIEW)**: propose swapping `pr merged` → `fix released` (Step 12), the release manager's cue to own Steps 13–15 (advisory send → URL capture → Vulnogram PUBLIC → close). **Also propose swapping the assignee from the remediation developer to the release manager**, looked up via the three-source cascade in Step 2c ([`<project-config>/release-trains.md`](../../../../<project-config>/release-trains.md) "Release managers for releases currently relevant to the security tracker" → Release Plan wiki → `[RESULT][VOTE]` thread on `dev@`). Full rule: the *Assignee hand-off at the `fix released` transition* paragraph under **Assignees** in Step 2b. | +| A release carrying the fix has shipped. Detection is **scope-dependent**: each scope label can ride its own release train, with its own *"is it released?"* signal (which artifact registry to consult, what to query, how to map a tracker's milestone to it, partial-release edge cases), per [`<project-config>/scope-labels.md` — *Detecting that a fix release has shipped*](../../../../<project-config>/scope-labels.md#detecting-that-a-fix-release-has-shipped). An explicit *fix shipped in X.Y.Z* comment counts for every scope. **Read the release signal from an authoritative source:** the project's release **announcement** (mailing-list `[ANNOUNCE]` / registry release feed) and, where available, the package **changelog naming the fix PR** and/or the **git-ancestry of the fix commit under the release tag** (e.g. a `compare <release-tag>...<merge-commit>` that reports the commit as an ancestor). **Do not infer release state from a prose-summarizing fetch of a large package-index JSON API**: an LLM `WebFetch`-style summary can mangle the version / upload date into a *false "not released"*, stranding a shippable advisory at `pr merged` with no RM hand-off; use a structured read or the announcement instead. A false negative withholds a hand-off, so when the signals disagree, trust the announcement + changelog over any summarized fetch. | **Two-stage gate: every mandatory CVE field must be populated AND the CVE record state must be `review-ready`.** Run both checks before proposing the label swap or the assignee swap. **Stage 1 — body fields**: all six populated (not empty, not `_No response_`): *CWE*, *Affected versions*, *Severity*, *Reporter credited as*, *Short public summary for publish*, *PR with the fix* — with the same rollup-recorded no-finder exception for *Reporter credited as* as the `pr merged` row above. If any is missing, **do NOT propose the hand-off**; propose posting (or PATCH-updating) the *Remediation-developer fill-fields comment* per the dedicated bullet in Step 2b — the issue stays assigned to the remediation developer; no label swap, no assignee swap, no RM hand-off. **Stage 2 — CVE state**: with Stage 1 clear, Step 5b's `cve-tool push` includes the `review-ready` state (for Vulnogram, `body.CNA_private.state = "REVIEW"`; auto-promote, see Step 5b). After the push, verify the record state is `review-ready` (via `cve-tool fetch --state-only`). If it is still `allocated` (push failed, CNA-schema validation rejected the JSON, transient error), **re-fire the fill-fields comment** with the refreshed blocker description, and **do NOT propose the hand-off / label swap / assignee swap on this pass**: the RM never receives a hand-off while the record is `allocated`. **When both stages are clear (state == review-ready)**: propose swapping `pr merged` → `fix released` (Step 12), the release manager's cue to own Steps 13–15 (advisory send → URL capture → CVE record public → close). **Also propose swapping the assignee from the remediation developer to the release manager**, looked up via the three-source cascade in Step 2c ([`<project-config>/release-trains.md`](../../../../<project-config>/release-trains.md) "Release managers for releases currently relevant to the security tracker" → Release Plan wiki → `[RESULT][VOTE]` thread on `dev@`). Full rule: the *Assignee hand-off at the `fix released` transition* paragraph under **Assignees** in Step 2b. | | New or edited comment on the GHSA discussion (`<sm> ghsa-discussion`, [`github-advisory.md`](github-advisory.md#read-the-advisory-discussion)) | Treat a reporter or other non-team comment like a new `<security-list>` message from the reporter: extract its signals with this table, quote it into the observed state with its `#advisory-comment-<id>` link, and, when `awaiting_team_reply`, propose a reporter reply through the advisory comments API. Advance the `ghsa-discussion` marker in the apply. | | GHSA state transition (opened, accepted, published, rejected) in a GHSA-forwarded email | If the GHSA is closed as "not accepted" but the security team accepted the report on `security@`, flag the divergence in the status comment so it is not lost. | | Team member saying *"let's also backport to v3-2-test"* / *"please mark X for backport"* | Note the requested backport label on the public PR as an item for Step 9 of the `security-issue-fix` workflow. | @@ -333,7 +333,7 @@ and determine which numbered step of the process the issue is currently at: | Release with the fix has shipped, advisory not sent yet (swap `pr merged` → `fix released`) | 12 | | `fix released` set, advisory not yet sent — release manager owns the advisory | 13 | | Advisory sent, no archive URL yet (no labels flipped; the `fix released → announced` label flip is deferred to the combined "archive URL captured" apply) | 13 → 14 | -| **Archive URL captured** — sync's combined close-out apply fires at this moment: URL and public short summary into the body, `fix released → announced - emails sent + announced`, JSON regen + re-push, Vulnogram record `REVIEW → PUBLIC` via API, board to `Announced` (when the project declares that column), close the tracker, **archive the tracker from the board**, **close the milestone if last-sibling**, and post the purely-informational wrap-up comment as a timeline marker (no manual asks). Full sequence: the `Advisory archived on <users-list>` row in [1d](#1d-mine-comments-and-mail-messages-for-actionable-signals). | 14 → 15 | +| **Archive URL captured** — sync's combined close-out apply fires at this moment: URL and public short summary into the body, `fix released → announced - emails sent + announced`, JSON regen + re-push, CVE record `review-ready → public` via `cve-tool publish`, board to `Announced` (when the project declares that column), close the tracker, **archive the tracker from the board**, **close the milestone if last-sibling**, and post the purely-informational wrap-up comment as a timeline marker (no manual asks). Full sequence: the `Advisory archived on <users-list>` row in [1d](#1d-mine-comments-and-mail-messages-for-actionable-signals). | 14 → 15 | | **Closed**, `announced` set, cve.org check **not yet run** for this tracker since close | post-15 (cve.org publication check — see [1g](#1g-recently-closed-trackers--check-cveorg-publication-state)) | | Closed, credits missing | 16 | diff --git a/plugins/magpie-security/skills/issue-sync/next-step.md b/plugins/magpie-security/skills/issue-sync/next-step.md index c29545e6d..a5fa06612 100644 --- a/plugins/magpie-security/skills/issue-sync/next-step.md +++ b/plugins/magpie-security/skills/issue-sync/next-step.md @@ -16,7 +16,7 @@ updates land, based on the process step. Examples: - *"Step 10: close the private PR at <tracker>#NNN now that <upstream>#NNNN has merged."* - *"Step 11: `pr merged` — tracker parked until the release train ships. No action needed from the security team; the next sync run will detect the PyPI / Helm release and propose the `fix released` swap (Step 12)."* - *"Step 12: `fix released` — the release carrying the fix is now on PyPI / the Helm registry. Ownership of the issue has transferred to the release manager; the label swap was the hand-off."* -- *"Step 13: the release manager should now fill in the CVE tool fields taken from the issue — CWE, product, versions, severity, patch link, credits — move the CVE to REVIEW → READY, and send the advisory to `<announce-list>` / `<users-list>`."* +- *"Step 13: the release manager should now fill in the CVE tool fields taken from the issue — CWE, product, versions, severity, patch link, credits — move the CVE record to `review-ready` → `publish-ready` (Vulnogram: `REVIEW` → `READY`), and send the advisory to `<announce-list>` / `<users-list>`."* - *"Step 14: scan the users@ archive for the CVE ID, populate the *Public advisory URL* body field, regenerate the CVE JSON attachment, and move the issue to `announced`. Sync does all of this automatically on the next run once the advisory is archived."* - *"Step 15: release manager — copy the regenerated CVE JSON into Vulnogram, close the issue."* diff --git a/plugins/magpie-security/skills/issue-sync/outcomes/cve-json-fallback.md b/plugins/magpie-security/skills/issue-sync/outcomes/cve-json-fallback.md index 2b151eef9..8269e1539 100644 --- a/plugins/magpie-security/skills/issue-sync/outcomes/cve-json-fallback.md +++ b/plugins/magpie-security/skills/issue-sync/outcomes/cve-json-fallback.md @@ -17,7 +17,7 @@ The tool keeps it `DRAFT` while *Short public summary for publish*, *Affected ve a `needs_decision` on one of those template fields stops the run with exit 2 and no record. An empty *Reporter credited as* does **not** hold the record back (anonymous and internally found reports have none, and a remediation credit suffices), and neither does a duplicate of a field the tool does not read. -Never advance the record (Vulnogram push, `REVIEW`, advisory) on a run whose report lists one of those five fields as missing; +Never advance the record (`cve-tool push`, `review-ready`, advisory) on a run whose report lists one of those five fields as missing; a `cna_private_state` other than `DRAFT` on such a run is a bug to report, not a state to act on. **Which model.** The proposals come from the model already running this skill, in this session. diff --git a/plugins/magpie-security/skills/issue-sync/release-manager-comments.md b/plugins/magpie-security/skills/issue-sync/release-manager-comments.md index 526d34d4d..2d038c18c 100644 --- a/plugins/magpie-security/skills/issue-sync/release-manager-comments.md +++ b/plugins/magpie-security/skills/issue-sync/release-manager-comments.md @@ -22,12 +22,12 @@ Part of the Step 2b lookup in [`signals-to-actions.md`](signals-to-actions.md), If any field is empty / `_No response_` without that exception, propose the fill-fields comment with that field list. The issue stays assigned to the remediation developer (in the common case also the fix-PR author and current assignee). **Do not propose any RM-related action at Step 11**; that belongs to Step 12. - 2. **At the `pr merged` → `fix released` transition (Step 12)** — after Step 5b's push attempt, check the CVE record state in Vulnogram. - If it is still `DRAFT` for any reason (a body field still empty, the push blocked, + 2. **At the `pr merged` → `fix released` transition (Step 12)** — after Step 5b's push attempt, check the CVE record state (`cve-tool fetch --state-only`). + If it is still `allocated` (Vulnogram: `DRAFT`) for any reason (a body field still empty, the push blocked, or the push landed but the state did not advance because the JSON failed CNA-schema validation), **re-fire** the fill-fields comment with the refreshed list of what is still blocking. - **Do not** fire the RM hand-off, flip the label to `fix released`, or swap the assignee — those all gate on `state == REVIEW`. - A later sync that finds the state promoted to `REVIEW` clears the gate and fires the RM hand-off then. + **Do not** fire the RM hand-off, flip the label to `fix released`, or swap the assignee — those all gate on `state == review-ready`. + A later sync that finds the state promoted to `review-ready` clears the gate and fires the RM hand-off then. **Idempotency + PATCH-in-place**. Same shape as the hand-off comment: scan for the marker @@ -77,13 +77,13 @@ Part of the Step 2b lookup in [`signals-to-actions.md`](signals-to-actions.md), **This is its own first-class comment, not a rollup entry**: it is the release manager's one-shot orientation, which a `<details>` block would bury. - **Trigger — gated on `state == REVIEW`.** Fires *exactly once* per tracker, at the pass that proposes `pr merged` → `fix released` - **AND** finds the CVE record state in Vulnogram is `REVIEW` - (verified after Step 5b's push attempt — the push includes the `body.CNA_private.state = - "REVIEW"` advance when all six mandatory body fields are populated). - When the record is still `DRAFT` after the push attempt, **do not** fire this hand-off; + **Trigger — gated on `state == review-ready`.** Fires *exactly once* per tracker, at the pass that proposes `pr merged` → `fix released` + **AND** finds the CVE record state is `review-ready` + (verified after Step 5b's push attempt — the push includes the `review-ready` advance, for Vulnogram `body.CNA_private.state = + "REVIEW"`, when all six mandatory body fields are populated). + When the record is still `allocated` after the push attempt, **do not** fire this hand-off; fire the *Remediation-developer fill-fields comment* instead and leave the tracker assigned to the remediation developer. - **The RM must never receive this hand-off while the record is in `DRAFT`** — the template body asserts it, so the RM can recognise a misfire. + **The RM must never receive this hand-off while the record is `allocated`** — the template body asserts it, so the RM can recognise a misfire. Do not propose it earlier than Step 12, nor again once posted (idempotency check below). **Idempotency + variant edit-in-place.** Before proposing, scan @@ -118,7 +118,7 @@ Part of the Step 2b lookup in [`signals-to-actions.md`](signals-to-actions.md), `tools/<cve-tool>/release-manager-handoff-comment-oauth-pushed.md` (for Vulnogram: [`tools/cve-tool-vulnogram/release-manager-handoff-comment-oauth-pushed.md`](../../../../tools/cve-tool-vulnogram/release-manager-handoff-comment-oauth-pushed.md)). - Used when Step 5b's `vulnogram-api-record-update` succeeded + Used when Step 5b's `cve-tool push` succeeded this sync run. - **Manual-paste variant (today's default)** — `tools/<cve-tool>/release-manager-handoff-comment.md` @@ -167,10 +167,10 @@ Part of the Step 2b lookup in [`signals-to-actions.md`](signals-to-actions.md), - **Publication-ready notification comment** — when this pass proposes populating the *Public advisory URL* body field (Step 14 — the *Advisory archived on `<users-list>`* row of the Step 1d table), **also** propose a separate publication-ready notification comment telling the release manager that the archive URL is captured, - the JSON is regenerated with it as a `vendor-advisory` reference, and the final paste + `READY` → `PUBLIC` move is unblocked. + the JSON is regenerated with it as a `vendor-advisory` reference, and the final paste + `publish-ready` → `public` move is unblocked. **Why a second comment instead of one comment with two states.** - The Step 12 hand-off ends at `READY` and points to this follow-up for `PUBLIC`; + The Step 12 hand-off ends at `publish-ready` and points to this follow-up for `public`; a fresh comment gives the RM a dated surface and a working `@`-mention. **Trigger.** Fires *exactly once* per tracker, at the pass that proposes the *Public advisory URL* body update — diff --git a/plugins/magpie-security/skills/issue-sync/signals-to-actions.md b/plugins/magpie-security/skills/issue-sync/signals-to-actions.md index 4212ed488..950be1b6c 100644 --- a/plugins/magpie-security/skills/issue-sync/signals-to-actions.md +++ b/plugins/magpie-security/skills/issue-sync/signals-to-actions.md @@ -124,7 +124,7 @@ Group them by category: and propose self-assigning a team member only if the user explicitly asks. **Assignee hand-off at the `fix released` transition (and its repair).** At the `fix released` transition (Step 12 — the fix has shipped to PyPI / the Helm registry), - ownership moves from the remediation developer to the release manager for Steps 13–15 (advisory send → URL capture → Vulnogram PUBLIC → close). + ownership moves from the remediation developer to the release manager for Steps 13–15 (advisory send → URL capture → CVE record public → close). **Propose swapping the assignee from the remediation developer to the release manager** in the same sync run that flips `pr merged` → `fix released`, as a concrete numbered item. Look up the release manager with the three-source cascade from Step 2c @@ -286,7 +286,7 @@ Group them by category: - The `Short public summary` already mentions the prior `CVE-YYYY-NNNNN` token but lacks the *"users who applied the prior fix should also..."* clause. - The rollup carries a *"sibling tracker"* / *"split for scope clarity"* / *"follow-up to `<PRIOR-CVE-ID>`"* entry. - - The body or title mentions *"incomplete fix for `<CVE-ID>`"*, *"follow-up to `<CVE-ID>`"*, or *"split from"* a sibling tracker whose CVE is already PUBLIC; + - The body or title mentions *"incomplete fix for `<CVE-ID>`"*, *"follow-up to `<CVE-ID>`"*, or *"split from"* a sibling tracker whose CVE is already public; an *"incomplete fix for `<PRIOR-CVE-ID>`"* title parenthetical is stripped by the title-strip cascade, but the relationship is preserved in body / rollup. - The CVE record's `affected[]` names a different `packageName` than the prior CVE's record, AND the prior CVE is on the same root-cause class. @@ -329,7 +329,7 @@ Group them by category: When neither key is declared, use the thread permalink the archive returned, or ask the release manager; never construct one. **A search hit is a candidate, not the advisory.** The search matches any public post that mentions the CVE ID - (a user question, a reply, a forward), and capturing this URL is what moves the CVE record to `PUBLIC`. + (a user question, a reply, a forward), and capturing this URL is what moves the CVE record to `public`. For each hit, fetch the thread (`mcp__ponymail__get_thread`, the HTTP API, or the archive's own thread page) for its list, root subject and root sender, write `{cve_id, url, thread: {list, subject, from}, advisory_list: <users-list>, mail_archive_url, authorized_senders}` to a file and run `security-management issue-sync advisory-archive --input <file>`; diff --git a/pyproject.toml b/pyproject.toml index a9c22905a..a6f3ac619 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -114,6 +114,7 @@ jira-bridge = { workspace = true } release-config = { workspace = true } github-body-field = { workspace = true } generate-cve-json = { workspace = true } +vulnogram-api = { workspace = true } magpie-vcs = { workspace = true } @@ -130,6 +131,7 @@ members = [ "tools/egress-gateway", "tools/container-gateway", "tools/contributor-metrics", + "tools/cve-tool", "tools/cve-tool-vulnogram/generate-cve-json", "tools/cve-tool-vulnogram/oauth-api", "tools/github-body-field", diff --git a/tools/cve-tool/README.md b/tools/cve-tool/README.md index a97d3fefe..1fda62c43 100644 --- a/tools/cve-tool/README.md +++ b/tools/cve-tool/README.md @@ -9,6 +9,10 @@ - [Prerequisites](#prerequisites) - [What this is](#what-this-is) - [Today's adapters](#todays-adapters) + - [The `cve-tool` command](#the-cve-tool-command) + - [Operations](#operations) + - [Exit codes](#exit-codes) + - [Adding an adapter](#adding-an-adapter) - [Interface](#interface) - [`allocate(scope, fields) to cve_id`](#allocatescope-fields-to-cve_id) - [`fetch_current_state(cve_id) to {state, fields}`](#fetch_current_statecve_id-to-state-fields) @@ -35,34 +39,21 @@ ## Prerequisites -- **Runtime:** None — this directory is a Markdown contract spec; no executable code ships here. It is read by the framework, not run. +- **Runtime:** Python 3.11+ and [`uv`](https://docs.astral.sh/uv/). The `cve-tool` command (`uv run --project <framework>/tools/cve-tool cve-tool <operation>`) is stdlib-only; it reads `cve_authority.tool` and runs that tool's adapter. - **CLIs / credentials / network:** Provided entirely by the resolved adapter — the sibling `tools/cve-tool-<name>/` directory named by `cve_authority.tool` in `project.md` (the ASF default is Vulnogram at `tools/cve-tool-vulnogram/`). See that adapter for its concrete prerequisites. ## What this is -The framework's CVE-tooling adapter contract. Today every CVE-aware -skill in this repository — `security-cve-allocate`, -`security-issue-sync`, `security-issue-invalidate`, -`security-issue-deduplicate` — speaks Vulnogram. They embed -Vulnogram's URL prefix (`cveprocess.apache.org`), Vulnogram's -`#source` / `#json` / `#email` tab shape, and Vulnogram's `DRAFT` → -`REVIEW` to `READY` to `PUBLIC` state machine directly in the -skill prose. That is fine for the ASF — the foundation runs a single -CNA tool and the skills can afford to be Vulnogram-shaped — but it -forecloses every other plausible adopter. A project running CVE.org's -direct submission portal, the MITRE form for non-CNAs, GHSA's own -CNA flow, or no CVE allocation at all has no seam to plug into. -This contract is that seam. It defines the methods every CVE-tool -backend must implement, the lifecycle moments at which the skills -fire each method, and the generic state verbs the skills use to -talk about a CVE record without baking in any one tool's vocabulary. -Skills consume the contract; the adapter consumes the wire protocol -of whichever CNA tool the adopter actually uses. - -The contract is read by the framework, not by humans during normal -operation. New adopters declare `cve_authority.tool: <adapter>` in -`projects/<project>/project.md`; the skills resolve that to a -sibling directory under `tools/` and call the methods named here. +The framework's CVE-tooling adapter contract, and the `cve-tool` command that implements it. +A project running the ASF's Vulnogram, CVE.org's direct submission portal, the MITRE form for non-CNAs, GHSA's own CNA flow, +or no CVE allocation at all needs the security skills to handle a CVE record the same way. +This contract is the seam that makes that possible. +It defines the operations every CVE-tool backend implements, the lifecycle moments at which the skills run each one, +and the generic state verbs the skills use to talk about a CVE record without baking in any one tool's vocabulary. + +The skills and the [`security-management`](../security-management/) helpers never run a CVE tool's own commands: +they run [`cve-tool <operation>`](#the-cve-tool-command), which reads `cve_authority.tool` and hands the call to that tool's adapter. +Skills consume the contract; the adapter consumes the wire protocol of whichever CNA tool the adopter actually uses. Adopters who run Vulnogram inherit the ASF defaults verbatim. ## Today's adapters @@ -71,7 +62,7 @@ Only one CVE-tool adapter ships today: | Adapter | Directory | Status | Notes | |---|---|---|---| -| Vulnogram (ASF) | `tools/cve-tool-vulnogram/` | Shipping | The reference implementation. PR4 of the ASF-pluggable security flow renames the directory to `tools/cve-tool-vulnogram/` to match the contract's adapter-naming rule. **Not renamed in this PR.** | +| Vulnogram (ASF) | `tools/cve-tool-vulnogram/` | Shipping | The reference implementation. `cve-tool` runs it through [`src/cve_tool/adapters/vulnogram.py`](src/cve_tool/adapters/vulnogram.py), which calls the [`oauth-api`](../cve-tool-vulnogram/oauth-api/README.md) commands. | | CVE.org direct submission | `tools/cve-tool-cve-org-direct/` *(planned)* | Placeholder | For adopters who are themselves a CNA and submit records straight to `cve.org` via the CVE Services API rather than through an intermediate CNA tool. | | MITRE form | `tools/cve-tool-mitre-form/` *(planned)* | Placeholder | For adopters who are not a CNA and request CVE IDs via the [MITRE CVE Request form](https://cveform.mitre.org/). Allocation is asynchronous and operator-mediated; `fetch_current_state` will frequently return `unknown` until the form's email reply arrives. | | GHSA-as-CNA | `tools/cve-tool-ghsa/` *(planned)* | Placeholder | For adopters who already drive their advisory flow through GitHub Security Advisories (GHSA) and use GitHub as the CNA. `allocate` becomes a GHSA draft create; `publish` becomes a GHSA publish. | @@ -79,10 +70,73 @@ Only one CVE-tool adapter ships today: The four placeholder adapters do not exist on disk yet; they exist in this table so the contract can describe the surface they will fit -into when an adopter actually needs one. PR4 of the ASF-pluggable -security flow will create `tools/cve-tool-vulnogram/` (the rename -target) but will not pre-create the other three — those land when an -adopter implements them. +into when an adopter actually needs one. +They land when an adopter implements them; +until then `cve-tool` refuses them with exit `21` (see [Exit codes](#exit-codes)). + +## The `cve-tool` command + +```bash +uv run --project <framework>/tools/cve-tool cve-tool [--tool NAME] [--project-root DIR] [--config-dir DIR] <operation> [args ...] +``` + +The command resolves `cve_authority.tool`, first hit wins: +`--tool`, then `<project-config>/project.md`, then the project's organization manifest +(`organizations/<org>/organization.md`, in-tree, then adopter-local), the chain AGENTS.md describes for every dotted config key. +`--project-root` (default: the current directory) is the adopter repository the configuration is read from. +`cve-tool which` prints, as JSON, the tool it resolved, where the value came from, and the operations its adapter implements. + +Everything after the operation goes to the adapter unchanged, +so the flags, output and exit codes are the adapter's own (for Vulnogram, see the [`oauth-api` README](../cve-tool-vulnogram/oauth-api/README.md)). +That makes the order matter: `cve-tool`'s own options (`--tool`, `--project-root`, `--config-dir`) go **before** the operation, +and the operation's own arguments go after it. + +```bash +cve-tool --project-root /path/to/adopter fetch --cve-id CVE-2026-12345 --state-only # right +cve-tool fetch --project-root /path/to/adopter --cve-id CVE-2026-12345 --state-only # refused, exit 2 +``` + +`cve-tool` refuses one of its own options after the operation with a usage error naming it, +before the adapter runs, rather than letting the adapter reject an argument it does not know. + +### Operations + +| Operation | Contract method | Vulnogram adapter runs | +|---|---|---| +| `allocate` | [`allocate`](#allocatescope-fields-to-cve_id) | `vulnogram-api-allocate` | +| `fetch` | [`fetch_current_state`](#fetch_current_statecve_id-to-state-fields) | `vulnogram-api-record-fetch` | +| `push` | [`push_update`](#push_updatecve_id-fields-state_transitionnone-to-diff) | `vulnogram-api-record-update` | +| `publish` | [`publish`](#publishcve_id-to-ok) | `vulnogram-api-record-publish` | +| `retract` | [`retract`](#retractcve_id-reason-to-ok) | *not implemented*: reject the record in the Vulnogram UI | +| `check` | session probe | `vulnogram-api-check` | +| `setup` | one-time session setup | `vulnogram-api-setup` | + +An adapter translates its tool's states at the edge, so callers see only the [generic state verbs](#generic-state-verbs): +`cve-tool fetch --state-only` prints `review-ready`, not Vulnogram's `REVIEW`, +and `cve-tool publish --allow-state` takes a generic state as well as a native one. + +### Exit codes + +An adapter's own exit codes pass through. +The interface adds three, clear of every adapter's own `0`–`7`: + +| Exit | Meaning | What the skill does | +|---|---|---| +| `20` | No `cve_authority.tool` is declared anywhere in the chain. | Declare one in `project.md`, or pass `--tool`. | +| `21` | The tool has no adapter (a [planned](#todays-adapters) one, or an unknown name). | Follow the skill's manual path for the step. | +| `22` | The adapter does not implement the operation. | Follow the hint in the message (for Vulnogram's `retract`, the UI). | + +A usage error in `cve-tool`'s own arguments (an unknown operation, or one of its options after the operation) +exits `2`, as any `argparse` command does, and names the problem on stderr. + +### Adding an adapter + +An adapter is one module under [`src/cve_tool/adapters/`](src/cve_tool/adapters/) and one entry in `ADAPTERS` there. +The module exposes `OPERATIONS` (the operation names it implements), `run(operation, argv) -> int`, +and optionally `UNSUPPORTED`, a hint per operation it cannot do. +Its commands must not define an option named `--tool`, `--project-root` or `--config-dir`: +`cve-tool` reserves those names (`OWN_OPTIONS` in [`cli.py`](src/cve_tool/cli.py)) and refuses them after the operation. +The skills and helpers do not change: they already run `cve-tool <operation>`. ## Interface @@ -267,7 +321,9 @@ verbs are what the skills see. ## Skills that consume this contract -The CVE-tool contract is consumed by four skills today: +The CVE-tool contract is consumed by four skills today. +Each runs the operations as `cve-tool <operation>`, mostly through the commands the +[`security-management`](../security-management/) helpers print: - **`security-cve-allocate`** — calls `allocate` to reserve the CVE ID and create the initial record, then writes the ID back @@ -278,16 +334,16 @@ The CVE-tool contract is consumed by four skills today: - **`security-issue-sync`** — calls `fetch_current_state` at Step 1e (to surface reviewer-comment signals) and at Step 5b (to gate the release-manager hand-off on the post-push state - being `review-ready`). Calls `push_update` at Step 5c whenever - the tracker body has drifted from the tool's view. Calls + being `review-ready`). Calls `push_update` at Step 5b on every run + that regenerates the CVE JSON, so the record never drifts from the body. Calls `publish` at the publication step after the public advisory archive URL has been captured. - **`security-issue-invalidate`** — uses `fetch_current_state` at - its Step 0 gate to refuse closure when a CVE record exists and - is past `review-ready`. Calls `retract` when the tracker - carries a CVE ID and the consensus decision is "invalid after - allocation" (escalates first via `governance.escalation_contact`, - since CVE retraction has public consequences). + its Step 0 gate. A record in `allocated` or `review-ready` stops + the skill until it is retracted at the CVE tool (the retraction is + a separate, governance-gated flow); `publish-ready` or `public` + escalates to `governance.escalation_contact`, since an invalid + close there has public consequences. - **`security-issue-deduplicate`** — calls `push_update` on the kept tracker's CVE record after merging in credits and references from the duplicate tracker. The duplicate tracker's @@ -317,7 +373,7 @@ skills are tested against. Key properties of the ASF default: out as a load-bearing checkpoint before the advisory-send step. - **Source tab URL:** `https://cveprocess.apache.org/cve5/<CVE-ID>#source`. The copy-paste fallback target for the JSON record. The default - write path is the OAuth-authenticated API (`vulnogram-api-record-update`); + write path is the OAuth-authenticated API (`cve-tool push`, which runs `vulnogram-api-record-update`); copy-paste is the documented fallback when the OAuth session has expired or the operator has opted out. - **State machine:** `DRAFT` to `REVIEW` to `READY` to `PUBLIC`, @@ -337,17 +393,11 @@ skills are tested against. Key properties of the ASF default: webhook). This is the `cve_authority.publication_propagation: poll` setting in `project.md`. -**Rename pending.** PR4 of the ASF-pluggable security flow renames -`tools/cve-tool-vulnogram/` to `tools/cve-tool-vulnogram/` to match the -contract's adapter-naming rule. **This PR does not perform the -rename** — the existing skill prose and the `cve_authority.tool: -vulnogram` setting in `project.md` continue to point at -`tools/cve-tool-vulnogram/` until PR4 lands. - ## Configuration -Every adopter declares its CVE-tool choice in -`projects/<project>/project.md` under the `cve_authority` block. +A project inherits its CVE-tool choice from its organization's `cve_authority` block +(the ASF's names `vulnogram`, the `independent` baseline's names `mitre-form`), +and declares the block in `<project-config>/project.md` only to differ from it. The shape is: ```yaml @@ -365,12 +415,11 @@ cve_authority: Field-by-field: -- **`tool`** — names the adapter directory the skills resolve to. - The ASF default is `vulnogram` (resolves to `tools/cve-tool-vulnogram/`; - becomes `tools/cve-tool-vulnogram/` after PR4). Adopters using - a different CVE-tool backend pick one of the other four +- **`tool`** — names the adapter `cve-tool` runs. + The ASF default is `vulnogram` (the adapter in `src/cve_tool/adapters/vulnogram.py`, over `tools/cve-tool-vulnogram/`). + Adopters using a different CVE-tool backend pick one of the other four enumerated values, each of which is expected to resolve to a - sibling `tools/cve-tool-<name>/` directory. + sibling `tools/cve-tool-<name>/` directory and an adapter module (see [Adding an adapter](#adding-an-adapter)). - **`allocate_url`** — the URL the human operator opens to begin the allocation flow (for tool-mediated allocation paths) or the API endpoint the adapter POSTs to (for fully automated diff --git a/tools/cve-tool/pyproject.toml b/tools/cve-tool/pyproject.toml new file mode 100644 index 000000000..3725f65db --- /dev/null +++ b/tools/cve-tool/pyproject.toml @@ -0,0 +1,91 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "cve-tool" +version = "0.1.0" +description = "The CVE-tool interface: one tool-neutral command that runs fetch, push, publish and allocate through the adapter `cve_authority.tool` names." +readme = "README.md" +requires-python = ">=3.11" +license = { text = "Apache-2.0" } +# stdlib-only, like the members it uses: release-config reads +# `cve_authority.tool` through the project -> organization chain, and +# vulnogram-api is the ASF adapter's implementation. +dependencies = ["release-config", "vulnogram-api"] + +[project.scripts] +cve-tool = "cve_tool.cli:main" + +[tool.hatch.build.targets.wheel] +packages = ["src/cve_tool"] + +[tool.ruff] +line-length = 110 +target-version = "py311" +src = ["src", "tests"] + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "UP", # pyupgrade + "SIM", # flake8-simplify + "C4", # flake8-comprehensions + "RUF", # ruff-specific +] +ignore = [ + "E501", # line-too-long — the 110-char limit above is already generous +] + +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["B", "SIM"] # test clarity beats these + +[tool.mypy] +python_version = "3.11" +files = ["src", "tests"] +warn_unused_ignores = true +warn_redundant_casts = true +warn_unreachable = true +check_untyped_defs = true +no_implicit_optional = true +disallow_untyped_defs = true +disallow_incomplete_defs = true + +[[tool.mypy.overrides]] +module = ["release_config.*"] +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "tests.*" +disallow_untyped_defs = false +disallow_incomplete_defs = false + +[tool.pytest.ini_options] +minversion = "8.0" +addopts = "-ra -q" +testpaths = ["tests"] + +[dependency-groups] +# The shared toolchain (mypy, pytest, ruff) comes from `magpie-dev` +# (tools/dev), declared once for the whole workspace. +dev = ["magpie-dev"] diff --git a/tools/cve-tool/src/cve_tool/__init__.py b/tools/cve-tool/src/cve_tool/__init__.py new file mode 100644 index 000000000..b83481608 --- /dev/null +++ b/tools/cve-tool/src/cve_tool/__init__.py @@ -0,0 +1,37 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""The CVE-tool interface of `tools/cve-tool/README.md`, as a command. + +Skills and helpers run `cve-tool <operation>` instead of a tool's own +commands; the command resolves `cve_authority.tool` and hands the call to +that tool's adapter under :mod:`cve_tool.adapters`. +""" + +from __future__ import annotations + +# The operations the contract defines (`allocate`, `fetch_current_state`, +# `push_update`, `publish`, `retract`), by their command names, and the two +# session helpers every authenticated adapter needs. +OPERATIONS = ("allocate", "fetch", "push", "publish", "retract", "check", "setup") + +# The generic record states the skills speak in (README: Generic state verbs). +STATES = ("allocated", "review-ready", "publish-ready", "public", "retracted", "unknown") + +# Exit codes of the interface itself, clear of every adapter's own 0-7. +EXIT_NOT_DECLARED = 20 +EXIT_NO_ADAPTER = 21 +EXIT_UNSUPPORTED = 22 diff --git a/tools/cve-tool/src/cve_tool/adapters/__init__.py b/tools/cve-tool/src/cve_tool/adapters/__init__.py new file mode 100644 index 000000000..2dd21e399 --- /dev/null +++ b/tools/cve-tool/src/cve_tool/adapters/__init__.py @@ -0,0 +1,39 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""The adapter registry: `cve_authority.tool` value -> adapter module. + +An adapter module exposes `OPERATIONS` (the operation names it implements) +and `run(operation, argv) -> int`. Adding a CVE tool is one module here and +one entry in `ADAPTERS`; the skills and helpers do not change. +""" + +from __future__ import annotations + +import importlib +from types import ModuleType + +ADAPTERS = { + "vulnogram": "cve_tool.adapters.vulnogram", +} + +# Tools the contract names (README: Today's adapters) that have no adapter yet. +PLANNED = ("cve-org-direct", "mitre-form", "ghsa", "none") + + +def load(tool: str) -> ModuleType | None: + module = ADAPTERS.get(tool) + return importlib.import_module(module) if module else None diff --git a/tools/cve-tool/src/cve_tool/adapters/vulnogram.py b/tools/cve-tool/src/cve_tool/adapters/vulnogram.py new file mode 100644 index 000000000..d609f4689 --- /dev/null +++ b/tools/cve-tool/src/cve_tool/adapters/vulnogram.py @@ -0,0 +1,111 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""The Vulnogram adapter (ASF): the operations run `tools/cve-tool-vulnogram/oauth-api`. + +Each operation calls the matching `vulnogram_api` command's `main()` with the +arguments as given, so flags, output and exit codes are Vulnogram's own, with +two translations at the edge so callers only see the generic states: + +- `fetch --state-only` prints the generic state (`review-ready`), not + `CNA_private.state` (`REVIEW`); +- `publish --allow-state` takes a generic state as well as a native one. +""" + +from __future__ import annotations + +import contextlib +import importlib +import io +import sys +from collections.abc import Callable + +# operation -> the vulnogram_api module whose main() implements it +OPERATIONS = { + "check": "check", + "setup": "setup_session", + "allocate": "allocate", + "fetch": "record_fetch", + "push": "record_update", + "publish": "record_publish", +} + +# CNA_private.state -> generic state (README: Generic state verbs) +NATIVE_TO_GENERIC = { + "DRAFT": "allocated", + "REVIEW": "review-ready", + "READY": "publish-ready", + "PUBLIC": "public", + "REJECTED": "retracted", +} +GENERIC_TO_NATIVE = {generic: native for native, generic in NATIVE_TO_GENERIC.items()} + +UNSUPPORTED = { + "retract": "reject the record in the Vulnogram UI; the API helpers have no reject call", +} + + +def generic_state(native: str) -> str: + return NATIVE_TO_GENERIC.get(native.strip().upper(), "unknown") + + +def _entry(operation: str) -> Callable[[list[str] | None], int]: + main: Callable[[list[str] | None], int] = importlib.import_module( + f"vulnogram_api.{OPERATIONS[operation]}" + ).main + return main + + +def _native_allow_states(argv: list[str]) -> list[str]: + out: list[str] = [] + expect_state = False + for arg in argv: + if expect_state: + out.append(GENERIC_TO_NATIVE.get(arg.lower(), arg)) + expect_state = False + elif arg == "--allow-state": + out.append(arg) + expect_state = True + elif arg.startswith("--allow-state="): + value = arg.split("=", 1)[1] + out.append(f"--allow-state={GENERIC_TO_NATIVE.get(value.lower(), value)}") + else: + out.append(arg) + return out + + +def _fetch_state(main: Callable[[list[str] | None], int], argv: list[str]) -> int: + captured = io.StringIO() + code = 1 + try: + with contextlib.redirect_stdout(captured): + code = main(argv) + finally: + text = captured.getvalue() + if code == 0 and text.strip(): + print(generic_state(text.strip())) + else: + sys.stdout.write(text) + return code + + +def run(operation: str, argv: list[str]) -> int: + main = _entry(operation) + if operation == "publish": + return main(_native_allow_states(argv)) + if operation == "fetch" and "--state-only" in argv: + return _fetch_state(main, argv) + return main(argv) diff --git a/tools/cve-tool/src/cve_tool/cli.py b/tools/cve-tool/src/cve_tool/cli.py new file mode 100644 index 000000000..8ef6c8821 --- /dev/null +++ b/tools/cve-tool/src/cve_tool/cli.py @@ -0,0 +1,141 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""cve-tool: run a CVE-record operation through the configured CVE tool. + + cve-tool [--tool NAME] [--project-root DIR] [--config-dir DIR] <operation> [args ...] + cve-tool which + +Everything after the operation goes to the adapter unchanged, so its flags, +output and exit codes are the adapter's own. cve-tool's own options go before +the operation; one placed after it is refused as a usage error (exit 2). The interface adds three exit +codes of its own: 20 (no `cve_authority.tool` declared), 21 (the tool has no +adapter), 22 (the adapter does not implement the operation). +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +from cve_tool import EXIT_NO_ADAPTER, EXIT_NOT_DECLARED, EXIT_UNSUPPORTED, OPERATIONS, adapters +from cve_tool.config import Resolved, resolve + + +def parse_args(argv: list[str] | None) -> argparse.Namespace: + ap = argparse.ArgumentParser( + prog="cve-tool", + description=(__doc__ or "").split("\n\n", 1)[0], + ) + ap.add_argument( + "--tool", + default=None, + help="The CVE tool to use, overriding cve_authority.tool from the project configuration.", + ) + ap.add_argument( + "--project-root", + type=Path, + default=Path.cwd(), + help="The adopter repository the configuration is read from. Default: the current directory.", + ) + ap.add_argument( + "--config-dir", + type=Path, + default=None, + help="Read project.md from this directory instead of the configuration layers.", + ) + ap.add_argument("operation", choices=(*OPERATIONS, "which")) + ap.add_argument("args", nargs=argparse.REMAINDER, help="Passed to the adapter unchanged.") + args = ap.parse_args(argv) + misplaced = _misplaced_option(args.args) + if misplaced: + ap.error( + f"{misplaced} is a cve-tool option, not a {args.operation} argument: " + f"put it before the operation (cve-tool {misplaced} ... {args.operation} ...)" + ) + return args + + +# cve-tool's own options. After the operation they would reach the adapter, +# so they are refused there, and no adapter may define an option of the same name. +OWN_OPTIONS = ("--tool", "--project-root", "--config-dir") + + +def _misplaced_option(adapter_args: list[str]) -> str | None: + """The first of cve-tool's own options among the adapter's arguments, as `--name` or `--name=value`.""" + for arg in adapter_args: + name = arg.split("=", 1)[0] + if name in OWN_OPTIONS: + return name + return None + + +def _fail(code: int, message: str) -> int: + print(f"✗ cve-tool: {message}", file=sys.stderr) + return code + + +def _describe(resolved: Resolved) -> str: + return f"cve_authority.tool is {resolved.tool!r} (from {resolved.source})" + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + resolved = resolve(args.tool, args.project_root, args.config_dir) + adapter = adapters.load(resolved.tool) if resolved.tool else None + + if args.operation == "which": + operations = sorted(adapter.OPERATIONS) if adapter else [] + json.dump( + { + "tool": resolved.tool, + "source": resolved.source, + "adapter": bool(adapter), + "operations": operations, + }, + sys.stdout, + ) + sys.stdout.write("\n") + return 0 + + if not resolved.tool: + return _fail( + EXIT_NOT_DECLARED, + "no cve_authority.tool in project.md or its organization manifest; declare one or pass --tool", + ) + if adapter is None: + if resolved.tool in adapters.PLANNED: + why = "the contract names this tool, but no adapter ships for it yet" + else: + why = f"no adapter is registered for it (known: {', '.join(sorted(adapters.ADAPTERS))})" + return _fail( + EXIT_NO_ADAPTER, f"{_describe(resolved)}: {why}; follow the skill's manual path for this step" + ) + if args.operation not in adapter.OPERATIONS: + hint = getattr(adapter, "UNSUPPORTED", {}).get(args.operation) + return _fail( + EXIT_UNSUPPORTED, + f"the {resolved.tool} adapter does not implement {args.operation}" + + (f": {hint}" if hint else ""), + ) + code: int = adapter.run(args.operation, list(args.args)) + return code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/cve-tool/src/cve_tool/config.py b/tools/cve-tool/src/cve_tool/config.py new file mode 100644 index 000000000..077acbc64 --- /dev/null +++ b/tools/cve-tool/src/cve_tool/config.py @@ -0,0 +1,63 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""Which CVE tool a project uses: `cve_authority.tool`, first hit wins. + +`--tool` on the command line, then `<project-config>/project.md`, then the +project's organization manifest (in-tree, then adopter-local), the same +chain AGENTS.md describes for every dotted config key. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from release_config import mdconfig as md +from release_config.config import org_manifest_chain + +KEY = "cve_authority.tool" + + +@dataclass(frozen=True) +class Resolved: + """The tool's name (lower-case), or None, and where it was read from.""" + + tool: str | None + source: str + + +def _declared(text: str | None) -> str | None: + value = md.dotted_value(text, KEY) + if not value or md.is_placeholder(value): + return None + return value.strip().lower() + + +def resolve(explicit: str | None, project_root: Path, config_dir: Path | None = None) -> Resolved: + if explicit and explicit.strip(): + return Resolved(explicit.strip().lower(), "--tool") + resolver = md.Resolver(project_root, config_dir) + project_path = resolver.find("project.md") + project_text = md.read_text(project_path) + tool = _declared(project_text) + if tool: + return Resolved(tool, resolver.display(project_path) or "project.md") + for path, label in org_manifest_chain(resolver, md.organization(project_text)): + tool = _declared(md.read_text(path)) + if tool: + return Resolved(tool, label) + return Resolved(None, "not declared") diff --git a/tools/cve-tool/tests/__init__.py b/tools/cve-tool/tests/__init__.py new file mode 100644 index 000000000..13a83393a --- /dev/null +++ b/tools/cve-tool/tests/__init__.py @@ -0,0 +1,16 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. diff --git a/tools/cve-tool/tests/test_cli.py b/tools/cve-tool/tests/test_cli.py new file mode 100644 index 000000000..ce162a5dd --- /dev/null +++ b/tools/cve-tool/tests/test_cli.py @@ -0,0 +1,138 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""The command: dispatch to the adapter, and the interface's own refusals.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from cve_tool import EXIT_NO_ADAPTER, EXIT_NOT_DECLARED, EXIT_UNSUPPORTED, cli +from cve_tool.adapters import vulnogram +from cve_tool.config import Resolved + + +@pytest.fixture +def calls(monkeypatch): + """Replace each vulnogram_api main() with a recorder that exits 0.""" + seen: list[tuple[str, list[str]]] = [] + + def entry(operation): + def main(argv): + seen.append((operation, list(argv))) + return 0 + + return main + + monkeypatch.setattr(vulnogram, "_entry", entry) + return seen + + +def _asf(tmp_path: Path) -> list[str]: + overrides = tmp_path / ".apache-magpie-overrides" + overrides.mkdir() + (overrides / "project.md").write_text("organization: ASF\n") + return ["--project-root", str(tmp_path)] + + +@pytest.mark.parametrize("operation", sorted(vulnogram.OPERATIONS)) +def test_each_operation_reaches_the_adapter_with_its_arguments(tmp_path, calls, operation): + argv = ["--cve-id", "CVE-2026-12345", "--json-file", "x.json"] + assert cli.main([*_asf(tmp_path), operation, *argv]) == 0 + assert calls == [(operation, argv)] + + +def test_adapter_exit_code_is_passed_through(tmp_path, monkeypatch): + monkeypatch.setattr(vulnogram, "_entry", lambda operation: lambda argv: 5) + assert cli.main([*_asf(tmp_path), "push", "--cve-id", "CVE-2026-12345"]) == 5 + + +@pytest.mark.parametrize( + ("misplaced", "name"), + [ + (["--tool", "ghsa"], "--tool"), + (["--project-root", "/x"], "--project-root"), + (["--config-dir=/x"], "--config-dir"), + ], +) +def test_own_option_after_the_operation_is_refused_before_the_adapter_runs( + tmp_path, calls, capsys, misplaced, name +): + with pytest.raises(SystemExit) as exit_: + cli.main([*_asf(tmp_path), "fetch", "--cve-id", "CVE-2026-12345", *misplaced]) + assert exit_.value.code == 2 + err = capsys.readouterr().err + assert f"{name} is a cve-tool option, not a fetch argument" in err + assert "put it before the operation" in err + assert calls == [] + + +def test_own_option_after_which_is_refused_rather_than_ignored(tmp_path, capsys): + with pytest.raises(SystemExit) as exit_: + cli.main(["which", "--project-root", str(tmp_path)]) + assert exit_.value.code == 2 + assert capsys.readouterr().out == "" + + +def test_adapter_option_sharing_a_prefix_with_an_own_option_still_passes(tmp_path, calls): + argv = ["--cve-id", "CVE-2026-12345", "--tooling", "x", "--config-directory=y"] + assert cli.main([*_asf(tmp_path), "fetch", *argv]) == 0 + assert calls == [("fetch", argv)] + + +def test_own_options_match_the_parser(capsys): + with pytest.raises(SystemExit): + cli.parse_args(["--help"]) + usage = capsys.readouterr().out + assert all(f"[{option} " in usage for option in cli.OWN_OPTIONS) + + +def test_which_reports_the_tool_and_where_it_came_from(tmp_path, capsys): + assert cli.main([*_asf(tmp_path), "which"]) == 0 + report = json.loads(capsys.readouterr().out) + assert report == { + "tool": "vulnogram", + "source": "<framework>/organizations/ASF/organization.md", + "adapter": True, + "operations": sorted(vulnogram.OPERATIONS), + } + + +def test_planned_tool_without_an_adapter_is_refused(tmp_path, capsys): + assert cli.main(["--tool", "mitre-form", "--project-root", str(tmp_path), "allocate"]) == EXIT_NO_ADAPTER + err = capsys.readouterr().err + assert "'mitre-form'" in err + assert "no adapter ships for it yet" in err + + +def test_unknown_tool_is_refused_naming_the_known_ones(tmp_path, capsys): + assert cli.main(["--tool", "acme", "--project-root", str(tmp_path), "fetch"]) == EXIT_NO_ADAPTER + assert "known: vulnogram" in capsys.readouterr().err + + +def test_operation_the_adapter_lacks_is_refused_with_its_hint(tmp_path, calls, capsys): + assert cli.main([*_asf(tmp_path), "retract", "--cve-id", "CVE-2026-12345"]) == EXIT_UNSUPPORTED + assert "Vulnogram UI" in capsys.readouterr().err + assert calls == [] + + +def test_undeclared_tool_is_refused(tmp_path, monkeypatch, capsys): + monkeypatch.setattr(cli, "resolve", lambda *args: Resolved(None, "not declared")) + assert cli.main(["fetch"]) == EXIT_NOT_DECLARED + assert "pass --tool" in capsys.readouterr().err diff --git a/tools/cve-tool/tests/test_config.py b/tools/cve-tool/tests/test_config.py new file mode 100644 index 000000000..b8dd53778 --- /dev/null +++ b/tools/cve-tool/tests/test_config.py @@ -0,0 +1,69 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""`cve_authority.tool` resolution: --tool, then project.md, then the organization.""" + +from __future__ import annotations + +from pathlib import Path + +from cve_tool.config import resolve + + +def _project(root: Path, text: str) -> Path: + overrides = root / ".apache-magpie-overrides" + overrides.mkdir(parents=True, exist_ok=True) + (overrides / "project.md").write_text(text) + return root + + +def test_explicit_tool_wins_over_the_configuration(tmp_path): + root = _project(tmp_path, "organization: ASF\n") + assert resolve("GHSA", root).tool == "ghsa" + assert resolve("GHSA", root).source == "--tool" + + +def test_asf_project_inherits_vulnogram_from_the_organization(tmp_path): + resolved = resolve(None, _project(tmp_path, "organization: ASF\n")) + assert resolved.tool == "vulnogram" + assert resolved.source == "<framework>/organizations/ASF/organization.md" + + +def test_independent_project_inherits_the_mitre_form(tmp_path): + assert resolve(None, _project(tmp_path, "organization: independent\n")).tool == "mitre-form" + + +def test_project_without_organization_is_independent(tmp_path): + assert resolve(None, _project(tmp_path, "# Project\n")).tool == "mitre-form" + + +def test_project_declaration_overrides_its_organization(tmp_path): + text = "organization: ASF\n\n```yaml\ncve_authority:\n tool: ghsa # GitHub as the CNA\n```\n" + resolved = resolve(None, _project(tmp_path, text)) + assert resolved.tool == "ghsa" + assert resolved.source == ".apache-magpie-overrides/project.md" + + +def test_placeholder_in_project_falls_through_to_the_organization(tmp_path): + text = "organization: ASF\n\n```yaml\ncve_authority:\n tool: TODO\n```\n" + assert resolve(None, _project(tmp_path, text)).tool == "vulnogram" + + +def test_explicit_config_dir_is_read_instead_of_the_layers(tmp_path): + config = tmp_path / "elsewhere" + config.mkdir() + (config / "project.md").write_text("cve_authority.tool: none\n") + assert resolve(None, tmp_path, config).tool == "none" diff --git a/tools/cve-tool/tests/test_vulnogram_adapter.py b/tools/cve-tool/tests/test_vulnogram_adapter.py new file mode 100644 index 000000000..3589cba56 --- /dev/null +++ b/tools/cve-tool/tests/test_vulnogram_adapter.py @@ -0,0 +1,95 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""The Vulnogram adapter: the real entry points, and the state translation at its edge.""" + +from __future__ import annotations + +import importlib + +import pytest + +from cve_tool import OPERATIONS, STATES +from cve_tool.adapters import vulnogram + + +@pytest.mark.parametrize("operation", sorted(vulnogram.OPERATIONS)) +def test_every_operation_names_a_real_vulnogram_api_command(operation): + module = importlib.import_module(f"vulnogram_api.{vulnogram.OPERATIONS[operation]}") + assert callable(module.main) + + +def test_operations_and_unsupported_cover_the_interface(): + assert set(vulnogram.OPERATIONS) | set(vulnogram.UNSUPPORTED) == set(OPERATIONS) + + +@pytest.mark.parametrize( + ("native", "generic"), + [ + ("DRAFT", "allocated"), + ("REVIEW", "review-ready"), + ("READY", "publish-ready"), + ("PUBLIC", "public"), + ("REJECTED", "retracted"), + ("review", "review-ready"), + ("SOMETHING-NEW", "unknown"), + ], +) +def test_native_states_map_onto_the_generic_states(native, generic): + assert vulnogram.generic_state(native) == generic + assert generic in STATES + + +def test_fetch_state_only_prints_the_generic_state(monkeypatch, capsys): + monkeypatch.setattr(vulnogram, "_entry", lambda operation: lambda argv: print("REVIEW") or 0) + assert vulnogram.run("fetch", ["--cve-id", "CVE-2026-12345", "--state-only"]) == 0 + assert capsys.readouterr().out == "review-ready\n" + + +def test_fetch_without_state_only_is_untouched(monkeypatch, capsys): + monkeypatch.setattr(vulnogram, "_entry", lambda operation: lambda argv: print('{"x":1}') or 0) + assert vulnogram.run("fetch", ["--cve-id", "CVE-2026-12345"]) == 0 + assert capsys.readouterr().out == '{"x":1}\n' + + +def test_failed_state_fetch_keeps_its_output_and_exit_code(monkeypatch, capsys): + monkeypatch.setattr(vulnogram, "_entry", lambda operation: lambda argv: print("partial") or 6) + assert vulnogram.run("fetch", ["--cve-id", "CVE-2026-12345", "--state-only"]) == 6 + assert capsys.readouterr().out == "partial\n" + + +def _recorder(monkeypatch) -> list[list[str]]: + seen: list[list[str]] = [] + + def main(argv): + seen.append(argv) + return 0 + + monkeypatch.setattr(vulnogram, "_entry", lambda operation: main) + return seen + + +def test_publish_accepts_generic_allow_states(monkeypatch): + seen = _recorder(monkeypatch) + argv = ["--cve-id", "CVE-2026-12345", "--allow-state", "review-ready", "--allow-state=publish-ready"] + assert vulnogram.run("publish", argv) == 0 + assert seen == [["--cve-id", "CVE-2026-12345", "--allow-state", "REVIEW", "--allow-state=READY"]] + + +def test_publish_still_accepts_native_allow_states(monkeypatch): + seen = _recorder(monkeypatch) + vulnogram.run("publish", ["--allow-state", "READY"]) + assert seen == [["--allow-state", "READY"]] diff --git a/tools/dev/check-family-plugins.py b/tools/dev/check-family-plugins.py index c27351db7..0313c9a57 100644 --- a/tools/dev/check-family-plugins.py +++ b/tools/dev/check-family-plugins.py @@ -325,6 +325,8 @@ "tools/vcs": "magpie_vcs", "tools/cve-tool-vulnogram/generate-cve-json": "generate_cve_json", "tools/cve-tool-vulnogram/oauth-api": "vulnogram_api", + # The CVE-tool interface the skills call; it runs the adapter above. + "tools/cve-tool": "cve_tool", "tools/privacy-llm/checker": "checker", "tools/asf-security-dash": "magpie_asf_security_dash", "tools/gmail/oauth-draft": "oauth_draft", diff --git a/tools/security-management/README.md b/tools/security-management/README.md index 2098c89f8..c9ace14d9 100644 --- a/tools/security-management/README.md +++ b/tools/security-management/README.md @@ -212,7 +212,7 @@ Every command takes `--input F`, a JSON file. | `blockers` | `verdict` (`proceed`, `blocked`), `blockers`, `warnings` (`needs triage`, only when no hard blocker fired), `existing_cve`, `already_wired`, `scope_label`; empty `scope_labels` exits `2`. | | `preflight` | `path` (`api`, `recipe`, or `null` with the next check `command`), `reason`, `offer_setup`, `setup_command`. | | `title` | `original_title`, `normalized_title`, `word_count`, `changed`, `over_strip_warning` (fewer than 3 words). | -| `recipe` | `path` (`api`, `recipe`, `relay`), `text`, the `vulnogram-api-allocate` `command` with the title shell-quoted, `bare_refs`; an unanswered authorisation question exits `2`. | +| `recipe` | `path` (`api`, `recipe`, `relay`), `text`, the `cve-tool allocate` `command` with the title shell-quoted, `bare_refs`; an unanswered authorisation question exits `2`. | | `allocate-result` | `next` (`step-4`, `stop`, `setup`, `check-records`, `recipe`), `cve_id`, `message`, `rerun_allocate` (true only after an expired token). | | `cve` | `cve_id` and `record_url` from the user's paste-back; none, a malformed token or two IDs exit `2`. | | `draft-plan` | `action` (`skip`, `ask`, `surface_existing`, `blocked`, `create`), `notification` (`omitted`, `team_member`, `notified`, `draft_pending`) and for a draft `to`, `cc`, `subject`, `backend`, `anchor`, `secondary_threads`. | diff --git a/tools/security-management/src/security_management/cve_allocate/cli.py b/tools/security-management/src/security_management/cve_allocate/cli.py index 07791297e..0c4b6d9df 100644 --- a/tools/security-management/src/security_management/cve_allocate/cli.py +++ b/tools/security-management/src/security_management/cve_allocate/cli.py @@ -38,7 +38,7 @@ "preflight": "Step 0 item 5: the API pre-flight exits as the Step 3 path", "title": "Step 2: the CVE-ready title and the over-strip warning (JSON: title, project_names)", "recipe": "Step 3: the API proposal, the self-service recipe or the relay message", - "allocate-result": "Step 3: what a vulnogram-api-allocate exit means next", + "allocate-result": "Step 3: what a cve-tool allocate exit means next", "cve": "Step 3: the one CVE ID in the user's paste-back (JSON: text)", "draft-plan": "Step 4 item 5: whether the reporter gets a draft, to whom and how", "check-draft": "Step 4 item 5: grep a composed draft for the CVE-tool URL and other leaks", diff --git a/tools/security-management/src/security_management/cve_allocate/gate.py b/tools/security-management/src/security_management/cve_allocate/gate.py index b7b55450e..cd940efca 100644 --- a/tools/security-management/src/security_management/cve_allocate/gate.py +++ b/tools/security-management/src/security_management/cve_allocate/gate.py @@ -22,9 +22,9 @@ - `blockers`: the Step 1 checks as a function of state, labels and the *CVE tool link* value `body-field-get` printed. The `needs triage` warning only fires when no hard blocker did. -- `preflight`: the two `vulnogram-api-check` exits as the Step 3 path. +- `preflight`: the two `cve-tool check` exits as the Step 3 path. - `cve`: the one `CVE-YYYY-NNNNN` in text the user pasted back. -- `allocate_result`: what a `vulnogram-api-allocate` exit means next. The +- `allocate_result`: what a `cve-tool allocate` exit means next. The allocation is never re-run automatically. """ @@ -34,7 +34,7 @@ from collections.abc import Mapping, Sequence from typing import Any -from security_management.shared import selectors +from security_management.shared import cve_tool, selectors from security_management.shared.rollup import one_line from security_management.shell import dq, w @@ -42,7 +42,6 @@ CVE_ALLOCATED = "cve allocated" DUPLICATE = "duplicate" NEEDS_TRIAGE = "needs triage" -OAUTH_API = "<framework>/tools/cve-tool-vulnogram/oauth-api" READ = "uv run --project ~/.claude/magpie/vetted-ops vetted-op-read --caller security-cve-allocate" _CVE_IN_FIELD = re.compile(r"CVE-\d{4}-\d+") @@ -183,15 +182,14 @@ def blockers(data: Mapping[str, Any]) -> dict[str, Any]: } -def _check(scope: str, oauth_api: str) -> str: - flags = "--server --quiet" if scope == "server" else "--allocate" - return f"uv run --project {w(oauth_api)} vulnogram-api-check {flags}" +def _check(scope: str, data: Mapping[str, Any]) -> str: + flags = ("--server", "--quiet") if scope == "server" else ("--allocate",) + return cve_tool.command(data, "check", *flags) def preflight(data: Mapping[str, Any]) -> dict[str, Any]: - oauth_api = str(data.get("oauth_api") or OAUTH_API) pmc = one_line(data.get("pmc")) - setup = f"uv run --project {w(oauth_api)} vulnogram-api-setup --pmc {w(pmc or '<pmc>')}" + setup = cve_tool.command(data, "setup", "--pmc", pmc or "<pmc>") def out( path: str | None, why: str, *, offer_setup: bool = False, command: str | None = None @@ -207,7 +205,7 @@ def out( if data.get("governance_member") is not True: return out("recipe", "API allocation is only for a governance-authorised user") if data.get("server_exit") is None: - return out(None, "run the server check first", command=_check("server", oauth_api)) + return out(None, "run the server check first", command=_check("server", data)) server = int(data["server_exit"]) if server == 4: return out( @@ -218,9 +216,7 @@ def out( if server != 0: return out("recipe", f"unexpected server-check exit {server}") if data.get("allocate_exit") is None: - return out( - None, "the server supports it: check the allocate token", command=_check("allocate", oauth_api) - ) + return out(None, "the server supports it: check the allocate token", command=_check("allocate", data)) code = int(data["allocate_exit"]) if code == 0: lines = [one_line(line) for line in str(data.get("allocate_stdout") or "").splitlines()] @@ -274,7 +270,7 @@ def allocate_result(data: Mapping[str, Any]) -> dict[str, Any]: first = next((one_line(line) for line in str(data.get("stdout") or "").splitlines() if line.strip()), "") stderr = str(data.get("stderr") or "") pmc = one_line(data.get("pmc")) or "<pmc>" - check = f"ask the user to check {pmc}'s records in Vulnogram before anything else" + check = f"ask the user to check {pmc}'s records in the CVE tool before anything else" def out(next_step: str, why: str, cve_id: str | None = None) -> dict[str, Any]: return {"next": next_step, "cve_id": cve_id, "message": why, "rerun_allocate": False} @@ -289,13 +285,13 @@ def out(next_step: str, why: str, cve_id: str | None = None) -> dict[str, Any]: return out("step-4", "allocated; no paste-back needed", first) return out( "step-4", - "the ID is reserved but Vulnogram did not save its record; the sync push creates it", + "the ID is reserved but the CVE tool did not save its record; the sync push creates it", first, ) if code == 5: return out( "stop", - "the PMC cannot allocate directly and Vulnogram mailed the security team: resume later with the ID as an override", + "the PMC cannot allocate directly and the CVE tool mailed the security team: resume later with the ID as an override", ) if code == 1: result = out( @@ -307,7 +303,7 @@ def out(next_step: str, why: str, cve_id: str | None = None) -> dict[str, Any]: if code == 4: return out( "check-records", - f"Vulnogram answered HTML with no ID: {check} (it may have allocated), then use the recipe", + f"the CVE tool answered HTML with no ID: {check} (it may have allocated), then use the recipe", ) if "may still have gone through" in stderr: return out("check-records", f"the request may still have gone through: {check}, then use the recipe") diff --git a/tools/security-management/src/security_management/cve_allocate/recipe.py b/tools/security-management/src/security_management/cve_allocate/recipe.py index f958a9868..bb45de778 100644 --- a/tools/security-management/src/security_management/cve_allocate/recipe.py +++ b/tools/security-management/src/security_management/cve_allocate/recipe.py @@ -18,7 +18,7 @@ - `api` (a governance-authorised user, pre-flight `api`): one proposal to allocate through the CVE tool's API, with the form URL as the fallback and - the one `vulnogram-api-allocate` command to run on a yes. + the one `cve-tool allocate` command to run on a yes. - `recipe` (authorised, any other pre-flight outcome): open the allocation URL, paste the title, submit, paste the ID back. - `relay` (not authorised): a terse request for an authorised member — the @@ -33,14 +33,11 @@ from __future__ import annotations import re -import shlex from collections.abc import Mapping, Sequence from typing import Any -from security_management.cve_allocate.gate import OAUTH_API -from security_management.shared import refs +from security_management.shared import cve_tool, refs from security_management.shared.rollup import one_line -from security_management.shell import w PASTE_BACK = "Paste the allocated `CVE-YYYY-NNNNN` back here when done." _HANDLE = re.compile(r"^@?([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))$") @@ -85,15 +82,14 @@ def render(data: Mapping[str, Any]) -> dict[str, Any]: pmc = one_line(data.get("pmc")) if not pmc: return {"ok": False, "refused": "the API path needs cna_private_owner (the PMC) from project.md"} - oauth_api = str(data.get("oauth_api") or OAUTH_API) path = "api" text = ( - f"**Allocate a CVE for {link}** through the Vulnogram API, for `{pmc}`, with this title:\n\n" + f"**Allocate a CVE for {link}** through the CVE tool's API, for `{pmc}`, with this title:\n\n" f"{block}\n\n" "This reserves a CVE ID immediately and cannot be undone.\n" f"(Fallback, if the API path fails: the form at {url}.)\n" ) - command = f"uv run --project {w(oauth_api)} vulnogram-api-allocate --pmc {w(pmc)} --title {shlex.quote(title)}" + command = cve_tool.command(data, "allocate", "--pmc", pmc, "--title", title) elif member: path = "recipe" indented = "\n".join(" " + line for line in block.split("\n")) diff --git a/tools/security-management/src/security_management/cve_allocate/render.py b/tools/security-management/src/security_management/cve_allocate/render.py index 7e58dc047..9f06e10a1 100644 --- a/tools/security-management/src/security_management/cve_allocate/render.py +++ b/tools/security-management/src/security_management/cve_allocate/render.py @@ -167,15 +167,10 @@ def rollup_entry(data: Mapping[str, Any]) -> dict[str, Any]: except ValueError as exc: return {"ok": False, "refused": str(exc)} allocated = str(data.get("cve_allocated_label") or CVE_ALLOCATED) - vulnogram = "vulnogram" in str(data.get("cve_tool") or "tools/cve-tool-vulnogram") source = one_line(data.get("source_tab_url_template")) - push = "push it to the record via the adapter's `push_update` method" - if vulnogram: - push += f" (for Vulnogram: `vulnogram-api-record-update --cve-id {cve_id} --json-file <path>`)" - if source: - push += ( - f" or, as a fallback, paste it into the record's [`source` tab]({record_url(source, cve_id)})" - ) + push = f"push it to the record with `cve-tool push --cve-id {cve_id} --json-file <path>`" + if source: + push += f" or, as a fallback, paste it into the record's [`source` tab]({record_url(source, cve_id)})" scope = one_line(data.get("scope_label")) product = one_line(data.get("product")) package = one_line(data.get("package_name")) diff --git a/tools/security-management/src/security_management/issue_sync/push.py b/tools/security-management/src/security_management/issue_sync/push.py index ec77ed69a..234250881 100644 --- a/tools/security-management/src/security_management/issue_sync/push.py +++ b/tools/security-management/src/security_management/issue_sync/push.py @@ -45,9 +45,8 @@ from typing import Any from security_management.cve_allocate.title import TRACKER_PREFIXES, strip -from security_management.shared import selectors +from security_management.shared import cve_tool, selectors from security_management.shared.rollup import one_line -from security_management.shell import w GATES = ( "title_strip_cascade", @@ -64,9 +63,11 @@ "anonymise_private_finder", "conservative_affected_range", ) +# The regenerated JSON is Vulnogram-shaped (generate-cve-json), so its +# CNA_private.state is native; `cve-tool fetch --state-only` already prints the +# generic state. `_state` reads either. NATIVE = {"DRAFT": "allocated", "REVIEW": "review-ready", "READY": "publish-ready", "PUBLIC": "public"} LATER = ("review-ready", "publish-ready", "public") -OAUTH_API = "<framework>/tools/cve-tool-vulnogram/oauth-api" _UPGRADE = re.compile(r"upgrade[^.\n]{0,80}?\b[A-Za-z][\w.-]{0,60} v?\d+\.\d+(?:\.\d+)?\b", re.IGNORECASE) _CWE_LONG = re.compile(r"^CWE-\d+: \S") @@ -94,7 +95,6 @@ def _state(value: object) -> str | None: def decide(data: Mapping[str, Any]) -> dict[str, Any]: cve_id = one_line(data.get("cve_id")) - oauth_api = w(str(data.get("oauth_api") or OAUTH_API)) def out(action: str, why: str, **extra: Any) -> dict[str, Any]: return { @@ -144,12 +144,10 @@ def out(action: str, why: str, **extra: Any) -> dict[str, Any]: stuck = transition and before == "allocated" probe = one_line(data.get("session")).lower() or None if probe is None: - return out( - "pending", "probe the CVE tool session", next=f"uv run --project {oauth_api} vulnogram-api-check" - ) + return out("pending", "probe the CVE tool session", next=cve_tool.command(data, "check")) if probe in ("expired", "not-configured"): why = ( - "the session expired: remind the operator to re-run vulnogram-api-setup --pmc <project>; manual-paste hand-off this run" + "the session expired: remind the operator to re-run cve-tool setup --pmc <project>; manual-paste hand-off this run" if probe == "expired" else "no session is configured: manual-paste hand-off through the source tab" ) @@ -163,7 +161,7 @@ def out(action: str, why: str, **extra: Any) -> dict[str, Any]: return out( "pending", "push the regenerated JSON", - next=f"uv run --project {oauth_api} vulnogram-api-record-update --cve-id {w(cve_id)} --json-file {w(json_file)}", + next=cve_tool.command(data, "push", "--cve-id", cve_id, "--json-file", json_file), ) if int(data["push_exit"]) != 0: return out( @@ -178,7 +176,7 @@ def out(action: str, why: str, **extra: Any) -> dict[str, Any]: return out( "pending", "verify the state advance", - next=f"uv run --project {oauth_api} vulnogram-api-record-fetch --cve-id {w(cve_id)} --state-only", + next=cve_tool.command(data, "fetch", "--cve-id", cve_id, "--state-only"), ) if fetched in LATER: return out( diff --git a/tools/security-management/src/security_management/shared/cve_tool.py b/tools/security-management/src/security_management/shared/cve_tool.py new file mode 100644 index 000000000..5507f8077 --- /dev/null +++ b/tools/security-management/src/security_management/shared/cve_tool.py @@ -0,0 +1,45 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""The CVE-record commands the renderers print, through the CVE-tool interface. + +The skills never name a CVE tool's own commands: they run `cve-tool +<operation>` (tools/cve-tool), which resolves `cve_authority.tool` and runs +that tool's adapter. Arguments after the operation are the adapter's own; +`command` shell-quotes each one, so callers pass raw values. +""" + +from __future__ import annotations + +import shlex +from collections.abc import Mapping +from typing import Any + +from security_management.shell import w + +PROJECT = "<framework>/tools/cve-tool" + + +def command(data: Mapping[str, Any], operation: str, *args: object) -> str: + """`uv run --project <cve-tool> cve-tool <operation> <args>`; `data["cve_tool_project"]` overrides the path. + + Each argument is shell-quoted here, so pass raw values and never quote them first: + quoting twice breaks a value. `shlex.quote`, not `w`, because an argument can come + from the tracker (an allocation title), and `w` leaves a `<placeholder>`-shaped value bare. + """ + project = str(data.get("cve_tool_project") or PROJECT) + quoted = (shlex.quote(str(arg)) for arg in args) + return " ".join(("uv run --project", w(project), "cve-tool", operation, *quoted)) diff --git a/tools/security-management/tests/test_cve_allocate.py b/tools/security-management/tests/test_cve_allocate.py index 7e306cad5..b8c3cbad9 100644 --- a/tools/security-management/tests/test_cve_allocate.py +++ b/tools/security-management/tests/test_cve_allocate.py @@ -297,7 +297,10 @@ def test_preflight_picks_the_step_3_path(capsys, tmp_path, data, path, setup): assert out["path"] == path assert out["offer_setup"] is setup if setup: - assert out["setup_command"].endswith("vulnogram-api-setup --pmc airflow") + assert ( + out["setup_command"] + == "uv run --project '<framework>/tools/cve-tool' cve-tool setup --pmc airflow" + ) @pytest.mark.parametrize( @@ -400,6 +403,7 @@ def test_the_relay_is_a_terse_request(capsys, tmp_path): def test_the_api_command_quotes_the_title(capsys, tmp_path): out = recipe(capsys, tmp_path, True, {"path": "api"}, title="x'; rm -rf / #`") + assert out["command"].startswith("uv run --project '<framework>/tools/cve-tool' cve-tool allocate --pmc ") assert out["command"].endswith("--title 'x'\"'\"'; rm -rf / #`'") assert "```text\nx'; rm -rf / #`\n```" in out["text"] @@ -672,6 +676,7 @@ def test_the_rollup_entry_links_and_lists_the_record_state(capsys, tmp_path): "**Sync 2026-10-19 — CVE [`CVE-2025-44812`](https://cveprocess.apache.org/cve5/CVE-2025-44812) allocated for " f"[{TRACKER}#312](https://github.com/{TRACKER}/issues/312).**" ) + assert "`cve-tool push --cve-id CVE-2025-44812 --json-file <path>`" in text assert "[`source` tab](https://cveprocess.apache.org/cve5/CVE-2025-44812?tab=source)" in text assert "Scope `airflow` → product `Apache Airflow` → `packageName` `apache-airflow`." in text assert "(CWE `CWE-502`, severity `HIGH`, affected `2.8.0, 2.8.1, 2.9.0`, 2 credits, 1 references)" in text diff --git a/tools/security-management/tests/test_issue_sync_rules.py b/tools/security-management/tests/test_issue_sync_rules.py index e745836c9..a46cec0cf 100644 --- a/tools/security-management/tests/test_issue_sync_rules.py +++ b/tools/security-management/tests/test_issue_sync_rules.py @@ -373,7 +373,7 @@ def test_case_1_skip_5a_skipped_invalid(capsys, tmp_path): def test_case_2_expired_session_fix_released(capsys, tmp_path): out = decide(capsys, tmp_path, session="expired", record_state="DRAFT", fix_released_transition=True) assert (out["action"], out["handoff_variant"], out["recap_blocker"]) == ("hand-off", "manual-paste", True) - assert "vulnogram-api-setup" in out["reason"] + assert "cve-tool setup --pmc" in out["reason"] def test_case_3_title_gate_ghsa_prefix(capsys, tmp_path): @@ -394,11 +394,14 @@ def test_case_4_private_scanner_credit(capsys, tmp_path): def test_case_5_clean_push_fix_released(capsys, tmp_path): base = {"session": "valid", "record_state": "DRAFT", "fix_released_transition": True} - assert decide(capsys, tmp_path, **base)["next"].endswith( - "--json-file '<scratch>/cve-CVE-2026-41107.json'" + assert decide(capsys, tmp_path, **base)["next"] == ( + "uv run --project '<framework>/tools/cve-tool' cve-tool push" + " --cve-id CVE-2026-41107 --json-file '<scratch>/cve-CVE-2026-41107.json'" ) pending = decide(capsys, tmp_path, **base, push_exit=0) - assert "vulnogram-api-record-fetch --cve-id CVE-2026-41107" in pending["next"] + assert pending["next"] == ( + "uv run --project '<framework>/tools/cve-tool' cve-tool fetch --cve-id CVE-2026-41107 --state-only" + ) out = decide(capsys, tmp_path, **base, push_exit=0, fetched_state="REVIEW") assert (out["action"], out["handoff_variant"], out["state_advance"], out["recap_blocker"]) == ( "push", @@ -408,6 +411,18 @@ def test_case_5_clean_push_fix_released(capsys, tmp_path): ) +@pytest.mark.parametrize("fetched", ["REVIEW", "review-ready"]) +def test_verify_reads_the_generic_state_cve_tool_fetch_prints(capsys, tmp_path, fetched): + base = {"session": "valid", "record_state": "DRAFT", "fix_released_transition": True, "push_exit": 0} + out = decide(capsys, tmp_path, **base, fetched_state=fetched) + assert (out["action"], out["state_advance"]) == ("push", "allocated->review-ready") + + +def test_cve_tool_project_overrides_the_command_path(capsys, tmp_path): + out = decide(capsys, tmp_path, cve_tool_project="/opt/magpie/tools/cve-tool") + assert out["next"] == "uv run --project /opt/magpie/tools/cve-tool cve-tool check" + + def test_case_6_public_record_advisory_closeout(capsys, tmp_path): out = decide( capsys, tmp_path, session="valid", record_state="PUBLIC", record=record(state="PUBLIC"), push_exit=0 diff --git a/tools/security-management/tests/test_shared.py b/tools/security-management/tests/test_shared.py index 2da4458c8..7c3cdcc38 100644 --- a/tools/security-management/tests/test_shared.py +++ b/tools/security-management/tests/test_shared.py @@ -22,7 +22,7 @@ import pytest -from security_management.shared import keyword, refs, rollup, titles +from security_management.shared import cve_tool, keyword, refs, rollup, titles def test_bare_refs_are_found_outside_code_and_links() -> None: @@ -169,3 +169,16 @@ def test_tracker_link_per_backend() -> None: refs.tracker_link("o/r", "SEC-1", "javascript:alert(1) x", "local-files") with pytest.raises(ValueError): refs.tracker_link("o/r", "[x](y)", None, "jira") + + +def test_cve_tool_command_quotes_each_raw_argument() -> None: + command = cve_tool.command({}, "push", "--cve-id", "CVE-2026-1", "--json-file", "/s/my file.json") + assert command == ( + "uv run --project '<framework>/tools/cve-tool' cve-tool push --cve-id CVE-2026-1 --json-file '/s/my file.json'" + ) + + +def test_cve_tool_command_quotes_a_placeholder_shaped_value() -> None: + # A title comes from the tracker; one shaped like `<a placeholder>` must not reach the shell bare. + command = cve_tool.command({}, "allocate", "--title", "<Some title>") + assert command.endswith("cve-tool allocate --title '<Some title>'") diff --git a/tools/skill-evals/evals/security-issue-sync/README.md b/tools/skill-evals/evals/security-issue-sync/README.md index 6f425e52b..f6dbc11a6 100644 --- a/tools/skill-evals/evals/security-issue-sync/README.md +++ b/tools/skill-evals/evals/security-issue-sync/README.md @@ -49,3 +49,14 @@ a CVE-affecting tracker is walked individually in ascending order, a reporter CV a bare CVE ID is flagged on markdown surfaces and the CVE-tool URL in a reporter email, the first layer declaring a CC key wins (an explicit null disables the fallback), an admin-only advisory change at the collaborator tier (or after a `403`, never retried) is a draft relay and never a tracker comment, the push is skipped, blocked at the first failing gate or handed off on an expired or missing session, and no bare `#NNN`. + +## Run history + +Live model runs over this whole eval directory, newest first. +Generated from [`runs.jsonl`](runs.jsonl) by `skill-eval-journal render`; +do not edit the table by hand. +Columns and rules: [Recording runs](../../README.md#recording-runs). + +| Date | Ref | Model | Runs | Result | Notes | +|---|---|---|---|---|---| +| 2026-11-13 | `c22d5769` (feat/cve-tool-interface) | `claude-sonnet-5-5` | 10 | `step-2b-proposed-changes/case-2-milestone-missing-create` failed 1 of 2; `step-2b-proposed-changes/case-5-pre-announce-distributors` failed 2 of 2; `step-2b-proposed-changes/case-6-reporter-unresponsive` errored 1 of 2 | cross-model check for #1800; cross-model check for #1800 (re-run of infra failures) | diff --git a/tools/skill-evals/evals/security-issue-sync/runs.jsonl b/tools/skill-evals/evals/security-issue-sync/runs.jsonl new file mode 100644 index 000000000..1e18777a8 --- /dev/null +++ b/tools/skill-evals/evals/security-issue-sync/runs.jsonl @@ -0,0 +1,10 @@ +{"branch": "feat/cve-tool-interface", "cases": 2, "cli": "claude -p --model claude-sonnet-5-5", "cost": {"cli_calls": 2, "grader_calls": 2, "wall_seconds": 15.0}, "date": "2026-11-13", "dirty": false, "errored": 0, "errored_cases": [], "failed": 0, "failed_cases": [], "graded_cases": ["step-5b-judged-gates/case-4-private-scanner-credit", "step-5b-judged-gates/case-5-clean-push-fix-released"], "grader_cli": "claude -p --model haiku", "manual": 0, "model": "claude-sonnet-5-5", "model_source": "cli-flag", "notes": "cross-model check for #1800", "passed": 2, "path": "tools/skill-evals/evals/security-issue-sync/step-5b-judged-gates/fixtures", "ref": "c22d5769", "schema": 1, "skill": "security-issue-sync", "tags": [], "timestamp": "2026-11-13T04:43:10Z"} +{"branch": "feat/cve-tool-interface", "cases": 10, "cli": "claude -p --model claude-sonnet-5-5", "cost": {"cli_calls": 10, "grader_calls": 0, "wall_seconds": 26.5}, "date": "2026-11-13", "dirty": false, "errored": 0, "errored_cases": [], "failed": 0, "failed_cases": [], "graded_cases": ["pointer-routing/case-1-routine-open-tracker", "pointer-routing/case-10-fix-released-no-handoff", "pointer-routing/case-2-fix-released-transition", "pointer-routing/case-3-pr-merged-transition", "pointer-routing/case-4-advisory-url-captured", "pointer-routing/case-5-existing-handoff-marker", "pointer-routing/case-6-closed-pages-pending", "pointer-routing/case-7-closed-records-pending", "pointer-routing/case-8-needs-triage", "pointer-routing/case-9-release-shipped-record-draft"], "grader_cli": "claude -p --model haiku", "manual": 0, "model": "claude-sonnet-5-5", "model_source": "cli-flag", "notes": "cross-model check for #1800", "passed": 10, "path": "tools/skill-evals/evals/security-issue-sync/pointer-routing/fixtures", "ref": "c22d5769", "schema": 1, "skill": "security-issue-sync", "tags": [], "timestamp": "2026-11-13T04:44:25Z"} +{"branch": "feat/cve-tool-interface", "cases": 7, "cli": "claude -p --model claude-sonnet-5-5", "cost": {"cli_calls": 7, "grader_calls": 0, "wall_seconds": 32.9}, "date": "2026-11-13", "dirty": false, "errored": 0, "errored_cases": [], "failed": 0, "failed_cases": [], "graded_cases": ["pointer-routing-gather/case-1-open-no-cve", "pointer-routing-gather/case-2-open-cve-link", "pointer-routing-gather/case-3-open-cve-label", "pointer-routing-gather/case-4-closed-announced", "pointer-routing-gather/case-5-gating-no-cve", "pointer-routing-gather/case-6-gating-with-cve", "pointer-routing-gather/case-7-closed-invalid"], "grader_cli": "claude -p --model haiku", "manual": 0, "model": "claude-sonnet-5-5", "model_source": "cli-flag", "notes": "cross-model check for #1800", "passed": 7, "path": "tools/skill-evals/evals/security-issue-sync/pointer-routing-gather/fixtures", "ref": "c22d5769", "schema": 1, "skill": "security-issue-sync", "tags": [], "timestamp": "2026-11-13T04:44:58Z"} +{"branch": "feat/cve-tool-interface", "cases": 7, "cli": "claude -p --model claude-sonnet-5-5", "cost": {"cli_calls": 7, "grader_calls": 6, "wall_seconds": 74.3}, "date": "2026-11-13", "dirty": false, "errored": 0, "errored_cases": [], "failed": 0, "failed_cases": [], "graded_cases": ["step-1d-signals/case-1-lower-bound-widen", "step-1d-signals/case-2-unwrapped-keep-lower-bound", "step-1d-signals/case-3-title-strip", "step-1d-signals/case-4-title-strip-too-short", "step-1d-signals/case-5-private-scanner-anonymise", "step-1d-signals/case-6-public-credit-exempt-reporter-cvss", "step-1d-signals/case-7-reporter-credit-opt-out"], "grader_cli": "claude -p --model haiku", "manual": 0, "model": "claude-sonnet-5-5", "model_source": "cli-flag", "notes": "cross-model check for #1800", "passed": 7, "path": "tools/skill-evals/evals/security-issue-sync/step-1d-signals/fixtures", "ref": "c22d5769", "schema": 1, "skill": "security-issue-sync", "tags": [], "timestamp": "2026-11-13T04:46:20Z"} +{"branch": "feat/cve-tool-interface", "cases": 12, "cli": "claude -p --model claude-sonnet-5-5", "cost": {"cli_calls": 12, "grader_calls": 7, "wall_seconds": 78.4}, "date": "2026-11-13", "dirty": false, "errored": 1, "errored_cases": ["step-2b-proposed-changes/case-6-reporter-unresponsive"], "failed": 2, "failed_cases": ["step-2b-proposed-changes/case-2-milestone-missing-create", "step-2b-proposed-changes/case-5-pre-announce-distributors"], "graded_cases": ["step-2b-proposed-changes/case-1-pr-merged-label-update", "step-2b-proposed-changes/case-10-security-pages-already-reminded", "step-2b-proposed-changes/case-11-advisory-records-proposal", "step-2b-proposed-changes/case-12-advisory-records-waiting-for-cve-org", "step-2b-proposed-changes/case-2-milestone-missing-create", "step-2b-proposed-changes/case-3-providers-scope-wave-milestone", "step-2b-proposed-changes/case-4-overdue-disclosure-window", "step-2b-proposed-changes/case-5-pre-announce-distributors", "step-2b-proposed-changes/case-6-reporter-unresponsive", "step-2b-proposed-changes/case-7-reporter-recently-replied", "step-2b-proposed-changes/case-8-security-pages-reminder", "step-2b-proposed-changes/case-9-security-pages-ticked"], "grader_cli": "claude -p --model haiku", "manual": 0, "model": "claude-sonnet-5-5", "model_source": "cli-flag", "notes": "cross-model check for #1800", "passed": 9, "path": "tools/skill-evals/evals/security-issue-sync/step-2b-proposed-changes/fixtures", "ref": "c22d5769", "schema": 1, "skill": "security-issue-sync", "tags": [], "timestamp": "2026-11-13T04:47:39Z"} +{"branch": "feat/cve-tool-interface", "cases": 1, "cli": "claude -p --model claude-sonnet-5-5", "cost": {"cli_calls": 1, "grader_calls": 1, "wall_seconds": 10.0}, "date": "2026-11-13", "dirty": false, "errored": 0, "errored_cases": [], "failed": 0, "failed_cases": [], "graded_cases": ["step-2b-proposed-changes/case-2-milestone-missing-create"], "grader_cli": "claude -p --model haiku", "manual": 0, "model": "claude-sonnet-5-5", "model_source": "cli-flag", "notes": "cross-model check for #1800 (re-run of infra failures)", "passed": 1, "path": "tools/skill-evals/evals/security-issue-sync/step-2b-proposed-changes/fixtures/case-2-milestone-missing-create", "ref": "c22d5769", "schema": 1, "skill": "security-issue-sync", "tags": [], "timestamp": "2026-11-13T04:48:10Z"} +{"branch": "feat/cve-tool-interface", "cases": 1, "cli": "claude -p --model claude-sonnet-5-5", "cost": {"cli_calls": 1, "grader_calls": 1, "wall_seconds": 9.7}, "date": "2026-11-13", "dirty": false, "errored": 0, "errored_cases": [], "failed": 1, "failed_cases": ["step-2b-proposed-changes/case-5-pre-announce-distributors"], "graded_cases": ["step-2b-proposed-changes/case-5-pre-announce-distributors"], "grader_cli": "claude -p --model haiku", "manual": 0, "model": "claude-sonnet-5-5", "model_source": "cli-flag", "notes": "cross-model check for #1800 (re-run of infra failures)", "passed": 0, "path": "tools/skill-evals/evals/security-issue-sync/step-2b-proposed-changes/fixtures/case-5-pre-announce-distributors", "ref": "c22d5769", "schema": 1, "skill": "security-issue-sync", "tags": [], "timestamp": "2026-11-13T04:48:20Z"} +{"branch": "feat/cve-tool-interface", "cases": 1, "cli": "claude -p --model claude-sonnet-5-5", "cost": {"cli_calls": 1, "grader_calls": 1, "wall_seconds": 6.9}, "date": "2026-11-13", "dirty": false, "errored": 0, "errored_cases": [], "failed": 0, "failed_cases": [], "graded_cases": ["step-2b-proposed-changes/case-6-reporter-unresponsive"], "grader_cli": "claude -p --model haiku", "manual": 0, "model": "claude-sonnet-5-5", "model_source": "cli-flag", "notes": "cross-model check for #1800 (re-run of infra failures)", "passed": 1, "path": "tools/skill-evals/evals/security-issue-sync/step-2b-proposed-changes/fixtures/case-6-reporter-unresponsive", "ref": "c22d5769", "schema": 1, "skill": "security-issue-sync", "tags": [], "timestamp": "2026-11-13T04:48:27Z"} +{"branch": "feat/cve-tool-interface", "cases": 4, "cli": "claude -p --model claude-sonnet-5-5", "cost": {"cli_calls": 4, "grader_calls": 0, "wall_seconds": 10.9}, "date": "2026-11-13", "dirty": false, "errored": 0, "errored_cases": [], "failed": 0, "failed_cases": [], "graded_cases": ["step-2c-next-step/case-1-step-3-start-discussion", "step-2c-next-step/case-2-step-6-allocate-cve", "step-2c-next-step/case-3-step-11-pr-merged-parked", "step-2c-next-step/case-4-step-13-fix-released-rm-action"], "grader_cli": "claude -p --model haiku", "manual": 0, "model": "claude-sonnet-5-5", "model_source": "cli-flag", "notes": "cross-model check for #1800", "passed": 4, "path": "tools/skill-evals/evals/security-issue-sync/step-2c-next-step/fixtures", "ref": "c22d5769", "schema": 1, "skill": "security-issue-sync", "tags": [], "timestamp": "2026-11-13T04:49:41Z"} +{"branch": "feat/cve-tool-interface", "cases": 4, "cli": "claude -p --model claude-sonnet-5-5", "cost": {"cli_calls": 4, "grader_calls": 0, "wall_seconds": 12.4}, "date": "2026-11-13", "dirty": false, "errored": 0, "errored_cases": [], "failed": 0, "failed_cases": [], "graded_cases": ["step-handoff-delegate/case-1-triage-delegated", "step-handoff-delegate/case-2-cve-allocate-delegated-after", "step-handoff-delegate/case-3-unrelated-delegate", "step-handoff-delegate/case-4-no-overrides-flag"], "grader_cli": "claude -p --model haiku", "manual": 0, "model": "claude-sonnet-5-5", "model_source": "cli-flag", "notes": "cross-model check for #1800", "passed": 4, "path": "tools/skill-evals/evals/security-issue-sync/step-handoff-delegate/fixtures", "ref": "c22d5769", "schema": 1, "skill": "security-issue-sync", "tags": [], "timestamp": "2026-11-13T04:49:54Z"} diff --git a/tools/skill-evals/evals/security-issue-sync/step-5b-judged-gates/fixtures/case-4-private-scanner-credit/report.md b/tools/skill-evals/evals/security-issue-sync/step-5b-judged-gates/fixtures/case-4-private-scanner-credit/report.md index a0c140664..4411e5bbd 100644 --- a/tools/skill-evals/evals/security-issue-sync/step-5b-judged-gates/fixtures/case-4-private-scanner-credit/report.md +++ b/tools/skill-evals/evals/security-issue-sync/step-5b-judged-gates/fixtures/case-4-private-scanner-credit/report.md @@ -37,4 +37,4 @@ Regenerated JSON excerpt: Not a follow-up to any earlier CVE. -Adapter session probe (`vulnogram-api-check`): `valid`. +Adapter session probe (`cve-tool check`): `valid`. diff --git a/tools/skill-evals/evals/security-issue-sync/step-5b-judged-gates/fixtures/case-5-clean-push-fix-released/report.md b/tools/skill-evals/evals/security-issue-sync/step-5b-judged-gates/fixtures/case-5-clean-push-fix-released/report.md index de30832b2..3962d0486 100644 --- a/tools/skill-evals/evals/security-issue-sync/step-5b-judged-gates/fixtures/case-5-clean-push-fix-released/report.md +++ b/tools/skill-evals/evals/security-issue-sync/step-5b-judged-gates/fixtures/case-5-clean-push-fix-released/report.md @@ -30,7 +30,6 @@ Regenerated JSON excerpt: Provenance: the reporter wrote to `security@` under her real name and asked to be credited. Not a follow-up to any earlier CVE. -Adapter session probe (`vulnogram-api-check`): `valid`. -`vulnogram-api-record-update`: exit 0 at 2026-10-03T14:12:09Z. -`vulnogram-api-record-fetch --state-only`: `REVIEW` -(normalised: `review-ready`). +Adapter session probe (`cve-tool check`): `valid`. +`cve-tool push`: exit 0 at 2026-10-03T14:12:09Z. +`cve-tool fetch --state-only`: `review-ready`. diff --git a/tools/spec-loop/specs/cve-tooling.md b/tools/spec-loop/specs/cve-tooling.md index 782caeedd..bbacef4e6 100644 --- a/tools/spec-loop/specs/cve-tooling.md +++ b/tools/spec-loop/specs/cve-tooling.md @@ -8,8 +8,8 @@ kind: feature mode: infra source: > README.md § Skill families (security) and AGENTS.md § Linking CVEs. - Implemented in tools/cve-tool-vulnogram/generate-cve-json/, tools/cve-org/, and - the security-cve-allocate skill. + Implemented in tools/cve-tool/, tools/cve-tool-vulnogram/generate-cve-json/, + tools/cve-org/, and the security-cve-allocate skill. acceptance: - A deterministic command produces a paste-ready CVE 5.x JSON record from a tracking issue's template fields. @@ -74,6 +74,19 @@ reviewable. order, every key with its type, default and meaning, and a minimal example, and the generator's config-not-found error points at it (#1829). +- `tools/cve-tool/` — the CVE-tool contract and the `cve-tool` command the + skills and the `security-management` helpers run for every CVE-record + operation (`allocate`, `fetch`, `push`, `publish`, `retract`, `check`, + `setup`, and `which`). It resolves `cve_authority.tool` (`--tool`, then + `project.md`, then the organization manifest) and runs that tool's adapter + with the arguments unchanged. Its own options (`--tool`, `--project-root`, + `--config-dir`) go before the operation: one placed after it is refused + with a usage error (exit `2`) before the adapter runs, so no adapter may + define an option of those names. The Vulnogram adapter calls the + `oauth-api` commands below and translates states at the edge, so + `fetch --state-only` prints the generic state (`review-ready`) and + `publish --allow-state` takes one. A tool with no adapter exits `21`, an + operation the adapter lacks `22`, an undeclared tool `20` (#1800). - `tools/cve-tool-vulnogram/oauth-api/` — a `uv` project exposing six console scripts that talk to the Vulnogram HTTP API with a Bearer token the operator copies from `https://cveprocess.apache.org/users/token` @@ -112,7 +125,7 @@ reviewable. it refuses a single `--out` path (#1554). Setup runs in the operator's own terminal and regular browser, never an agent-driven browser, so the login, MFA and token stay out of the agent - session (#1558). The skill detects token expiry via `vulnogram-api-check` + session (#1558). The skill detects token expiry via `cve-tool check` (`vulnogram-api-check`) and falls back to the manual paste path when no token is configured or it has expired. `vulnogram-api-setup --pmc` first checks the server (`--server`) and falls back to the paste path on a server without the @@ -123,7 +136,7 @@ reviewable. instructions instead of a traceback; expired-token hints name `--pmc <project>` first, and after a `--pmc` fallback to the paste prompt they point at `--token` rather than back at `--pmc` (#1742). `security-cve-allocate` allocates - through `vulnogram-api-allocate` after one confirmation when both + through `cve-tool allocate` (`vulnogram-api-allocate`) after one confirmation when both `--server` and `--allocate` pass, and prints the form recipe otherwise. - `tools/cve-org/` — CVE.org / CVE-services helpers; the `check-published` recipe runs as the `cve-check-published` vetted-ops read operation, not raw @@ -156,9 +169,9 @@ reviewable. Allocation never reads the CNA org id: the allocate form takes only the PMC and the title, which a test pins (#1779). - **API allocation runs once, after one confirmation.** When the Step 0 - pre-flight finds both `vulnogram-api-check --server` and `--allocate` + pre-flight finds both `cve-tool check --server` and `--allocate` passing, Step 3 shows a single proposal (the title, the PMC, and the form - URL as fallback) and on a yes runs `vulnogram-api-allocate` exactly once + URL as fallback) and on a yes runs `cve-tool allocate` exactly once (`plugins/magpie-security/skills/cve-allocate/api-allocation.md`). Each successful call reserves a new ID, so the command never retries and the skill never re-runs it without a fresh confirmation; an unclear @@ -254,12 +267,18 @@ reviewable. 9. `generate-cve-json` with no `[cna].org_id` exits with a pointer to the organization manifest, never a `KeyError` and never a default UUID (#1779). +10. `cve-tool` resolves `vulnogram` for an ASF project and `mitre-form` for an + independent one, passes each operation's arguments and exit code through + to the adapter, prints the generic state for `fetch --state-only`, and + refuses a tool without an adapter with exit `21` and an operation the + adapter lacks with exit `22` (#1800). ## Validation ```bash uv run --project tools/cve-tool-vulnogram/generate-cve-json --group dev pytest uv run --project tools/cve-tool-vulnogram/oauth-api --group dev pytest +uv run --project tools/cve-tool --group dev pytest ``` ## Known gaps @@ -286,3 +305,11 @@ uv run --project tools/cve-tool-vulnogram/oauth-api --group dev pytest - **API allocation is Vulnogram-only.** Other `cve_authority` adapters keep the print-the-recipe allocation flow until they offer an equivalent API. <!-- gap-closes-when: none (narrative) --> +- **`cve-tool` has one adapter, and it cannot retract.** `vulnogram` is the + only registered adapter; `mitre-form` (the `independent` default), + `cve-org-direct`, `ghsa` and `none` exit `21`, and `retract` exits `22` + because the Vulnogram API helpers have no reject call. The ASF + allow-list recommendation (`permission-audit`, `setup verify`) still names + `Bash(vulnogram-api-record-fetch *)`, which matches neither the old nor the + new `uv run` spelling the skills use. + <!-- gap-closes-when: none (narrative) --> diff --git a/uv.lock b/uv.lock index fb921fb31..3c06aa387 100644 --- a/uv.lock +++ b/uv.lock @@ -22,6 +22,7 @@ members = [ "checker", "container-gateway", "contributor-metrics", + "cve-tool", "egress-gateway", "generate-cve-json", "github-body-field", @@ -616,6 +617,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/b5/c2c5fce26f0ee40d21bafe7f191d29a34b35a65ac4fe8a1191d1983612e9/cryptography-50.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9", size = 3813796, upload-time = "2026-07-31T14:25:02.298Z" }, ] +[[package]] +name = "cve-tool" +version = "0.1.0" +source = { editable = "tools/cve-tool" } +dependencies = [ + { name = "release-config" }, + { name = "vulnogram-api" }, +] + +[package.dev-dependencies] +dev = [ + { name = "magpie-dev" }, +] + +[package.metadata] +requires-dist = [ + { name = "release-config", editable = "tools/release-config" }, + { name = "vulnogram-api", editable = "tools/cve-tool-vulnogram/oauth-api" }, +] + +[package.metadata.requires-dev] +dev = [{ name = "magpie-dev", editable = "tools/dev" }] + [[package]] name = "egress-gateway" version = "0.1.0"