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 = {}
 

Reply via email to