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 6c6d082  feat(vulnogram-api): require @apache.org from-address on ASF 
hosts (#134)
6c6d082 is described below

commit 6c6d082f6fc0f014ec9633cabef7e5efc39e683a
Author: Jarek Potiuk <[email protected]>
AuthorDate: Tue May 12 05:19:07 2026 +0200

    feat(vulnogram-api): require @apache.org from-address on ASF hosts (#134)
    
    * feat(security-issue-sync): auto-push CVE JSON via Vulnogram OAuth API at 
sync time
    
    When the operator's machine has a valid Vulnogram OAuth session
    configured (the one-time `vulnogram-api-setup` per machine),
    `security-issue-sync` now pushes the regenerated CVE JSON to the
    record directly via `vulnogram-api-record-update` immediately after
    Step 5a's regen. The push is mechanical and follows from the same
    JSON the user just approved as part of the body update; no separate
    confirmation prompt. State-machine transitions
    (`DRAFT` → `REVIEW` → `READY` → `PUBLIC`) stay with the release
    manager because they include the CNA-feed dispatch trigger.
    
    Three outcomes from `vulnogram-api-check`:
    
    - `valid` → push runs; on success the timestamp is recorded and the
      release-manager hand-off comment is rendered from the new
      OAuth-pushed variant template (RM verifies + does the UI
      state-transition clicks, no paste step).
    - `expired` → push skipped, manual-paste hand-off applies; the
      recap nudges the operator to re-run `vulnogram-api-setup`.
    - `not-configured` → push skipped silently; today's manual-paste
      hand-off applies.
    
    New templates carry the same v1 marker as the manual-paste
    variants, so idempotency detection is unchanged. The Step 4 apply
    mechanic now flips into PATCH-edit-in-place mode when the marker
    is found and the existing body's variant differs from the variant
    the current sync would render — the comment URL, timeline position,
    and prior notifications survive the variant flip. Same
    PATCH-don't-post rationale as the rollup-comment upsert: a fresh
    duplicate comment buries the timeline.
    
    Generated-by: Claude Code (Opus 4.7)
    
    * ci(security-issue-sync): fix prek typos + doctoc on new oauth templates
    
    Two prek failures on apache/airflow-steward#133:
    
    1. typos hook flagged 'PATCHed' (typo tool reads 'Hed' as 'Head').
       Replaced with 'PATCH-edited' — the variant already used elsewhere
       in the same skill, no semantic change.
    2. doctoc hook regenerated TOCs for the two new template files. Added
       them at the top of each file matching the existing
       release-manager-handoff-comment.md convention.
    
    No behavior change — docs only.
    
    Generated-by: Claude Code (Opus 4.7)
    
    * ci(gmail-threading): add language tag to fenced example block (MD040)
    
    Markdownlint MD040 — fenced code blocks must declare a language. The 
example block at threading.md:125 (worked example of multi-thread Security 
mailing list field shape, landed in #131) was bare, now tagged `text`. Surfaces 
on this PR's CI because #131 merged with the prek failure unresolved.
    
    * feat(vulnogram-api): require @apache.org from-address on ASF hosts; 
prompt if missing
    
    The Apache Vulnogram instance at \`cveprocess.apache.org\` is gated
    behind ASF OAuth — the session cookie is only valid when captured
    from an \`<id>@apache.org\` login. Before this change,
    \`vulnogram-api-setup\` accepted any auto-detected from-address
    (typically the personal email of the operator), which led to two
    failure modes:
    
      1. The walkthrough's "log in normally" instruction did not tell
         the operator *which* identity to authenticate with, so they
         could log in with the wrong account and only discover the
         mistake at probe time (a 302 to oauth.apache.org).
      2. The credentials file recorded the personal address, so
         \`vulnogram-api-check\` could not surface a meaningful audit
         trail of which @apache.org account the cookie belonged to.
    
    \`setup_session.resolve_from_address(host, auto_detected, *, prompter)\`
    now enforces an \`@apache.org\` address whenever the host is
    \`cveprocess.apache.org\` or any other \`*.apache.org\` Vulnogram
    deployment. Three outcomes:
    
      - Auto-detected value already ends in \`@apache.org\` -> passthrough.
      - Auto-detected value missing or [email protected] -> prompt
        interactively; bare names (e.g. \`potiuk\`) get \`@apache.org\`
        appended; [email protected] responses are rejected up to 3
        attempts before aborting cleanly before any cookie is captured.
      - Non-ASF host -> no enforcement; auto-detected value passes
        through as before.
    
    The walkthrough then names the resolved address explicitly so the
    operator knows which identity to authenticate with. \`check.py\`
    surfaces the address on a second line after \`valid\` for
    audit-trail visibility (first line stays a bare \`valid\` so
    exact-match parsers in \`security-issue-sync\` Step 5b are
    unaffected). Six new unit tests cover the resolver branches.
    
    Generated-by: Claude Code (Opus 4.7)
---
 .../vulnogram/oauth-api/src/vulnogram_api/check.py |   8 ++
 .../oauth-api/src/vulnogram_api/setup_session.py   | 117 +++++++++++++++++++--
 .../oauth-api/tests/test_setup_session.py          |  72 +++++++++++++
 3 files changed, 186 insertions(+), 11 deletions(-)

diff --git a/tools/vulnogram/oauth-api/src/vulnogram_api/check.py 
b/tools/vulnogram/oauth-api/src/vulnogram_api/check.py
index 5d31251..3c9847d 100644
--- a/tools/vulnogram/oauth-api/src/vulnogram_api/check.py
+++ b/tools/vulnogram/oauth-api/src/vulnogram_api/check.py
@@ -99,7 +99,15 @@ def main(argv: list[str] | None = None) -> int:
     session = Session.load(creds_path)
     result = probe(session, section=args.section)
     if not args.quiet:
+        # Keep the first line a bare `valid` / `expired` / etc. so callers
+        # that exact-match the result token still parse cleanly (per the
+        # parse rules in `.claude/skills/security-issue-sync/SKILL.md`
+        # Step 5b). When a from-address is on file, surface it on a
+        # second line for audit-trail visibility — *"did I capture the
+        # cookie from the right @apache.org login?"*.
         print(result)
+        if result == "valid" and session.from_address:
+            print(f"logged in as {session.from_address}")
     if result == "valid":
         return 0
     if result == "expired":
diff --git a/tools/vulnogram/oauth-api/src/vulnogram_api/setup_session.py 
b/tools/vulnogram/oauth-api/src/vulnogram_api/setup_session.py
index c9916cd..2d0d872 100644
--- a/tools/vulnogram/oauth-api/src/vulnogram_api/setup_session.py
+++ b/tools/vulnogram/oauth-api/src/vulnogram_api/setup_session.py
@@ -45,6 +45,7 @@ import getpass
 import os
 import subprocess
 import sys
+from collections.abc import Callable
 from pathlib import Path
 
 from vulnogram_api.client import probe
@@ -72,6 +73,80 @@ def detect_from_address() -> str | None:
         return None
 
 
+def _is_asf_host(host: str) -> bool:
+    """True when the Vulnogram host is the Apache instance.
+
+    The Apache Vulnogram (``cveprocess.apache.org``) is ASF-OAuth-gated
+    and only accepts logins from `<id>@apache.org` accounts. Other
+    Vulnogram deployments — e.g. self-hosted CNA instances at different
+    domains — do not have this restriction; for them the from-address
+    stays informational and any value is accepted.
+    """
+    return host.lower() == "cveprocess.apache.org" or 
host.lower().endswith(".apache.org")
+
+
+def resolve_from_address(
+    host: str,
+    auto_detected: str | None,
+    *,
+    prompter: Callable[[str], str] = input,
+) -> str:
+    """Resolve the ``from_address`` for the session file.
+
+    For ASF Vulnogram hosts (``cveprocess.apache.org`` and other
+    ``*.apache.org`` deployments), the address **must** be an
+    ``@apache.org`` address — that is the identity the ASF OAuth flow
+    authenticates. If ``auto_detected`` is empty or does not end in
+    ``@apache.org``, prompt the operator for their ASF account.
+
+    For non-ASF hosts, return ``auto_detected`` verbatim (may be empty)
+    — the field is purely informational on those.
+
+    The prompter argument exists so tests can inject a deterministic
+    input source; production callers leave the default ``input``.
+    """
+    if not _is_asf_host(host):
+        return auto_detected or ""
+
+    if auto_detected and auto_detected.lower().endswith("@apache.org"):
+        return auto_detected
+
+    print(
+        f"\nVulnogram on {host} is gated behind ASF OAuth — the session cookie 
"
+        f"will only be valid when captured from an @apache.org login."
+    )
+    if auto_detected:
+        print(
+            f"  Auto-detected from-address `{auto_detected}` does not look 
like "
+            f"an @apache.org address; ignoring."
+        )
+    print(
+        "  Enter your ASF account name (e.g. `potiuk` → `[email protected]`) "
+        "or the full address (e.g. `[email protected]`).\n"
+        "  (Suppress this prompt on future runs by passing "
+        "`--from-address <id>@apache.org`, setting `$VULNOGRAM_FROM`, or "
+        "configuring git `user.email` to your @apache.org address.)"
+    )
+
+    for _attempt in range(3):
+        answer = prompter("ASF account: ").strip()
+        if not answer:
+            print("  Empty — required for *.apache.org hosts. Try again.")
+            continue
+        if "@" not in answer:
+            answer = f"{answer}@apache.org"
+        if not answer.lower().endswith("@apache.org"):
+            print(f"  Address must end in @apache.org; got: {answer}. Try 
again.")
+            continue
+        return answer
+
+    raise SystemExit(
+        "Could not resolve an @apache.org address after 3 attempts; aborting "
+        "before any cookie is captured. Re-run with `--from-address "
+        "<id>@apache.org` to skip the prompt."
+    )
+
+
 def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
     ap = argparse.ArgumentParser(
         description=(__doc__ or "").split("\n\n", 1)[0],
@@ -129,17 +204,30 @@ def parse_args(argv: list[str] | None = None) -> 
argparse.Namespace:
     return ap.parse_args(argv)
 
 
-def _print_walkthrough(host: str, cookie_name: str) -> None:
+def _print_walkthrough(host: str, cookie_name: str, from_address: str = "") -> 
None:
     print(f"Vulnogram session-cookie capture for {host}.")
+    if from_address:
+        print(f"Authenticating as: {from_address}")
     print()
     print("Step 1. Open this URL in a regular browser (not curl):")
     print(f"  https://{host}/users/login";)
     print()
-    print(
-        "Step 2. Complete the ASF OAuth login normally (username + 2FA via "
-        "oauth.apache.org). After the redirect lands you back on the "
-        f"{host} home page, you have a live session cookie."
-    )
+    if from_address and _is_asf_host(host):
+        print(
+            "Step 2. Complete the ASF OAuth login normally (username + 2FA via 
"
+            "oauth.apache.org). **Make sure you are logged in as "
+            f"`{from_address}`** — the @apache.org account that owns the "
+            "session cookie. If you are logged in under a different identity, "
+            f"log out first, then log back in as `{from_address}` before "
+            "continuing. After the redirect lands you back on the "
+            f"{host} home page, you have a live session cookie."
+        )
+    else:
+        print(
+            "Step 2. Complete the ASF OAuth login normally (username + 2FA via 
"
+            "oauth.apache.org). After the redirect lands you back on the "
+            f"{host} home page, you have a live session cookie."
+        )
     print()
     print(
         "Step 3. Open DevTools (Cmd-Option-I / Ctrl-Shift-I), go to:\n"
@@ -163,7 +251,14 @@ def _print_walkthrough(host: str, cookie_name: str) -> 
None:
 def main(argv: list[str] | None = None) -> int:
     args = parse_args(argv)
 
-    _print_walkthrough(args.host, args.cookie_name)
+    # Resolve the from-address before printing the walkthrough so the
+    # operator sees which @apache.org account to authenticate with.
+    # For *.apache.org hosts, this is required and the resolver prompts
+    # when the auto-detected value is missing or not @apache.org. For
+    # other hosts, the auto-detected value passes through verbatim.
+    from_address = resolve_from_address(args.host, args.from_address)
+
+    _print_walkthrough(args.host, args.cookie_name, from_address)
 
     cookie_value = args.cookie_value
     if not cookie_value:
@@ -177,11 +272,11 @@ def main(argv: list[str] | None = None) -> int:
         host=args.host,
         cookie_name=args.cookie_name,
         cookie_value=cookie_value,
-        from_address=args.from_address,
+        from_address=from_address or None,
     )
     print(f"Wrote session to {out_path} (mode 600).")
-    if args.from_address:
-        print(f"From-address baked in: {args.from_address}")
+    if from_address:
+        print(f"From-address baked in: {from_address}")
 
     if args.skip_validate:
         print("Skipping validation per --skip-validate. Run 
`vulnogram-api-check` later to test.")
@@ -191,7 +286,7 @@ def main(argv: list[str] | None = None) -> int:
         host=args.host,
         cookie_name=args.cookie_name,
         cookie_value=cookie_value,
-        from_address=args.from_address,
+        from_address=from_address or None,
     )
     print()
     print(f"Validating session by probing 
https://{args.host}/{args.section}/new ...")
diff --git a/tools/vulnogram/oauth-api/tests/test_setup_session.py 
b/tools/vulnogram/oauth-api/tests/test_setup_session.py
index 07129ba..97954d9 100644
--- a/tools/vulnogram/oauth-api/tests/test_setup_session.py
+++ b/tools/vulnogram/oauth-api/tests/test_setup_session.py
@@ -120,3 +120,75 @@ def test_custom_host_flag_round_trips(tmp_path, 
monkeypatch):
     assert payload["host"] == "vuln.example.com"
     assert payload["session_cookie_name"] == "session.id"
     assert payload["session_cookie_value"] == "abc"
+
+
+# 
------------------------------------------------------------------------------
+# resolve_from_address — @apache.org enforcement on ASF Vulnogram hosts
+# 
------------------------------------------------------------------------------
+
+
+def test_resolve_from_address_passthrough_for_non_asf_host():
+    """Non-ASF hosts: any value (or empty) passes through verbatim."""
+    assert setup_session.resolve_from_address("vuln.example.com", 
"[email protected]") == "[email protected]"
+    assert setup_session.resolve_from_address("vuln.example.com", None) == ""
+
+
+def test_resolve_from_address_accepts_apache_org_on_asf_host():
+    """ASF host + [email protected] auto-detected → no prompt, 
passthrough."""
+    assert (
+        setup_session.resolve_from_address("cveprocess.apache.org", 
"[email protected]")
+        == "[email protected]"
+    )
+
+
+def test_resolve_from_address_prompts_when_missing_on_asf_host(capsys):
+    """ASF host + empty auto-detected → prompt; bare name gets @apache.org 
appended."""
+
+    inputs = iter(["potiuk"])
+
+    def prompt(_):
+        return next(inputs)
+
+    answer = setup_session.resolve_from_address("cveprocess.apache.org", None, 
prompter=prompt)
+    assert answer == "[email protected]"
+
+
+def test_resolve_from_address_prompts_when_personal_email_on_asf_host(capsys):
+    """ASF host + [email protected] auto-detected → prompt; explicit value 
retained."""
+
+    inputs = iter(["[email protected]"])
+
+    def prompt(_):
+        return next(inputs)
+
+    answer = setup_session.resolve_from_address(
+        "cveprocess.apache.org", "[email protected]", prompter=prompt
+    )
+    assert answer == "[email protected]"
+    out = capsys.readouterr().out
+    assert "[email protected]" in out  # surfaced the rejected auto-detected 
value
+
+
+def test_resolve_from_address_rejects_non_apache_after_three_attempts(capsys):
+    """ASF host + repeated [email protected] input → SystemExit after 3 tries."""
+
+    inputs = iter(["[email protected]", "[email protected]", "[email protected]"])
+
+    def prompt(_):
+        return next(inputs)
+
+    with pytest.raises(SystemExit) as excinfo:
+        setup_session.resolve_from_address("cveprocess.apache.org", None, 
prompter=prompt)
+    assert "3 attempts" in str(excinfo.value)
+
+
+def test_resolve_from_address_apache_subdomain_also_enforced():
+    """Other *.apache.org hosts (hypothetical Vulnogram deployments) also 
enforced."""
+
+    inputs = iter(["[email protected]"])
+
+    def prompt(_):
+        return next(inputs)
+
+    answer = setup_session.resolve_from_address("vulnogram-test.apache.org", 
None, prompter=prompt)
+    assert answer == "[email protected]"

Reply via email to