websec-validator 0.2.8__tar.gz → 0.3.0__tar.gz

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 (72) hide show
  1. {websec_validator-0.2.8/src/websec_validator.egg-info → websec_validator-0.3.0}/PKG-INFO +16 -12
  2. {websec_validator-0.2.8 → websec_validator-0.3.0}/README.md +15 -11
  3. {websec_validator-0.2.8 → websec_validator-0.3.0}/pyproject.toml +2 -2
  4. {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/briefing.py +24 -1
  5. {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/constitution.py +16 -0
  6. {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/extractors/__init__.py +4 -0
  7. {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/extractors/auth.py +58 -2
  8. {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/extractors/authz.py +23 -0
  9. {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/extractors/base.py +7 -1
  10. websec_validator-0.3.0/src/websec_validator/extractors/client_exposure.py +81 -0
  11. websec_validator-0.3.0/src/websec_validator/extractors/client_integrity.py +126 -0
  12. websec_validator-0.3.0/src/websec_validator/extractors/graphql.py +146 -0
  13. {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/extractors/iac_ci.py +41 -0
  14. websec_validator-0.3.0/src/websec_validator/extractors/policy_consistency.py +125 -0
  15. {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/extractors/surface.py +16 -0
  16. websec_validator-0.3.0/src/websec_validator/extractors/tenant.py +42 -0
  17. {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/findings.py +103 -15
  18. {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/probes.py +41 -0
  19. websec_validator-0.3.0/src/websec_validator/rules/error-stack-disclosure.yml +21 -0
  20. websec_validator-0.3.0/src/websec_validator/rules/insecure-default-secret.yml +25 -0
  21. {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/scanners.py +16 -0
  22. websec_validator-0.3.0/src/websec_validator/templates/probes/appsync-cswsh.sh +44 -0
  23. websec_validator-0.3.0/src/websec_validator/templates/probes/appsync-introspection.sh +42 -0
  24. websec_validator-0.3.0/src/websec_validator/templates/probes/appsync-subscription-bola.sh +46 -0
  25. websec_validator-0.3.0/src/websec_validator/templates/probes/client-integrity-checklist.sh +41 -0
  26. websec_validator-0.3.0/src/websec_validator/templates/probes/error-disclosure-probe.sh +46 -0
  27. {websec_validator-0.2.8 → websec_validator-0.3.0/src/websec_validator.egg-info}/PKG-INFO +16 -12
  28. {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator.egg-info/SOURCES.txt +10 -0
  29. {websec_validator-0.2.8 → websec_validator-0.3.0}/tests/test_hardening.py +58 -0
  30. websec_validator-0.3.0/tests/test_pentest_regressions.py +251 -0
  31. websec_validator-0.2.8/src/websec_validator/extractors/client_exposure.py +0 -48
  32. websec_validator-0.2.8/src/websec_validator/extractors/graphql.py +0 -71
  33. websec_validator-0.2.8/src/websec_validator/extractors/tenant.py +0 -33
  34. {websec_validator-0.2.8 → websec_validator-0.3.0}/LICENSE +0 -0
  35. {websec_validator-0.2.8 → websec_validator-0.3.0}/setup.cfg +0 -0
  36. {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/__init__.py +0 -0
  37. {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/calibration.json +0 -0
  38. {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/calibration.py +0 -0
  39. {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/cli.py +0 -0
  40. {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/corpus.json +0 -0
  41. {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/dynamic.py +0 -0
  42. {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/extractors/integrations.py +0 -0
  43. {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/extractors/routes.py +0 -0
  44. {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/extractors/schemas.py +0 -0
  45. {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/extractors/stack.py +0 -0
  46. {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/proof.py +0 -0
  47. {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/recon.py +0 -0
  48. {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/report.py +0 -0
  49. {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/templates/probes/_lib.py +0 -0
  50. {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/templates/probes/bola-cross-tenant.sh +0 -0
  51. {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/templates/probes/bola-write-verbs.py +0 -0
  52. {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/templates/probes/compare-roles.sh +0 -0
  53. {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/templates/probes/dlp-bypass-offline.py +0 -0
  54. {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/templates/probes/forged-token.sh +0 -0
  55. {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/templates/probes/hs256-brute-force.py +0 -0
  56. {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/templates/probes/jwt-attacks.sh +0 -0
  57. {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/templates/probes/mass-assignment.py +0 -0
  58. {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/templates/probes/race-conditions.py +0 -0
  59. {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/templates/probes/rate-limit-burst.sh +0 -0
  60. {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/templates/probes/s3-assess.sh +0 -0
  61. {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/templates/probes/ssrf-probes.sh +0 -0
  62. {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/templates/probes/unauth-baseline.sh +0 -0
  63. {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/templates/probes/webhook-forgery.py +0 -0
  64. {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/templates/reports/FINDINGS-SUMMARY.md.template +0 -0
  65. {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/templates/reports/access-control-matrix.md.template +0 -0
  66. {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/templates/reports/findings-triage.md.template +0 -0
  67. {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/templates/reports/pentest-handover-brief.md.template +0 -0
  68. {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/templates/reports/per-tool-FINDINGS.md.template +0 -0
  69. {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator.egg-info/dependency_links.txt +0 -0
  70. {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator.egg-info/entry_points.txt +0 -0
  71. {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator.egg-info/top_level.txt +0 -0
  72. {websec_validator-0.2.8 → websec_validator-0.3.0}/tests/test_recon.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: websec-validator
3
- Version: 0.2.8
3
+ Version: 0.3.0
4
4
  Summary: Local-first security recon that briefs your AI coding agent: facts + tailored probe scripts, code-in / artifacts-out. No LLM, no server, no running app.
5
5
  Author: Ricardo Accioly
6
6
  License: MIT
@@ -82,20 +82,22 @@ Then point your agent at the output: **"Read `websec-out/AGENT-BRIEFING.md` and
82
82
 
83
83
  > That's the whole user surface: **`run`** (plus the optional, advanced **`dynamic`** live-probing step below). `recon`/`proof`/`calibrate` exist for developing the tool itself and are hidden from `--help` — you never need them.
84
84
 
85
- ## What it extracts (11 deterministic extractors, no LLM)
85
+ ## What it extracts (13 deterministic extractors, no LLM)
86
86
 
87
87
  | | Dimension | Notable output |
88
88
  |---|---|---|
89
89
  | stack | languages, frameworks, datastores | monorepo-aware (aggregates every manifest) |
90
90
  | routes | every endpoint via **OWASP Noir** | method · path · typed params · code path |
91
- | auth | scheme + login surface | multi-scheme (primary jwt > passport), PyJWT/NextAuth/session aware |
91
+ | auth | scheme + login surface + **insecure-default signing secrets** | multi-scheme; flags a hard-coded `JWT_SECRET \|\| 'dev-secret'` fallback (forgeable JWT) |
92
92
  | **authz** | access-control map | guard coverage + **write endpoints with no visible guard** + roles |
93
93
  | tenant | multi-tenancy key candidates | the BOLA boundary, by frequency |
94
- | surface | 12 user-input-gated sink classes | SSRF/SQLi/NoSQLi/traversal/SSTI/redirect/deser/XXE/proto-pollution/ReDoS/cmd/eval |
94
+ | **password_policy** | cross-route policy consistency | flags a route enforcing fewer character classes than the strongest sibling (policy drift) |
95
+ | surface | 14 sink classes | 12 user-input-gated (SSRF/SQLi/traversal/SSTI/…) **+ var-arg SSRF + response-side error-disclosure** |
95
96
  | schemas | data models + **privileged fields** | Pydantic/SQLAlchemy/Django/Prisma/Mongoose/TypeORM/Zod → `role`/`isAdmin`/`groupId` for mass-assignment targeting |
96
- | iac_ci | IaC + CI/CD | GitHub Actions injection, unpinned actions, Dockerfile-root, tfstate |
97
- | client_exposure | browser leakage | `NEXT_PUBLIC_*` secrets, server-secret-in-client, source maps |
98
- | graphql | GraphQL surface | introspection / playground / missing depth-limit |
97
+ | iac_ci | IaC + CI/CD | GHA injection, unpinned actions, Dockerfile-root, tfstate **+ CDK AppSync `API_KEY` default-auth (CSWSH)** |
98
+ | client_exposure | browser leakage | public-var secrets by **name + value-shape (`da2-…`) + CDK build-injection**, server-secret-in-client, source maps |
99
+ | **client_integrity** | tamperable display (man-in-the-browser) | a fund-redirecting value (wallet address/QR) shown without a strict CSP / out-of-band anchor |
100
+ | graphql | GraphQL surface | introspection / playground / depth-limit **+ AppSync subscription-authz (cross-group BOLA) + WAF-bypass-aware introspection** |
99
101
  | integrations | third-party + webhooks | webhooks missing signature verification |
100
102
 
101
103
  Plus **derived targeting** — IDOR / SSRF / open-redirect / upload / write / auth-endpoint
@@ -204,11 +206,13 @@ publisher** with project `websec-validator`, owner `raccioly`, repo `websec-vali
204
206
 
205
207
  ## Status / roadmap
206
208
 
207
- **Done:** 11-extractor recon (incl. schema/entity → mass-assignment targeting), cross-tool de-dup,
208
- tailored probe staging, agent briefing, traceable findings ledger with **calibrated confidence
209
- (CJEWilson CIs)**, proof harness, test suite, **Docker bundle** (all scanners + Noir, arch-aware),
210
- **dynamic phase v1** (authenticated read-only cross-tenant BOLA validated live, reproduced a
211
- hand-pentest's 14/14).
209
+ **Done:** 13-extractor recon (incl. schema/entity → mass-assignment targeting, the **AWS-CDK /
210
+ managed-AppSync / VTL boundary** — CSWSH, cross-group subscription BOLA, forgeable-JWT default
211
+ secretsand a **man-in-the-browser / tamperable-display** class), cross-tool de-dup + **bundled
212
+ Semgrep rules**, tailored probe staging, agent briefing, traceable findings ledger with **calibrated
213
+ confidence (CJE — Wilson CIs)**, proof harness, test suite, **Docker bundle** (all scanners + Noir,
214
+ arch-aware), **dynamic phase v1** (authenticated read-only cross-tenant BOLA — validated live,
215
+ reproduced a hand-pentest's 14/14).
212
216
  **Next:** dynamic write-verb BOLA + JWT/auth probes + ZAP/Nuclei two-role diff (gated, they mutate),
213
217
  calibration on hand-labeled real repos (more representative base rate), ASVS index lookup, optional
214
218
  model-SDK adapters for no-agent fallback.
@@ -70,20 +70,22 @@ Then point your agent at the output: **"Read `websec-out/AGENT-BRIEFING.md` and
70
70
 
71
71
  > That's the whole user surface: **`run`** (plus the optional, advanced **`dynamic`** live-probing step below). `recon`/`proof`/`calibrate` exist for developing the tool itself and are hidden from `--help` — you never need them.
72
72
 
73
- ## What it extracts (11 deterministic extractors, no LLM)
73
+ ## What it extracts (13 deterministic extractors, no LLM)
74
74
 
75
75
  | | Dimension | Notable output |
76
76
  |---|---|---|
77
77
  | stack | languages, frameworks, datastores | monorepo-aware (aggregates every manifest) |
78
78
  | routes | every endpoint via **OWASP Noir** | method · path · typed params · code path |
79
- | auth | scheme + login surface | multi-scheme (primary jwt > passport), PyJWT/NextAuth/session aware |
79
+ | auth | scheme + login surface + **insecure-default signing secrets** | multi-scheme; flags a hard-coded `JWT_SECRET \|\| 'dev-secret'` fallback (forgeable JWT) |
80
80
  | **authz** | access-control map | guard coverage + **write endpoints with no visible guard** + roles |
81
81
  | tenant | multi-tenancy key candidates | the BOLA boundary, by frequency |
82
- | surface | 12 user-input-gated sink classes | SSRF/SQLi/NoSQLi/traversal/SSTI/redirect/deser/XXE/proto-pollution/ReDoS/cmd/eval |
82
+ | **password_policy** | cross-route policy consistency | flags a route enforcing fewer character classes than the strongest sibling (policy drift) |
83
+ | surface | 14 sink classes | 12 user-input-gated (SSRF/SQLi/traversal/SSTI/…) **+ var-arg SSRF + response-side error-disclosure** |
83
84
  | schemas | data models + **privileged fields** | Pydantic/SQLAlchemy/Django/Prisma/Mongoose/TypeORM/Zod → `role`/`isAdmin`/`groupId` for mass-assignment targeting |
84
- | iac_ci | IaC + CI/CD | GitHub Actions injection, unpinned actions, Dockerfile-root, tfstate |
85
- | client_exposure | browser leakage | `NEXT_PUBLIC_*` secrets, server-secret-in-client, source maps |
86
- | graphql | GraphQL surface | introspection / playground / missing depth-limit |
85
+ | iac_ci | IaC + CI/CD | GHA injection, unpinned actions, Dockerfile-root, tfstate **+ CDK AppSync `API_KEY` default-auth (CSWSH)** |
86
+ | client_exposure | browser leakage | public-var secrets by **name + value-shape (`da2-…`) + CDK build-injection**, server-secret-in-client, source maps |
87
+ | **client_integrity** | tamperable display (man-in-the-browser) | a fund-redirecting value (wallet address/QR) shown without a strict CSP / out-of-band anchor |
88
+ | graphql | GraphQL surface | introspection / playground / depth-limit **+ AppSync subscription-authz (cross-group BOLA) + WAF-bypass-aware introspection** |
87
89
  | integrations | third-party + webhooks | webhooks missing signature verification |
88
90
 
89
91
  Plus **derived targeting** — IDOR / SSRF / open-redirect / upload / write / auth-endpoint
@@ -192,11 +194,13 @@ publisher** with project `websec-validator`, owner `raccioly`, repo `websec-vali
192
194
 
193
195
  ## Status / roadmap
194
196
 
195
- **Done:** 11-extractor recon (incl. schema/entity → mass-assignment targeting), cross-tool de-dup,
196
- tailored probe staging, agent briefing, traceable findings ledger with **calibrated confidence
197
- (CJEWilson CIs)**, proof harness, test suite, **Docker bundle** (all scanners + Noir, arch-aware),
198
- **dynamic phase v1** (authenticated read-only cross-tenant BOLA validated live, reproduced a
199
- hand-pentest's 14/14).
197
+ **Done:** 13-extractor recon (incl. schema/entity → mass-assignment targeting, the **AWS-CDK /
198
+ managed-AppSync / VTL boundary** — CSWSH, cross-group subscription BOLA, forgeable-JWT default
199
+ secretsand a **man-in-the-browser / tamperable-display** class), cross-tool de-dup + **bundled
200
+ Semgrep rules**, tailored probe staging, agent briefing, traceable findings ledger with **calibrated
201
+ confidence (CJE — Wilson CIs)**, proof harness, test suite, **Docker bundle** (all scanners + Noir,
202
+ arch-aware), **dynamic phase v1** (authenticated read-only cross-tenant BOLA — validated live,
203
+ reproduced a hand-pentest's 14/14).
200
204
  **Next:** dynamic write-verb BOLA + JWT/auth probes + ZAP/Nuclei two-role diff (gated, they mutate),
201
205
  calibration on hand-labeled real repos (more representative base rate), ASVS index lookup, optional
202
206
  model-SDK adapters for no-agent fallback.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "websec-validator"
7
- version = "0.2.8"
7
+ version = "0.3.0"
8
8
  description = "Local-first security recon that briefs your AI coding agent: facts + tailored probe scripts, code-in / artifacts-out. No LLM, no server, no running app."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -25,4 +25,4 @@ package-dir = { "" = "src" }
25
25
  where = ["src"]
26
26
 
27
27
  [tool.setuptools.package-data]
28
- websec_validator = ["templates/probes/*", "templates/reports/*", "corpus.json", "calibration.json"]
28
+ websec_validator = ["templates/probes/*", "templates/reports/*", "rules/*.yml", "corpus.json", "calibration.json"]
@@ -49,9 +49,27 @@ def render(facts: dict, scanners: dict, scan_results: list, probe_manifest: list
49
49
  iac_lines = "\n".join(f"- **{f['severity']}** `{f['kind']}` — `{f['file']}` — {f['detail']}"
50
50
  for f in iac_findings[:20]) or "_none_"
51
51
  client = facts.get("client_exposure", {})
52
- client_leaks = client.get("public_secret_leaks", []) + client.get("server_secret_in_client_component", [])
52
+ client_leaks = (client.get("public_secret_leaks", []) + client.get("server_secret_in_client_component", [])
53
+ + client.get("public_secret_value_leaks", []) + client.get("public_var_from_cfn_output", []))
53
54
  client_section = _bullets(client_leaks) if client_leaks else "_none detected_"
54
55
 
56
+ ci = facts.get("client_integrity", {})
57
+ ci_findings = ci.get("findings", [])
58
+ ci_section = ("\n".join(f"- **{f.get('severity')}/{f.get('confidence','LOW')}** {f.get('issue')}"
59
+ for f in ci_findings) if ci_findings
60
+ else "_no fund-redirecting display values detected (MITB class N/A)_" if not ci.get("sensitive_display")
61
+ else "_sensitive display present; strict CSP + out-of-band anchor look present — spot-check_")
62
+
63
+ pp = facts.get("password_policy", {})
64
+ if pp.get("drift"):
65
+ pp_line = f"⚠ DRIFT — {len(pp['drift'])} sibling route(s) weaker than the strongest set {pp.get('strongest_policy')}"
66
+ elif pp.get("weak_policy"):
67
+ pp_line = f"uniform but WEAK — enforces only {pp.get('weak_policy')}"
68
+ elif pp.get("password_blocks"):
69
+ pp_line = f"looks consistent across {len(pp['password_blocks'])} validator block(s)"
70
+ else:
71
+ pp_line = "_no password validators detected_"
72
+
55
73
  gql = facts.get("graphql", {})
56
74
  if gql.get("present"):
57
75
  gfind = "; ".join(f"{x['severity']} {x['issue']}" for x in gql.get("findings", [])) or "no obvious issues"
@@ -158,6 +176,11 @@ Production source maps exposed: {client.get("production_source_maps", False)}
158
176
 
159
177
  **GraphQL surface:** {gql_line}
160
178
 
179
+ **Password policy (cross-route consistency):** {pp_line}
180
+
181
+ **Client integrity — man-in-the-browser / tamperable display:**
182
+ {ci_section}
183
+
161
184
  **Third-party integrations:** {integ_line}
162
185
  {wh_line}
163
186
 
@@ -60,6 +60,22 @@ def build(facts: dict, ledger: dict | None = None) -> list:
60
60
  add("Secret hygiene", "Given the repo + git history, Then no live credential is present and no secret "
61
61
  "reaches the client bundle", "recon")
62
62
 
63
+ # P6 — Signing-secret integrity (forgeable JWT, PTREQ0013000 #8)
64
+ for sd in ((facts.get("auth", {}) or {}).get("insecure_secret_defaults", []) or [])[:5]:
65
+ add("Signing-secret integrity", f"Given the signing-secret env var is unset, When the app boots, Then it "
66
+ f"FAILS CLOSED — no hard-coded fallback ({sd.get('literal')!r} in {sd.get('file')})",
67
+ sd.get("file", "recon"))
68
+
69
+ # P7 — Subscription authorization (cross-group BOLA, #5)
70
+ for s in ((facts.get("graphql", {}) or {}).get("subscription_authz", []) or [])[:6]:
71
+ add("Subscription authorization", f"Given a tenant id you do NOT own, When subscribing to `{s.get('field')}`, "
72
+ f"Then the server rejects it (binds the tenant arg to your identity)", "recon")
73
+
74
+ # P8 — Display integrity (man-in-the-browser, the agent-wallet class)
75
+ if (facts.get("client_integrity", {}) or {}).get("sensitive_display"):
76
+ add("Display integrity", "Given a fund-redirecting value is displayed, Then a strict CSP kills the scalable "
77
+ "tamper vector AND an out-of-band anchor makes single-surface tampering user-detectable", "recon")
78
+
63
79
  return inv
64
80
 
65
81
 
@@ -13,9 +13,11 @@ from .auth import AuthExtractor
13
13
  from .authz import AuthzExtractor
14
14
  from .base import Extractor, RepoContext
15
15
  from .client_exposure import ClientExposureExtractor
16
+ from .client_integrity import ClientIntegrityExtractor
16
17
  from .graphql import GraphQLExtractor
17
18
  from .iac_ci import IacCiExtractor
18
19
  from .integrations import IntegrationsExtractor
20
+ from .policy_consistency import PolicyConsistencyExtractor
19
21
  from .routes import RoutesExtractor
20
22
  from .schemas import SchemasExtractor
21
23
  from .stack import StackExtractor
@@ -30,10 +32,12 @@ REGISTRY: list[Extractor] = [
30
32
  AuthExtractor(),
31
33
  AuthzExtractor(),
32
34
  TenantExtractor(),
35
+ PolicyConsistencyExtractor(),
33
36
  SurfaceExtractor(),
34
37
  SchemasExtractor(),
35
38
  IacCiExtractor(),
36
39
  ClientExposureExtractor(),
40
+ ClientIntegrityExtractor(),
37
41
  GraphQLExtractor(),
38
42
  IntegrationsExtractor(),
39
43
  ]
@@ -27,6 +27,31 @@ COOKIE_READ = re.compile(
27
27
  _COOKIE_RESERVED = {"get", "set", "getall", "has", "delete", "clear", "tostring",
28
28
  "foreach", "entries", "keys", "values", "size", "name", "value", "length"}
29
29
 
30
+ # Insecure DEFAULT signing secret — a hard-coded fallback on a secret/key var (the forgeable-JWT
31
+ # class, PTREQ0013000 #8). JS/TS: `process.env.JWT_SECRET || 'dev-secret-do-not-use-in-prod'`;
32
+ # Python: os.environ.get('JWT_SECRET', 'dev-secret'). A quoted fallback on a *SECRET/*KEY var is
33
+ # almost never benign — and if it's a dev-ish placeholder AND the repo actually signs JWTs, anyone
34
+ # who reads the source can forge tokens for any user/role.
35
+ _SECRET_VAR = (r"(?:JWT[_-]?SECRET|TOKEN[_-]?SECRET|REFRESH[_-]?SECRET|SIGNING[_-]?KEY"
36
+ r"|SESSION[_-]?SECRET|COOKIE[_-]?SECRET|AUTH[_-]?SECRET|APP[_-]?SECRET"
37
+ r"|HMAC[_-]?KEY|PRIVATE[_-]?KEY|SECRET[_-]?KEY|SECRET)")
38
+ SECRET_DEFAULT_JS = re.compile(
39
+ _SECRET_VAR + r"['\"\]\s]*\s*(?:\|\||\?\?)\s*[`'\"]([^`'\"]{3,80})[`'\"]", re.I)
40
+ SECRET_DEFAULT_PY = re.compile(
41
+ r"(?:os\.environ\.get|os\.getenv|getenv)\(\s*['\"][^'\"]*" + _SECRET_VAR
42
+ + r"[^'\"]*['\"]\s*,\s*['\"]([^'\"]{3,80})['\"]", re.I)
43
+ # placeholder markers that make a fallback unambiguously a non-production dev secret
44
+ SECRET_DEVISH = re.compile(r"dev|do[_-]?not[_-]?use|change[_-]?(?:me|it|this)|placeholder|secret|test"
45
+ r"|local|example|sample|default|your[_-]|xxx|todo|fixme|123456|password", re.I)
46
+ JWT_SIGN_VERIFY = re.compile(r"jwt\.(?:sign|verify)|jsonwebtoken|\bjose\b|jwtVerify|SignJWT|jwt\.encode", re.I)
47
+
48
+
49
+ def _looks_like_example(rel: str) -> bool:
50
+ """Example/doc files are MEANT to hold placeholder secrets — don't cry forgeable-JWT on them."""
51
+ r = rel.lower()
52
+ return (".example" in r or ".sample" in r or ".dist" in r or ".template" in r
53
+ or "/docs/" in r or "/doc/" in r or "/examples/" in r or r.endswith((".md", ".mdx")))
54
+
30
55
 
31
56
  class AuthExtractor(Extractor):
32
57
  name = "auth"
@@ -41,6 +66,8 @@ class AuthExtractor(Extractor):
41
66
  jwt = passport = session = apikey = 0
42
67
  guard_files = []
43
68
  cookie_names: list[str] = []
69
+ secret_defaults: list = [] # (file, literal) hard-coded fallback signing secrets
70
+ jwt_sign_verify = False # does the repo actually sign/verify JWTs?
44
71
  for _p, rel, text in ctx.iter_code():
45
72
  if JWT_LIBS.search(text):
46
73
  jwt += 1
@@ -57,12 +84,34 @@ class AuthExtractor(Extractor):
57
84
  name = m.group(1) or m.group(2) or m.group(3)
58
85
  if name and name.lower() not in _COOKIE_RESERVED and name not in cookie_names:
59
86
  cookie_names.append(name)
87
+ if JWT_SIGN_VERIFY.search(text):
88
+ jwt_sign_verify = True
89
+ if not _looks_like_example(rel):
90
+ for mm in SECRET_DEFAULT_JS.finditer(text):
91
+ secret_defaults.append((rel, mm.group(1)))
92
+ for mm in SECRET_DEFAULT_PY.finditer(text):
93
+ secret_defaults.append((rel, mm.group(1)))
94
+
95
+ # Hard-coded fallback signing secret → forgeable-JWT lead (PTREQ0013000 #8). De-dup by
96
+ # (file, literal); mark dev-ish placeholders. findings.py escalates dev-ish + jwt-in-use to
97
+ # CRITICAL; probes.stage seeds the literal into the hs256 brute-force candidate list.
98
+ seen_sd: set = set()
99
+ insecure_secret_defaults: list = []
100
+ for rel_, lit in secret_defaults:
101
+ if (rel_, lit) in seen_sd:
102
+ continue
103
+ seen_sd.add((rel_, lit))
104
+ insecure_secret_defaults.append({"file": rel_, "literal": lit,
105
+ "dev_ish": bool(SECRET_DEVISH.search(lit))})
106
+ if len(insecure_secret_defaults) >= 20:
107
+ break
60
108
 
61
109
  nextauth = "nextauth" in frameworks or any("nextauth" in e.lower() for e in auth_eps)
62
110
 
63
111
  # Detect ALL schemes present, then pick a primary by priority. A JWT app
64
112
  # that also wires Passport for SSO must read as primary=jwt, not passport
65
113
  # (Passport is often SSO-only). Priority: nextauth > jwt > session > passport > api-key.
114
+ route_count = len(routes.get("endpoints", []))
66
115
  detected = []
67
116
  if nextauth:
68
117
  detected.append("nextauth (session JWT in cookie)")
@@ -88,6 +137,13 @@ class AuthExtractor(Extractor):
88
137
  "cookie_names": cookie_names[:15],
89
138
  "guard_files": guard_files,
90
139
  "signal_counts": {"jwt": jwt, "passport": passport, "session": session, "api_key": apikey},
91
- "note": "AGENT: confirm the PRIMARY auth flow + how a test token is minted before the JWT/auth "
92
- "probes. Multiple schemes often mean primary bearer/session + secondary SSO (passport).",
140
+ "insecure_secret_defaults": insecure_secret_defaults, # CRITICAL-class (forgeable JWT #8)
141
+ "jwt_sign_verify_present": jwt_sign_verify,
142
+ "route_count": route_count,
143
+ "reliable_signal": route_count > 0 or bool(nextauth),
144
+ "note": (("⚠ No HTTP routes detected — this auth scheme is LOW-CONFIDENCE (likely a "
145
+ "library/CLI/scanner that merely mentions auth, or routes weren't parsed). "
146
+ if not (route_count > 0 or nextauth) else "")
147
+ + "AGENT: confirm the PRIMARY auth flow + how a test token is minted before the "
148
+ "JWT/auth probes. Multiple schemes often mean primary bearer/session + secondary SSO."),
93
149
  }
@@ -142,6 +142,28 @@ class AuthzExtractor(Extractor):
142
142
  for dec in sorted(set(UNSAFE_DECODER.findall(text))):
143
143
  unsafe_decoders.append({"file": rel, "decoder": dec})
144
144
 
145
+ # A guard DEFINED in a file that also calls an unsafe/unverified decoder authenticates via
146
+ # an unverified decode. Routes that call such a guard are the static "at-risk" set for the
147
+ # forged-token bypass class — the dynamic probe confirms which actually fall, but this points
148
+ # at them even with NO live target (turns the F5 hypothesis into named routes).
149
+ unverified_routes: list = []
150
+ unsafe_files = {ud["file"] for ud in unsafe_decoders}
151
+ if unsafe_files:
152
+ guard_def = re.compile(r"(?:export\s+)?(?:async\s+)?(?:function|const)\s+"
153
+ r"(require\w+|ensure\w+|\w*[Aa]uth\w*|verify\w+)\b")
154
+ unsafe_guards = set()
155
+ for _p, rel, text in ctx.iter_code():
156
+ if rel in unsafe_files:
157
+ unsafe_guards.update(g for g in guard_def.findall(text) if len(g) >= 5)
158
+ if unsafe_guards:
159
+ call = re.compile(r"\b(?:" + "|".join(re.escape(g) for g in sorted(unsafe_guards)) + r")\s*\(")
160
+ for e in endpoints:
161
+ cp = e.get("code_path", "")
162
+ t = ctx.text(Path(cp)) if cp else ""
163
+ if t and call.search(t):
164
+ unverified_routes.append(f"{e.get('method')} {e.get('path')}")
165
+ unverified_routes = sorted(set(unverified_routes))[:60]
166
+
145
167
  if global_auth:
146
168
  where = f"`{mw['file']}` (matcher {mw.get('matchers') or '—'})" if mw_auth else "`app.use(<auth>)`"
147
169
  note = (f"A GLOBAL auth middleware ({where}) was detected — most routes are protected by default. "
@@ -162,5 +184,6 @@ class AuthzExtractor(Extractor):
162
184
  "endpoint_guards": egs[:400],
163
185
  "write_endpoints_without_visible_guard": sorted(set(no_guard_writes))[:60],
164
186
  "unsafe_auth_decoders": unsafe_decoders[:30],
187
+ "unverified_signature_routes": unverified_routes,
165
188
  "note": note,
166
189
  }
@@ -20,7 +20,13 @@ SKIP_DIRS = {".git", "node_modules", "dist", "build", ".next", ".nuxt", "venv",
20
20
  # agent tooling + editor dirs + worktree copies — not the target app
21
21
  ".wolf", ".claude", ".worktrees", ".idea", ".vscode", ".agent", ".agents"}
22
22
  CODE_EXT = {".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".py", ".go", ".rb",
23
- ".java", ".php", ".prisma"}
23
+ ".java", ".php", ".prisma",
24
+ # Managed-cloud surfaces: AppSync GraphQL SDL (@aws_* auth directives) + VTL
25
+ # resolvers (where realtime/subscription authz actually lives, or is missing).
26
+ # PTREQ0013000 #2/#5 lived in these file types — previously invisible to every
27
+ # iter_code()-based extractor. routes.py SPEC_PATH still splits .graphql/.gql out
28
+ # of the route list so SDL doesn't generate phantom endpoints.
29
+ ".graphql", ".gql", ".vtl"}
24
30
  MAX_FILES = 12000
25
31
  MAX_BYTES = 2_000_000
26
32
 
@@ -0,0 +1,81 @@
1
+ """Client-side exposure extractor — secrets that leak into the browser bundle.
2
+
3
+ The Next.js/Vite footgun: any `NEXT_PUBLIC_*` / `VITE_*` var is inlined into the
4
+ client bundle, and a server-only secret referenced from a client component ships
5
+ to every visitor. Cheap static scan, high signal.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+
12
+ from .base import Extractor, RepoContext
13
+
14
+ PUBLIC_ENV = re.compile(r"\b(NEXT_PUBLIC_\w+|VITE_\w+|REACT_APP_\w+|GATSBY_\w+|EXPO_PUBLIC_\w+|PUBLIC_\w{2,})\b")
15
+ SECRETISH = re.compile(r"SECRET|PRIVATE|TOKEN|PASSWORD|PASSWD|API_?KEY|ACCESS_?KEY|CLIENT_SECRET|CREDENTIAL", re.I)
16
+ SERVER_SECRET = re.compile(r"process\.env\.([A-Z0-9_]*(?:SECRET|PRIVATE|TOKEN|PASSWORD|API_?KEY|ACCESS_?KEY)[A-Z0-9_]*)")
17
+
18
+ # VALUE-aware leak detection — hardens the name-based scan above so it survives a benign rename
19
+ # (the PTREQ0013000 #3 gap: a real key carried in a non-secret-named public var slips the name scan).
20
+ # We match distinctive secret SHAPES, not var names. AppSync's `da2-` key has NO scanner rule at all,
21
+ # so we always flag it; the generic shapes (which trivy/gitleaks already catch) are only flagged when
22
+ # the file is client-reachable, to add the ships-to-browser angle without duplicating those scanners.
23
+ SECRET_SHAPES = [
24
+ (re.compile(r"\bda2-[a-z0-9]{26}\b"), "AWS AppSync API key (da2-…)", True),
25
+ (re.compile(r"\bAKIA[0-9A-Z]{16}\b"), "AWS access key id (AKIA)", False),
26
+ (re.compile(r"\bAIza[0-9A-Za-z_\-]{35}\b"), "Google API key (AIza…)", False),
27
+ (re.compile(r"\bsk_live_[0-9A-Za-z]{16,}\b"), "Stripe live secret key (sk_live_…)", False),
28
+ (re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}\b"), "JWT (eyJ…)", False),
29
+ ]
30
+ # CDK build-time injection: a CloudFormation output / SSM param / Secret wired INTO a public build
31
+ # var — e.g. CodeBuild `envFromCfnOutputs: { VITE_APPSYNC_API_KEY: appsyncApiKeyOutput }`. Invisible
32
+ # to every secret scanner because the value isn't in source; it's injected at build time (the exact
33
+ # mechanism that shipped the AppSync key to the browser in PTREQ0013000 #3).
34
+ CFN_TO_PUBLIC = re.compile(
35
+ r"(?:envFromCfnOutputs|buildEnvironment|environmentVariables|partialBuildSpec)"
36
+ r"[\s\S]{0,400}?((?:NEXT_PUBLIC_|VITE_|REACT_APP_|GATSBY_|EXPO_PUBLIC_)\w*)\s*[:=]\s*"
37
+ r"(\w+Output\b|[\w.]+\.value\b|CfnOutput|StringParameter|(?:Fn\.)?importValue|Secret\b)", re.I)
38
+
39
+
40
+ class ClientExposureExtractor(Extractor):
41
+ name = "client_exposure"
42
+ category = "exposure"
43
+
44
+ def extract(self, ctx: RepoContext, facts: dict) -> dict:
45
+ public_vars: set = set()
46
+ public_secret_leaks = [] # public-prefixed AND secret-named → ships to client
47
+ server_secret_in_client = [] # server secret referenced from a 'use client' file
48
+ public_value_leaks = [] # secret-SHAPE literal in client-reachable code (rename-proof, #3)
49
+ public_var_from_cfn = [] # CDK output/secret injected into a public build var (#3)
50
+
51
+ for _p, rel, text in ctx.iter_code():
52
+ for v in PUBLIC_ENV.findall(text):
53
+ public_vars.add(v)
54
+ if SECRETISH.search(v):
55
+ public_secret_leaks.append(f"{v} ({rel})")
56
+ if "use client" in text[:200] or "'use client'" in text[:200] or '"use client"' in text[:200]:
57
+ for s in SERVER_SECRET.findall(text):
58
+ server_secret_in_client.append(f"{s} ({rel})")
59
+ client_reachable = bool(PUBLIC_ENV.search(text)) or "use client" in text[:400]
60
+ for rx, label, always in SECRET_SHAPES:
61
+ if (always or client_reachable) and rx.search(text):
62
+ public_value_leaks.append(f"{label} ({rel})")
63
+ for m in CFN_TO_PUBLIC.finditer(text):
64
+ public_var_from_cfn.append(f"{m.group(1)} ← {m.group(2)} ({rel})")
65
+
66
+ nextcfg = (ctx.manifest("next.config.js") + ctx.manifest("next.config.mjs")
67
+ + ctx.manifest("next.config.ts"))
68
+ sourcemaps = "productionBrowserSourceMaps: true" in nextcfg
69
+
70
+ return {
71
+ "public_env_vars": sorted(public_vars)[:40],
72
+ "public_secret_leaks": sorted(set(public_secret_leaks)), # HIGH if non-empty
73
+ "server_secret_in_client_component": sorted(set(server_secret_in_client)), # HIGH if non-empty
74
+ "public_secret_value_leaks": sorted(set(public_value_leaks)), # HIGH — value-detected, rename-proof
75
+ "public_var_from_cfn_output": sorted(set(public_var_from_cfn)), # HIGH — CDK build-injected to client
76
+ "production_source_maps": sourcemaps,
77
+ "note": "public_secret_leaks / server_secret_in_client_component / public_secret_value_leaks / "
78
+ "public_var_from_cfn_output ship secrets to the browser — treat as HIGH and confirm. "
79
+ "Value/CFN-injection detection survives a benign var rename (the #3 gap). Plain "
80
+ "NEXT_PUBLIC_* without a secret name/value/CFN-wire are usually fine.",
81
+ }
@@ -0,0 +1,126 @@
1
+ """Client-integrity / tamperable-display extractor — the man-in-the-browser (MITB) class.
2
+
3
+ This is the agent-wallet lesson, generalized. When an app renders a **security-critical value**
4
+ whose tampering redirects money — a wallet/receive address, a payment routing/account number, the
5
+ QR that encodes it — that on-screen value is rewritable by code running in the victim's own browser
6
+ (malware, a rogue extension, or a poisoned JS dependency in the app's own bundle). No web app can
7
+ make on-screen display cryptographically tamper-proof; that's an inherent limit of the platform
8
+ (it's why hardware wallets exist), accepted by Coinbase/MetaMask/banks alike.
9
+
10
+ So this is deliberately a **LOW-confidence, architectural** flag, not a deterministic vuln. It can't
11
+ prove tampering is possible; it checks whether the two controls that actually move the needle are
12
+ present — and says so honestly:
13
+
14
+ Layer A (kill the SCALABLE vector): a strict Content-Security-Policy (`script-src 'self'` + a
15
+ nonce, no `unsafe-inline` / `unsafe-eval`) so an injected / supply-chain script can't run.
16
+ Layer B (anchor trust OFF the browser surface): an out-of-band verification path — emailed
17
+ canonical address, a short safety code / fingerprint, a server-rendered identicon, an
18
+ EIP-55 checksum — so a single-surface tamper is at least *detectable* by the user.
19
+
20
+ A sensitive-display app missing A and/or B gets a flag pointing at exactly those layers. This is
21
+ NOT a "your app is broken" claim — it's a "verify these compensating controls" lead for the agent.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import re
27
+
28
+ from .base import Extractor, RepoContext
29
+
30
+ # A value whose on-screen tampering redirects funds (the gate — financial/address-class signal).
31
+ SENSITIVE_VALUE = re.compile(
32
+ r"\b(?:wallet|receive|receiving|deposit|recipient|payout|beneficiary|payment|destination)[_-]?address\b"
33
+ r"|\bwalletAddress\b|\btoAddress\b|\bpayTo\b|\brouting[_-]?number\b|\baccount[_-]?number\b|\biban\b"
34
+ r"|\b0x[0-9a-fA-F]{40}\b|crypto.{0,12}address|blockchain.{0,12}address", re.I)
35
+ QR_SIGNAL = re.compile(r"\bqrcode\b|\bQRCode\b|react-qr|qrcode\.react|qr-code|toDataURL\(", re.I)
36
+ CLIPBOARD = re.compile(r"navigator\.clipboard|clipboard\.writeText|copyToClipboard|useCopyToClipboard|writeText\(")
37
+ CLIENT_MARKER = re.compile(r"['\"]use client['\"]|from\s+['\"]react|next/|\.tsx['\"]?|document\.|window\.")
38
+
39
+ # Layer A — strict CSP detection
40
+ CSP_PRESENT = re.compile(r"Content-Security-Policy|contentSecurityPolicy", re.I)
41
+ CSP_SCRIPT_SELF = re.compile(r"script-src[^;'\"]*'self'", re.I)
42
+ CSP_NONCE = re.compile(r"'nonce-|nonce-\$\{|\bstrict-dynamic\b", re.I)
43
+ CSP_UNSAFE = re.compile(r"'unsafe-(?:inline|eval)'", re.I)
44
+
45
+ # Layer B — out-of-band trust anchor detection
46
+ OOB_ANCHOR = re.compile(
47
+ r"safety[_-]?code|safetyCode|fingerprint|identicon|blockie|jazzicon|emoji[_-]?code"
48
+ r"|out[_-]of[_-]band|toChecksumAddress|getAddress\(|checksumAddress|\beip[_-]?55\b|verifyAddress"
49
+ r"|address[_-]?verif|verif\w*[_-]?address|sendVerificationEmail|canonical[_-]?address", re.I)
50
+
51
+
52
+ class ClientIntegrityExtractor(Extractor):
53
+ name = "client_integrity"
54
+ category = "exposure"
55
+
56
+ def extract(self, ctx: RepoContext, facts: dict) -> dict:
57
+ sensitive, qr_files, clip_files = [], [], []
58
+ csp_present = csp_self = csp_nonce = csp_unsafe = False
59
+ oob = []
60
+ for _p, rel, text in ctx.iter_code():
61
+ if SENSITIVE_VALUE.search(text):
62
+ if len(sensitive) < 30:
63
+ sensitive.append(rel)
64
+ if QR_SIGNAL.search(text) and len(qr_files) < 30:
65
+ qr_files.append(rel)
66
+ if CLIPBOARD.search(text) and len(clip_files) < 30:
67
+ clip_files.append(rel)
68
+ if CSP_PRESENT.search(text):
69
+ csp_present = True
70
+ if CSP_SCRIPT_SELF.search(text):
71
+ csp_self = True
72
+ if CSP_NONCE.search(text):
73
+ csp_nonce = True
74
+ if CSP_UNSAFE.search(text):
75
+ csp_unsafe = True
76
+ if OOB_ANCHOR.search(text) and len(oob) < 20:
77
+ oob.append(rel)
78
+
79
+ # strict = a real `script-src 'self'` (+ a nonce / strict-dynamic) with NO unsafe-inline/eval
80
+ strict_csp = bool(csp_present and csp_self and csp_nonce and not csp_unsafe)
81
+ out_of_band = bool(oob)
82
+
83
+ findings = []
84
+ present = bool(sensitive)
85
+ if present:
86
+ shown = ", ".join(sorted(set(sensitive))[:5])
87
+ if not strict_csp:
88
+ why = ("no Content-Security-Policy found" if not csp_present
89
+ else "CSP allows 'unsafe-inline'/'unsafe-eval' in script-src" if csp_unsafe
90
+ else "CSP present but not a strict script-src 'self' + nonce policy")
91
+ findings.append({
92
+ "severity": "MEDIUM", "confidence": "LOW", "attack_class": "tamperable-display",
93
+ "issue": "security-critical value rendered client-side without a strict CSP",
94
+ "detail": f"This app renders a fund-redirecting value ({shown}) but {why}. A poisoned "
95
+ "dependency or injected script (man-in-the-browser) can then rewrite the "
96
+ "displayed/copied address or swap the QR for EVERY user at once (the scalable "
97
+ "vector). Add Layer A: `script-src 'self'` + per-request nonce + `strict-dynamic`, "
98
+ "no unsafe-inline/eval, object-src 'none'. (Ship report-only first to avoid "
99
+ "breaking wallet SDKs, then enforce.)"})
100
+ if not out_of_band:
101
+ findings.append({
102
+ "severity": "LOW", "confidence": "LOW", "attack_class": "tamperable-display",
103
+ "issue": "no out-of-band trust anchor for the displayed address",
104
+ "detail": f"No second, browser-independent source of truth was found for {shown} "
105
+ "(emailed canonical address, a short safety code / fingerprint, a server-rendered "
106
+ "identicon, or an EIP-55 checksum). Without one, a single-surface tamper is "
107
+ "undetectable by the user. Add Layer B: anchor trust OFF the browser surface so "
108
+ "the user can cross-check. NOTE: on-screen display can never be made "
109
+ "cryptographically tamper-proof on the web — the goal is detectable, not "
110
+ "impossible (the limit that hardware wallets exist to solve)."})
111
+
112
+ return {
113
+ "sensitive_display": sorted(set(sensitive)),
114
+ "qr_generation": sorted(set(qr_files)),
115
+ "clipboard_copy": sorted(set(clip_files)),
116
+ "strict_csp": strict_csp,
117
+ "csp_present": csp_present,
118
+ "csp_has_unsafe": csp_unsafe,
119
+ "out_of_band_anchor": out_of_band,
120
+ "anchors_found": sorted(set(oob)),
121
+ "findings": findings,
122
+ "note": ("Renders fund-redirecting value(s) — review man-in-the-browser exposure: strict CSP (kill the "
123
+ "scalable vector) + an out-of-band anchor (make tamper detectable). This is the inherent "
124
+ "web-platform limit; treat as architectural, LOW-confidence." if present else
125
+ "No security-critical display values detected — MITB/tamperable-display class N/A."),
126
+ }