Skip to content

Commit aa3a65c

Browse files
Daniel Henleyclaude
andcommitted
feat(validate): authenticate the probe on auth-protected apps + 🔑 auth_failed benchmark outcome
The test-apply couldn't validate a band-aid on an auth-protected endpoint: the probe ran unauthenticated, hit 401, and every protected finding was mislabeled auth_required. Now the validation probe can authenticate, so an exploit is demonstrated (real 200 baseline → 403 block) instead of a bare 401. A — auto token capture + injection: probe_from_spec captures a bearer token from a login/setup JSON response (access_token/token/jwt/…, nested one level) and injects Authorization: Bearer on the exploit/legit requests. Cookie apps already carry the session via the shared jar; token apps (VAmPI/crAPI) now work too. B — operator-supplied login: VPCOPILOT_PROBE_USER/PASS (or a bearer VPCOPILOT_PROBE_TOKEN, + optional LOGIN_PATH/USER_FIELD/PASS_FIELD) log in first over the shared session and FAIL LOUD (honest auth_failed) if no session is established — so a wrong credential can't masquerade as "needs auth". apply._probe_auth_from_env() is the single chokepoint every _run_validation reads, reaching apply_* + refiner + the console (which load_dotenv's before each action) with no param threading. CLI: apply --probe-user/--probe-pass/--probe-login-path/--probe-token. Keys documented in .env.example and surfaced (masked) in the console Setup panel. auth_failed is a distinct benchmark outcome from auth_required (creds supplied but login failed vs no creds at all) — counted separately, excluded from failures. Rendered as 🔑 login-failed in the ⑥ Benchmark full matrix, outcome bar (plum) + legend, per-finding outcome column, and the live ③ Mitigate result, each pointing at the VPCOPILOT_PROBE_* knob to fix it. Tests: probe token capture/injection, cookie login, loud auth-failure, guessed-login supersede, env builder, and the auth_failed benchmark classification. Full non-live suite green (130), ruff clean, console JS node --check clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent fae944a commit aa3a65c

9 files changed

Lines changed: 353 additions & 31 deletions

File tree

.env.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@ GITHUB_TOKEN= # repo-scoped PAT for opening PRs (or use `gh auth login`)
2222
# VPCOPILOT_PROTECTED_LBS=nimbus-www # comma-separated LBs that refuse mutation without an override
2323
# VPCOPILOT_REFINE_ATTEMPTS=3 # max self-heal attempts when a policy doesn't block the exploit
2424
# VPCOPILOT_REQUIRE_PROBE=1 # fail-closed: don't validate a finding that has no derived probe
25+
# --- Validation auth (auth-protected targets: let the probe log in so it can demonstrate the exploit) ---
26+
# VPCOPILOT_PROBE_USER= # username the validation probe logs in with (cookie or token app)
27+
# VPCOPILOT_PROBE_PASS= # its password
28+
# VPCOPILOT_PROBE_LOGIN_PATH=/api/login # login endpoint (default /api/login)
29+
# VPCOPILOT_PROBE_TOKEN= # OR a bearer token to inject directly (instead of user/pass)
30+
# VPCOPILOT_PROBE_USER_FIELD=username # login JSON field names, if the app isn't username/password
31+
# VPCOPILOT_PROBE_PASS_FIELD=password
2532
# Console action defaults (so the UI isn't pinned to one app):
2633
# VPCOPILOT_DEFAULT_LB=your-lb
2734
# VPCOPILOT_DEFAULT_URL=https://your-app.example.com

src/vpcopilot/apply.py

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -178,19 +178,47 @@ def _load_probe(out_dir: str, finding_id) -> dict | None:
178178
return None
179179

180180

181+
def _probe_auth_from_env() -> dict | None:
182+
"""Operator-supplied validation auth (Layer B), read once from the environment so it reaches
183+
every apply_* / refiner validation through the single `_run_validation` chokepoint — no param
184+
threaded through each signature, and the console picks it up because it load_dotenv's before
185+
each action. Returns None when nothing is set (validation then runs unauthenticated, as before).
186+
187+
VPCOPILOT_PROBE_TOKEN a bearer token injected as `Authorization: Bearer <token>`
188+
VPCOPILOT_PROBE_USER/PASS credentials the probe logs in with first (cookie or token)
189+
VPCOPILOT_PROBE_LOGIN_PATH login endpoint (default /api/login)
190+
VPCOPILOT_PROBE_USER_FIELD / _PASS_FIELD JSON field names, if the app isn't username/password
191+
"""
192+
import os
193+
token = os.environ.get("VPCOPILOT_PROBE_TOKEN")
194+
user, pw = os.environ.get("VPCOPILOT_PROBE_USER"), os.environ.get("VPCOPILOT_PROBE_PASS")
195+
if not (token or (user and pw)):
196+
return None
197+
auth: dict = {"login_path": os.environ.get("VPCOPILOT_PROBE_LOGIN_PATH", "/api/login")}
198+
if token:
199+
auth["token"] = token
200+
if user and pw:
201+
auth.update(username=user, password=pw,
202+
user_field=os.environ.get("VPCOPILOT_PROBE_USER_FIELD", "username"),
203+
pass_field=os.environ.get("VPCOPILOT_PROBE_PASS_FIELD", "password"))
204+
return auth
205+
206+
181207
def _run_validation(target_url: str, finding_id, out_dir: str, fallback, log, *,
182-
require_probe: bool = False) -> dict:
208+
require_probe: bool = False, auth: dict | None = None) -> dict:
183209
"""Normalized {exploit_status, exploit_blocked, legit_ok}. Prefers the finding's derived probe
184210
(works on any app). B5 — fail closed: when no finding-probe exists, the Nimbus-specific fallback
185211
is only meaningful against the Nimbus demo, so we (a) log a loud warning and tag the result
186212
`fallback`, and (b) if require_probe (param or VPCOPILOT_REQUIRE_PROBE) is set, refuse to fall
187213
back at all and return a non-passing `no_probe` result — never silently 'validate' a real app
188-
with a probe that hits the wrong endpoints."""
214+
with a probe that hits the wrong endpoints. `auth` (else VPCOPILOT_PROBE_*) authenticates the
215+
probe so an auth-protected endpoint validates for real instead of returning a bare 401."""
189216
import os
190217
from .probe import probe_from_spec
218+
auth = auth or _probe_auth_from_env()
191219
spec = _load_probe(out_dir, finding_id)
192220
if spec:
193-
return probe_from_spec(target_url, spec, log=log)
221+
return probe_from_spec(target_url, spec, log=log, auth=auth)
194222
require_probe = require_probe or os.environ.get("VPCOPILOT_REQUIRE_PROBE", "").lower() in ("1", "true", "yes")
195223
if require_probe:
196224
log(" ⚠ no finding-derived probe and require_probe set — cannot validate this target; "
@@ -215,8 +243,9 @@ def _log_baseline(before: dict, log: Callable) -> None:
215243
log(" ⚠ baseline exploit → 404: the finding's endpoint likely does not exist on this target "
216244
"— the band-aid can't be validated (check the endpoint). Applying anyway.")
217245
elif st == 401:
218-
log(" ⚠ baseline exploit → 401: the endpoint requires authentication, so the unauthenticated "
219-
"probe can't demonstrate the exploit — the band-aid can't be validated this way. Applying anyway.")
246+
log(" ⚠ baseline exploit → 401: the endpoint requires authentication. Supply validation "
247+
"credentials so the probe authenticates — VPCOPILOT_PROBE_USER/PASS (or --probe-user/"
248+
"--probe-pass), or a bearer VPCOPILOT_PROBE_TOKEN — then re-run. Applying anyway.")
220249

221250

222251
def apply_service_policy(lb: str, policy_name: str, target_url: str, *,

src/vpcopilot/bench_model.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ def build(out_dir: str, model_tag: str, target: str = "", config_path: str | Non
7171
"passed": bool(a.get("passed")), "attempts": a.get("attempts") or 1,
7272
"unfixable": bool(a.get("unfixable")),
7373
"before_status": (ba.get("before") or {}).get("exploit_status"),
74+
"before_auth_failed": bool((ba.get("before") or {}).get("auth_failed")),
7475
"after_status": (ba.get("after") or {}).get("exploit_status"),
7576
"reason": a.get("reason"),
7677
})
@@ -81,10 +82,12 @@ def build(out_dir: str, model_tag: str, target: str = "", config_path: str | Non
8182
from .controls import CONTROLS
8283

8384
def _outcome(p):
85+
if p.get("before_auth_failed"): # operator supplied creds but the login didn't establish a
86+
return "auth_failed" # session (wrong credential/path) — distinct from auth_required
8487
if p.get("before_status") == 404: # baseline exploit 404'd — the finding's endpoint doesn't
8588
return "endpoint_missing" # exist on the target; the band-aid can't be validated
86-
if p.get("before_status") == 401: # exploit needs auth — the unauthenticated probe can't
87-
return "auth_required" # demonstrate it, so the band-aid can't be validated
89+
if p.get("before_status") == 401: # exploit needs auth and none was supplied — the probe
90+
return "auth_required" # can't demonstrate it, so the band-aid can't be validated
8891
kind = CONTROLS[p["control"]].validation if p["control"] in CONTROLS else "live"
8992
if kind in ("config", "behavioral"): # config-level defense-in-depth (waf / data_guard /
9093
return "applied" # rate_limit / bot / malicious_user): enabled = applied;
@@ -102,6 +105,7 @@ def _outcome(p):
102105
applied = sum(1 for p in per if p["outcome"] == "applied")
103106
missing = sum(1 for p in per if p["outcome"] == "endpoint_missing")
104107
needs_auth = sum(1 for p in per if p["outcome"] == "auth_required")
108+
auth_failed = sum(1 for p in per if p["outcome"] == "auth_failed")
105109
healed = sum(1 for p in per if p["passed"] and (p["attempts"] or 1) > 1)
106110
atts = [p["attempts"] for p in per if p["attempts"]]
107111
v = metrics.get("verify") or {}
@@ -127,7 +131,8 @@ def _outcome(p):
127131
"blocked": blocked, # real single-request exploit block (→403)
128132
"applied_behavioral": applied, # passed at config level; not single-request testable
129133
"endpoint_missing": missing, # baseline exploit 404'd — finding's endpoint doesn't exist
130-
"auth_required": needs_auth, # baseline exploit 401'd — needs auth; probe can't validate
134+
"auth_required": needs_auth, # baseline exploit 401'd — no creds supplied; probe can't validate
135+
"auth_failed": auth_failed, # creds WERE supplied but the login failed — fix the credential
131136
"block_rate": round(blocked / attempted, 2) if attempted else None,
132137
"pass_rate": round(passed / attempted, 2) if attempted else None,
133138
"self_healed": healed,
@@ -165,7 +170,7 @@ def to_markdown(b: dict) -> str:
165170
f"- attempted **{pq['attempted']}** · **blocked** (real single-request exploit→403) "
166171
f"**{pq['blocked']}** ({br}) · applied-but-behavioral {pq['applied_behavioral']} · "
167172
f"failed {pq['failed']} · endpoint-missing {pq.get('endpoint_missing', 0)} · "
168-
f"needs-auth {pq.get('auth_required', 0)} · "
173+
f"needs-auth {pq.get('auth_required', 0)} · login-failed {pq.get('auth_failed', 0)} · "
169174
f"self-healed {pq['self_healed']} · avg attempts {pq['avg_attempts'] or '—'}",
170175
"",
171176
"> _blocked_ = a fired exploit was stopped at the edge (per-request positive security). "
@@ -178,7 +183,8 @@ def to_markdown(b: dict) -> str:
178183
"|---|---|---|---|---|---|---|"]
179184
icon = {"blocked": "✅ blocked", "applied": "🟡 applied (behavioral)",
180185
"not_blocked": "❌ not blocked", "unfixable": "⚠️ unfixable",
181-
"endpoint_missing": "🚫 endpoint missing", "auth_required": "🔒 needs auth"}
186+
"endpoint_missing": "🚫 endpoint missing", "auth_required": "🔒 needs auth",
187+
"auth_failed": "🔑 login failed"}
182188
for p in pq["per_finding"]:
183189
ba = (f"{p['before_status']}{p['after_status']}" if p["before_status"] is not None else "—")
184190
lines.append(f"| {p['finding_id']} | {p['severity'] or '—'} | {p['vuln_class'] or '—'} | "

src/vpcopilot/cli.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,9 +127,20 @@ def apply(
127127
refine: bool = typer.Option(True, "--refine/--no-refine", help="refine the policy until it actually blocks the exploit (default on)"),
128128
refine_attempts: int = typer.Option(None, "--refine-attempts", help="max refine attempts (default $VPCOPILOT_REFINE_ATTEMPTS or 3)"),
129129
allow_protected_lb: bool = typer.Option(False, "--allow-protected-lb", help="permit mutating a protected LB"),
130+
probe_user: str = typer.Option(None, "--probe-user", help="validation login username for an auth-protected app (or set VPCOPILOT_PROBE_USER)"),
131+
probe_pass: str = typer.Option(None, "--probe-pass", help="validation login password (or VPCOPILOT_PROBE_PASS)"),
132+
probe_login_path: str = typer.Option(None, "--probe-login-path", help="login endpoint path, default /api/login (or VPCOPILOT_PROBE_LOGIN_PATH)"),
133+
probe_token: str = typer.Option(None, "--probe-token", help="bearer token for validation instead of user/pass (or VPCOPILOT_PROBE_TOKEN)"),
130134
out: str = typer.Option("out", help="output directory"),
131135
):
132136
"""Gated apply: (create from scan) -> snapshot -> self-test -> attach -> validate -> refine/rollback."""
137+
import os
138+
# Auth for the validation probe (auth-protected targets). Set env so it reaches every
139+
# _run_validation call inside apply/refine via _probe_auth_from_env — the single chokepoint.
140+
for flag, key in ((probe_user, "VPCOPILOT_PROBE_USER"), (probe_pass, "VPCOPILOT_PROBE_PASS"),
141+
(probe_login_path, "VPCOPILOT_PROBE_LOGIN_PATH"), (probe_token, "VPCOPILOT_PROBE_TOKEN")):
142+
if flag:
143+
os.environ[key] = flag
133144
logf = lambda m: rprint(f"[dim]{m}[/dim]") # noqa: E731
134145
kw = dict(dry_run=dry_run, keep=keep, allow_protected=allow_protected_lb, probe=probe, out_dir=out, log=logf)
135146
if from_scan and refine and not dry_run and not create_only:

src/vpcopilot/console/app.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,14 @@ def _active_tag() -> str:
5757
except Exception: # noqa: BLE001
5858
return "default"
5959

60-
SECRET_KEYS = {"ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY", "XC_API_TOKEN", "GITHUB_TOKEN"}
60+
SECRET_KEYS = {"ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY", "XC_API_TOKEN", "GITHUB_TOKEN",
61+
"VPCOPILOT_PROBE_PASS", "VPCOPILOT_PROBE_TOKEN"}
6162
MANAGED_KEYS = [
6263
"ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY", "OLLAMA_API_BASE",
6364
"XC_API_URL", "XC_API_TOKEN", "XC_NAMESPACE", "GITHUB_TOKEN",
65+
# Validation auth for an auth-protected target — the probe logs in so it can demonstrate the
66+
# exploit (loaded before every apply via load_dotenv, so a change here takes effect next run).
67+
"VPCOPILOT_PROBE_USER", "VPCOPILOT_PROBE_PASS", "VPCOPILOT_PROBE_LOGIN_PATH", "VPCOPILOT_PROBE_TOKEN",
6468
]
6569

6670
app = FastAPI(title="virtual-patch-copilot console")

0 commit comments

Comments
 (0)