This is an automated email from the ASF dual-hosted git repository.
potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow-steward.git
The following commit(s) were added to refs/heads/main by this push:
new 6046fba feat(vulnogram-api): add vulnogram-api-record-publish CLI
(#223)
6046fba is described below
commit 6046fbaa831b641f6fae5dce8d2fe88c28defd1d
Author: Jarek Potiuk <[email protected]>
AuthorDate: Tue May 19 00:52:44 2026 +0200
feat(vulnogram-api): add vulnogram-api-record-publish CLI (#223)
Add a new ``vulnogram-api-record-publish`` command that drives the
``REVIEW`` → ``PUBLIC`` state transition over the OAuth API. The
existing ``vulnogram-api-record-update`` is intentionally
content-only — it writes arbitrary JSON to a record but does not
encode state semantics. This new command is intentionally narrow:
1. Fetch the current stored JSON via the existing ``get_record``
helper.
2. Refuse the transition unless the current state is in the
accepted set (default ``{"REVIEW"}``; ``--allow-state`` widens
it).
3. Set ``CNA_private.state = "PUBLIC"`` (preserving every other
field).
4. POST the modified document via the existing ``update_record``
helper.
Idempotent on records already in ``PUBLIC`` (exits 0 with an
informational message). Refuses unexpected states with exit 3
rather than silently flipping — the safety net for a sync that
re-runs the publish on a tracker that already published.
Driver: the new convention documented in #222 (RM-handoff no shell
commands; sync drives the post-advisory close-out). On the
"published archive URL captured" signal the sync skill now runs
this command to drive the CNA-feed dispatch to ``cve.org`` rather
than waiting on a manual Vulnogram UI click. The full
sync-skill-side wiring (label flips, tracker close, wrap-up comment
composition) lands in a follow-up PR.
Tests cover the seven invariants:
- Invalid CVE-ID form rejected before any network call.
- ``PUBLIC`` already → no-op, exit 0, informational stderr.
- Unexpected state → refusal, exit 3, name the observed state.
- ``--allow-state REVIEW --allow-state READY`` widens the
accepted set (verified with a ``READY`` fixture).
- ``--dry-run`` reports the proposed transition without POSTing.
- Apply path flips ``state`` to ``PUBLIC`` and POSTs once.
- ``SessionExpired`` from ``get_record`` returns exit 2.
Stdlib-only — no new runtime deps, consistent with the rest of
``vulnogram-api``.
---
tools/vulnogram/oauth-api/pyproject.toml | 1 +
.../oauth-api/src/vulnogram_api/record_publish.py | 204 +++++++++++++++++++++
.../oauth-api/tests/test_record_publish.py | 153 ++++++++++++++++
3 files changed, 358 insertions(+)
diff --git a/tools/vulnogram/oauth-api/pyproject.toml
b/tools/vulnogram/oauth-api/pyproject.toml
index dfdbda5..cc4a488 100644
--- a/tools/vulnogram/oauth-api/pyproject.toml
+++ b/tools/vulnogram/oauth-api/pyproject.toml
@@ -35,6 +35,7 @@ dependencies = []
[project.scripts]
vulnogram-api-setup = "vulnogram_api.setup_session:main"
vulnogram-api-record-update = "vulnogram_api.record_update:main"
+vulnogram-api-record-publish = "vulnogram_api.record_publish:main"
vulnogram-api-check = "vulnogram_api.check:main"
[dependency-groups]
diff --git a/tools/vulnogram/oauth-api/src/vulnogram_api/record_publish.py
b/tools/vulnogram/oauth-api/src/vulnogram_api/record_publish.py
new file mode 100644
index 0000000..08d2a57
--- /dev/null
+++ b/tools/vulnogram/oauth-api/src/vulnogram_api/record_publish.py
@@ -0,0 +1,204 @@
+# 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.
+"""Move a Vulnogram CVE record `REVIEW` → `PUBLIC` over the OAuth API.
+
+Step 15 of the security handling process — the CNA-feed dispatch to
+`cve.org` — used to be intentionally human-only (a Vulnogram UI
+button click) because of the out-of-band side effects. The post-2026
+convention drives it from `security-issue-sync` on the
+*"published archive URL captured"* gate: at that point the advisory
+has provably shipped on `<users-list>`, which is the same real-world
+signal a human would use before clicking the button.
+
+Mechanics: Vulnogram's state machine lives in the
+``CNA_private.state`` field of the document body. This script:
+
+1. Fetches the current stored JSON via ``GET /<section>/json/<CVE-ID>``.
+2. Sets ``CNA_private.state = "PUBLIC"`` (preserves every other
+ field — this is a state-flip only, not a content edit).
+3. POSTs the modified document back via the same path
+ :class:`vulnogram_api.client.update_record` already uses.
+
+If the record is not currently in ``REVIEW`` state, the script
+refuses the transition with a non-zero exit (a sync that re-runs
+the publish on a tracker that already published is a bug, not an
+idempotent no-op — surfacing it loudly is the right escalation).
+
+The companion :mod:`vulnogram_api.record_update` script writes
+arbitrary JSON to a record. ``record_publish`` is intentionally
+narrower: it only flips the state field, so a future schema change
+to the body cannot accidentally smuggle through the publish path.
+"""
+
+from __future__ import annotations
+
+import argparse
+import re
+import sys
+
+from vulnogram_api.client import (
+ CSRFNotFound,
+ RecordSaveFailed,
+ SessionExpired,
+ VulnogramAPIError,
+ get_record,
+ update_record,
+)
+from vulnogram_api.credentials import Session, locate_session
+
+CVE_ID_RE = re.compile(r"^CVE-\d{4}-\d{4,7}$")
+
+
+class UnexpectedRecordState(VulnogramAPIError):
+ """Refuse to publish a record that is not currently in ``REVIEW``."""
+
+
+def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
+ ap = argparse.ArgumentParser(
+ description=(__doc__ or "").split("\n\n", 1)[0],
+ )
+ ap.add_argument(
+ "--cve-id",
+ required=True,
+ help="The CVE ID, e.g. CVE-2026-12345.",
+ )
+ ap.add_argument(
+ "--credentials",
+ default=None,
+ help=(
+ "Path to the session JSON. Defaults to "
+ "$VULNOGRAM_SESSION, else "
+ "~/.config/apache-steward/vulnogram-session.json."
+ ),
+ )
+ ap.add_argument(
+ "--section",
+ default="cve5",
+ help="Vulnogram section path component. Default: cve5.",
+ )
+ ap.add_argument(
+ "--allow-state",
+ action="append",
+ default=None,
+ help=(
+ "Accepted current states. Default: REVIEW only. Pass multiple "
+ "times to allow more (e.g. --allow-state REVIEW --allow-state
READY). "
+ "Use sparingly — refusing unexpected states is the safety net."
+ ),
+ )
+ ap.add_argument(
+ "--dry-run",
+ action="store_true",
+ help="Fetch and report the proposed transition; do not POST.",
+ )
+ return ap.parse_args(argv)
+
+
+def _state(document: dict[str, object]) -> str | None:
+ cna = document.get("CNA_private")
+ if not isinstance(cna, dict):
+ return None
+ s = cna.get("state")
+ return s if isinstance(s, str) else None
+
+
+def main(argv: list[str] | None = None) -> int:
+ args = parse_args(argv)
+
+ if not CVE_ID_RE.match(args.cve_id):
+ raise SystemExit(f"--cve-id {args.cve_id!r} does not match
CVE-YYYY-NNNN form. Refusing to publish.")
+
+ accepted_states = set(args.allow_state or ["REVIEW"])
+
+ creds_path = locate_session(args.credentials)
+ session = Session.load(creds_path)
+
+ try:
+ document = get_record(session, args.cve_id, section=args.section)
+ except SessionExpired as e:
+ print(f"✗ {e}", file=sys.stderr)
+ return 2
+ except VulnogramAPIError as e:
+ print(f"✗ {e}", file=sys.stderr)
+ return 6
+
+ current_state = _state(document)
+ if current_state == "PUBLIC":
+ print(
+ f"= {args.cve_id} already PUBLIC; no transition needed.",
+ file=sys.stderr,
+ )
+ return 0
+ if current_state not in accepted_states:
+ print(
+ f"✗ {args.cve_id} is in state {current_state!r}; expected one of "
+ f"{sorted(accepted_states)!r}. Refusing the publish — investigate "
+ f"manually via the Vulnogram UI.",
+ file=sys.stderr,
+ )
+ return 3
+
+ document.setdefault("CNA_private", {})
+ if not isinstance(document["CNA_private"], dict):
+ print(
+ f"✗ {args.cve_id} document's CNA_private field is not an object: "
+ f"{type(document['CNA_private']).__name__}. Refusing to publish.",
+ file=sys.stderr,
+ )
+ return 3
+ document["CNA_private"]["state"] = "PUBLIC"
+
+ if args.dry_run:
+ print(
+ f"= {args.cve_id} dry-run: would POST CNA_private.state = PUBLIC
(currently {current_state!r}).",
+ )
+ return 0
+
+ try:
+ envelope = update_record(session, args.cve_id, document,
section=args.section)
+ except SessionExpired as e:
+ print(f"✗ {e}", file=sys.stderr)
+ return 2
+ except CSRFNotFound as e:
+ print(f"✗ {e}", file=sys.stderr)
+ return 4
+ except RecordSaveFailed as e:
+ print(f"✗ {e}", file=sys.stderr)
+ return 5
+ except VulnogramAPIError as e:
+ print(f"✗ {e}", file=sys.stderr)
+ return 6
+
+ if envelope.get("type") == "saved":
+ print(
+ f"✓ {args.cve_id} published "
+ f"(state {current_state!r} → 'PUBLIC') at "
+ f"https://{session.host}/{args.section}/{args.cve_id}"
+ )
+ return 0
+ if envelope.get("type") == "go":
+ print(
+ f"✓ {args.cve_id} published; Vulnogram redirected to "
+ f"{envelope.get('to')!r} (record was renamed mid-save)."
+ )
+ return 0
+ print(f"? Unexpected envelope shape: {envelope}", file=sys.stderr)
+ return 7
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/vulnogram/oauth-api/tests/test_record_publish.py
b/tools/vulnogram/oauth-api/tests/test_record_publish.py
new file mode 100644
index 0000000..7498926
--- /dev/null
+++ b/tools/vulnogram/oauth-api/tests/test_record_publish.py
@@ -0,0 +1,153 @@
+# 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.
+from __future__ import annotations
+
+import json
+
+import pytest
+
+from vulnogram_api import record_publish
+
+
+def _write_session(path):
+ path.write_text(
+ json.dumps(
+ {
+ "host": "cveprocess.apache.org",
+ "session_cookie_name": "connect.sid",
+ "session_cookie_value": "s%3Atest",
+ "from_address": "[email protected]",
+ }
+ )
+ )
+ return path
+
+
+def test_invalid_cve_id_rejected(tmp_path, monkeypatch):
+ _write_session(tmp_path / "session.json")
+ monkeypatch.setenv("VULNOGRAM_SESSION", str(tmp_path / "session.json"))
+ with pytest.raises(SystemExit) as excinfo:
+ record_publish.main(["--cve-id", "not-a-cve"])
+ assert "CVE-YYYY-NNNN" in str(excinfo.value)
+
+
+def test_already_public_is_noop(tmp_path, monkeypatch, capsys):
+ _write_session(tmp_path / "session.json")
+ monkeypatch.setenv("VULNOGRAM_SESSION", str(tmp_path / "session.json"))
+
+ def _fake_get(*a, **kw):
+ return {"CNA_private": {"state": "PUBLIC"}, "cveMetadata": {"cveId":
"CVE-2026-12345"}}
+
+ monkeypatch.setattr(record_publish, "get_record", _fake_get)
+ rc = record_publish.main(["--cve-id", "CVE-2026-12345"])
+ assert rc == 0
+ err = capsys.readouterr().err
+ assert "already PUBLIC" in err
+
+
+def test_unexpected_state_refused(tmp_path, monkeypatch, capsys):
+ _write_session(tmp_path / "session.json")
+ monkeypatch.setenv("VULNOGRAM_SESSION", str(tmp_path / "session.json"))
+
+ def _fake_get(*a, **kw):
+ return {"CNA_private": {"state": "DRAFT"}}
+
+ monkeypatch.setattr(record_publish, "get_record", _fake_get)
+ rc = record_publish.main(["--cve-id", "CVE-2026-12345"])
+ assert rc == 3
+ err = capsys.readouterr().err
+ assert "'DRAFT'" in err
+ assert "Refusing the publish" in err
+
+
+def test_allow_state_widens_acceptance(tmp_path, monkeypatch):
+ _write_session(tmp_path / "session.json")
+ monkeypatch.setenv("VULNOGRAM_SESSION", str(tmp_path / "session.json"))
+
+ def _fake_get(*a, **kw):
+ return {"CNA_private": {"state": "READY"}}
+
+ monkeypatch.setattr(record_publish, "get_record", _fake_get)
+ rc = record_publish.main(
+ [
+ "--cve-id",
+ "CVE-2026-12345",
+ "--allow-state",
+ "REVIEW",
+ "--allow-state",
+ "READY",
+ "--dry-run",
+ ]
+ )
+ assert rc == 0
+
+
+def test_review_to_public_dry_run(tmp_path, monkeypatch, capsys):
+ _write_session(tmp_path / "session.json")
+ monkeypatch.setenv("VULNOGRAM_SESSION", str(tmp_path / "session.json"))
+
+ def _fake_get(*a, **kw):
+ return {"CNA_private": {"state": "REVIEW"}, "cveMetadata": {"cveId":
"CVE-2026-12345"}}
+
+ monkeypatch.setattr(record_publish, "get_record", _fake_get)
+ rc = record_publish.main(["--cve-id", "CVE-2026-12345", "--dry-run"])
+ assert rc == 0
+ out = capsys.readouterr().out
+ assert "dry-run" in out
+ assert "'REVIEW'" in out
+
+
+def test_review_to_public_apply_flips_state(tmp_path, monkeypatch, capsys):
+ _write_session(tmp_path / "session.json")
+ monkeypatch.setenv("VULNOGRAM_SESSION", str(tmp_path / "session.json"))
+
+ fetched = {"CNA_private": {"state": "REVIEW"}, "cveMetadata": {"cveId":
"CVE-2026-12345"}}
+ captured: dict[str, object] = {}
+
+ def _fake_get(*a, **kw):
+ return fetched
+
+ def _fake_update(session, cve_id, document, *, section="cve5", **kw):
+ captured["cve_id"] = cve_id
+ captured["state"] = document["CNA_private"]["state"]
+ return {"type": "saved"}
+
+ monkeypatch.setattr(record_publish, "get_record", _fake_get)
+ monkeypatch.setattr(record_publish, "update_record", _fake_update)
+ rc = record_publish.main(["--cve-id", "CVE-2026-12345"])
+ assert rc == 0
+ assert captured == {"cve_id": "CVE-2026-12345", "state": "PUBLIC"}
+ out = capsys.readouterr().out
+ assert "published" in out
+ assert "'REVIEW'" in out
+ assert "'PUBLIC'" in out
+
+
+def test_session_expired_returns_2(tmp_path, monkeypatch, capsys):
+ _write_session(tmp_path / "session.json")
+ monkeypatch.setenv("VULNOGRAM_SESSION", str(tmp_path / "session.json"))
+
+ def _raise_expired(*a, **kw):
+ from vulnogram_api.client import SessionExpired
+
+ raise SessionExpired("session expired")
+
+ monkeypatch.setattr(record_publish, "get_record", _raise_expired)
+ rc = record_publish.main(["--cve-id", "CVE-2026-12345"])
+ assert rc == 2
+ err = capsys.readouterr().err
+ assert "session expired" in err