Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions src/vpcopilot/console/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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")
Expand Down Expand Up @@ -788,15 +793,13 @@ 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,
"url": os.environ.get("VPCOPILOT_DEFAULT_URL", "https://your-app.example.com"),
"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"),
Expand Down
48 changes: 38 additions & 10 deletions src/vpcopilot/console/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,7 @@
<span class="muted repnote"></span>
<pre id="xcOut" class="logbox" style="display:none"></pre>
</div>
<details class="adv"><summary>Advanced integrations <span class="muted">— BIG-IP lab (L2) &amp; off-box audit sink</span></summary>
<details class="adv"><summary>Advanced integrations <span class="muted">— BIG-IP &amp; NGINX labs (L2) &amp; off-box audit sink</span></summary>
<div class="card"><h2>BIG-IP lab <span class="muted">— the appliance the declarative WAF policy is validated against (L2)</span></h2>
<div id="bigipStatus" class="muted">…</div>
<button class="go ghost" onclick="loadBigip()">Refresh</button>
Expand All @@ -367,6 +367,17 @@
<span class="mono">/Common</span> is refused outright, and anything in
<span class="mono">VPCOPILOT_PROTECTED_BIGIP_TENANTS</span> needs an explicit override.</p>
</div>
<div class="card"><h2>NGINX + App Protect box <span class="muted">— the box the App Protect policy is applied on (L2)</span></h2>
<div id="nginxSetupStatus" class="muted">…</div>
<button class="go ghost" onclick="loadNginx()">Refresh</button>
<p class="sub" style="margin-top:8px">Set <span class="mono">NGINX_SSH_HOST</span>,
<span class="mono">NGINX_SSH_USER</span> and <span class="mono">NGINX_SSH_KEY</span> above (SSH to
the box — a tunnel's near end is fine; <span class="mono">NGINX_SSH_PASSWORD</span> is the
key-less fallback). Stand up the copilot vhost with
<span class="mono">vpcopilot nginx-lab create|rm</span> — the catch-all
<span class="mono">_</span> server is refused, and anything in
<span class="mono">VPCOPILOT_PROTECTED_NGINX_SITES</span> needs an explicit override.</p>
</div>
<div class="card"><h2>Audit event sink <span class="muted">— ships every audit entry off the box as it is written</span></h2>
<div id="sinkStatus" class="muted">…</div>
<button class="go ghost" onclick="checkSink(false)">Refresh</button>
Expand Down Expand Up @@ -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, …).
Expand Down Expand Up @@ -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(){
Expand All @@ -466,15 +477,19 @@
if(!im.vulns){ hero.innerHTML='<span class="muted" style="color:#cfccce">No verified findings yet — start at ① Scan.</span>'; return; }
const mttm = im.mttm_seconds!=null ? (im.mttm_seconds+"s") : "minutes";
const speed = im.speedup ? (" · "+im.speedup.toLocaleString()+"× faster") : "";
const dash = DASHBOARD ? `<a href="${DASHBOARD}" target="_blank" style="margin-left:auto;color:#fff;font-weight:700;font-size:12px">XC security dashboard ↗</a>` : "";
// 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 ? `<div class="hsep">vs</div>`+
`<div class="hstat dim"><div class="big redline">${im.change_control_days} days</div><div class="lbl">normal change control</div></div>` : "";
hero.innerHTML = `
<div class="hstat"><div class="big">${im.vulns}</div><div class="lbl">exploitable vulns</div></div>
<div class="hsep">→</div>
<div class="hstat"><div class="big">${im.mitigated}</div><div class="lbl">mitigated live by XC</div></div>
<div class="hstat"><div class="big">${im.mitigated}</div><div class="lbl">mitigated live${esc(where)}</div></div>
<div class="hstat"><div class="big">${mttm}</div><div class="lbl">time to mitigate${speed}</div></div>
<div class="hsep">vs</div>
<div class="hstat dim"><div class="big redline">${im.change_control_days} days</div><div class="lbl">normal change control</div></div>
<div class="hstat"><div class="big">${im.code_prs}</div><div class="lbl">code-fix PRs</div></div>${dash}`;
${cc}
<div class="hstat"><div class="big">${im.code_prs}</div><div class="lbl">code-fix PRs</div></div>`;
}

// ---- shared results load + step badges ----
Expand Down Expand Up @@ -1204,6 +1219,20 @@ <h2 style="margin-top:14px">Full matrix</h2><table><thead>${head}</thead><tbody>
if(s.reason) bits.push('<span class="bad">'+esc(s.reason)+'</span>');
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='<span class="bad">'+esc(e.message)+'</span>'; return; }
if(!s.configured){ box.innerHTML='<span class="muted">no NGINX box configured — set NGINX_SSH_HOST, NGINX_SSH_USER and NGINX_SSH_KEY above</span>'; return; }
if(!s.reachable){ box.innerHTML='<span class="bad">unreachable — '+esc(s.reason)+'</span>'; return; }
const bits=[];
if(s.version) bits.push(esc(s.version));
bits.push('App Protect: '+(s.app_protect?'<b>loaded</b>':'<span class="bad">not loaded</span>'));
if(s.protected&&s.protected.length) bits.push('protected: '+s.protected.map(t=>`<span class="mono">${esc(t)}</span>`).join(", "));
if(s.reason) bits.push('<span class="bad">'+esc(s.reason)+'</span>');
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.
Expand Down Expand Up @@ -1296,8 +1325,7 @@ <h2 style="margin-top:14px">Full matrix</h2><table><thead>${head}</thead><tbody>

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(); }

Expand Down
39 changes: 30 additions & 9 deletions src/vpcopilot/impact.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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,
Expand All @@ -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),
}
19 changes: 10 additions & 9 deletions src/vpcopilot/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'<div class="h{dim}"><span class="n">{_e(n)}</span><span class="l">{_e(lbl)}</span></div>' # noqa: E731
from .impact import xc_dashboard_url
dash = xc_dashboard_url()
dash_link = (f'<a href="{_e(dash)}" target="_blank" style="margin-left:auto;color:#fff;font-weight:700;font-size:12px">'
'XC security dashboard ↗</a>') 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'<span class="sep">vs</span>'
f'<div class="h dim"><span class="n red">{_e(im["change_control_days"])} days</span>'
'<span class="l">normal change control</span></div>') if im.get("change_control_days") else ""
return ('<div class="hero">'
+ h(im["vulns"], "exploitable vulns")
+ '<span class="sep">→</span>'
+ h(im["mitigated"], "mitigated live by XC")
+ h(im["mitigated"], "mitigated live" + where)
+ h(mttm, "time to mitigate" + speed)
+ '<span class="sep">vs</span>'
+ f'<div class="h dim"><span class="n red">{_e(im["change_control_days"])} days</span>'
'<span class="l">normal change control</span></div>'
+ 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
+ '</div>')


Expand Down
21 changes: 19 additions & 2 deletions tests/test_console_nginx.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading