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 8c3591b feat(vulnogram-api/record-update): refuse PUBLIC pushes
without vendor-advisory ref (#385)
8c3591b is described below
commit 8c3591b85c53d46f4588c847ca15ad3f56ff5c9d
Author: Jarek Potiuk <[email protected]>
AuthorDate: Sat May 30 19:00:09 2026 +0200
feat(vulnogram-api/record-update): refuse PUBLIC pushes without
vendor-advisory ref (#385)
Per Arnout Engelen's 2026-05-29 review on CVE-2026-40913: a record may
only reach PUBLIC after the advisory has actually shipped to
users@ / announce@ and the archived users-list URL has been added to
references[]. The generator's compute_cna_private_state already
enforces this on the emit side; this PR adds the symmetric guard on
the push side so the merge-mode guards refuse any
vulnogram-api-record-update push that carries state=PUBLIC without a
vendor-advisory-tagged reference.
The check runs AFTER the references merge so a record whose current
state already carries the vendor-advisory ref (idempotent re-push)
passes — the merge restores the ref, the guard sees the final state,
and the push lands. The guard fires only when neither the new doc
nor the merged-from-current refs include vendor-advisory.
There is intentionally no --allow-state-upgrade override: the
sanctioned path to PUBLIC is vulnogram-api-record-publish, which
fires on the archive-URL signal and inserts the vendor-advisory
reference at the same time.
Tests: new TestStateUpgradeToPublicGuard class with 6 cases
(without-advisory refused; with-advisory allowed; merged-from-current
restores ref; replace-references drops ref and refuses;
REVIEW pushes unaffected; error message names the sanctioned path).
Two existing references-merge tests adjusted to use state=REVIEW
since they test references behaviour, not state — the docstring
notes the scoping. Full suite 92/92.
Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
---
.../oauth-api/src/vulnogram_api/merge_mode.py | 59 +++++++++
tools/vulnogram/oauth-api/tests/test_merge_mode.py | 142 ++++++++++++++++++++-
.../oauth-api/tests/test_record_update.py | 10 +-
3 files changed, 206 insertions(+), 5 deletions(-)
diff --git a/tools/vulnogram/oauth-api/src/vulnogram_api/merge_mode.py
b/tools/vulnogram/oauth-api/src/vulnogram_api/merge_mode.py
index 69586ba..54b8254 100644
--- a/tools/vulnogram/oauth-api/src/vulnogram_api/merge_mode.py
+++ b/tools/vulnogram/oauth-api/src/vulnogram_api/merge_mode.py
@@ -93,6 +93,26 @@ def _new_references(new_doc: dict[str, Any]) ->
list[dict[str, Any]]:
return list(refs) if isinstance(refs, list) else []
+def _has_vendor_advisory_reference(refs: list[dict[str, Any]]) -> bool:
+ """Return ``True`` when ``refs`` contains an entry tagged
+ ``vendor-advisory``.
+
+ The ``vendor-advisory`` tag is the structural signal that a public
+ advisory URL has shipped (typically the archived ``users-list``
+ thread on ``lists.apache.org`` or ``security.apache.org``). The
+ state-upgrade-to-PUBLIC guard in :func:`apply_merge_mode_guards`
+ refuses any push that lacks one — per ASF Security policy (Arnout
+ Engelen's 2026-05-29 review on CVE-2026-40913), a record may only
+ reach ``PUBLIC`` *after* the advisory has actually shipped, not
+ while the team is still preparing it.
+ """
+ for ref in refs:
+ tags = ref.get("tags")
+ if isinstance(tags, list) and "vendor-advisory" in tags:
+ return True
+ return False
+
+
def _current_affected(current_doc: dict[str, Any]) -> list[dict[str, Any]]:
aff = _path(current_doc, "body", "containers", "cna", "affected")
return list(aff) if isinstance(aff, list) else []
@@ -252,6 +272,45 @@ def apply_merge_mode_guards(
cna = containers.setdefault("cna", {})
cna["references"] = merged_refs
+ # State-upgrade-to-PUBLIC guard: refuse pushing state=PUBLIC when
+ # the document about to be pushed (post-references-merge) does not
+ # carry a `vendor-advisory` reference. PUBLIC is only legitimate
+ # after the advisory has shipped to users@ / announce@ and the
+ # archived users-list URL has been added to references[] (which
+ # `classify_reference` tags as `vendor-advisory`). Without that,
+ # the operator is hand-flipping the state ahead of the actual
+ # advisory send — the exact failure mode Arnout Engelen flagged on
+ # CVE-2026-40913 (2026-05-29). The generator's
+ # `compute_cna_private_state` already enforces this on the emit
+ # side; this guard catches the case where the operator hand-edits
+ # the JSON file (or pastes via Vulnogram's `#source` tab and then
+ # re-pushes) before re-running.
+ #
+ # The check runs AFTER the references merge so a record whose
+ # current state already has the vendor-advisory reference (e.g.
+ # an idempotent re-push of an already-PUBLIC record where the new
+ # JSON dropped the advisory ref for some reason) passes — the
+ # merge restores the ref, and that is the intended behaviour. The
+ # guard catches the case where neither side carries the
+ # vendor-advisory reference, which is the only true failure mode.
+ #
+ # There is intentionally no `--allow-state-upgrade` override: the
+ # sanctioned path to PUBLIC is `vulnogram-api-record-publish`,
+ # which fires on the archive-URL signal and inserts the
+ # `vendor-advisory` reference at the same time.
+ if new_state_value == "PUBLIC" and not
_has_vendor_advisory_reference(_new_references(merged)):
+ raise MergeModeRefused(
+ 'Refusing CNA_private.state = "PUBLIC" push: the JSON '
+ "carries no `vendor-advisory` reference. PUBLIC is only "
+ "legitimate after the advisory has shipped to users@ / "
+ "announce@ and the archived users-list URL has been added "
+ "to references[]. The sanctioned path to PUBLIC is "
+ "`vulnogram-api-record-publish`, which fires on the archive-"
+ "URL signal — invoke that instead of pushing PUBLIC via "
+ "record-update. (See Arnout Engelen's 2026-05-29 review "
+ "comment on CVE-2026-40913 for why this guard exists.)"
+ )
+
diffs = _diff_affected_products(
current=_current_affected(current_doc),
new=_new_affected(merged),
diff --git a/tools/vulnogram/oauth-api/tests/test_merge_mode.py
b/tools/vulnogram/oauth-api/tests/test_merge_mode.py
index 8918106..6923e52 100644
--- a/tools/vulnogram/oauth-api/tests/test_merge_mode.py
+++ b/tools/vulnogram/oauth-api/tests/test_merge_mode.py
@@ -184,7 +184,15 @@ class TestReferencesMerge:
assert "https://lists.apache.org/thread/abc" in urls
def test_apply_replaces_references_with_flag(self):
- merged = apply_merge_mode_guards(_current(), _new(),
replace_references=True)
+ # state=REVIEW so this stays scoped to the references-merge
+ # guard; the new state-upgrade-to-PUBLIC guard fires only on
+ # PUBLIC pushes without a vendor-advisory reference and is
+ # covered separately in TestStateUpgradeToPublicGuard.
+ merged = apply_merge_mode_guards(
+ _current(state="REVIEW"),
+ _new(state="REVIEW"),
+ replace_references=True,
+ )
urls = {ref["url"] for ref in
merged["containers"]["cna"]["references"]}
assert urls == {"https://github.com/apache/foo/pull/1"}
@@ -192,8 +200,10 @@ class TestReferencesMerge:
"""When both current and new have no references, the merged
document should not sprout an empty ``references: []`` field.
"""
- current = _current(references=[])
- new = _new(references=[])
+ # state=REVIEW for the same reason as above — keeps this test
+ # scoped to the references-merge guard.
+ current = _current(state="REVIEW", references=[])
+ new = _new(state="REVIEW", references=[])
del current["body"]["containers"]["cna"]["references"]
new_copy = copy.deepcopy(new)
del new_copy["containers"]["cna"]["references"]
@@ -202,6 +212,132 @@ class TestReferencesMerge:
assert "references" not in merged["containers"]["cna"]
+# ---------------------------------------------------------------------------
+# State-upgrade-to-PUBLIC guard
+# ---------------------------------------------------------------------------
+
+
+class TestStateUpgradeToPublicGuard:
+ """The guard refuses ``state=PUBLIC`` pushes when the document
+ (after the references merge) carries no ``vendor-advisory``
+ reference. Per Arnout Engelen's 2026-05-29 review on
+ CVE-2026-40913: PUBLIC is only legitimate after the advisory has
+ shipped and the archived users-list URL has been added to
+ ``references[]``.
+ """
+
+ def test_public_without_vendor_advisory_refused(self):
+ with pytest.raises(MergeModeRefused, match='state = "PUBLIC" push'):
+ apply_merge_mode_guards(
+ _current(state="REVIEW", references=[]),
+ _new(
+ state="PUBLIC",
+ references=[
+ {"url": "https://github.com/apache/foo/pull/1",
"tags": ["patch"]},
+ ],
+ ),
+ )
+
+ def test_public_with_vendor_advisory_allowed(self):
+ merged = apply_merge_mode_guards(
+ _current(state="REVIEW", references=[]),
+ _new(
+ state="PUBLIC",
+ references=[
+ {"url": "https://github.com/apache/foo/pull/1", "tags":
["patch"]},
+ {"url": "https://lists.apache.org/thread/abc", "tags":
["vendor-advisory"]},
+ ],
+ ),
+ )
+ assert merged["CNA_private"]["state"] == "PUBLIC"
+
+ def test_public_picks_up_vendor_advisory_from_current_via_merge(self):
+ """When the new doc lacks the vendor-advisory reference but
+ the current doc has it, the references merge restores it
+ before the guard runs — the push succeeds.
+
+ This is the idempotent re-push case: the generator emits a
+ record that omits the advisory URL the operator added by
+ hand on a previous push; the merge restores it; the guard
+ sees the final state has the vendor-advisory ref and passes.
+ """
+ merged = apply_merge_mode_guards(
+ _current(
+ state="PUBLIC",
+ references=[
+ {"url": "https://github.com/apache/foo/pull/1", "tags":
["patch"]},
+ {"url": "https://lists.apache.org/thread/abc", "tags":
["vendor-advisory"]},
+ ],
+ ),
+ _new(
+ state="PUBLIC",
+ references=[
+ {"url": "https://github.com/apache/foo/pull/1", "tags":
["patch"]},
+ ],
+ ),
+ )
+ assert merged["CNA_private"]["state"] == "PUBLIC"
+ urls = {ref["url"] for ref in
merged["containers"]["cna"]["references"]}
+ assert "https://lists.apache.org/thread/abc" in urls
+
+ def
test_public_with_replace_references_and_no_vendor_advisory_refused(self):
+ """``replace_references=True`` drops the current doc's
+ references — the merge does NOT restore the vendor-advisory
+ ref. State=PUBLIC must still be refused.
+ """
+ with pytest.raises(MergeModeRefused, match='state = "PUBLIC" push'):
+ apply_merge_mode_guards(
+ _current(
+ state="REVIEW",
+ references=[
+ {"url": "https://lists.apache.org/thread/abc", "tags":
["vendor-advisory"]},
+ ],
+ ),
+ _new(
+ state="PUBLIC",
+ references=[
+ {"url": "https://github.com/apache/foo/pull/1",
"tags": ["patch"]},
+ ],
+ ),
+ replace_references=True,
+ )
+
+ def test_review_to_review_no_vendor_advisory_passes(self):
+ """The guard only fires on PUBLIC pushes — REVIEW pushes
+ without a vendor-advisory ref are normal.
+ """
+ merged = apply_merge_mode_guards(
+ _current(state="REVIEW", references=[]),
+ _new(
+ state="REVIEW",
+ references=[
+ {"url": "https://github.com/apache/foo/pull/1", "tags":
["patch"]},
+ ],
+ ),
+ )
+ assert merged["CNA_private"]["state"] == "REVIEW"
+
+ def test_error_message_names_record_publish(self):
+ """The error message must point the operator at the sanctioned
+ path (``vulnogram-api-record-publish``) so they don't reach for
+ the ``--allow-state-upgrade`` flag (which intentionally
+ doesn't exist).
+ """
+ with pytest.raises(MergeModeRefused) as exc_info:
+ apply_merge_mode_guards(
+ _current(state="REVIEW", references=[]),
+ _new(
+ state="PUBLIC",
+ references=[
+ {"url": "https://github.com/apache/foo/pull/1",
"tags": ["patch"]},
+ ],
+ ),
+ )
+ msg = str(exc_info.value)
+ assert "vulnogram-api-record-publish" in msg
+ assert "vendor-advisory" in msg
+
+
# ---------------------------------------------------------------------------
# Product / packageName change guard
# ---------------------------------------------------------------------------
diff --git a/tools/vulnogram/oauth-api/tests/test_record_update.py
b/tools/vulnogram/oauth-api/tests/test_record_update.py
index 542bee5..c6f13f5 100644
--- a/tools/vulnogram/oauth-api/tests/test_record_update.py
+++ b/tools/vulnogram/oauth-api/tests/test_record_update.py
@@ -290,14 +290,20 @@ def test_references_merged_by_default(tmp_path,
monkeypatch):
def test_references_wholesale_replace_with_flag(tmp_path, monkeypatch):
_write_session(tmp_path / "session.json")
new_body = _new_doc_review_state_with_provider()
- new_body["CNA_private"]["state"] = "PUBLIC" # bypass state guard
body = tmp_path / "body.json"
body.write_text(json.dumps(new_body))
monkeypatch.setenv("VULNOGRAM_SESSION", str(tmp_path / "session.json"))
+
+ # Use a current record in REVIEW state so neither the
+ # state-downgrade guard (PUBLIC → REVIEW) nor the new state-
+ # upgrade-to-PUBLIC guard fires. The references-merge guard is
+ # the only behaviour under test here.
+ review_record = _public_record()
+ review_record["body"]["CNA_private"]["state"] = "REVIEW"
monkeypatch.setattr(
record_update,
"_fetch_current_or_none",
- lambda *a, **kw: _public_record(),
+ lambda *a, **kw: review_record,
)
captured = {}