websec-validator 0.2.9__tar.gz → 0.4.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 (77) hide show
  1. {websec_validator-0.2.9/src/websec_validator.egg-info → websec_validator-0.4.0}/PKG-INFO +20 -12
  2. {websec_validator-0.2.9 → websec_validator-0.4.0}/README.md +19 -11
  3. {websec_validator-0.2.9 → websec_validator-0.4.0}/pyproject.toml +2 -2
  4. {websec_validator-0.2.9 → websec_validator-0.4.0}/src/websec_validator/briefing.py +46 -1
  5. {websec_validator-0.2.9 → websec_validator-0.4.0}/src/websec_validator/constitution.py +16 -0
  6. {websec_validator-0.2.9 → websec_validator-0.4.0}/src/websec_validator/extractors/__init__.py +8 -0
  7. {websec_validator-0.2.9 → websec_validator-0.4.0}/src/websec_validator/extractors/auth.py +50 -0
  8. {websec_validator-0.2.9 → websec_validator-0.4.0}/src/websec_validator/extractors/base.py +7 -1
  9. websec_validator-0.4.0/src/websec_validator/extractors/client_exposure.py +81 -0
  10. websec_validator-0.4.0/src/websec_validator/extractors/client_integrity.py +158 -0
  11. websec_validator-0.4.0/src/websec_validator/extractors/graphql.py +163 -0
  12. websec_validator-0.4.0/src/websec_validator/extractors/iac_ci.py +121 -0
  13. websec_validator-0.4.0/src/websec_validator/extractors/pii_exposure.py +98 -0
  14. websec_validator-0.4.0/src/websec_validator/extractors/policy_consistency.py +163 -0
  15. {websec_validator-0.2.9 → websec_validator-0.4.0}/src/websec_validator/extractors/surface.py +29 -0
  16. websec_validator-0.4.0/src/websec_validator/extractors/upload_security.py +89 -0
  17. {websec_validator-0.2.9 → websec_validator-0.4.0}/src/websec_validator/findings.py +138 -10
  18. {websec_validator-0.2.9 → websec_validator-0.4.0}/src/websec_validator/probes.py +55 -0
  19. websec_validator-0.4.0/src/websec_validator/rules/error-stack-disclosure.yml +21 -0
  20. websec_validator-0.4.0/src/websec_validator/rules/insecure-default-secret.yml +25 -0
  21. {websec_validator-0.2.9 → websec_validator-0.4.0}/src/websec_validator/scanners.py +16 -0
  22. websec_validator-0.4.0/src/websec_validator/templates/probes/appsync-cswsh.sh +44 -0
  23. websec_validator-0.4.0/src/websec_validator/templates/probes/appsync-introspection.sh +42 -0
  24. websec_validator-0.4.0/src/websec_validator/templates/probes/appsync-subscription-bola.sh +46 -0
  25. websec_validator-0.4.0/src/websec_validator/templates/probes/client-integrity-checklist.sh +41 -0
  26. websec_validator-0.4.0/src/websec_validator/templates/probes/error-disclosure-probe.sh +46 -0
  27. websec_validator-0.4.0/src/websec_validator/templates/probes/password-reuse.sh +40 -0
  28. websec_validator-0.4.0/src/websec_validator/templates/probes/pii-output-diff.sh +48 -0
  29. websec_validator-0.4.0/src/websec_validator/templates/probes/upload-matrix.sh +44 -0
  30. {websec_validator-0.2.9 → websec_validator-0.4.0/src/websec_validator.egg-info}/PKG-INFO +20 -12
  31. {websec_validator-0.2.9 → websec_validator-0.4.0}/src/websec_validator.egg-info/SOURCES.txt +15 -0
  32. websec_validator-0.4.0/tests/test_pentest_regressions.py +375 -0
  33. websec_validator-0.2.9/src/websec_validator/extractors/client_exposure.py +0 -48
  34. websec_validator-0.2.9/src/websec_validator/extractors/graphql.py +0 -71
  35. websec_validator-0.2.9/src/websec_validator/extractors/iac_ci.py +0 -65
  36. {websec_validator-0.2.9 → websec_validator-0.4.0}/LICENSE +0 -0
  37. {websec_validator-0.2.9 → websec_validator-0.4.0}/setup.cfg +0 -0
  38. {websec_validator-0.2.9 → websec_validator-0.4.0}/src/websec_validator/__init__.py +0 -0
  39. {websec_validator-0.2.9 → websec_validator-0.4.0}/src/websec_validator/calibration.json +0 -0
  40. {websec_validator-0.2.9 → websec_validator-0.4.0}/src/websec_validator/calibration.py +0 -0
  41. {websec_validator-0.2.9 → websec_validator-0.4.0}/src/websec_validator/cli.py +0 -0
  42. {websec_validator-0.2.9 → websec_validator-0.4.0}/src/websec_validator/corpus.json +0 -0
  43. {websec_validator-0.2.9 → websec_validator-0.4.0}/src/websec_validator/dynamic.py +0 -0
  44. {websec_validator-0.2.9 → websec_validator-0.4.0}/src/websec_validator/extractors/authz.py +0 -0
  45. {websec_validator-0.2.9 → websec_validator-0.4.0}/src/websec_validator/extractors/integrations.py +0 -0
  46. {websec_validator-0.2.9 → websec_validator-0.4.0}/src/websec_validator/extractors/routes.py +0 -0
  47. {websec_validator-0.2.9 → websec_validator-0.4.0}/src/websec_validator/extractors/schemas.py +0 -0
  48. {websec_validator-0.2.9 → websec_validator-0.4.0}/src/websec_validator/extractors/stack.py +0 -0
  49. {websec_validator-0.2.9 → websec_validator-0.4.0}/src/websec_validator/extractors/tenant.py +0 -0
  50. {websec_validator-0.2.9 → websec_validator-0.4.0}/src/websec_validator/proof.py +0 -0
  51. {websec_validator-0.2.9 → websec_validator-0.4.0}/src/websec_validator/recon.py +0 -0
  52. {websec_validator-0.2.9 → websec_validator-0.4.0}/src/websec_validator/report.py +0 -0
  53. {websec_validator-0.2.9 → websec_validator-0.4.0}/src/websec_validator/templates/probes/_lib.py +0 -0
  54. {websec_validator-0.2.9 → websec_validator-0.4.0}/src/websec_validator/templates/probes/bola-cross-tenant.sh +0 -0
  55. {websec_validator-0.2.9 → websec_validator-0.4.0}/src/websec_validator/templates/probes/bola-write-verbs.py +0 -0
  56. {websec_validator-0.2.9 → websec_validator-0.4.0}/src/websec_validator/templates/probes/compare-roles.sh +0 -0
  57. {websec_validator-0.2.9 → websec_validator-0.4.0}/src/websec_validator/templates/probes/dlp-bypass-offline.py +0 -0
  58. {websec_validator-0.2.9 → websec_validator-0.4.0}/src/websec_validator/templates/probes/forged-token.sh +0 -0
  59. {websec_validator-0.2.9 → websec_validator-0.4.0}/src/websec_validator/templates/probes/hs256-brute-force.py +0 -0
  60. {websec_validator-0.2.9 → websec_validator-0.4.0}/src/websec_validator/templates/probes/jwt-attacks.sh +0 -0
  61. {websec_validator-0.2.9 → websec_validator-0.4.0}/src/websec_validator/templates/probes/mass-assignment.py +0 -0
  62. {websec_validator-0.2.9 → websec_validator-0.4.0}/src/websec_validator/templates/probes/race-conditions.py +0 -0
  63. {websec_validator-0.2.9 → websec_validator-0.4.0}/src/websec_validator/templates/probes/rate-limit-burst.sh +0 -0
  64. {websec_validator-0.2.9 → websec_validator-0.4.0}/src/websec_validator/templates/probes/s3-assess.sh +0 -0
  65. {websec_validator-0.2.9 → websec_validator-0.4.0}/src/websec_validator/templates/probes/ssrf-probes.sh +0 -0
  66. {websec_validator-0.2.9 → websec_validator-0.4.0}/src/websec_validator/templates/probes/unauth-baseline.sh +0 -0
  67. {websec_validator-0.2.9 → websec_validator-0.4.0}/src/websec_validator/templates/probes/webhook-forgery.py +0 -0
  68. {websec_validator-0.2.9 → websec_validator-0.4.0}/src/websec_validator/templates/reports/FINDINGS-SUMMARY.md.template +0 -0
  69. {websec_validator-0.2.9 → websec_validator-0.4.0}/src/websec_validator/templates/reports/access-control-matrix.md.template +0 -0
  70. {websec_validator-0.2.9 → websec_validator-0.4.0}/src/websec_validator/templates/reports/findings-triage.md.template +0 -0
  71. {websec_validator-0.2.9 → websec_validator-0.4.0}/src/websec_validator/templates/reports/pentest-handover-brief.md.template +0 -0
  72. {websec_validator-0.2.9 → websec_validator-0.4.0}/src/websec_validator/templates/reports/per-tool-FINDINGS.md.template +0 -0
  73. {websec_validator-0.2.9 → websec_validator-0.4.0}/src/websec_validator.egg-info/dependency_links.txt +0 -0
  74. {websec_validator-0.2.9 → websec_validator-0.4.0}/src/websec_validator.egg-info/entry_points.txt +0 -0
  75. {websec_validator-0.2.9 → websec_validator-0.4.0}/src/websec_validator.egg-info/top_level.txt +0 -0
  76. {websec_validator-0.2.9 → websec_validator-0.4.0}/tests/test_hardening.py +0 -0
  77. {websec_validator-0.2.9 → websec_validator-0.4.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.9
3
+ Version: 0.4.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,24 @@ 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 (15 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 consistency **+ reuse/history** | complexity drift across routes **+ a set-password path that hashes without a reuse check** |
95
+ | surface | 14 sink classes **+ redirect-SSRF** | user-input-gated sinks + var-arg SSRF + error-disclosure **+ follows-redirects-without-per-hop-guard** |
96
+ | **upload_security** | unrestricted upload + unsafe serve | deny-list-only, stored-name-from-filename, trust-client-MIME, accept-SVG, **serve without `nosniff`** |
95
97
  | 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 |
98
+ | iac_ci | IaC + CI/CD | GHA injection, unpinned actions, tfstate, **CDK AppSync `API_KEY` anonymous-default-auth + WAF-as-control smell** |
99
+ | client_exposure | browser leakage | public-var secrets by **name + value-shape (`da2-…`) + CDK build-injection**, server-secret-in-client, source maps |
100
+ | **client_integrity** | tamperable display + **WS auth model** | wallet value without strict CSP / out-of-band anchor **+ the CSWSH determinant (ambient-cookie WS auth)** |
101
+ | **pii_exposure** | unmasked PII at the output boundary | `res.json(rawEntity)` with PII + **a masking control defined but with zero live call sites** (value-shape, not field-name) |
102
+ | graphql | GraphQL surface | introspection (**AppSync `introspectionConfig: DISABLED`-aware**) / playground / depth-limit **+ AppSync subscription-authz (cross-group BOLA)** |
99
103
  | integrations | third-party + webhooks | webhooks missing signature verification |
100
104
 
101
105
  Plus **derived targeting** — IDOR / SSRF / open-redirect / upload / write / auth-endpoint
@@ -204,11 +208,15 @@ publisher** with project `websec-validator`, owner `raccioly`, repo `websec-vali
204
208
 
205
209
  ## Status / roadmap
206
210
 
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
- (CJE Wilson 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).
211
+ **Done:** 15-extractor recon (incl. schema/entity → mass-assignment targeting, the **AWS-CDK /
212
+ managed-AppSync / VTL boundary**, **upload-security** + **PII-output-boundary** + **redirect-SSRF**
213
+ + **password-reuse** classes, and a **man-in-the-browser / tamperable-display** class), cross-tool
214
+ de-dup + **bundled Semgrep rules**, tailored probe staging, agent briefing, traceable findings ledger
215
+ with **calibrated confidence (CJE — Wilson CIs)**, proof harness, test suite, **Docker bundle** (all
216
+ scanners + Noir, arch-aware), **dynamic phase v1** (authenticated read-only cross-tenant BOLA —
217
+ validated live, reproduced a hand-pentest's 14/14). Validated against the **PTREQ0013000 pen test +
218
+ retest** (incl. correcting two findings the retest disproved: AppSync introspection *is* disablable
219
+ engine-level, and API_KEY-default is anonymous-auth, not CSWSH).
212
220
  **Next:** dynamic write-verb BOLA + JWT/auth probes + ZAP/Nuclei two-role diff (gated, they mutate),
213
221
  calibration on hand-labeled real repos (more representative base rate), ASVS index lookup, optional
214
222
  model-SDK adapters for no-agent fallback.
@@ -70,20 +70,24 @@ 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 (15 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 consistency **+ reuse/history** | complexity drift across routes **+ a set-password path that hashes without a reuse check** |
83
+ | surface | 14 sink classes **+ redirect-SSRF** | user-input-gated sinks + var-arg SSRF + error-disclosure **+ follows-redirects-without-per-hop-guard** |
84
+ | **upload_security** | unrestricted upload + unsafe serve | deny-list-only, stored-name-from-filename, trust-client-MIME, accept-SVG, **serve without `nosniff`** |
83
85
  | 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 |
86
+ | iac_ci | IaC + CI/CD | GHA injection, unpinned actions, tfstate, **CDK AppSync `API_KEY` anonymous-default-auth + WAF-as-control smell** |
87
+ | client_exposure | browser leakage | public-var secrets by **name + value-shape (`da2-…`) + CDK build-injection**, server-secret-in-client, source maps |
88
+ | **client_integrity** | tamperable display + **WS auth model** | wallet value without strict CSP / out-of-band anchor **+ the CSWSH determinant (ambient-cookie WS auth)** |
89
+ | **pii_exposure** | unmasked PII at the output boundary | `res.json(rawEntity)` with PII + **a masking control defined but with zero live call sites** (value-shape, not field-name) |
90
+ | graphql | GraphQL surface | introspection (**AppSync `introspectionConfig: DISABLED`-aware**) / playground / depth-limit **+ AppSync subscription-authz (cross-group BOLA)** |
87
91
  | integrations | third-party + webhooks | webhooks missing signature verification |
88
92
 
89
93
  Plus **derived targeting** — IDOR / SSRF / open-redirect / upload / write / auth-endpoint
@@ -192,11 +196,15 @@ publisher** with project `websec-validator`, owner `raccioly`, repo `websec-vali
192
196
 
193
197
  ## Status / roadmap
194
198
 
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
- (CJE Wilson 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).
199
+ **Done:** 15-extractor recon (incl. schema/entity → mass-assignment targeting, the **AWS-CDK /
200
+ managed-AppSync / VTL boundary**, **upload-security** + **PII-output-boundary** + **redirect-SSRF**
201
+ + **password-reuse** classes, and a **man-in-the-browser / tamperable-display** class), cross-tool
202
+ de-dup + **bundled Semgrep rules**, tailored probe staging, agent briefing, traceable findings ledger
203
+ with **calibrated confidence (CJE — Wilson CIs)**, proof harness, test suite, **Docker bundle** (all
204
+ scanners + Noir, arch-aware), **dynamic phase v1** (authenticated read-only cross-tenant BOLA —
205
+ validated live, reproduced a hand-pentest's 14/14). Validated against the **PTREQ0013000 pen test +
206
+ retest** (incl. correcting two findings the retest disproved: AppSync introspection *is* disablable
207
+ engine-level, and API_KEY-default is anonymous-auth, not CSWSH).
200
208
  **Next:** dynamic write-verb BOLA + JWT/auth probes + ZAP/Nuclei two-role diff (gated, they mutate),
201
209
  calibration on hand-labeled real repos (more representative base rate), ASVS index lookup, optional
202
210
  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.9"
7
+ version = "0.4.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,41 @@ 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
+ if ((pp.get("password_reuse") or {}).get("gap")):
73
+ pp_line += " · ⚠ NO reuse/history control (#6)"
74
+
75
+ up = facts.get("upload_security", {})
76
+ up_findings = up.get("findings", [])
77
+ up_section = ("\n".join(f"- **{f.get('severity')}** {f.get('kind')} — `{f.get('file')}`" for f in up_findings[:20])
78
+ if up_findings else
79
+ ("_upload handler(s) present; allow-list + nosniff look ok — spot-check_" if up.get("upload_handlers")
80
+ else "_no upload handlers detected_"))
81
+ pii = facts.get("pii_exposure", {})
82
+ pii_findings = pii.get("findings", [])
83
+ pii_section = ("\n".join(f"- **{f.get('severity')}** {f.get('kind')} — `{f.get('file')}`" for f in pii_findings[:20])
84
+ if pii_findings else "_no obvious raw-PII responses / dead masking controls_")
85
+ ws_line = (facts.get("client_integrity", {}) or {}).get("websocket_auth", "no websocket detected")
86
+
55
87
  gql = facts.get("graphql", {})
56
88
  if gql.get("present"):
57
89
  gfind = "; ".join(f"{x['severity']} {x['issue']}" for x in gql.get("findings", [])) or "no obvious issues"
@@ -158,6 +190,19 @@ Production source maps exposed: {client.get("production_source_maps", False)}
158
190
 
159
191
  **GraphQL surface:** {gql_line}
160
192
 
193
+ **Password policy (cross-route consistency):** {pp_line}
194
+
195
+ **Client integrity — man-in-the-browser / tamperable display:**
196
+ {ci_section}
197
+
198
+ **WebSocket auth model (CSWSH determinant — is it an ambient cookie?):** {ws_line}
199
+
200
+ **File-upload security (#2b — sniff bytes, derive stored name, nosniff on serve):**
201
+ {up_section}
202
+
203
+ **PII output boundary (#8 — verify by VALUE SHAPE, not field name):**
204
+ {pii_section}
205
+
161
206
  **Third-party integrations:** {integ_line}
162
207
  {wh_line}
163
208
 
@@ -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,14 +13,18 @@ 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 .pii_exposure import PiiExposureExtractor
21
+ from .policy_consistency import PolicyConsistencyExtractor
19
22
  from .routes import RoutesExtractor
20
23
  from .schemas import SchemasExtractor
21
24
  from .stack import StackExtractor
22
25
  from .surface import SurfaceExtractor
23
26
  from .tenant import TenantExtractor
27
+ from .upload_security import UploadSecurityExtractor
24
28
 
25
29
  # Order matters: stack first (others read facts['stack']); authz after routes
26
30
  # (reads facts['routes']).
@@ -30,10 +34,14 @@ REGISTRY: list[Extractor] = [
30
34
  AuthExtractor(),
31
35
  AuthzExtractor(),
32
36
  TenantExtractor(),
37
+ PolicyConsistencyExtractor(),
33
38
  SurfaceExtractor(),
39
+ UploadSecurityExtractor(),
34
40
  SchemasExtractor(),
35
41
  IacCiExtractor(),
36
42
  ClientExposureExtractor(),
43
+ ClientIntegrityExtractor(),
44
+ PiiExposureExtractor(),
37
45
  GraphQLExtractor(),
38
46
  IntegrationsExtractor(),
39
47
  ]
@@ -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,6 +84,27 @@ 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
 
@@ -89,6 +137,8 @@ class AuthExtractor(Extractor):
89
137
  "cookie_names": cookie_names[:15],
90
138
  "guard_files": guard_files,
91
139
  "signal_counts": {"jwt": jwt, "passport": passport, "session": session, "api_key": apikey},
140
+ "insecure_secret_defaults": insecure_secret_defaults, # CRITICAL-class (forgeable JWT #8)
141
+ "jwt_sign_verify_present": jwt_sign_verify,
92
142
  "route_count": route_count,
93
143
  "reliable_signal": route_count > 0 or bool(nextauth),
94
144
  "note": (("⚠ No HTTP routes detected — this auth scheme is LOW-CONFIDENCE (likely a "
@@ -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,158 @@
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
+ # WebSocket / realtime auth model — the CSWSH determinant (PTREQ0013000 #4). CSWSH is only
52
+ # exploitable when the socket authenticates via an AMBIENT COOKIE the browser auto-attaches
53
+ # cross-origin. A token placed in the connection payload / subprotocol and stored origin-scoped is
54
+ # NOT exploitable (SOP blocks a cross-origin page from reading it). This lets us ANSWER a CSWSH
55
+ # scanner flag instead of guessing — the retest pushed back on exactly this and won.
56
+ WS_USAGE = re.compile(r"new\s+WebSocket\(|socket\.io|graphql-ws|subscriptions-transport-ws|appsync-realtime"
57
+ r"|\bwss?://", re.I)
58
+ WS_COOKIE_AUTH = re.compile(r"withCredentials\s*:\s*true|credentials\s*:\s*['\"]include['\"]"
59
+ r"|document\.cookie[\s\S]{0,80}?(?:socket|ws\b|websocket)", re.I)
60
+
61
+
62
+ class ClientIntegrityExtractor(Extractor):
63
+ name = "client_integrity"
64
+ category = "exposure"
65
+
66
+ def extract(self, ctx: RepoContext, facts: dict) -> dict:
67
+ sensitive, qr_files, clip_files = [], [], []
68
+ csp_present = csp_self = csp_nonce = csp_unsafe = False
69
+ oob = []
70
+ ws_usage = ws_cookie = False
71
+ for _p, rel, text in ctx.iter_code():
72
+ if SENSITIVE_VALUE.search(text):
73
+ if len(sensitive) < 30:
74
+ sensitive.append(rel)
75
+ if QR_SIGNAL.search(text) and len(qr_files) < 30:
76
+ qr_files.append(rel)
77
+ if CLIPBOARD.search(text) and len(clip_files) < 30:
78
+ clip_files.append(rel)
79
+ if CSP_PRESENT.search(text):
80
+ csp_present = True
81
+ if CSP_SCRIPT_SELF.search(text):
82
+ csp_self = True
83
+ if CSP_NONCE.search(text):
84
+ csp_nonce = True
85
+ if CSP_UNSAFE.search(text):
86
+ csp_unsafe = True
87
+ if OOB_ANCHOR.search(text) and len(oob) < 20:
88
+ oob.append(rel)
89
+ if WS_USAGE.search(text):
90
+ ws_usage = True
91
+ if WS_COOKIE_AUTH.search(text):
92
+ ws_cookie = True
93
+
94
+ # strict = a real `script-src 'self'` (+ a nonce / strict-dynamic) with NO unsafe-inline/eval
95
+ strict_csp = bool(csp_present and csp_self and csp_nonce and not csp_unsafe)
96
+ out_of_band = bool(oob)
97
+ ws_cookie_auth = bool(ws_usage and ws_cookie) # the CSWSH determinant (ambient-cookie WS auth)
98
+
99
+ findings = []
100
+ present = bool(sensitive)
101
+ if present:
102
+ shown = ", ".join(sorted(set(sensitive))[:5])
103
+ if not strict_csp:
104
+ why = ("no Content-Security-Policy found" if not csp_present
105
+ else "CSP allows 'unsafe-inline'/'unsafe-eval' in script-src" if csp_unsafe
106
+ else "CSP present but not a strict script-src 'self' + nonce policy")
107
+ findings.append({
108
+ "severity": "MEDIUM", "confidence": "LOW", "attack_class": "tamperable-display",
109
+ "issue": "security-critical value rendered client-side without a strict CSP",
110
+ "detail": f"This app renders a fund-redirecting value ({shown}) but {why}. A poisoned "
111
+ "dependency or injected script (man-in-the-browser) can then rewrite the "
112
+ "displayed/copied address or swap the QR for EVERY user at once (the scalable "
113
+ "vector). Add Layer A: `script-src 'self'` + per-request nonce + `strict-dynamic`, "
114
+ "no unsafe-inline/eval, object-src 'none'. (Ship report-only first to avoid "
115
+ "breaking wallet SDKs, then enforce.)"})
116
+ if not out_of_band:
117
+ findings.append({
118
+ "severity": "LOW", "confidence": "LOW", "attack_class": "tamperable-display",
119
+ "issue": "no out-of-band trust anchor for the displayed address",
120
+ "detail": f"No second, browser-independent source of truth was found for {shown} "
121
+ "(emailed canonical address, a short safety code / fingerprint, a server-rendered "
122
+ "identicon, or an EIP-55 checksum). Without one, a single-surface tamper is "
123
+ "undetectable by the user. Add Layer B: anchor trust OFF the browser surface so "
124
+ "the user can cross-check. NOTE: on-screen display can never be made "
125
+ "cryptographically tamper-proof on the web — the goal is detectable, not "
126
+ "impossible (the limit that hardware wallets exist to solve)."})
127
+
128
+ # CSWSH is ONLY real when the WS auth is an ambient cookie (PTREQ0013000 #4). This lets us
129
+ # answer a CSWSH scanner flag instead of guessing — a bearer token in the payload is not it.
130
+ if ws_cookie_auth:
131
+ findings.append({
132
+ "severity": "MEDIUM", "confidence": "LOW", "attack_class": "cswsh",
133
+ "issue": "WebSocket authenticated via an ambient cookie (Cross-Site WebSocket Hijacking)",
134
+ "detail": "A WebSocket/realtime connection appears to authenticate via a cookie "
135
+ "(withCredentials / credentials:'include'), which the browser auto-attaches "
136
+ "cross-origin — so a page on any origin can open an authenticated socket (CSWSH, #4). "
137
+ "Validate the Origin on the handshake, or move the credential into the connection "
138
+ "payload / subprotocol and store it origin-scoped (not a cookie). If WS auth is "
139
+ "already a token in the payload, CSWSH is NOT exploitable."})
140
+
141
+ return {
142
+ "sensitive_display": sorted(set(sensitive)),
143
+ "websocket_auth": ("cookie (CSWSH-exposed — validate Origin)" if ws_cookie_auth
144
+ else "token-or-none (CSWSH not exploitable)" if ws_usage
145
+ else "no websocket detected"),
146
+ "qr_generation": sorted(set(qr_files)),
147
+ "clipboard_copy": sorted(set(clip_files)),
148
+ "strict_csp": strict_csp,
149
+ "csp_present": csp_present,
150
+ "csp_has_unsafe": csp_unsafe,
151
+ "out_of_band_anchor": out_of_band,
152
+ "anchors_found": sorted(set(oob)),
153
+ "findings": findings,
154
+ "note": ("Renders fund-redirecting value(s) — review man-in-the-browser exposure: strict CSP (kill the "
155
+ "scalable vector) + an out-of-band anchor (make tamper detectable). This is the inherent "
156
+ "web-platform limit; treat as architectural, LOW-confidence." if present else
157
+ "No security-critical display values detected — MITB/tamperable-display class N/A."),
158
+ }