diff --git a/demo/build_demo_out.py b/demo/build_demo_out.py index 520ee15..5ced4d4 100644 --- a/demo/build_demo_out.py +++ b/demo/build_demo_out.py @@ -48,6 +48,30 @@ "exploit_sketch": "Diff the 'already registered' vs 'ok' responses to enumerate users."}, ] +# The three candidates the verify agent did NOT confirm. The curated summary has always claimed +# "9 candidates, 6 verified, 2 refuted, 1 dropped" while shipping only the 6 — so the report could +# not render the refuted section at all, and the dataset advertised a funnel it could not show. +# These are real-looking false positives of the kind verify actually catches: a sink that is not +# reachable, a "secret" that is a test fixture, and a rate limit that already exists upstream. +REFUTED = [ + {"id": "crapi-eval-007", "title": "Remote code execution via eval in report renderer", + "vuln_class": "command_injection", "severity": "critical", + "file": "services/community/render.js", "line": 118, + "description": "A template string reaches eval() in the report renderer.", + "exploit_sketch": "Inject a template expression that closes the string and calls process.exit.", + "snippet": "const out = eval('`' + tpl + '`'); // flagged"}, + {"id": "crapi-hardcoded-008", "title": "Hardcoded database password", + "vuln_class": "sensitive_data", "severity": "high", + "file": "services/identity/test/fixtures.js", "line": 9, + "description": "A database password appears as a literal in the source.", + "exploit_sketch": "Read the credential from the repository and connect directly."}, + {"id": "crapi-nolimit-009", "title": "No rate limit on password reset", + "vuln_class": "rate_abuse", "severity": "medium", + "file": "services/identity/reset.js", "line": 47, + "description": "The password-reset endpoint has no throttle in application code.", + "exploit_sketch": "Loop reset requests to enumerate accounts or exhaust the mail quota."}, +] + # control per finding (005 is code-cure-only) BANDAID = {"crapi-sqli-001": "service_policy", "crapi-bola-002": "api_schema", "crapi-mass-003": "waf", "crapi-bruteforce-004": "rate_limit", "crapi-tokenleak-006": "waf_data_guard"} @@ -138,10 +162,13 @@ def main(): # it never passes the pipeline choke point; without this the demo dataset would render as # "never classified" while a real run of the same findings classified fine. from vpcopilot.weakness import classify - for _f in FINDINGS: + for _f in FINDINGS + REFUTED: _g = classify(_f.get("vuln_class", "")) _f["cwe"], _f["owasp"], _f["cwe_source"] = _g["cwe"], _g["owasp"], _g["cwe_source"] - (OUT / "findings.json").write_text(json.dumps(FINDINGS, indent=2)) + # Refuted candidates ship in findings.json but NOT in triage.json — which is exactly how a + # real run represents them, and what makes the report render its "did not confirm" + # section. They carry no band-aid and are excluded from every count. + (OUT / "findings.json").write_text(json.dumps(FINDINGS + REFUTED, indent=2)) (OUT / "triage.json").write_text(json.dumps(TRIAGE, indent=2)) (OUT / "remediations.json").write_text(json.dumps(REMEDIATIONS, indent=2)) (OUT / "policies.json").write_text(json.dumps(POLICIES, indent=2)) diff --git a/demo/out/audit.log b/demo/out/audit.log index 01b8862..4b14428 100644 --- a/demo/out/audit.log +++ b/demo/out/audit.log @@ -1,13 +1,13 @@ -{"ts": "2026-08-05T13:06:09.732288+00:00", "action": "refine_apply", "run_id": "cfc003c6cfb6", "actor": "security-oncall", "host": "vpcopilot-demo", "tool_version": "0.1.0", "finding_id": "crapi-sqli-001", "namespace": "crapi-demo", "control": "service_policy", "policy": "deny-login-sqli", "lb": "crapi-lab", "passed": true, "attempts": 2, "before_after": {"before": {"exploit_status": 200, "exploit_blocked": false, "legit_ok": true}, "after": {"exploit_status": 403, "exploit_blocked": true, "legit_ok": true}}} -{"ts": "2026-08-05T13:06:13.732288+00:00", "action": "apply_timing", "run_id": "cfc003c6cfb6", "actor": "security-oncall", "host": "vpcopilot-demo", "tool_version": "0.1.0", "control": "service_policy", "finding_id": "crapi-sqli-001", "passed": true, "elapsed_s": 48.0, "attempts": 2, "before_after": {"before": {"exploit_status": 200, "exploit_blocked": false, "legit_ok": true}, "after": {"exploit_status": 403, "exploit_blocked": true, "legit_ok": true}}} -{"ts": "2026-08-05T13:07:05.732288+00:00", "action": "create_api_definition", "run_id": "cfc003c6cfb6", "actor": "security-oncall", "host": "vpcopilot-demo", "tool_version": "0.1.0", "finding_id": "crapi-bola-002", "namespace": "crapi-demo", "name": "crapi-lab-apidef", "swagger": "crapi-lab-swagger"} -{"ts": "2026-08-05T13:07:09.732288+00:00", "action": "apply_api_schema", "run_id": "cfc003c6cfb6", "actor": "security-oncall", "host": "vpcopilot-demo", "tool_version": "0.1.0", "finding_id": "crapi-bola-002", "namespace": "crapi-demo", "apidef": "crapi-lab-apidef", "lb": "crapi-lab", "passed": true, "kept": true, "before_after": {"before": {"exploit_status": 200, "exploit_blocked": false, "legit_ok": true}, "after": {"exploit_status": 403, "exploit_blocked": true, "legit_ok": true}}} -{"ts": "2026-08-05T13:07:13.732288+00:00", "action": "apply_timing", "run_id": "cfc003c6cfb6", "actor": "security-oncall", "host": "vpcopilot-demo", "tool_version": "0.1.0", "control": "api_schema", "finding_id": "crapi-bola-002", "passed": true, "elapsed_s": 33.0, "attempts": 1, "before_after": {"before": {"exploit_status": 200, "exploit_blocked": false, "legit_ok": true}, "after": {"exploit_status": 403, "exploit_blocked": true, "legit_ok": true}}} -{"ts": "2026-08-05T13:07:50.732288+00:00", "action": "apply_waf", "run_id": "cfc003c6cfb6", "actor": "security-oncall", "host": "vpcopilot-demo", "tool_version": "0.1.0", "finding_id": "crapi-mass-003", "namespace": "crapi-demo", "app_firewall": "crapi-lab-waf", "lb": "crapi-lab", "config_enabled": true, "kept": true, "before_after": {"before": {"exploit_status": 200, "exploit_blocked": false, "legit_ok": true}, "after": {"exploit_status": 403, "exploit_blocked": true, "legit_ok": true}}} -{"ts": "2026-08-05T13:07:54.732288+00:00", "action": "apply_timing", "run_id": "cfc003c6cfb6", "actor": "security-oncall", "host": "vpcopilot-demo", "tool_version": "0.1.0", "control": "waf", "finding_id": "crapi-mass-003", "passed": true, "elapsed_s": 21.0, "attempts": 1, "before_after": {"before": {"exploit_status": 200, "exploit_blocked": false, "legit_ok": true}, "after": {"exploit_status": 403, "exploit_blocked": true, "legit_ok": true}}} -{"ts": "2026-08-05T13:08:19.732288+00:00", "action": "apply_rate_limit", "run_id": "cfc003c6cfb6", "actor": "security-oncall", "host": "vpcopilot-demo", "tool_version": "0.1.0", "finding_id": "crapi-bruteforce-004", "namespace": "crapi-demo", "rate": "5/MINUTE", "lb": "crapi-lab", "passed": true, "kept": true, "behavioral": {"sent": 30, "limited": 25, "passed": 5, "codes": {"200": 5, "429": 25}}} -{"ts": "2026-08-05T13:08:23.732288+00:00", "action": "apply_timing", "run_id": "cfc003c6cfb6", "actor": "security-oncall", "host": "vpcopilot-demo", "tool_version": "0.1.0", "control": "rate_limit", "finding_id": "crapi-bruteforce-004", "passed": true, "elapsed_s": 27.0, "attempts": 1} -{"ts": "2026-08-05T13:08:54.732288+00:00", "action": "apply_data_guard", "run_id": "cfc003c6cfb6", "actor": "security-oncall", "host": "vpcopilot-demo", "tool_version": "0.1.0", "finding_id": "crapi-tokenleak-006", "namespace": "crapi-demo", "app_firewall": "crapi-lab-waf", "lb": "crapi-lab", "enabled": true, "kept": true} -{"ts": "2026-08-05T13:08:58.732288+00:00", "action": "apply_timing", "run_id": "cfc003c6cfb6", "actor": "security-oncall", "host": "vpcopilot-demo", "tool_version": "0.1.0", "control": "waf_data_guard", "finding_id": "crapi-tokenleak-006", "passed": true, "elapsed_s": 19.0, "attempts": 1} -{"ts": "2026-08-05T13:09:21.732288+00:00", "action": "open_pr", "run_id": "cfc003c6cfb6", "actor": "security-oncall", "host": "vpcopilot-demo", "tool_version": "0.1.0", "finding_id": "crapi-sqli-001", "finding": "crapi-sqli-001", "repo": "acme/crapi", "url": "https://github.com/acme/crapi/pull/311", "number": 311} -{"ts": "2026-08-05T13:09:25.732288+00:00", "action": "retire", "run_id": "cfc003c6cfb6", "actor": "security-oncall", "host": "vpcopilot-demo", "tool_version": "0.1.0", "finding_id": "crapi-sqli-001", "namespace": "crapi-demo", "control": "service_policy", "lb": "crapi-lab", "forced": false} +{"ts": "2026-08-05T16:05:09.359184+00:00", "action": "refine_apply", "run_id": "7bc42a95f23d", "actor": "security-oncall", "host": "vpcopilot-demo", "tool_version": "0.1.0", "finding_id": "crapi-sqli-001", "namespace": "crapi-demo", "control": "service_policy", "policy": "deny-login-sqli", "lb": "crapi-lab", "passed": true, "attempts": 2, "before_after": {"before": {"exploit_status": 200, "exploit_blocked": false, "legit_ok": true}, "after": {"exploit_status": 403, "exploit_blocked": true, "legit_ok": true}}} +{"ts": "2026-08-05T16:05:13.359184+00:00", "action": "apply_timing", "run_id": "7bc42a95f23d", "actor": "security-oncall", "host": "vpcopilot-demo", "tool_version": "0.1.0", "control": "service_policy", "finding_id": "crapi-sqli-001", "passed": true, "elapsed_s": 48.0, "attempts": 2, "before_after": {"before": {"exploit_status": 200, "exploit_blocked": false, "legit_ok": true}, "after": {"exploit_status": 403, "exploit_blocked": true, "legit_ok": true}}} +{"ts": "2026-08-05T16:06:05.359184+00:00", "action": "create_api_definition", "run_id": "7bc42a95f23d", "actor": "security-oncall", "host": "vpcopilot-demo", "tool_version": "0.1.0", "finding_id": "crapi-bola-002", "namespace": "crapi-demo", "name": "crapi-lab-apidef", "swagger": "crapi-lab-swagger"} +{"ts": "2026-08-05T16:06:09.359184+00:00", "action": "apply_api_schema", "run_id": "7bc42a95f23d", "actor": "security-oncall", "host": "vpcopilot-demo", "tool_version": "0.1.0", "finding_id": "crapi-bola-002", "namespace": "crapi-demo", "apidef": "crapi-lab-apidef", "lb": "crapi-lab", "passed": true, "kept": true, "before_after": {"before": {"exploit_status": 200, "exploit_blocked": false, "legit_ok": true}, "after": {"exploit_status": 403, "exploit_blocked": true, "legit_ok": true}}} +{"ts": "2026-08-05T16:06:13.359184+00:00", "action": "apply_timing", "run_id": "7bc42a95f23d", "actor": "security-oncall", "host": "vpcopilot-demo", "tool_version": "0.1.0", "control": "api_schema", "finding_id": "crapi-bola-002", "passed": true, "elapsed_s": 33.0, "attempts": 1, "before_after": {"before": {"exploit_status": 200, "exploit_blocked": false, "legit_ok": true}, "after": {"exploit_status": 403, "exploit_blocked": true, "legit_ok": true}}} +{"ts": "2026-08-05T16:06:50.359184+00:00", "action": "apply_waf", "run_id": "7bc42a95f23d", "actor": "security-oncall", "host": "vpcopilot-demo", "tool_version": "0.1.0", "finding_id": "crapi-mass-003", "namespace": "crapi-demo", "app_firewall": "crapi-lab-waf", "lb": "crapi-lab", "config_enabled": true, "kept": true, "before_after": {"before": {"exploit_status": 200, "exploit_blocked": false, "legit_ok": true}, "after": {"exploit_status": 403, "exploit_blocked": true, "legit_ok": true}}} +{"ts": "2026-08-05T16:06:54.359184+00:00", "action": "apply_timing", "run_id": "7bc42a95f23d", "actor": "security-oncall", "host": "vpcopilot-demo", "tool_version": "0.1.0", "control": "waf", "finding_id": "crapi-mass-003", "passed": true, "elapsed_s": 21.0, "attempts": 1, "before_after": {"before": {"exploit_status": 200, "exploit_blocked": false, "legit_ok": true}, "after": {"exploit_status": 403, "exploit_blocked": true, "legit_ok": true}}} +{"ts": "2026-08-05T16:07:19.359184+00:00", "action": "apply_rate_limit", "run_id": "7bc42a95f23d", "actor": "security-oncall", "host": "vpcopilot-demo", "tool_version": "0.1.0", "finding_id": "crapi-bruteforce-004", "namespace": "crapi-demo", "rate": "5/MINUTE", "lb": "crapi-lab", "passed": true, "kept": true, "behavioral": {"sent": 30, "limited": 25, "passed": 5, "codes": {"200": 5, "429": 25}}} +{"ts": "2026-08-05T16:07:23.359184+00:00", "action": "apply_timing", "run_id": "7bc42a95f23d", "actor": "security-oncall", "host": "vpcopilot-demo", "tool_version": "0.1.0", "control": "rate_limit", "finding_id": "crapi-bruteforce-004", "passed": true, "elapsed_s": 27.0, "attempts": 1} +{"ts": "2026-08-05T16:07:54.359184+00:00", "action": "apply_data_guard", "run_id": "7bc42a95f23d", "actor": "security-oncall", "host": "vpcopilot-demo", "tool_version": "0.1.0", "finding_id": "crapi-tokenleak-006", "namespace": "crapi-demo", "app_firewall": "crapi-lab-waf", "lb": "crapi-lab", "enabled": true, "kept": true} +{"ts": "2026-08-05T16:07:58.359184+00:00", "action": "apply_timing", "run_id": "7bc42a95f23d", "actor": "security-oncall", "host": "vpcopilot-demo", "tool_version": "0.1.0", "control": "waf_data_guard", "finding_id": "crapi-tokenleak-006", "passed": true, "elapsed_s": 19.0, "attempts": 1} +{"ts": "2026-08-05T16:08:21.359184+00:00", "action": "open_pr", "run_id": "7bc42a95f23d", "actor": "security-oncall", "host": "vpcopilot-demo", "tool_version": "0.1.0", "finding_id": "crapi-sqli-001", "finding": "crapi-sqli-001", "repo": "acme/crapi", "url": "https://github.com/acme/crapi/pull/311", "number": 311} +{"ts": "2026-08-05T16:08:25.359184+00:00", "action": "retire", "run_id": "7bc42a95f23d", "actor": "security-oncall", "host": "vpcopilot-demo", "tool_version": "0.1.0", "finding_id": "crapi-sqli-001", "namespace": "crapi-demo", "control": "service_policy", "lb": "crapi-lab", "forced": false} diff --git a/demo/out/findings.json b/demo/out/findings.json index 0b99d03..7f72b79 100644 --- a/demo/out/findings.json +++ b/demo/out/findings.json @@ -89,5 +89,45 @@ "cwe": "", "owasp": "API2:2023", "cwe_source": "" + }, + { + "id": "crapi-eval-007", + "title": "Remote code execution via eval in report renderer", + "vuln_class": "command_injection", + "severity": "critical", + "file": "services/community/render.js", + "line": 118, + "description": "A template string reaches eval() in the report renderer.", + "exploit_sketch": "Inject a template expression that closes the string and calls process.exit.", + "snippet": "const out = eval('`' + tpl + '`'); // flagged", + "cwe": "CWE-77", + "owasp": "", + "cwe_source": "mapped" + }, + { + "id": "crapi-hardcoded-008", + "title": "Hardcoded database password", + "vuln_class": "sensitive_data", + "severity": "high", + "file": "services/identity/test/fixtures.js", + "line": 9, + "description": "A database password appears as a literal in the source.", + "exploit_sketch": "Read the credential from the repository and connect directly.", + "cwe": "", + "owasp": "API3:2023", + "cwe_source": "" + }, + { + "id": "crapi-nolimit-009", + "title": "No rate limit on password reset", + "vuln_class": "rate_abuse", + "severity": "medium", + "file": "services/identity/reset.js", + "line": 47, + "description": "The password-reset endpoint has no throttle in application code.", + "exploit_sketch": "Loop reset requests to enumerate accounts or exhaust the mail quota.", + "cwe": "CWE-799", + "owasp": "API4:2023", + "cwe_source": "mapped" } ] \ No newline at end of file diff --git a/demo/out/ledger.json b/demo/out/ledger.json index be54930..41e5932 100644 --- a/demo/out/ledger.json +++ b/demo/out/ledger.json @@ -24,9 +24,9 @@ "no_bandaid": false, "has_cure": true, "ttl": { - "applied_at": "2026-08-05T13:15:09.715566+00:00", + "applied_at": "2026-08-05T16:14:09.347397+00:00", "ttl_hours": 168, - "expires_at": "2026-08-12T13:15:09.715566+00:00" + "expires_at": "2026-08-12T16:14:09.347397+00:00" } }, "crapi-bola-002": { @@ -54,9 +54,9 @@ "no_bandaid": false, "has_cure": true, "ttl": { - "applied_at": "2026-08-05T13:15:09.716815+00:00", + "applied_at": "2026-08-05T16:14:09.348285+00:00", "ttl_hours": 168, - "expires_at": "2026-08-12T13:15:09.716815+00:00" + "expires_at": "2026-08-12T16:14:09.348285+00:00" } }, "crapi-mass-003": { @@ -81,9 +81,9 @@ "no_bandaid": false, "has_cure": true, "ttl": { - "applied_at": "2026-08-05T13:15:09.717778+00:00", + "applied_at": "2026-08-05T16:14:09.348948+00:00", "ttl_hours": 168, - "expires_at": "2026-08-12T13:15:09.717778+00:00" + "expires_at": "2026-08-12T16:14:09.348948+00:00" } }, "crapi-bruteforce-004": { @@ -108,9 +108,9 @@ "no_bandaid": false, "has_cure": true, "ttl": { - "applied_at": "2026-08-05T13:15:09.720633+00:00", + "applied_at": "2026-08-05T16:14:09.349658+00:00", "ttl_hours": 168, - "expires_at": "2026-08-12T13:15:09.720633+00:00" + "expires_at": "2026-08-12T16:14:09.349658+00:00" } }, "crapi-tokenleak-006": { @@ -138,9 +138,9 @@ "no_bandaid": false, "has_cure": true, "ttl": { - "applied_at": "2026-08-05T13:15:09.721386+00:00", + "applied_at": "2026-08-05T16:14:09.350331+00:00", "ttl_hours": 168, - "expires_at": "2026-08-12T13:15:09.721386+00:00" + "expires_at": "2026-08-12T16:14:09.350331+00:00" } }, "crapi-userenum-005": { diff --git a/demo/out/report.html b/demo/out/report.html index 1048633..4ba5f41 100644 --- a/demo/out/report.html +++ b/demo/out/report.html @@ -62,7 +62,7 @@ .model .a{color:var(--grey)}.model .m{font-family:ui-monospace,Menlo,monospace;color:var(--f5)}

virtual-patch·copilot — scan report

-
target: crapi-lab · generated 2026-08-05 13:15 UTC
+
target: crapi-lab · generated 2026-08-05 16:14 UTC
6exploitable vulns
5mitigated live by XC
29.6stime to mitigate · 72,973× faster
vs
25 daysnormal change control
6code-fix PRs (the cure)

Run summary

9candidates
6verified
5band-aided
1code-cure only
5XC policies
6code-fix PRs
@@ -70,9 +70,9 @@

At a glance

each agent's model is set per-agent in config/agents.yaml — swap Claude / OpenAI / Gemini / Ollama with no code change
resolve · anthropic/claude-opus-4-8discover · anthropic/claude-opus-4-8verify · anthropic/claude-opus-4-8triage · anthropic/claude-opus-4-8generate · anthropic/claude-opus-4-8remediate · anthropic/claude-opus-4-8probe · anthropic/claude-opus-4-8refine · anthropic/claude-opus-4-8

Pipeline metrics

19.2stotal time
6.4sdiscover
4.1sverify
8.7ssynthesize
67%verify confirm-rate
0.86avg confidence
1dupe band-aids collapsed

verify: 9 candidates → 6 verified, 2 refuted, 1 dropped < 0.5 confidence

-

Findings & band-aid coverage

criticalSQL injection in logincrapi-sqli-001sqliservices/identity/login.js:42
service_policy · full✓ code fix drafted
Details

Description: The email field is concatenated straight into the auth query.

Exploit: email=" OR 1=1 -- lets any password through and dumps the users table.

Code cure: Fix sql injection in login

highBOLA on vehicle locationcrapi-bola-002broken_object_authzservices/identity/vehicle.js:88
api_schema · full✓ code fix drafted
Details

Description: Any authenticated user can read another user's vehicle GPS by id.

Exploit: Swap {id} to another user's vehicle uuid; server returns their live location.

Code cure: Fix bola on vehicle location

highMass assignment on profilecrapi-mass-003mass_assignmentservices/identity/dashboard.js:61
waf · partial✓ code fix drafted
Details

Description: The update handler binds the whole body, so `role` and `credit` are writable.

Exploit: POST {"role":"admin","available_credit":9999} — privilege + balance escalation.

Code cure: Fix mass assignment on profile

highJWT + card data in responsecrapi-tokenleak-006sensitive_dataservices/workshop/mechanic.js:120
waf_data_guard · partial✓ code fix drafted
Details

Description: The receipt payload echoes the full PAN and a signed service token.

Exploit: GET a receipt; response body contains the 16-digit card number in cleartext.

Code cure: Fix jwt + card data in response

mediumNo rate limit on OTP verifycrapi-bruteforce-004rate_abuseservices/identity/otp.js:30
rate_limit · full✓ code fix drafted
Details

Description: The 4-digit OTP endpoint has no throttle — brute-forceable in minutes.

Exploit: Fire all 10k OTPs; no lockout, no delay.

Code cure: Fix no rate limit on otp verify

mediumUsername enumeration on signupcrapi-userenum-005broken_authservices/identity/signup.js:25
no band-aid — code cure only✓ code fix drafted
Residual risk: no positive-security band-aid fits; ships as code-only.
Details

Description: Distinct errors for taken vs free emails leak which accounts exist.

Exploit: Diff the 'already registered' vs 'ok' responses to enumerate users.

Code cure: Fix username enumeration on signup

+

Findings & band-aid coverage

criticalSQL injection in logincrapi-sqli-001sqliservices/identity/login.js:42
service_policy · full✓ code fix drafted
Details

Description: The email field is concatenated straight into the auth query.

Exploit: email=" OR 1=1 -- lets any password through and dumps the users table.

Code cure: Fix sql injection in login

highBOLA on vehicle locationcrapi-bola-002broken_object_authzservices/identity/vehicle.js:88
api_schema · full✓ code fix drafted
Details

Description: Any authenticated user can read another user's vehicle GPS by id.

Exploit: Swap {id} to another user's vehicle uuid; server returns their live location.

Code cure: Fix bola on vehicle location

highMass assignment on profilecrapi-mass-003mass_assignmentservices/identity/dashboard.js:61
waf · partial✓ code fix drafted
Details

Description: The update handler binds the whole body, so `role` and `credit` are writable.

Exploit: POST {"role":"admin","available_credit":9999} — privilege + balance escalation.

Code cure: Fix mass assignment on profile

highJWT + card data in responsecrapi-tokenleak-006sensitive_dataservices/workshop/mechanic.js:120
waf_data_guard · partial✓ code fix drafted
Details

Description: The receipt payload echoes the full PAN and a signed service token.

Exploit: GET a receipt; response body contains the 16-digit card number in cleartext.

Code cure: Fix jwt + card data in response

mediumNo rate limit on OTP verifycrapi-bruteforce-004rate_abuseservices/identity/otp.js:30
rate_limit · full✓ code fix drafted
Details

Description: The 4-digit OTP endpoint has no throttle — brute-forceable in minutes.

Exploit: Fire all 10k OTPs; no lockout, no delay.

Code cure: Fix no rate limit on otp verify

mediumUsername enumeration on signupcrapi-userenum-005broken_authservices/identity/signup.js:25
no band-aid — code cure only✓ code fix drafted
Residual risk: no positive-security band-aid fits; ships as code-only.
Details

Description: Distinct errors for taken vs free emails leak which accounts exist.

Exploit: Diff the 'already registered' vs 'ok' responses to enumerate users.

Code cure: Fix username enumeration on signup

Candidates the verify agent did not confirm

3 of 9 candidates. The discover agent proposed these; the adversarial verify step could not confirm them, so they carry no band-aid and are not counted anywhere else on this page. They are shown because "considered and rejected" is a result worth seeing — not because they are findings.

criticalRemote code execution via eval in report renderercrapi-eval-007command_injectionservices/community/render.js:118
Details

Description: A template string reaches eval() in the report renderer.

Exploit: Inject a template expression that closes the string and calls process.exit.

highHardcoded database passwordcrapi-hardcoded-008sensitive_dataservices/identity/test/fixtures.js:9
Details

Description: A database password appears as a literal in the source.

Exploit: Read the credential from the repository and connect directly.

mediumNo rate limit on password resetcrapi-nolimit-009rate_abuseservices/identity/reset.js:47
Details

Description: The password-reset endpoint has no throttle in application code.

Exploit: Loop reset requests to enumerate accounts or exhaust the mail quota.

Generated XC band-aid policies

api_schema crapi-lab-apidef
rate_limit otp-throttle
service_policy deny-login-sqli
waf crapi-lab-waf
waf_data_guard mask-pan
-

Band-aid impact exploit before → after (live validation)

controlpolicyexploit beforeexploit afterlegitresultwhen
service_policy self-healed ×2deny-login-sqli200 allowed403 blockedokPASS2026-08-05T13:06:09
apply_timing self-healed ×2200 allowed403 blockedokPASS2026-08-05T13:06:13
api_schemacrapi-lab-apidef200 allowed403 blockedokPASS2026-08-05T13:07:09
apply_timing200 allowed403 blockedokPASS2026-08-05T13:07:13
wafcrapi-lab-waf200 allowed403 blockedokfail2026-08-05T13:07:50
apply_timing200 allowed403 blockedokPASS2026-08-05T13:07:54
rate_limit5/MINUTEburst 30 allowed25/30 rate-limited (429)PASS2026-08-05T13:08:19

Blast radius what each band-aid would block in recorded traffic — 500 of 500 recorded request(s) replayed through crapi-lab · window 2026-07-20T09:00:00Z..2026-07-20T10:00:00Z

policyevaluatedwould blockrateverdicttop blocked pathscaveats
deny-login-sqli50030.6%within threshold/identity/api/auth/login ×3
+

Band-aid impact exploit before → after (live validation)

controlpolicyexploit beforeexploit afterlegitresultwhen
service_policy self-healed ×2deny-login-sqli200 allowed403 blockedokPASS2026-08-05T16:05:09
apply_timing self-healed ×2200 allowed403 blockedokPASS2026-08-05T16:05:13
api_schemacrapi-lab-apidef200 allowed403 blockedokPASS2026-08-05T16:06:09
apply_timing200 allowed403 blockedokPASS2026-08-05T16:06:13
wafcrapi-lab-waf200 allowed403 blockedokfail2026-08-05T16:06:50
apply_timing200 allowed403 blockedokPASS2026-08-05T16:06:54
rate_limit5/MINUTEburst 30 allowed25/30 rate-limited (429)PASS2026-08-05T16:07:19

Blast radius what each band-aid would block in recorded traffic — 500 of 500 recorded request(s) replayed through crapi-lab · window 2026-07-20T09:00:00Z..2026-07-20T10:00:00Z

policyevaluatedwould blockrateverdicttop blocked pathscaveats
deny-login-sqli50030.6%within threshold/identity/api/auth/login ×3

Remediation ledger found → mitigated → remediated → retired

findingstateband-aidcode cure
crapi-sqli-001retired{'control': 'service_policy', 'policy_name': 'deny-login-sqli', 'lb': 'crapi-lab'}{'pr_url': 'https://github.com/acme/crapi/pull/311', 'pr_number': 311}
crapi-bola-002remediated{'control': 'api_schema', 'policy_name': 'crapi-lab-apidef', 'lb': 'crapi-lab'}{'pr_url': 'https://github.com/acme/crapi/pull/312', 'pr_number': 312}
crapi-mass-003mitigated{'control': 'waf', 'policy_name': 'crapi-lab-waf', 'lb': 'crapi-lab'}
crapi-bruteforce-004mitigated{'control': 'rate_limit', 'policy_name': 'otp-throttle', 'lb': 'crapi-lab'}
crapi-tokenleak-006remediated{'control': 'waf_data_guard', 'policy_name': 'mask-pan', 'lb': 'crapi-lab'}{'pr_url': 'https://github.com/acme/crapi/pull/313', 'pr_number': 313}
crapi-userenum-005found
diff --git a/demo/out/run.json b/demo/out/run.json index 02fd888..c1f0c1f 100644 --- a/demo/out/run.json +++ b/demo/out/run.json @@ -1,6 +1,6 @@ { - "run_id": "cfc003c6cfb6", - "created": "2026-08-05T13:15:09.728200+00:00", + "run_id": "7bc42a95f23d", + "created": "2026-08-05T16:14:09.354173+00:00", "repo": "/src/crapi", "config_path": "config/agents.yaml", "models": { diff --git a/demo/out/simulation.json b/demo/out/simulation.json index e98faf6..ae8f738 100644 --- a/demo/out/simulation.json +++ b/demo/out/simulation.json @@ -1,5 +1,5 @@ { - "ts": "2026-08-05T13:15:09.733157+00:00", + "ts": "2026-08-05T16:14:09.360187+00:00", "lb": "crapi-lab", "source": "xc:crapi-lab", "records": 500, diff --git a/docs/DEMO.md b/docs/DEMO.md index c7450c2..2fc87dd 100644 --- a/docs/DEMO.md +++ b/docs/DEMO.md @@ -1,96 +1,231 @@ -# DEMO — virtual-patch-copilot in five minutes +# DEMO — the whole product, from the console -Two ways to run it. **Path A** needs no cloud and no API keys — it tells the whole story from a -curated dataset, ideal for a laptop walkthrough or a screen recording. **Path B** is the live -end-to-end: a real app behind F5 Distributed Cloud (XC), a real exploit blocked in minutes. +Everything here is driven from the **ops console** in a browser. No terminal is needed once the +server is up. CLI equivalents are given where you might want them, but the demo is the GUI. The one-line story to keep in mind: -> A frontier model finds exploitable vulns. The code fix needs 20–30 days of change control. -> The copilot puts an **XC band-aid in front of the app in minutes** — exploit blocked, legit -> traffic untouched — and opens the **code-fix PR (the cure)** in the same pass. When the cure -> ships, it **retires** the band-aid. Every step is gated, validated, and reversible. +> A frontier model finds exploitable vulns. The code fix needs 20–30 days of change control. The +> copilot puts an **F5 band-aid in front of the app in minutes** — exploit blocked, legit traffic +> untouched — and opens the **code-fix PR (the cure)** in the same pass. When the cure ships, it +> **retires** the band-aid. Every step is gated, validated, reversible and recorded. + +There are three demos, in increasing order of what they need. **Pick one and finish it** — they +tell the same story at different levels of proof, and running half of each is worse than running +one whole. + +| | needs | proves | wall clock | +|---|---|---|---| +| **A — Offline** | nothing | the whole arc, from a curated dataset | 5 min | +| **B — Live on F5 XC** | XC tenant + a model key | a real exploit blocked at a real edge | 12 min | +| **C — Declarative WAF** | B, plus a BIG-IP | the same policy, emitted for someone else's WAF | +6 min | --- -## Path A — the offline walkthrough (no XC, no keys) +## The console at a glance -```bash -pip install -e ".[console]" -python3 demo/build_demo_out.py # writes a curated demo/out (crAPI-flavoured) -VPCOPILOT_OUT=demo/out vpcopilot console # http://127.0.0.1:8787 ``` +1 Scan → 2 Review → 3 Simulate → 4 Mitigate → 5 Cure → 6 Retire → 7 Benchmark ⚙ Setup +``` + +The seven numbered steps are the arc, left to right. **⚙ Setup** holds credentials, the XC and +BIG-IP status panels, the audit sink and the per-agent model table. A **Run settings** bar +(`dry-run · rollback · LB · refine×3`) appears on the four steps that can change something — +Simulate, Mitigate, Cure, Retire — and never on the ones that cannot. + +The **hero band** across the top is the headline: exploitable vulns → mitigated live → time to +mitigate, against your change-control window. + +--- -Walk the steps top to bottom — the whole arc is already in the data: - -1. **② Review → hero band.** "6 exploitable vulns → mitigated live in ~30s, vs a 25-day change - window." Five XC control families are in play (service_policy, api_schema, waf, rate_limit, - waf_data_guard). One finding ships code-only (no band-aid fits) — honesty, not theatre. -2. **② Review → findings.** Click any row to inspect the exploit, the vulnerable code, the - generated band-aid, and the code cure. Note the SQLi row. -3. **② Review → Open HTML report ↗** (one click, right there in the step — no hunting in Setup). - The same story as a shareable, self-contained `report.html`: hero, severity/coverage bars, - model-independence, and the band-aid impact table where the SQLi service policy shows - **self-healed ×2** — the refiner's first policy didn't block, it diagnosed and retried until the - exploit actually returned 403 — and rate-limit shows the behavioral proof (25/30 requests 429'd). - **Download** grabs a stamped copy. It is rebuilt from the current out dir every time you open - it, so it is never a stale file. -4. **⑥ Retire → ledger.** The four-state track: `found → mitigated → remediated → retired`. - `crapi-sqli-001` is walked all the way to **retired** — its cure PR merged, so the band-aid was - detached. -5. **⑥ Retire → audit trail.** *Every change made to a load balancer*, one row each: when (UTC) · - action · justified by (the finding + severity) · control (+ the XC object) · load balancer - (+ namespace) · outcome (with the `200 allowed → 403 blocked` proof and a self-heal ×N badge) · - by. Filter it, expand `▸` for the raw JSON, then **Export evidence bundle (.zip)** — the - normalized `audit.csv`, the raw `audit.log`, the exact XC configs pushed, the pre-change LB - snapshots, and a manifest that SHA-256s every member. **All runs** does the same for every run - dir on disk. The curated log is hand-built, so a couple of rows carry no finding or actor; a - live apply (Path B) stamps both on every record. Detail: [AUDIT.md](AUDIT.md). - -The report also lives at `demo/out/report.html` — open it directly with no server. The same bundle -is available from the CLI: +## Demo A — offline (no cloud, no keys) ```bash -vpcopilot export --out demo/out # -> demo/out/audit-bundle.zip +pip install -e ".[console]" +python3 demo/build_demo_out.py # writes a curated demo/out (crAPI-flavoured) +VPCOPILOT_OUT=demo/out vpcopilot console # http://127.0.0.1:8787 ``` +Nine candidates, six verified — the whole arc already in the data. Walk the steps left to right. + +### ② Review — what was found, and what was not + +The chip row is the funnel: **candidates: 9 → verified: 6 → band-aids: 5 → code-fix PRs: 6**. + +The gap between 9 and 6 is the point. An adversarial verify step refuted three candidates — an +`eval()` sink that is not reachable, a "hardcoded password" that is a test fixture, a missing rate +limit that already exists upstream — and they are **not** silently dropped. In the HTML report they +render below the fold in their own section, labelled *"Candidates the verify agent did not +confirm"*, excluded from every count and every chart on the page. + +Say this out loud: *the tool argues with itself, and shows you the argument.* + +Click any finding row to expand the exploit, the vulnerable code, the generated policy and the +code cure. + +Each row carries its **CWE and OWASP API category**, with the tier spelled out — `CWE-89 · mapped` +versus `CWE-22 · advisory`. That distinction is the honesty: `advisory` means the OSV record named +the weakness and we are quoting it; `mapped` means the copilot classified it. Two findings carry +**no CWE at all**, and that is the correct answer — `CWE-840` is *prohibited* by MITRE for mapping +to real vulnerabilities, and username enumeration is not "weak authentication". A blank you can +explain beats a guess. + +### ② Review → **Open HTML report ↗** + +The shareable artifact, rebuilt from the run directory every time you open it, so it is never +stale. **Download** takes a stamped copy. + +Point at three things: + +1. **Findings by OWASP API Top 10** — and the `(no category)` bar. Injection was *removed* from the + 2023 API list, so `sqli` carries a CWE and no category. The chart accounts for every finding + rather than quietly charting a subset. +2. **The blast-radius table** — a verdict per policy, with a caveats column. A replay that could + not be measured says *not measured*, not a green 0.0%. +3. **Self-healed ×2** on the SQLi policy — the first generated policy did not block; the refiner + diagnosed it and retried until the exploit actually returned 403. + +### ③ Simulate — the blast radius, before anything is applied + +The step people do not expect, and the one that lands with security teams. The safety spine proves +a band-aid blocks *the finding's exploit* and passes *one* legit request. It says nothing about the +other million requests a day, and that gap is why controls sit in monitor mode. + +Simulate replays a recorded traffic sample against each candidate policy through a **spare** load +balancer and reports what each one *would* block. Nothing is applied; the spare LB is snapshotted +and restored. + +### ⑥ Retire — the ledger and the audit trail + +The four-state track: `found → mitigated → remediated → retired`. `crapi-sqli-001` is walked all +the way — its cure PR merged, so the band-aid was detached. + +Below it, **every change ever made to a load balancer**, one row each: when (UTC) · action · +justified by (finding + severity) · control · load balancer + namespace · outcome (with the +`200 allowed → 403 blocked` proof) · by whom. Expand `▸` for the raw JSON. + +**Export evidence bundle (.zip)** — the normalized `audit.csv`, the raw `audit.log`, the exact +configs pushed, the pre-change LB snapshots, and a manifest that SHA-256s every member. **All +runs** does the same across every run directory on disk. + +Two things worth saying while it downloads: + +- Dry runs are deliberately **absent** from the trail. Nothing changed, so there is nothing to + answer for. +- Overriding a protected load balancer writes its **own** audit event, so crossing a rail is + visible to anyone scanning the trail rather than buried in a field of an ordinary record. + +### ⑦ Benchmark + +Compare models per agent. The point is that *nothing here is Anthropic-specific* — every agent's +model is set in `config/agents.yaml`, and ⚙ Setup shows the current assignment for all eight. + --- -## Path B — live, behind XC (the real thing) +## Demo B — live on F5 Distributed Cloud -Prereqs in `.env`: `XC_API_URL`, `XC_API_TOKEN`, `XC_NAMESPACE`, a model key (e.g. -`ANTHROPIC_API_KEY`), and a `GITHUB_TOKEN` for PRs. Optional: `XC_DASHBOARD_URL` for the -"XC security dashboard ↗" deep link; `CHANGE_CONTROL_DAYS` to match the customer's number. +**⚙ Setup** first. Fill in `XC_API_URL`, `XC_API_TOKEN`, `XC_NAMESPACE` and a model key, then +**Save to .env**. A value already supplied through the environment shows as **(set in +environment)** — saving `.env` will not override it until the console restarts, and the page says +so rather than letting you believe a change took effect. -```bash -vpcopilot console # http://127.0.0.1:8787 -``` +Then `vpcopilot console`, and pick a lab load balancer in **Run settings**. + +### ① Scan — four kinds of input -1. **① Scan** a vulnerable app repo (VAmPI / crAPI / Nimbus). Watch discover → verify → triage → - generate → remediate stream live. The log box is scrollable and holds the **whole** transcript — - scroll up mid-scan to re-read the discover output and it stays put; a **↓ follow** chip and a - line counter appear until you scroll back to the bottom. Long scans no longer push the page down. -2. **② Review** the findings, and hit **Open HTML report ↗** if someone wants the artifact now. -3. **④ Mitigate.** With `dry-run` OFF and `keep live` ON (**Run settings** — the collapsible bar at the top of the Mitigate step), click - **Mitigate service_policy ▶** on a finding. The refiner streams in the row: attach → validate → - (refine → retry)* → **before 200 through → after 403 BLOCKED · legit ok**, with a *self-healed in - N attempts* badge if it took more than one try. It never claims success unless the live exploit - is actually blocked. -4. **XC security dashboard ↗** (hero band) — jump to the native WAF/API-Security telemetry to show - the block landing in XC. -5. **⑤ Cure → Open PR** on the same finding to draft the real code fix against your repo. -6. **⑥ Retire** once the cure merges — the band-aid is detached and the finding goes `retired`. The - loop is closed. Below the ledger, the **audit trail** now has a row per live change: which - finding justified it, which LB and namespace it touched, whether it stuck, and who ran it - (`VPCOPILOT_ACTOR`, else the OS user). **Export evidence bundle (.zip)** hands that to whoever - asks why the load balancer changed — see [AUDIT.md](AUDIT.md). - -Dry runs are deliberately *not* in the trail: nothing changed, so there is nothing to answer for. -The bundle is evidence for a human reviewer, not a compliance certification. - -Guardrails hold throughout: protected LBs (`VPCOPILOT_PROTECTED_LBS`, default `nimbus-www`) and -`nimbus-*` policies refuse mutation unless you explicitly opt in; every apply snapshots first and -rolls back on failure. +The step takes any of these, and the last three are the ones people have not seen before: + +| Field | What it does | +|---|---| +| **Target repo** | source scan — discover → verify → triage → generate → remediate | +| **…or a security advisory** | a CVE / GHSA / PYSEC id, resolved against OSV. No repo, no credentials | +| **…or an OpenAPI spec** | scans the *contract*. With a repo, also cross-checks the spec against the code | +| **…or dependency manifests** | every pinned package resolved against OSV — `requirements.txt`, `package-lock.json`, `pom.xml` | + +**Preview (no model calls)** surveys the dependencies without spending a token — a good answer when +someone asks what a run costs. + +The run log is the whole transcript and stays where you scroll it; a **↓ follow** chip appears if +you scroll up mid-scan. + +### ④ Mitigate — the moment that sells it + +**Run settings**: `dry-run` OFF, `keep live` ON. Then **Mitigate ▶** on a finding. + +The row streams: attach → validate → *(refine → retry)* → **before 200 through → after 403 +BLOCKED · legit ok**. It never reports success unless the live exploit is actually blocked, and +never reports a block without also confirming a legitimate request still passes. + +Seven control families are available — `service_policy`, `waf`, `waf_data_guard`, `api_schema`, +`rate_limit`, `malicious_user`, `bot_defense` — and triage picks per finding. Some findings get +**no band-aid at all**: plaintext password storage is a data-at-rest problem the edge never sees, +and the tool says "code cure only" rather than inventing a control for it. + +Then **XC security dashboard ↗** in the hero band, to show the block landing in F5's own telemetry. + +### ⑤ Cure → ⑥ Retire + +**Open PR** drafts the real fix against your repo. When it merges, **Reconcile & retire proven +fixes** re-fires the exploit at the app's **origin** — around the band-aid, so a blocked request +cannot be mistaken for a fixed bug — and only then detaches the control. + +If the cure has not merged, the console **refuses and tells you why**, with an explicit *retire +anyway* button. That is the pattern throughout: warn, explain, let a human override, record the +override. + +--- + +## Demo C — the declarative WAF (not just F5 XC) + +The objection you will hear is *"we are not an F5 shop"*. This answers it. + +### ② Review → **Emit** + +Pick a target and emit the same finding's policy as a declarative WAF policy for: + +- `xc` — F5 Distributed Cloud +- `bigip-awaf` — BIG-IP Advanced WAF, as an AS3 declaration +- `nginx-app-protect` — NGINX App Protect + +Same finding, same evidence, three vendors' syntax. Where a target genuinely cannot express a +control, it is listed as **unsupported** rather than emitted as something approximate. + +### ⚙ Setup → BIG-IP lab + +With an appliance configured, the panel shows the AS3 version, the tenants it can see, and the +protected ones. `/Common` is refused outright — not overridable, not even on a dry run, because +previewing the deletion of the appliance's own configuration is previewing an outage. + +The emitted policy is validated against the vendor's own published schema and — in the reference +lab — pushed to a real BIG-IP, where the exploit is fired and the account balance is checked +afterwards. The assertion is the **balance**, not the status code: BIG-IP's blocking page returns +HTTP 200, so a status-code assertion would pass on a policy that blocked nothing. + +--- + +## The safety rails, if someone asks + +All of these hold on **all three surfaces** — CLI, console and the MCP server: + +- **Protected load balancers** refuse mutation unless explicitly overridden, and the name is parsed + before the check, so `./nimbus-www` is refused too. +- **Every apply snapshots first** and rolls back on failure — and the rollback is *verified*, not + assumed. A control plane that accepts the restore and applies nothing raises loudly. +- **The blast-radius gate** blocks promotion of a policy that would block too much real traffic, + and honours the threshold you set rather than silently substituting a default. +- **Secrets never reach an artifact.** Headers, bodies *and query strings* are redacted before a + request sample is written, and the redaction is counted, so a sample cannot report itself clean. +- **Refusing to guess.** "We could not check this" and "this is clean" never render alike, anywhere. + An unreadable snapshot is not "no drift". A scan of a path that does not exist is refused, not + answered with an empty report. + +## For an agent audience: MCP + +`vpcopilot mcp` serves the pipeline over stdio as 15 tools — `scan_start`, `scan_status`, +`scan_result`, `impact`, `patches_list`, `deps`, `drift`, `simulate`, `ledger`, `reconcile`, +`verify_bundle` and more. Tools that would change a load balancer, open a PR or retire a control +are **absent** unless the operator started the server with writes enabled. The human gate is not +something a tool call can satisfy on its own. --- @@ -99,37 +234,46 @@ rolls back on failure. - **Band-aids, not cures.** Every mitigated finding also gets a code-fix PR. The band-aid buys the 20–30 days; the PR is the fix. The ledger tracks both to `retired`. - **Self-healing.** The copilot validates its own policy against the finding's real exploit and - refines until it works — so it never ships a band-aid that doesn't block. -- **Model-independent.** Every agent's model is set per-agent in `config/agents.yaml` (Workflow / - Model independence panels show it) — Claude, OpenAI, Gemini, or local Ollama, no code change. -- **Reversible + gated.** Snapshot → self-test → attach → validate → keep or rollback. A human - approves every live change in the console. -- **Auditable.** Every live change is recorded with the finding that justified it, the LB + - namespace it touched, whether it stuck, and who ran it — exportable as a .zip with a SHA-256 - manifest ([AUDIT.md](AUDIT.md)). It is evidence for a human reviewer, not a compliance - certification. + refines until it works, so it never ships a band-aid that does not block. +- **Model-independent.** Eight agents, each with its own model in `config/agents.yaml` — Claude, + OpenAI, Gemini or local Ollama, with no code change. +- **Reversible and gated.** Snapshot → self-test → attach → validate → keep or roll back. A human + approves every live change. +- **Auditable.** Every live change carries the finding that justified it, the LB and namespace it + touched, whether it stuck, and who ran it — exportable with a SHA-256 manifest + ([AUDIT.md](AUDIT.md)). Evidence for a human reviewer, **not** a compliance certification. + +## When something declines mid-demo + +Do not treat it as a failure — it is the product working, and in front of a security audience it is +the most credible thing that can happen. Read the reason aloud. + +| You see | Say | +|---|---| +| a finding with **no band-aid** | "the edge cannot see this one — it gets a code fix, and it says so" | +| a finding with **no CWE** | "MITRE prohibits the obvious mapping here; a blank we can explain beats a guess" | +| **"not measured"** in the blast radius | "that replay could not be measured — it will not pretend it came back clean" | +| a **refused retire** | "the cure has not merged; the band-aid is the only thing holding this shut" | +| a **refused protected LB** | "that one is off-limits, and the override would be recorded" | ## Screenshots -Captured from `demo/out` (Path A) and checked in under [`docs/images/`](images/) — they carry the -story on their own: +Checked in under [`docs/images/`](images/): | Shot | File | |---|---| | Scan — the target form and its scrollable run log | [`1-scan.png`](images/1-scan.png) | -| Review — hero band + findings + the HTML-report buttons | [`2-review.png`](images/2-review.png) | -| Simulate — blast radius of each candidate before it is applied | [`3-simulate.png`](images/3-simulate.png) | +| Review — hero band + findings + report buttons | [`2-review.png`](images/2-review.png) | +| Simulate — blast radius before anything is applied | [`3-simulate.png`](images/3-simulate.png) | | Mitigate — per-finding live apply | [`4-mitigate.png`](images/4-mitigate.png) | -| Retire — four-state ledger (`crapi-sqli-001` at *retired*) + the audit trail | [`6-retire.png`](images/6-retire.png) | -| The shareable HTML report (self-heal ×2 + rate-limit proof) | [`report.png`](images/report.png) | +| Retire — four-state ledger + audit trail | [`6-retire.png`](images/6-retire.png) | +| The shareable HTML report | [`report.png`](images/report.png) | -To regenerate them: rebuild the dataset with `python3 demo/build_demo_out.py`, run -`VPCOPILOT_OUT=demo/out vpcopilot console`, then capture the `#scan`, `#review`, `#mitigate` and -`#simulate`, `#mitigate` and `#retire` steps plus `demo/out/report.html` at 1200px wide / 2× device pixel ratio. +To regenerate: `python3 demo/build_demo_out.py`, then `VPCOPILOT_OUT=demo/out vpcopilot console`, +and capture each step at 1200px wide / 2× device pixel ratio. -Point the console at a **credential-free** `.env` when you do (`VPCOPILOT_ENV=…`): with XC creds +Point the console at a **credential-free** `.env` when you do (`VPCOPILOT_ENV=…`). With XC creds loaded, the hero band renders a deep link carrying your tenant hostname and namespace, and the -Mitigate step's drift strip renders the target LB's live control set — both would ship in the image. -Without creds the drift strip renders nothing at all, which is why it does not appear in -`4-mitigate.png`. `build_demo_out.py` curates `actor`/`host`/`out_dir` in the fixture for the -same reason — no real machine identity in a shared dataset. +BIG-IP panel renders your appliance's tenants — both would ship in the image. +`build_demo_out.py` curates `actor`/`host`/`out_dir` in the fixture for the same reason: no real +machine identity in a shared dataset.