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.
- {websec_validator-0.2.8/src/websec_validator.egg-info → websec_validator-0.3.0}/PKG-INFO +16 -12
- {websec_validator-0.2.8 → websec_validator-0.3.0}/README.md +15 -11
- {websec_validator-0.2.8 → websec_validator-0.3.0}/pyproject.toml +2 -2
- {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/briefing.py +24 -1
- {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/constitution.py +16 -0
- {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/extractors/__init__.py +4 -0
- {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/extractors/auth.py +58 -2
- {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/extractors/authz.py +23 -0
- {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/extractors/base.py +7 -1
- websec_validator-0.3.0/src/websec_validator/extractors/client_exposure.py +81 -0
- websec_validator-0.3.0/src/websec_validator/extractors/client_integrity.py +126 -0
- websec_validator-0.3.0/src/websec_validator/extractors/graphql.py +146 -0
- {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/extractors/iac_ci.py +41 -0
- websec_validator-0.3.0/src/websec_validator/extractors/policy_consistency.py +125 -0
- {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/extractors/surface.py +16 -0
- websec_validator-0.3.0/src/websec_validator/extractors/tenant.py +42 -0
- {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/findings.py +103 -15
- {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/probes.py +41 -0
- websec_validator-0.3.0/src/websec_validator/rules/error-stack-disclosure.yml +21 -0
- websec_validator-0.3.0/src/websec_validator/rules/insecure-default-secret.yml +25 -0
- {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/scanners.py +16 -0
- websec_validator-0.3.0/src/websec_validator/templates/probes/appsync-cswsh.sh +44 -0
- websec_validator-0.3.0/src/websec_validator/templates/probes/appsync-introspection.sh +42 -0
- websec_validator-0.3.0/src/websec_validator/templates/probes/appsync-subscription-bola.sh +46 -0
- websec_validator-0.3.0/src/websec_validator/templates/probes/client-integrity-checklist.sh +41 -0
- websec_validator-0.3.0/src/websec_validator/templates/probes/error-disclosure-probe.sh +46 -0
- {websec_validator-0.2.8 → websec_validator-0.3.0/src/websec_validator.egg-info}/PKG-INFO +16 -12
- {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator.egg-info/SOURCES.txt +10 -0
- {websec_validator-0.2.8 → websec_validator-0.3.0}/tests/test_hardening.py +58 -0
- websec_validator-0.3.0/tests/test_pentest_regressions.py +251 -0
- websec_validator-0.2.8/src/websec_validator/extractors/client_exposure.py +0 -48
- websec_validator-0.2.8/src/websec_validator/extractors/graphql.py +0 -71
- websec_validator-0.2.8/src/websec_validator/extractors/tenant.py +0 -33
- {websec_validator-0.2.8 → websec_validator-0.3.0}/LICENSE +0 -0
- {websec_validator-0.2.8 → websec_validator-0.3.0}/setup.cfg +0 -0
- {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/__init__.py +0 -0
- {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/calibration.json +0 -0
- {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/calibration.py +0 -0
- {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/cli.py +0 -0
- {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/corpus.json +0 -0
- {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/dynamic.py +0 -0
- {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/extractors/integrations.py +0 -0
- {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/extractors/routes.py +0 -0
- {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/extractors/schemas.py +0 -0
- {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/extractors/stack.py +0 -0
- {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/proof.py +0 -0
- {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/recon.py +0 -0
- {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/report.py +0 -0
- {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/templates/probes/_lib.py +0 -0
- {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/templates/probes/bola-cross-tenant.sh +0 -0
- {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/templates/probes/bola-write-verbs.py +0 -0
- {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/templates/probes/compare-roles.sh +0 -0
- {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/templates/probes/dlp-bypass-offline.py +0 -0
- {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/templates/probes/forged-token.sh +0 -0
- {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/templates/probes/hs256-brute-force.py +0 -0
- {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/templates/probes/jwt-attacks.sh +0 -0
- {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/templates/probes/mass-assignment.py +0 -0
- {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/templates/probes/race-conditions.py +0 -0
- {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/templates/probes/rate-limit-burst.sh +0 -0
- {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/templates/probes/s3-assess.sh +0 -0
- {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/templates/probes/ssrf-probes.sh +0 -0
- {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/templates/probes/unauth-baseline.sh +0 -0
- {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/templates/probes/webhook-forgery.py +0 -0
- {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/templates/reports/FINDINGS-SUMMARY.md.template +0 -0
- {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/templates/reports/access-control-matrix.md.template +0 -0
- {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/templates/reports/findings-triage.md.template +0 -0
- {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/templates/reports/pentest-handover-brief.md.template +0 -0
- {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/templates/reports/per-tool-FINDINGS.md.template +0 -0
- {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator.egg-info/dependency_links.txt +0 -0
- {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator.egg-info/entry_points.txt +0 -0
- {websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator.egg-info/top_level.txt +0 -0
- {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.
|
|
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 (
|
|
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
|
|
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
|
-
|
|
|
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 |
|
|
97
|
-
| client_exposure | browser leakage |
|
|
98
|
-
|
|
|
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:**
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
+
secrets — and 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 (
|
|
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
|
|
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
|
-
|
|
|
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 |
|
|
85
|
-
| client_exposure | browser leakage |
|
|
86
|
-
|
|
|
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:**
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
+
secrets — and 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.
|
|
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
|
|
{websec_validator-0.2.8 → websec_validator-0.3.0}/src/websec_validator/extractors/__init__.py
RENAMED
|
@@ -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
|
-
"
|
|
92
|
-
|
|
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
|
+
}
|