From e69049555cc3a2a197eae716eb9e3749bcb07e64 Mon Sep 17 00:00:00 2001 From: Daniel Henley Date: Tue, 18 Aug 2026 13:33:47 -0500 Subject: [PATCH 1/2] =?UTF-8?q?feat(console):=20NGINX=20connection=20field?= =?UTF-8?q?s=20in=20=E2=9A=99=20Setup=20(they=20were=20missing)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ④ Mitigate "Apply on your own NGINX" panel + its help told the operator to set NGINX_SSH_* "in ⚙ Setup", but Setup had no NGINX fields — the only way to point it at the box was hand-editing .env. Added the NGINX_SSH_* keys to MANAGED_KEYS (so the generic Setup config editor renders them as fields) with NGINX_SSH_PASSWORD in SECRET_KEYS (the key PATH is not secret), plus a NGINX + App Protect card in Setup's Advanced integrations with a live status readout (loadNginx → /api/nginx-lab), mirroring the BIG-IP card. +2 tests. Co-Authored-By: Claude Opus 4.8 --- src/vpcopilot/console/app.py | 7 +++++- src/vpcopilot/console/static/index.html | 29 +++++++++++++++++++++++-- tests/test_console_nginx.py | 21 ++++++++++++++++-- 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/src/vpcopilot/console/app.py b/src/vpcopilot/console/app.py index b0af058..2c7d14b 100644 --- a/src/vpcopilot/console/app.py +++ b/src/vpcopilot/console/app.py @@ -61,7 +61,7 @@ def _active_tag() -> str: SECRET_KEYS = {"ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY", "XC_API_TOKEN", "GITHUB_TOKEN", "VPCOPILOT_PROBE_PASS", "VPCOPILOT_PROBE_TOKEN", "VPCOPILOT_AUDIT_SINK_TOKEN", - "BIGIP_PASSWORD"} + "BIGIP_PASSWORD", "NGINX_SSH_PASSWORD"} MANAGED_KEYS = [ "ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY", "OLLAMA_API_BASE", "XC_API_URL", "XC_API_TOKEN", "XC_NAMESPACE", "GITHUB_TOKEN", @@ -75,6 +75,11 @@ def _active_tag() -> str: # L2 — the BIG-IP lab appliance. The password is a credential; the URL and user are not, so # the page can show what is configured. "BIGIP_URL", "BIGIP_USER", "BIGIP_PASSWORD", + # L2 — the NGINX + App Protect box, reached over SSH. Only the password is secret; the host, port, + # user, key PATH, reload command and dirs are echoed so the page can show what is configured. This + # is what the ④ Mitigate "Apply on your own NGINX" panel connects with. + "NGINX_SSH_HOST", "NGINX_SSH_PORT", "NGINX_SSH_USER", "NGINX_SSH_KEY", "NGINX_SSH_PASSWORD", + "NGINX_RELOAD_CMD", "NGINX_POLICY_DIR", "NGINX_INCLUDE_DIR", "NGINX_SSH_STRICT", ] app = FastAPI(title="virtual-patch-copilot console") diff --git a/src/vpcopilot/console/static/index.html b/src/vpcopilot/console/static/index.html index 5131430..c87bf1a 100644 --- a/src/vpcopilot/console/static/index.html +++ b/src/vpcopilot/console/static/index.html @@ -357,7 +357,7 @@ -
Advanced integrations — BIG-IP lab (L2) & off-box audit sink +
Advanced integrations — BIG-IP & NGINX labs (L2) & off-box audit sink

BIG-IP lab — the appliance the declarative WAF policy is validated against (L2)

@@ -367,6 +367,17 @@ /Common is refused outright, and anything in VPCOPILOT_PROTECTED_BIGIP_TENANTS needs an explicit override.

+

NGINX + App Protect box — the box the App Protect policy is applied on (L2)

+
+ +

Set NGINX_SSH_HOST, + NGINX_SSH_USER and NGINX_SSH_KEY above (SSH to + the box — a tunnel's near end is fine; NGINX_SSH_PASSWORD is the + key-less fallback). Stand up the copilot vhost with + vpcopilot nginx-lab create|rm — the catch-all + _ server is refused, and anything in + VPCOPILOT_PROTECTED_NGINX_SITES needs an explicit override.

+

Audit event sink — ships every audit entry off the box as it is written

@@ -418,7 +429,7 @@ if(id==="cure"){ loadResults().then(renderCure); loadHero(); } if(id==="retire"){ loadLedger(); loadAudit(); loadHero(); } if(id==="benchmark"){ loadBenchmarks(); } - if(id==="setup"){ loadConfig(); loadAgents(); reportNote(); checkSink(false); loadBigip(); } + if(id==="setup"){ loadConfig(); loadAgents(); reportNote(); checkSink(false); loadBigip(); loadNginx(); } } // The report is rebuilt server-side on every open, so it always reflects the LATEST run — name the // run dir it comes from, since a scan can repoint it (out-claude-vampi, demo/out, …). @@ -1204,6 +1215,20 @@

Full matrix

${head} if(s.reason) bits.push(''+esc(s.reason)+''); box.innerHTML=bits.join(" · "); } +async function loadNginx(){ const box=document.getElementById("nginxSetupStatus"); + if(!box) return; + box.textContent="checking…"; + let s; try { s=await jget("/api/nginx-lab"); } + catch(e){ box.innerHTML=''+esc(e.message)+''; return; } + if(!s.configured){ box.innerHTML='no NGINX box configured — set NGINX_SSH_HOST, NGINX_SSH_USER and NGINX_SSH_KEY above'; return; } + if(!s.reachable){ box.innerHTML='unreachable — '+esc(s.reason)+''; return; } + const bits=[]; + if(s.version) bits.push(esc(s.version)); + bits.push('App Protect: '+(s.app_protect?'loaded':'not loaded')); + if(s.protected&&s.protected.length) bits.push('protected: '+s.protected.map(t=>`${esc(t)}`).join(", ")); + if(s.reason) bits.push(''+esc(s.reason)+''); + box.innerHTML=bits.join(" · "); +} // J3 — a sink that is configured and silently not delivering is the failure this panel exists to // make visible: the run succeeds either way, so "unset" and "set but unreachable" must never // render the same. `send` is the only thing here that touches the network. diff --git a/tests/test_console_nginx.py b/tests/test_console_nginx.py index 55dc2ab..2e1f083 100644 --- a/tests/test_console_nginx.py +++ b/tests/test_console_nginx.py @@ -91,12 +91,29 @@ def test_emit_endpoint_feeds_the_nginx_panel_supported_and_declined(tmp_path, mo assert by["f-rl"]["supported"] is False and by["f-rl"]["reason"] # declined, with a why -def test_the_mitigate_page_wires_the_nginx_panel(): +def test_setup_exposes_the_nginx_connection_fields(monkeypatch): + """The ④ Mitigate panel + its help text tell the operator to set NGINX_SSH_* 'in ⚙ Setup', so the + Setup config MUST manage those keys — otherwise there is nowhere in the UI to point it at the box. + The password is secret (never echoed); the key PATH is not.""" + from vpcopilot.console import app as A + cfg = TestClient(A.app, raise_server_exceptions=False).get("/api/config").json() + for k in ("NGINX_SSH_HOST", "NGINX_SSH_PORT", "NGINX_SSH_USER", "NGINX_SSH_KEY", + "NGINX_SSH_PASSWORD", "NGINX_RELOAD_CMD", "NGINX_POLICY_DIR", "NGINX_INCLUDE_DIR"): + assert k in cfg, f"{k} is not a managed config key — the Setup UI can't set it" + assert cfg["NGINX_SSH_PASSWORD"]["secret"] is True and cfg["NGINX_SSH_PASSWORD"]["value"] == "" + assert cfg["NGINX_SSH_KEY"]["secret"] is False # a path, echoed so the page shows what is set + + +def test_the_mitigate_page_wires_the_nginx_panel_and_setup_card(): """The static page must call the NGINX endpoints — a panel that renders but posts nothing is worse - than no panel (the agent-native parity Task A held for BIG-IP, mirrored here).""" + than no panel (the agent-native parity Task A held for BIG-IP, mirrored here) — AND Setup must + carry the NGINX connection card so 'set it in ⚙ Setup' is actually true.""" from pathlib import Path html = (Path(__file__).resolve().parents[1] / "src/vpcopilot/console/static/index.html").read_text() assert 'id="nginxApply"' in html and 'runNginxApply()' in html assert '/api/apply-nginx' in html and '"nginx-app-protect"' in html and '/api/nginx-lab' in html assert "loadNginxApply();" in html # actually invoked on the ④ Mitigate render + # Setup card + its status loader + assert 'id="nginxSetupStatus"' in html and "loadNginx()" in html + assert "loadNginx();" in html # invoked on the ⚙ Setup render From 73596a1cb8f977c20267c76aca9ed666ea8a5dcd Mon Sep 17 00:00:00 2001 From: Daniel Henley Date: Tue, 18 Aug 2026 14:01:07 -0500 Subject: [PATCH 2/2] fix(hero): make the top strip reflect the user's own scan, drop the XC dashboard link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The persistent hero band (console + standalone report) mixed real scan-derived numbers with two things that were not the user's data: - a fixed "vs 25 days normal change control · N× faster" contrast (CHANGE_CONTROL_DAYS defaulted to 25 — a narrative baseline, not a scan number) - a hardcoded "mitigated live by XC" label, wrong now that a band-aid can land on BIG-IP or NGINX And a persistent "XC security dashboard ↗" link the operator asked to remove. Changes: - change_control_days() returns None when CHANGE_CONTROL_DAYS is unset; impact() omits the contrast and the derived speedup unless the operator opts in. - impact() exposes points_live (bigip_awaf→BIG-IP, nginx_app_protect→NGINX, else XC), derived from the same set the mitigated count uses so label and number never disagree; the hero label reads "mitigated live · ". - Remove the XC dashboard link + its plumbing (hero DASHBOARD var, /api/defaults `dashboard` field). xc_dashboard_url() helper kept (still tested). Full suite: 1182 passed, 15 skipped. Co-Authored-By: Claude Opus 4.8 --- src/vpcopilot/console/app.py | 2 -- src/vpcopilot/console/static/index.html | 19 +++++++----- src/vpcopilot/impact.py | 39 +++++++++++++++++++------ src/vpcopilot/report.py | 19 ++++++------ tests/test_impact.py | 38 +++++++++++++++++++++--- tests/test_report.py | 19 +++++++++++- 6 files changed, 103 insertions(+), 33 deletions(-) diff --git a/src/vpcopilot/console/app.py b/src/vpcopilot/console/app.py index 2c7d14b..f73494e 100644 --- a/src/vpcopilot/console/app.py +++ b/src/vpcopilot/console/app.py @@ -793,7 +793,6 @@ def defaults(): """Action-settings defaults — env-overridable so the console isn't pinned to one app/demo. Set VPCOPILOT_DEFAULT_LB / _URL / _REPO / _BASE / _PREFIX to match whatever you're testing.""" load_dotenv(ENV_PATH, override=True) - from ..impact import xc_dashboard_url lb = os.environ.get("VPCOPILOT_DEFAULT_LB", "vpcopilot-lab") return { "lb": lb, @@ -801,7 +800,6 @@ def defaults(): "repo": os.environ.get("VPCOPILOT_DEFAULT_REPO", ""), "base": os.environ.get("VPCOPILOT_DEFAULT_BASE", "main"), "prefix": os.environ.get("VPCOPILOT_DEFAULT_PREFIX", ""), - "dashboard": xc_dashboard_url(lb) or "", "out": str(OUT), # so a scan lands in the same dir the console reads (per-model runs) # default on for normal use; the benchmark console launches with VPCOPILOT_SCAN_REMEDIATE=0 "draft_code_fixes": os.environ.get("VPCOPILOT_SCAN_REMEDIATE", "1").lower() not in ("0", "false", "no"), diff --git a/src/vpcopilot/console/static/index.html b/src/vpcopilot/console/static/index.html index c87bf1a..9f20163 100644 --- a/src/vpcopilot/console/static/index.html +++ b/src/vpcopilot/console/static/index.html @@ -468,7 +468,7 @@ modeLine.textContent = `${st.dry?"dry-run":"LIVE"} · ${st.keep?"keep":"rollback"} · LB=${st.lb||"?"}` + (st.refine?` · refine×${st.refineAttempts}`:"") + (st.allow?" · allow-protected":""); } -let RESULTS=null, IMPACT=null, DASHBOARD=""; +let RESULTS=null, IMPACT=null; // ---- hero (persistent) ---- async function loadHero(){ @@ -477,15 +477,19 @@ if(!im.vulns){ hero.innerHTML='No verified findings yet — start at ① Scan.'; return; } const mttm = im.mttm_seconds!=null ? (im.mttm_seconds+"s") : "minutes"; const speed = im.speedup ? (" · "+im.speedup.toLocaleString()+"× faster") : ""; - const dash = DASHBOARD ? `XC security dashboard ↗` : ""; + // Name where the band-aids actually run (XC / BIG-IP / NGINX), not a hardcoded "by XC". + const where = (im.points_live && im.points_live.length) ? " · "+im.points_live.join(", ") : ""; + // The change-control contrast is a narrative baseline, not the user's scan data — show it only when + // the operator opted in (CHANGE_CONTROL_DAYS set, so /api/impact returns a non-null value). + const cc = im.change_control_days ? `
vs
`+ + `
${im.change_control_days} days
normal change control
` : ""; hero.innerHTML = `
${im.vulns}
exploitable vulns
-
${im.mitigated}
mitigated live by XC
+
${im.mitigated}
mitigated live${esc(where)}
${mttm}
time to mitigate${speed}
-
vs
-
${im.change_control_days} days
normal change control
-
${im.code_prs}
code-fix PRs
${dash}`; + ${cc} +
${im.code_prs}
code-fix PRs
`; } // ---- shared results load + step badges ---- @@ -1321,8 +1325,7 @@

Full matrix

${head} async function loadDefaults(){ try { const d=await jget("/api/defaults"); // Fields are NOT pre-filled — you pick them from the dropdowns (no stale/wrong defaults like a - // prior app's LB). Only the dashboard link + the code-fix toggle come from server config. - DASHBOARD=d.dashboard||""; + // prior app's LB). Only the code-fix toggle comes from server config. if(d.draft_code_fixes!==undefined) scanRemediate.checked=d.draft_code_fixes; } catch(e){} updateMode(); } diff --git a/src/vpcopilot/impact.py b/src/vpcopilot/impact.py index 56d9337..70f637d 100644 --- a/src/vpcopilot/impact.py +++ b/src/vpcopilot/impact.py @@ -11,6 +11,15 @@ _LIVE = ("mitigated", "remediated", "retired") # ledger states with a band-aid in front of the app +# Which enforcement point a live control runs on, so the hero names where the mitigation actually +# landed instead of always claiming XC. The three shared forms (service_policy / waf_data_guard / +# api_schema) are stamped with an appliance-specific control on BIG-IP and NGINX; everything else is XC. +_POINT = {"bigip_awaf": "BIG-IP", "nginx_app_protect": "NGINX"} + + +def _point_for(control: str) -> str: + return _POINT.get(control, "XC") + def xc_dashboard_url(lb: str | None = None) -> str | None: """Deep link to the XC security dashboard so the demo can jump straight from a mitigation to the @@ -27,13 +36,18 @@ def xc_dashboard_url(lb: str | None = None) -> str | None: return f"{m.group(1)}/web/workspaces/web-app-and-api-protection/namespaces/{ns}/security" -def change_control_days() -> int: - """The contrast stat — how long a real code fix would take through change control. Env-tunable - so the number matches the customer telling the story (default 25 = middle of 20–30).""" +def change_control_days() -> int | None: + """The contrast stat — how long a real code fix would take through change control. Off by default: + it is a narrative comparison, not a number from the user's scan, so the hero shows it only when the + operator opts in by setting CHANGE_CONTROL_DAYS (env-tunable so the number matches the story being + told). Returns None when unset, which the renderers read as 'omit the contrast entirely'.""" + raw = os.environ.get("CHANGE_CONTROL_DAYS") + if raw is None or raw.strip() == "": + return None try: - return max(1, int(os.environ.get("CHANGE_CONTROL_DAYS", "25"))) + return max(1, int(raw)) except ValueError: - return 25 + return None def _rj(out_dir: str, name: str, default): @@ -71,8 +85,13 @@ def impact(out_dir: str) -> dict: # code-cure-only, which never touched XC at all. Counting states alone made the hero claim a # live mitigation for a finding with `mitigation: null`, while `controls_live` (two lines up, # which does require a mitigation) simultaneously reported none. Same requirement, one answer. - mitigated = sum(1 for e in led.values() - if e.get("state") in _LIVE and e.get("mitigation")) + mitigated_entries = [e for e in led.values() + if e.get("state") in _LIVE and e.get("mitigation")] + mitigated = len(mitigated_entries) + # The enforcement points behind that count, so the hero label names where the band-aids actually + # landed (XC / BIG-IP / NGINX) rather than always saying "by XC". Derived from the SAME entries the + # count uses, so the label can never disagree with the number above it. + points_live = sorted({_point_for(e["mitigation"]["control"]) for e in mitigated_entries}) return { "candidates": summary.get("candidates", 0), "vulns": verified, @@ -83,9 +102,11 @@ def impact(out_dir: str) -> dict: # H2: upgrades are cures we CANNOT open a PR for. Counted separately so the hero panel # never claims a drafted PR that does not exist. "dependency_upgrades": len(summary.get("dependency_upgrades", []) or []), - "change_control_days": days, + "change_control_days": days, # None when CHANGE_CONTROL_DAYS is unset — hero omits the contrast "mttm_seconds": mttm, "controls_live": controls, + "points_live": points_live, # e.g. ["BIG-IP", "XC"] — where the live band-aids actually run "states": counts, - "speedup": (round(days * 86400 / mttm) if mttm else None), # how many× faster than change control + # how many× faster than change control — only meaningful when the operator configured that baseline + "speedup": (round(days * 86400 / mttm) if (days and mttm) else None), } diff --git a/src/vpcopilot/report.py b/src/vpcopilot/report.py index 5f07a0f..8c60c1e 100644 --- a/src/vpcopilot/report.py +++ b/src/vpcopilot/report.py @@ -242,26 +242,27 @@ def _hero_html(im: dict) -> str: return "" mttm = f"{im['mttm_seconds']}s" if im.get("mttm_seconds") is not None else "minutes" speed = f" · {im['speedup']:,}× faster" if im.get("speedup") else "" + # Name where the band-aids actually landed (XC / BIG-IP / NGINX) rather than always saying "by XC". + points = im.get("points_live") or [] + where = " · " + ", ".join(points) if points else "" h = lambda n, lbl, dim="": f'
{_e(n)}{_e(lbl)}
' # noqa: E731 - from .impact import xc_dashboard_url - dash = xc_dashboard_url() - dash_link = (f'' - 'XC security dashboard ↗') if dash else "" + # The change-control contrast is a narrative baseline, not a number from this scan — render it only + # when the operator opted in (CHANGE_CONTROL_DAYS set, so impact() returns a non-null value). + contrast = (f'vs' + f'
{_e(im["change_control_days"])} days' + 'normal change control
') if im.get("change_control_days") else "" return ('
' + h(im["vulns"], "exploitable vulns") + '' - + h(im["mitigated"], "mitigated live by XC") + + h(im["mitigated"], "mitigated live" + where) + h(mttm, "time to mitigate" + speed) - + 'vs' - + f'
{_e(im["change_control_days"])} days' - 'normal change control
' + + contrast + h(im["code_prs"], "code-fix PRs (the cure)") # H2: an advisory's cure is an upgrade in someone else's package — no PR was drafted # and none can be. Shown beside the PR count, never folded into it. Omitted entirely # when there are none, so a repo-only report is unchanged. + (h(im["dependency_upgrades"], "upgrades to ship (no PR)") if im.get("dependency_upgrades") else "") - + dash_link + '
') diff --git a/tests/test_impact.py b/tests/test_impact.py index a423582..b0a1252 100644 --- a/tests/test_impact.py +++ b/tests/test_impact.py @@ -29,19 +29,49 @@ def test_impact_numbers(tmp_path, monkeypatch): assert im["change_control_days"] == 20 assert im["mttm_seconds"] == 40.0 # mean of the two PASSED timings (30, 50); failed one excluded assert im["controls_live"] == {"service_policy": 1, "waf": 1} + assert im["points_live"] == ["XC"] # both live controls are native XC families assert im["speedup"] == round(20 * 86400 / 40.0) -def test_change_control_days_default_and_bad(monkeypatch): +def test_change_control_days_off_by_default(monkeypatch): + """The change-control contrast is opt-in: unset or malformed -> None, so the hero omits the + contrast (and its derived speedup) and shows only numbers from the user's own scan. A valid + value still comes through.""" monkeypatch.delenv("CHANGE_CONTROL_DAYS", raising=False) - assert impact.change_control_days() == 25 + assert impact.change_control_days() is None monkeypatch.setenv("CHANGE_CONTROL_DAYS", "notanint") - assert impact.change_control_days() == 25 + assert impact.change_control_days() is None + monkeypatch.setenv("CHANGE_CONTROL_DAYS", "30") + assert impact.change_control_days() == 30 -def test_impact_empty_out(tmp_path): +def test_contrast_and_speedup_omitted_when_unset(tmp_path, monkeypatch): + """With no CHANGE_CONTROL_DAYS the hero has no baseline to contrast against, so both the days and + the 'N× faster' speedup are None even though a real mitigation timing exists.""" + monkeypatch.delenv("CHANGE_CONTROL_DAYS", raising=False) + _seed(tmp_path) + im = impact.impact(str(tmp_path)) + assert im["change_control_days"] is None and im["speedup"] is None + assert im["mttm_seconds"] == 40.0 # the real scan number is still there + + +def test_points_live_names_the_enforcement_point(tmp_path): + """The hero label names where each live band-aid actually runs — XC for the native controls, + BIG-IP / NGINX for the appliance forms — sorted and de-duped from the counted set.""" + ledger.save(str(tmp_path), { + "a": {"finding_id": "a", "state": "mitigated", "mitigation": {"control": "service_policy", "lb": "l"}}, + "b": {"finding_id": "b", "state": "mitigated", "mitigation": {"control": "bigip_awaf", "lb": "l"}}, + "c": {"finding_id": "c", "state": "remediated", "mitigation": {"control": "nginx_app_protect", "lb": "l"}}}) + im = impact.impact(str(tmp_path)) + assert im["points_live"] == ["BIG-IP", "NGINX", "XC"] + assert im["mitigated"] == 3 + + +def test_impact_empty_out(tmp_path, monkeypatch): + monkeypatch.delenv("CHANGE_CONTROL_DAYS", raising=False) im = impact.impact(str(tmp_path)) assert im["vulns"] == 0 and im["mttm_seconds"] is None and im["speedup"] is None + assert im["change_control_days"] is None and im["points_live"] == [] def test_controls_live_excludes_retired(tmp_path): diff --git a/tests/test_report.py b/tests/test_report.py index 3fa54e7..e12a253 100644 --- a/tests/test_report.py +++ b/tests/test_report.py @@ -176,8 +176,9 @@ def test_report_tolerates_a_legacy_list_shaped_before_after(tmp_path): # ---- C5: hero + self-heal + model-independence + bars ---- -def test_report_c5_hero_and_selfheal(tmp_path): +def test_report_c5_hero_and_selfheal(tmp_path, monkeypatch): from vpcopilot import audit, ledger + monkeypatch.setenv("CHANGE_CONTROL_DAYS", "25") # opt in to the contrast so it renders _seed(tmp_path) # verified=2, so the hero renders ledger.save(str(tmp_path), {"a-001": {"finding_id": "a-001", "state": "mitigated", "severity": "critical", "title": "SQLi", "mitigation": {"control": "waf", "lb": "crapi-lab"}}}) @@ -187,12 +188,28 @@ def test_report_c5_hero_and_selfheal(tmp_path): audit.record(str(tmp_path), "apply_timing", control="waf", passed=True, elapsed_s=40.0) html = report.build_report(str(tmp_path)) assert 'class="hero"' in html and "normal change control" in html + assert "mitigated live" in html # names the point (here XC), no hardcoded "by XC" assert "self-healed ×3" in html # the refine loop's retry is visible assert "At a glance" in html # severity + control bars assert "Model independence" in html # per-agent model chips assert "target: crapi-lab" in html # humanized header from the live LB +def test_report_contrast_is_opt_in(tmp_path, monkeypatch): + """Without CHANGE_CONTROL_DAYS the report hero still renders the scan's own numbers, but omits the + change-control contrast entirely — no dangling 'None days' — and carries no XC dashboard link.""" + from vpcopilot import ledger + monkeypatch.delenv("CHANGE_CONTROL_DAYS", raising=False) + _seed(tmp_path) + ledger.save(str(tmp_path), {"a-001": {"finding_id": "a-001", "state": "mitigated", + "mitigation": {"control": "bigip_awaf", "lb": "l"}}}) + html = report.build_report(str(tmp_path)) + assert 'class="hero"' in html + assert "normal change control" not in html and "None days" not in html + assert "mitigated live · BIG-IP" in html # the point is named from the live control + assert "security dashboard" not in html # the XC dashboard link is gone + + def test_report_no_hero_without_vulns(tmp_path): (tmp_path / "summary.json").write_text(json.dumps({"candidates": 0, "verified": 0})) (tmp_path / "findings.json").write_text("[]")