websec-validator 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. websec_validator/__init__.py +14 -0
  2. websec_validator/briefing.py +218 -0
  3. websec_validator/calibration.json +75 -0
  4. websec_validator/calibration.py +226 -0
  5. websec_validator/cli.py +395 -0
  6. websec_validator/constitution.py +81 -0
  7. websec_validator/corpus.json +49 -0
  8. websec_validator/dynamic.py +249 -0
  9. websec_validator/extractors/__init__.py +56 -0
  10. websec_validator/extractors/auth.py +77 -0
  11. websec_validator/extractors/authz.py +130 -0
  12. websec_validator/extractors/base.py +101 -0
  13. websec_validator/extractors/client_exposure.py +48 -0
  14. websec_validator/extractors/graphql.py +71 -0
  15. websec_validator/extractors/iac_ci.py +65 -0
  16. websec_validator/extractors/integrations.py +55 -0
  17. websec_validator/extractors/routes.py +215 -0
  18. websec_validator/extractors/schemas.py +75 -0
  19. websec_validator/extractors/stack.py +80 -0
  20. websec_validator/extractors/surface.py +86 -0
  21. websec_validator/extractors/tenant.py +33 -0
  22. websec_validator/findings.py +199 -0
  23. websec_validator/probes.py +79 -0
  24. websec_validator/proof.py +96 -0
  25. websec_validator/recon.py +28 -0
  26. websec_validator/report.py +114 -0
  27. websec_validator/scanners.py +248 -0
  28. websec_validator/templates/probes/bola-cross-tenant.sh +192 -0
  29. websec_validator/templates/probes/bola-write-verbs.py +147 -0
  30. websec_validator/templates/probes/compare-roles.sh +69 -0
  31. websec_validator/templates/probes/dlp-bypass-offline.py +149 -0
  32. websec_validator/templates/probes/hs256-brute-force.py +90 -0
  33. websec_validator/templates/probes/jwt-attacks.sh +161 -0
  34. websec_validator/templates/probes/mass-assignment.py +201 -0
  35. websec_validator/templates/probes/race-conditions.py +144 -0
  36. websec_validator/templates/probes/rate-limit-burst.sh +136 -0
  37. websec_validator/templates/probes/s3-assess.sh +120 -0
  38. websec_validator/templates/probes/ssrf-probes.sh +189 -0
  39. websec_validator/templates/probes/webhook-forgery.py +113 -0
  40. websec_validator/templates/reports/FINDINGS-SUMMARY.md.template +75 -0
  41. websec_validator/templates/reports/access-control-matrix.md.template +65 -0
  42. websec_validator/templates/reports/findings-triage.md.template +28 -0
  43. websec_validator/templates/reports/pentest-handover-brief.md.template +121 -0
  44. websec_validator/templates/reports/per-tool-FINDINGS.md.template +37 -0
  45. websec_validator-0.2.0.dist-info/METADATA +232 -0
  46. websec_validator-0.2.0.dist-info/RECORD +50 -0
  47. websec_validator-0.2.0.dist-info/WHEEL +5 -0
  48. websec_validator-0.2.0.dist-info/entry_points.txt +2 -0
  49. websec_validator-0.2.0.dist-info/licenses/LICENSE +21 -0
  50. websec_validator-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,33 @@
1
+ """Tenant-boundary extractor — the multi-tenancy key candidates.
2
+
3
+ The single most important and easiest-to-get-wrong fact for BOLA testing. The
4
+ tool reports candidates by frequency; the agent confirms THE one with the human.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from .base import Extractor, RepoContext
10
+
11
+ TENANT_KEYS = ["groupId", "group_id", "orgId", "org_id", "organizationId",
12
+ "tenantId", "tenant_id", "workspaceId", "workspace_id",
13
+ "accountId", "account_id", "companyId", "company_id",
14
+ "teamId", "team_id", "projectId", "project_id"]
15
+
16
+
17
+ class TenantExtractor(Extractor):
18
+ name = "tenant"
19
+ category = "authz"
20
+
21
+ def extract(self, ctx: RepoContext, facts: dict) -> dict:
22
+ hits: dict = {}
23
+ for _p, _rel, text in ctx.iter_code():
24
+ for key in TENANT_KEYS:
25
+ if key in text:
26
+ hits[key] = hits.get(key, 0) + text.count(key)
27
+ ranked = sorted(hits.items(), key=lambda kv: -kv[1])
28
+ return {
29
+ "candidates": [{"key": k, "occurrences": n} for k, n in ranked[:6]],
30
+ "multi_tenant_likely": bool(ranked and ranked[0][1] >= 3),
31
+ "note": "AGENT: confirm with the human which key (if any) is THE tenant boundary. "
32
+ "If single-tenant, skip the cross-tenant BOLA probes.",
33
+ }
@@ -0,0 +1,199 @@
1
+ """Traceable findings ledger — correlate recon + static scanners + dynamic into ONE
2
+ ranked, standards-cited, confidence-scored record set.
3
+
4
+ Each finding carries an **evidence chain** across layers (recon → static → dynamic),
5
+ an **OWASP/CWE/ASVS citation**, a **rule-based confidence** (HIGH/MEDIUM/LOW — no ML;
6
+ dynamic-confirmed beats static hypothesis), and a **remediation**. This is the
7
+ deterministic half of the AITPG/TRACE design — the consuming agent then runs the
8
+ adversarial debate (Advocate→Challenger→Mediator→Explainer) to verify, per the briefing.
9
+
10
+ Confidence rule (deterministic):
11
+ HIGH — dynamically confirmed (executed unauth / cross-tenant leak), OR a verified
12
+ secret, OR a fixed-version CVE at HIGH/CRITICAL.
13
+ MEDIUM — static evidence with a concrete pattern (recon no-guard write, SAST hit,
14
+ user-input-gated sink, real-but-lower CVE).
15
+ LOW — single-source hypothesis with no corroboration (recon-only signal).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import fnmatch
21
+ from pathlib import Path
22
+
23
+ from . import calibration
24
+
25
+ # attack class → authoritative citations + a remediation pattern
26
+ STANDARDS = {
27
+ "missing-auth": (["CWE-862 Missing Authorization", "CWE-306 Missing Authentication"],
28
+ "ASVS V4.1.1", ["API1:2023 BOLA", "API5:2023 BFLA"]),
29
+ "bola": (["CWE-639 Authorization Bypass (IDOR)"], "ASVS V4.2.1", ["API1:2023 BOLA"]),
30
+ "ssrf": (["CWE-918 SSRF"], "ASVS V12.6", ["API7:2023 SSRF"]),
31
+ "secret": (["CWE-798 Hard-coded Credentials"], "ASVS V2.10", ["API8:2023 Misconfiguration"]),
32
+ "sqli": (["CWE-89 SQL Injection"], "ASVS V5.3.4", ["API8:2023"]),
33
+ "command-injection": (["CWE-78 OS Command Injection"], "ASVS V5.3.8", []),
34
+ "path-traversal": (["CWE-22 Path Traversal"], "ASVS V12.3", []),
35
+ "ssti": (["CWE-1336 SSTI"], "ASVS V5.2.5", []),
36
+ "open-redirect": (["CWE-601 Open Redirect"], "ASVS V5.1.5", []),
37
+ "insecure-deserialization": (["CWE-502 Deserialization"], "ASVS V5.5", []),
38
+ "xxe": (["CWE-611 XXE"], "ASVS V5.5.2", []),
39
+ "prototype-pollution": (["CWE-1321 Prototype Pollution"], "ASVS V5.1", []),
40
+ "mass-assignment": (["CWE-915 Mass Assignment"], "ASVS V5.1.2", ["API3:2023 BOPLA"]),
41
+ "cve": (["CWE-1395 Vulnerable Dependency"], "ASVS V14.2.1", ["API8:2023"]),
42
+ "iac": (["CWE-1188 Insecure Default"], "ASVS V14.1", []),
43
+ "client-exposure": (["CWE-200 Information Exposure"], "ASVS V14.3", []),
44
+ "graphql": (["CWE-200 Information Exposure"], "ASVS V13.1", ["API8:2023"]),
45
+ "sast": (["CWE-710 Coding Standards"], "ASVS V1.1", []),
46
+ }
47
+ REMEDIATION = {
48
+ "missing-auth": "Add an auth guard to the handler (e.g. requireAuth()/getServerSession()), or a "
49
+ "middleware matcher over /api/(.*) with an explicit public allowlist so it can't be forgotten.",
50
+ "bola": "Enforce object ownership: verify the authenticated principal owns/can access the resource id (tenant scope).",
51
+ "ssrf": "Validate + allowlist outbound URLs; block RFC1918/IMDS/file://; never fetch a raw user-supplied URL.",
52
+ "secret": "Rotate the credential, remove from code/history, load from a secrets manager.",
53
+ "cve": "Upgrade the dependency to the fixed version.",
54
+ "iac": "Apply the hardening (non-root user, pin actions to a SHA, enforce TLS, etc.).",
55
+ "client-exposure": "Move the secret server-side; never reference it from a client component or a NEXT_PUBLIC_/VITE_ var.",
56
+ "graphql": "Disable introspection + the playground in production; add query depth/complexity limits.",
57
+ }
58
+ _DEFAULT_REM = "Review and remediate per the cited standard."
59
+
60
+ SEV_RANK = {"CRITICAL": 4, "HIGH": 3, "MEDIUM": 2, "LOW": 1, "INFO": 0}
61
+ CONF_RANK = {"HIGH": 2, "MEDIUM": 1, "LOW": 0}
62
+ WRITE_VERBS = {"POST", "PUT", "PATCH", "DELETE"}
63
+
64
+
65
+ def _cite(cls):
66
+ cwe, asvs, api = STANDARDS.get(cls, ([], "", []))
67
+ return {"cwe": cwe, "asvs": asvs, "owasp_api": api}
68
+
69
+
70
+ def load_suppressions(repo_root: Path) -> list:
71
+ """Read `.websec-ignore` (repo root or cwd): glob path patterns or `category:<x>` lines."""
72
+ pats = []
73
+ for cand in (repo_root / ".websec-ignore", Path.cwd() / ".websec-ignore"):
74
+ try:
75
+ if cand.is_file():
76
+ for ln in cand.read_text().splitlines():
77
+ ln = ln.split("#", 1)[0].strip()
78
+ if ln:
79
+ pats.append(ln)
80
+ except Exception:
81
+ pass
82
+ return pats
83
+
84
+
85
+ def _suppressed(f, pats):
86
+ hay = f"{f.get('category','')} {f.get('location','')} {f.get('title','')}".lower()
87
+ for p in pats:
88
+ pl = p.lower()
89
+ if pl.startswith("category:") and f.get("category", "").lower() == pl.split(":", 1)[1]:
90
+ return True
91
+ if fnmatch.fnmatch(f.get("location", "").lower(), pl) or pl in hay:
92
+ return True
93
+ return False
94
+
95
+
96
+ def _f(title, category, attack_class, severity, confidence, location, evidence):
97
+ return {"title": title, "category": category, "attack_class": attack_class,
98
+ "severity": severity, "confidence": confidence,
99
+ "location": location, "evidence": evidence, "standards": _cite(attack_class),
100
+ "remediation": REMEDIATION.get(attack_class, _DEFAULT_REM), "status": "open"}
101
+
102
+
103
+ def build_ledger(facts: dict, unified: dict | None, dynamic: dict | None = None,
104
+ suppressions: list | None = None) -> dict:
105
+ suppressions = suppressions or []
106
+ out = []
107
+
108
+ # ---- 1. Access control: correlate recon (per-endpoint guard) with dynamic verdicts ----
109
+ authz = facts.get("authz", {})
110
+ dyn_write = {(r["method"], r["path"]): r for r in
111
+ ((dynamic or {}).get("write_auth_enforcement", {}) or {}).get("results", [])}
112
+ dyn_get = {r["path"]: r for r in
113
+ ((dynamic or {}).get("unauth_reachability", {}) or {}).get("results", [])}
114
+ for eg in authz.get("endpoint_guards", []):
115
+ if eg.get("guarded") or eg.get("public_hint") or not eg.get("analyzed"):
116
+ continue
117
+ m, p = eg.get("method"), eg.get("path")
118
+ is_write = m in WRITE_VERBS
119
+ ev = [{"layer": "recon", "detail": f"no auth guard found in handler {eg.get('code_path','?')}"}]
120
+ conf, sev = "MEDIUM", ("HIGH" if is_write else "MEDIUM")
121
+ dv = dyn_write.get((m, p)) or dyn_get.get(p)
122
+ if dv:
123
+ verdict = dv.get("verdict", "")
124
+ if "EXECUTED-UNAUTH" in verdict:
125
+ ev.append({"layer": "dynamic", "detail": f"{m} executed UNAUTHENTICATED (HTTP {dv.get('status')})"})
126
+ conf, sev = "HIGH", "CRITICAL"
127
+ elif "no-auth-gate" in verdict or verdict == "OPEN-no-auth":
128
+ ev.append({"layer": "dynamic", "detail": f"reached unauthenticated (HTTP {dv.get('status')}, {verdict})"})
129
+ conf = "HIGH"
130
+ sev = "HIGH" if is_write else "MEDIUM"
131
+ elif verdict in ("auth-enforced", "protected"):
132
+ continue # dynamic says it's actually protected → not a finding
133
+ out.append(_f(f"Missing authorization: {m} {p}", "access-control", "missing-auth",
134
+ sev, conf, p, ev))
135
+
136
+ # ---- 1b. Cross-tenant BOLA leaks (dynamically confirmed) ----
137
+ for lk in ((dynamic or {}).get("cross_tenant_bola", {}) or {}).get("leaks", []):
138
+ out.append(_f(f"Cross-tenant read: {lk.get('direction')} {lk.get('path')}", "access-control", "bola",
139
+ "CRITICAL", "HIGH", lk.get("path", ""),
140
+ [{"layer": "dynamic", "detail": f"cross-tenant GET returned another tenant's data "
141
+ f"(HTTP {lk.get('status')}, {lk.get('direction')})"}]))
142
+
143
+ # ---- 2. Static scanner findings (de-duplicated `unified`) ----
144
+ cat_to_class = {"sca": "cve", "secret": "secret", "iac": "iac", "sast": "sast"}
145
+ for t in (unified or {}).get("top", []):
146
+ cat = t.get("category", "")
147
+ cls = cat_to_class.get(cat, "sast")
148
+ sev = t.get("severity", "MEDIUM")
149
+ conf = "HIGH" if cat in ("secret",) or (cat == "sca" and sev in ("HIGH", "CRITICAL")) else "MEDIUM"
150
+ out.append(_f(t.get("title", cat), f"static-{cat}", cls, sev, conf, t.get("file", ""),
151
+ [{"layer": "static", "detail": f"{'+'.join(t.get('tools', []))}: {t.get('title','')}"}]))
152
+
153
+ # ---- 3. Attack-surface sinks (recon hypotheses) ----
154
+ for cls, info in (facts.get("surface", {}).get("sinks", {}) or {}).items():
155
+ out.append(_f(f"{cls} sink ({info.get('count')} site(s))", "attack-surface",
156
+ cls if cls in STANDARDS else "sast", "MEDIUM", "LOW",
157
+ (info.get("files") or ["?"])[0],
158
+ [{"layer": "recon", "detail": f"user-input-gated {cls} in {info.get('count')} file(s)"}]))
159
+
160
+ # ---- 4. Client-side secret exposure (HIGH — ships to browser) ----
161
+ for leak in (facts.get("client_exposure", {}).get("public_secret_leaks", []) +
162
+ facts.get("client_exposure", {}).get("server_secret_in_client_component", [])):
163
+ out.append(_f(f"Secret exposed to client: {leak}", "client-exposure", "client-exposure",
164
+ "HIGH", "HIGH", leak, [{"layer": "recon", "detail": "secret-named var reaches the browser bundle"}]))
165
+
166
+ # ---- 5. IaC / CI-CD ----
167
+ for fnd in (facts.get("iac_ci", {}).get("findings", []) or []):
168
+ out.append(_f(f"{fnd.get('kind')}: {fnd.get('detail','')[:80]}", "iac-ci", "iac",
169
+ fnd.get("severity", "MEDIUM"), "MEDIUM", fnd.get("file", ""),
170
+ [{"layer": "recon", "detail": fnd.get("detail", "")}]))
171
+
172
+ # ---- 6. GraphQL ----
173
+ g = facts.get("graphql", {})
174
+ if g.get("present"):
175
+ for fnd in g.get("findings", []):
176
+ out.append(_f(f"GraphQL: {fnd.get('issue')}", "graphql", "graphql",
177
+ fnd.get("severity", "MEDIUM"), "MEDIUM", (g.get("endpoints") or ["/graphql"])[0],
178
+ [{"layer": "recon", "detail": fnd.get("detail", "")}]))
179
+
180
+ # ---- suppress + rank ----
181
+ kept = [f for f in out if not _suppressed(f, suppressions)]
182
+ suppressed_n = len(out) - len(kept)
183
+ kept.sort(key=lambda f: (-SEV_RANK.get(f["severity"], 0), -CONF_RANK.get(f["confidence"], 0)))
184
+
185
+ # ---- calibrate: attach a measured real-rate + CI to each finding (best-effort) ----
186
+ cal_table = calibration.load()
187
+ by_sev, by_conf, by_basis = {}, {}, {}
188
+ for f in kept:
189
+ f["calibrated"] = calibration.apply(f.get("attack_class", ""), f["confidence"], cal_table)
190
+ by_sev[f["severity"]] = by_sev.get(f["severity"], 0) + 1
191
+ by_conf[f["confidence"]] = by_conf.get(f["confidence"], 0) + 1
192
+ by_basis[f["calibrated"]["basis"]] = by_basis.get(f["calibrated"]["basis"], 0) + 1
193
+ return {"findings": kept, "total": len(kept), "suppressed": suppressed_n,
194
+ "by_severity": by_sev, "by_confidence": by_conf,
195
+ "calibration": {"loaded": bool(cal_table), "by_basis": by_basis,
196
+ "personalized": bool((cal_table or {}).get("meta", {}).get("personalized")),
197
+ "local_samples": (cal_table or {}).get("meta", {}).get("local_samples", 0),
198
+ "caveat": (cal_table or {}).get("meta", {}).get("caveat")},
199
+ "dynamic_included": bool(dynamic)}
@@ -0,0 +1,79 @@
1
+ """Stage the probe library, tailored to the extracted attack surface.
2
+
3
+ Probe selection is now driven by the real recon facts — we only stage what the
4
+ surface justifies, and the briefing tells the agent exactly which endpoints to
5
+ point each probe at.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from importlib import resources
11
+ from pathlib import Path
12
+
13
+ # label -> (filename, attack class, what the agent must supply)
14
+ PROBES = {
15
+ "bola-cross-tenant": ("bola-cross-tenant.sh", "BOLA / cross-tenant read (OWASP API #1)",
16
+ "two role tokens in different tenants + the IDOR-candidate routes"),
17
+ "bola-write-verbs": ("bola-write-verbs.py", "BOLA on PATCH/PUT/POST/DELETE",
18
+ "two role tokens + the write endpoints + a sample object id per tenant"),
19
+ "mass-assignment": ("mass-assignment.py", "BOPLA / mass assignment (OWASP API #3)",
20
+ "a low-priv token + a write endpoint that updates a record"),
21
+ "jwt-attacks": ("jwt-attacks.sh", "JWT: alg:none, tamper, expiry, replay",
22
+ "a valid token + the login + a protected endpoint"),
23
+ "hs256-brute-force": ("hs256-brute-force.py", "Offline HS256 weak-secret brute",
24
+ "one HS256 JWT (offline — no live app needed)"),
25
+ "ssrf-probes": ("ssrf-probes.sh", "SSRF: IMDS / RFC1918 / file://",
26
+ "an authorized token + the SSRF-candidate endpoints/params"),
27
+ "race-conditions": ("race-conditions.py", "Race / claim-collision invariants",
28
+ "a token + an endpoint with a single-winner invariant + an idempotency key"),
29
+ "webhook-forgery": ("webhook-forgery.py", "Inbound webhook signature/replay",
30
+ "the webhook path + signature header name + scheme"),
31
+ "rate-limit-burst": ("rate-limit-burst.sh", "Rate-limit + X-Forwarded-For bypass",
32
+ "the login + a rate-limited endpoint"),
33
+ "compare-roles": ("compare-roles.sh", "Two-role DAST surface diff",
34
+ "two SARIF reports from a role-A and role-B scan (dynamic phase)"),
35
+ "dlp-bypass-offline": ("dlp-bypass-offline.py", "DLP/detection regex encoding bypass",
36
+ "your DLP/redaction regexes (offline)"),
37
+ "s3-assess": ("s3-assess.sh", "S3 bucket posture", "a bucket name + AWS creds"),
38
+ }
39
+
40
+ ALWAYS = ["jwt-attacks", "hs256-brute-force", "rate-limit-burst"]
41
+
42
+
43
+ def applicable(facts: dict) -> list:
44
+ """Pick probes the extracted surface actually justifies."""
45
+ chosen = list(ALWAYS)
46
+ targeting = (facts.get("routes") or {}).get("targeting", {})
47
+ tenant = (facts.get("tenant") or {}).get("candidates")
48
+
49
+ if targeting.get("write_endpoints"):
50
+ chosen += ["mass-assignment"]
51
+ if tenant:
52
+ chosen += ["bola-cross-tenant", "bola-write-verbs", "compare-roles"]
53
+ if targeting.get("ssrf_candidates") or (facts.get("surface") or {}).get("sinks", {}).get("ssrf-outbound-http"):
54
+ chosen += ["ssrf-probes"]
55
+ if targeting.get("write_endpoints"):
56
+ chosen += ["webhook-forgery", "race-conditions"]
57
+
58
+ seen, ordered = set(), []
59
+ for k in chosen:
60
+ if k in PROBES and k not in seen:
61
+ seen.add(k)
62
+ ordered.append(k)
63
+ return ordered
64
+
65
+
66
+ def stage(chosen: list, outdir: Path) -> list:
67
+ dest = outdir / "probes"
68
+ dest.mkdir(parents=True, exist_ok=True)
69
+ manifest = []
70
+ src_root = resources.files("websec_validator").joinpath("templates/probes")
71
+ for key in chosen:
72
+ fname, attack, needs = PROBES[key]
73
+ try:
74
+ (dest / fname).write_bytes(src_root.joinpath(fname).read_bytes())
75
+ manifest.append({"key": key, "file": f"probes/{fname}",
76
+ "attack_class": attack, "agent_must_supply": needs})
77
+ except Exception as e:
78
+ manifest.append({"key": key, "file": fname, "status": f"stage-error: {e}"})
79
+ return manifest
@@ -0,0 +1,96 @@
1
+ """Proof harness — score the recon engine against a known-vuln-app corpus.
2
+
3
+ WHAT THIS MEASURES (honest scope): for each deliberately-vulnerable app, does the
4
+ recon engine SURFACE the attack surface the app is known to have (right framework,
5
+ auth scheme, endpoint count, IDOR/GraphQL presence)? That's a deterministic,
6
+ regression-trackable PROXY for the engine's quality — it tells us the briefing
7
+ points the agent at the right places.
8
+
9
+ WHAT IT DOES NOT MEASURE: the full kill-criterion — whether handing the briefing
10
+ to a coding agent makes it find the *planted bugs* better than a generic prompt.
11
+ That A/B requires driving real agents against running apps; the protocol for it is
12
+ in corpus/PROOF-PROTOCOL.md and is a manual step.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import subprocess
19
+ from pathlib import Path
20
+
21
+ from . import __version__, recon
22
+
23
+
24
+ def _ensure_repo(entry: dict, workdir: Path) -> Path | None:
25
+ if entry.get("local_path") and Path(entry["local_path"]).is_dir():
26
+ return Path(entry["local_path"])
27
+ dest = workdir / entry["name"]
28
+ if dest.is_dir() and any(dest.iterdir()): # already cloned — reuse
29
+ return dest
30
+ if not entry.get("repo"):
31
+ return None
32
+ try:
33
+ subprocess.run(["git", "clone", "--depth", "1", entry["repo"], str(dest)],
34
+ capture_output=True, text=True, check=True, timeout=240)
35
+ return dest
36
+ except Exception:
37
+ return None
38
+
39
+
40
+ def _score(entry: dict, facts: dict) -> dict:
41
+ exp = entry.get("expect", {})
42
+ stack = facts.get("stack", {})
43
+ routes = facts.get("routes", {})
44
+ tgt = routes.get("targeting", {})
45
+ auth = facts.get("auth", {})
46
+ gql = facts.get("graphql", {})
47
+ checks = []
48
+
49
+ def chk(name, ok, got):
50
+ checks.append({"check": name, "pass": bool(ok), "got": got})
51
+
52
+ if "frameworks" in exp:
53
+ got = stack.get("frameworks", [])
54
+ chk("frameworks ⊇ expected", set(exp["frameworks"]).issubset(set(got)), got)
55
+ if "min_endpoints" in exp:
56
+ chk(f"endpoints ≥ {exp['min_endpoints']}", routes.get("count", 0) >= exp["min_endpoints"], routes.get("count", 0))
57
+ if "auth_scheme_contains" in exp:
58
+ hay = (auth.get("scheme", "") + " " + " ".join(auth.get("schemes_detected", []))).lower()
59
+ chk(f"auth ~ '{exp['auth_scheme_contains']}'", exp["auth_scheme_contains"] in hay, auth.get("scheme"))
60
+ if exp.get("idor_present"):
61
+ n = len(tgt.get("idor_candidates", []))
62
+ chk("IDOR candidates found", n > 0, n)
63
+ if exp.get("graphql_present"):
64
+ chk("GraphQL detected", gql.get("present", False), gql.get("present", False))
65
+ if exp.get("tenant_key"):
66
+ keys = [c["key"] for c in facts.get("tenant", {}).get("candidates", [])]
67
+ chk(f"tenant key '{exp['tenant_key']}'", exp["tenant_key"] in keys, keys[:3])
68
+
69
+ passed = sum(1 for c in checks if c["pass"])
70
+ return {"checks": checks, "passed": passed, "total": len(checks),
71
+ "score": round(passed / len(checks), 2) if checks else None}
72
+
73
+
74
+ def run_proof(corpus_path: Path, workdir: Path) -> dict:
75
+ corpus = json.loads(Path(corpus_path).read_text())
76
+ workdir.mkdir(parents=True, exist_ok=True)
77
+ results = []
78
+ for entry in corpus:
79
+ repo = _ensure_repo(entry, workdir)
80
+ if not repo:
81
+ results.append({"name": entry["name"], "status": "unavailable (clone failed / no local_path)"})
82
+ continue
83
+ try:
84
+ facts = recon.build_facts(repo, __version__)
85
+ except Exception as e:
86
+ results.append({"name": entry["name"], "status": f"recon error: {e}"})
87
+ continue
88
+ results.append({"name": entry["name"], "endpoints": facts.get("routes", {}).get("count"),
89
+ "vulns": entry.get("vulns", ""), **_score(entry, facts)})
90
+
91
+ total_checks = sum(r.get("total", 0) for r in results)
92
+ total_pass = sum(r.get("passed", 0) for r in results)
93
+ return {"results": results,
94
+ "aggregate": {"apps": len(results),
95
+ "overall_coverage": round(total_pass / total_checks, 2) if total_checks else None,
96
+ "checks_passed": total_pass, "checks_total": total_checks}}
@@ -0,0 +1,28 @@
1
+ """Recon entry point — thin wrapper over the extractor framework.
2
+
3
+ All the real work lives in extractors/. This module just exposes the stable
4
+ build_facts / write_facts / detect_stack API the CLI depends on.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from pathlib import Path
11
+
12
+ from . import extractors
13
+ from .extractors.base import RepoContext
14
+ from .extractors.stack import StackExtractor
15
+
16
+
17
+ def build_facts(root: Path, version: str) -> dict:
18
+ return extractors.run_all(root, version)
19
+
20
+
21
+ def write_facts(facts: dict, out: Path) -> Path:
22
+ out.write_text(json.dumps(facts, indent=2))
23
+ return out
24
+
25
+
26
+ def detect_stack(root: Path) -> dict:
27
+ """Lightweight stack-only detection for scanner relevance (CLI doctor)."""
28
+ return StackExtractor().extract(RepoContext(Path(root)), {})
@@ -0,0 +1,114 @@
1
+ """Comprehensive, human-readable REPORT.md — the historical artifact.
2
+
3
+ Every `websec run` writes one of these into an immutable timestamped run dir, so
4
+ you get a durable record of the whole pass: stack, attack surface, access-control
5
+ map, de-duplicated static findings, and (when present) dynamic results — all in
6
+ one doc. Structured so it can grow into the traceable findings ledger (evidence
7
+ chain + standards citations + calibrated confidence) without being rebuilt.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from .briefing import _bullets, _section
13
+
14
+
15
+ def render(facts: dict, scanners: dict, scan_results: list, unified: dict | None,
16
+ probe_manifest: list, timestamp: str, ledger: dict | None = None) -> str:
17
+ stack = facts.get("stack", {})
18
+ routes = facts.get("routes", {})
19
+ tgt = routes.get("targeting", {})
20
+ authz = facts.get("authz", {})
21
+ gs = authz.get("guard_summary", {})
22
+ surface = facts.get("surface", {})
23
+
24
+ # executive summary
25
+ sev = (unified or {}).get("by_severity", {})
26
+ sev_line = " · ".join(f"{k}: {v}" for k, v in sev.items()) if sev else "_run with --scan for static findings_"
27
+ unprot = authz.get("write_endpoints_without_visible_guard", [])
28
+
29
+ top_findings = ""
30
+ if unified and unified.get("top"):
31
+ top_findings = "\n".join(
32
+ f"- **{t['severity']}** [{t['category']}] {t['title']} — `{t['file']}` ({'+'.join(t['tools'])})"
33
+ for t in unified["top"])
34
+ else:
35
+ top_findings = "_no static scan run (use `--scan`)_"
36
+
37
+ sinks = ", ".join(f"{k} ({n})" for k, n in surface.get("sink_counts", {}).items()) or "none"
38
+
39
+ if ledger and ledger.get("findings"):
40
+ _ll = []
41
+ for f in ledger["findings"][:60]:
42
+ cwe = (f["standards"]["cwe"][:1] or [""])[0]
43
+ chain = " → ".join(e["layer"] for e in f["evidence"])
44
+ api = (" · " + ", ".join(f["standards"]["owasp_api"])) if f["standards"]["owasp_api"] else ""
45
+ cal = f.get("calibrated") or {}
46
+ calstr = (f" · P(real)≈**{cal.get('p')}** CI {cal.get('ci')} (n={cal.get('n')}, {cal.get('basis')})"
47
+ if cal else "")
48
+ _ll.append(f"- **[{f['severity']}/{f['confidence']}]** {f['title']} \n"
49
+ f" `{f['location']}` · evidence: {chain} · {cwe}{api}{calstr} \n"
50
+ f" _fix:_ {f['remediation']}")
51
+ ledger_block = "\n".join(_ll)
52
+ ledger_hdr = (f"**{ledger['total']} findings** · {ledger['by_severity']} · "
53
+ f"confidence {ledger['by_confidence']}"
54
+ + (f" · {ledger['suppressed']} suppressed" if ledger.get('suppressed') else ""))
55
+ else:
56
+ ledger_block, ledger_hdr = top_findings, sev_line
57
+
58
+ cal_caveat = ((ledger or {}).get("calibration", {}).get("caveat")
59
+ or "calibrated on a vuln-app corpus — indicative only, skews optimistic on clean code")
60
+
61
+ return f"""# websec-validator report — {facts.get('target','')}
62
+
63
+ > Generated {timestamp} · websec-validator v{facts.get('version','')} · **immutable run record** (never overwritten).
64
+ > Deterministic recon — no LLM. Hand `AGENT-BRIEFING.md` (same dir) to your coding agent to act on this.
65
+
66
+ ## Executive summary
67
+
68
+ | | |
69
+ |---|---|
70
+ | Stack | {", ".join(stack.get("languages", [])) or "?"} · {", ".join(stack.get("frameworks", [])) or "?"} · {", ".join(stack.get("datastores", [])) or "?"} |
71
+ | Endpoints | **{routes.get('count', 0)}** (via {routes.get('engine','?').split(' ')[0]}) |
72
+ | Auth | {facts.get('auth', {}).get('scheme','?')} · roles: {', '.join(authz.get('roles_detected', [])) or 'none'} |
73
+ | Access control | {gs.get('with_visible_guard', 0)} guarded · **{gs.get('no_visible_guard', 0)} no visible guard** · global-middleware: {authz.get('global_auth_middleware', False)} |
74
+ | Findings (ledger) | {ledger_hdr} |
75
+ | Attack surface | IDOR: {len(tgt.get('idor_candidates', []))} · SSRF: {len(tgt.get('ssrf_candidates', []))} · upload: {len(tgt.get('upload_candidates', []))} · writes: {len(tgt.get('write_endpoints', []))} |
76
+
77
+ ## 1. Findings ledger (ranked · evidence chain · standards · confidence)
78
+
79
+ {ledger_block}
80
+
81
+ _Full ledger with complete evidence chains + remediation in `findings-ledger.json`. Confidence: HIGH = dynamically confirmed or verified; MEDIUM = concrete static evidence; LOW = single-source hypothesis to verify._
82
+
83
+ _**P(real)** = measured real-vuln rate for that attack-class/confidence bucket, with a 95% confidence interval and sample size `n` ({cal_caveat}). A wide CI or `basis: prior (uncalibrated)` means thin data — lean on the verification debate, not the number; to be conservative, threshold on the CI lower bound._
84
+
85
+ ## 2. Access control
86
+
87
+ {_section("⚠ Write endpoints with no visible guard (verify — top missing-authz leads)", unprot)}
88
+ {authz.get("note","")}
89
+
90
+ ## 3. Attack surface & targeting
91
+
92
+ {_section("IDOR / BOLA candidates", tgt.get("idor_candidates"))}
93
+ {_section("SSRF candidates", tgt.get("ssrf_candidates"))}
94
+ {_section("File-upload candidates", tgt.get("upload_candidates"))}
95
+ **Code-level sinks (user-input-gated):** {sinks}
96
+
97
+ **Mass-assignment targets (privileged model fields):** {", ".join(facts.get("schemas", {}).get("sensitive_fields", [])) or "none detected"} · ORMs: {", ".join(facts.get("schemas", {}).get("orms", [])) or "?"}
98
+
99
+ ## 4. Config / CI-CD / client-side
100
+
101
+ **IaC/CI:** {len((facts.get("iac_ci") or {}).get("findings", []))} finding(s) · **GraphQL:** {(facts.get("graphql") or {}).get("present", False)} · **client-side secret exposure:** {len((facts.get("client_exposure") or {}).get("public_secret_leaks", []) + (facts.get("client_exposure") or {}).get("server_secret_in_client_component", []))}
102
+
103
+ ## 5. Staged probes
104
+
105
+ {_bullets([f"`{p['key']}` — {p.get('attack_class','')}" for p in probe_manifest if 'attack_class' in p])}
106
+
107
+ ## Appendix — endpoint inventory
108
+
109
+ {_bullets([f"`{e['method']:6}` {e['path']}" for e in routes.get("endpoints", [])], cap=200)}
110
+
111
+ ---
112
+ _Roadmap: this report grows into a traceable findings ledger — each finding gaining an evidence
113
+ chain (recon → static → dynamic), an OWASP/CWE citation, and a calibrated H/M/L confidence._
114
+ """