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

Reply via email to