diff --git a/src/vpcopilot/console/app.py b/src/vpcopilot/console/app.py
index b0af058..f73494e 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")
@@ -788,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,
@@ -796,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 5131430..9f20163 100644
--- a/src/vpcopilot/console/static/index.html
+++ b/src/vpcopilot/console/static/index.html
@@ -357,7 +357,7 @@
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, …).
@@ -457,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(){
@@ -466,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 ? `
${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.
@@ -1296,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_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
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("[]")