websec-validator 0.4.2__tar.gz → 0.6.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.4.2/src/websec_validator.egg-info → websec_validator-0.6.0}/PKG-INFO +8 -7
- {websec_validator-0.4.2 → websec_validator-0.6.0}/README.md +7 -6
- {websec_validator-0.4.2 → websec_validator-0.6.0}/pyproject.toml +1 -1
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/briefing.py +2 -0
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/constitution.py +1 -1
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/extractors/__init__.py +2 -0
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/extractors/auth.py +24 -5
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/extractors/base.py +2 -2
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/extractors/client_exposure.py +16 -5
- websec_validator-0.6.0/src/websec_validator/extractors/client_integrity.py +272 -0
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/extractors/graphql.py +10 -5
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/extractors/iac_ci.py +2 -2
- websec_validator-0.6.0/src/websec_validator/extractors/integrations.py +129 -0
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/extractors/pii_exposure.py +4 -4
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/extractors/policy_consistency.py +2 -2
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/extractors/routes.py +63 -5
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/extractors/stack.py +29 -0
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/extractors/surface.py +10 -3
- websec_validator-0.6.0/src/websec_validator/extractors/transport_security.py +168 -0
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/extractors/upload_security.py +2 -2
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/findings.py +61 -8
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/probes.py +21 -8
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/rules/error-stack-disclosure.yml +1 -1
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/rules/insecure-default-secret.yml +1 -1
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/scanners.py +34 -1
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/templates/probes/appsync-cswsh.sh +1 -1
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/templates/probes/appsync-introspection.sh +1 -1
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/templates/probes/appsync-subscription-bola.sh +1 -1
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/templates/probes/error-disclosure-probe.sh +1 -1
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/templates/probes/password-reuse.sh +1 -1
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/templates/probes/pii-output-diff.sh +2 -2
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/templates/probes/upload-matrix.sh +1 -1
- {websec_validator-0.4.2 → websec_validator-0.6.0/src/websec_validator.egg-info}/PKG-INFO +8 -7
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator.egg-info/SOURCES.txt +1 -0
- {websec_validator-0.4.2 → websec_validator-0.6.0}/tests/test_hardening.py +11 -3
- {websec_validator-0.4.2 → websec_validator-0.6.0}/tests/test_pentest_regressions.py +246 -3
- {websec_validator-0.4.2 → websec_validator-0.6.0}/tests/test_recon.py +37 -0
- websec_validator-0.4.2/src/websec_validator/extractors/client_integrity.py +0 -158
- websec_validator-0.4.2/src/websec_validator/extractors/integrations.py +0 -61
- {websec_validator-0.4.2 → websec_validator-0.6.0}/LICENSE +0 -0
- {websec_validator-0.4.2 → websec_validator-0.6.0}/setup.cfg +0 -0
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/__init__.py +0 -0
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/calibration.json +0 -0
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/calibration.py +0 -0
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/cli.py +0 -0
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/corpus.json +0 -0
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/dynamic.py +0 -0
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/extractors/authz.py +0 -0
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/extractors/schemas.py +0 -0
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/extractors/tenant.py +0 -0
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/proof.py +0 -0
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/recon.py +0 -0
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/report.py +0 -0
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/templates/probes/_lib.py +0 -0
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/templates/probes/bola-cross-tenant.sh +0 -0
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/templates/probes/bola-write-verbs.py +0 -0
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/templates/probes/client-integrity-checklist.sh +0 -0
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/templates/probes/compare-roles.sh +0 -0
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/templates/probes/dlp-bypass-offline.py +0 -0
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/templates/probes/forged-token.sh +0 -0
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/templates/probes/hs256-brute-force.py +0 -0
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/templates/probes/jwt-attacks.sh +0 -0
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/templates/probes/mass-assignment.py +0 -0
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/templates/probes/race-conditions.py +0 -0
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/templates/probes/rate-limit-burst.sh +0 -0
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/templates/probes/s3-assess.sh +0 -0
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/templates/probes/ssrf-probes.sh +0 -0
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/templates/probes/unauth-baseline.sh +0 -0
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/templates/probes/webhook-forgery.py +0 -0
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/templates/reports/FINDINGS-SUMMARY.md.template +0 -0
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/templates/reports/access-control-matrix.md.template +0 -0
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/templates/reports/findings-triage.md.template +0 -0
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/templates/reports/pentest-handover-brief.md.template +0 -0
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/templates/reports/per-tool-FINDINGS.md.template +0 -0
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator.egg-info/dependency_links.txt +0 -0
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator.egg-info/entry_points.txt +0 -0
- {websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: websec-validator
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.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
|
|
@@ -84,7 +84,7 @@ Then point your agent at the output: **"Read `websec-out/AGENT-BRIEFING.md` and
|
|
|
84
84
|
|
|
85
85
|
> 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.
|
|
86
86
|
|
|
87
|
-
## What it extracts (
|
|
87
|
+
## What it extracts (16 deterministic extractors, no LLM)
|
|
88
88
|
|
|
89
89
|
| | Dimension | Notable output |
|
|
90
90
|
|---|---|---|
|
|
@@ -94,15 +94,16 @@ Then point your agent at the output: **"Read `websec-out/AGENT-BRIEFING.md` and
|
|
|
94
94
|
| **authz** | access-control map | guard coverage + **write endpoints with no visible guard** + roles |
|
|
95
95
|
| tenant | multi-tenancy key candidates | the BOLA boundary, by frequency |
|
|
96
96
|
| **password_policy** | cross-route consistency **+ reuse/history** | complexity drift across routes **+ a set-password path that hashes without a reuse check** |
|
|
97
|
-
| surface |
|
|
97
|
+
| surface | 15 sink classes **+ redirect-SSRF** | user-input-gated sinks (incl. **mass-assignment via object spread**) + var-arg SSRF + error-disclosure **+ follows-redirects-without-per-hop-guard** |
|
|
98
98
|
| **upload_security** | unrestricted upload + unsafe serve | deny-list-only, stored-name-from-filename, trust-client-MIME, accept-SVG, **serve without `nosniff`** |
|
|
99
99
|
| schemas | data models + **privileged fields** | Pydantic/SQLAlchemy/Django/Prisma/Mongoose/TypeORM/Zod → `role`/`isAdmin`/`groupId` for mass-assignment targeting |
|
|
100
100
|
| iac_ci | IaC + CI/CD | GHA injection, unpinned actions, tfstate, **CDK AppSync `API_KEY` anonymous-default-auth + WAF-as-control smell** |
|
|
101
101
|
| client_exposure | browser leakage | public-var secrets by **name + value-shape (`da2-…`) + CDK build-injection**, server-secret-in-client, source maps |
|
|
102
|
-
| **client_integrity** | tamperable display + **WS auth model** |
|
|
102
|
+
| **client_integrity** | tamperable display (client trust boundary) + **WS auth model** | any security-critical sink value (address/IBAN/2FA-seed/API-key/webhook) the user reads or copies, without strict CSP / out-of-band anchor **+ client-tamper-vector, grindable-fingerprint, over-claimed-control, the CSWSH determinant** |
|
|
103
|
+
| **transport_security** | CSP + HSTS header baseline | missing/weak CSP, inline event handlers, **partial HSTS (set on /api but not the HTML page)** |
|
|
103
104
|
| **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) |
|
|
104
105
|
| graphql | GraphQL surface | introspection (**AppSync `introspectionConfig: DISABLED`-aware**) / playground / depth-limit **+ AppSync subscription-authz (cross-group BOLA)** |
|
|
105
|
-
| integrations | third-party + webhooks | webhooks
|
|
106
|
+
| integrations | third-party + webhooks **+ outbound-action endpoints** | unsigned webhooks **+ email/SMS/push handlers with no auth or IP-only rate-limit + redundant secret-fetch** |
|
|
106
107
|
|
|
107
108
|
Plus **derived targeting** — IDOR / SSRF / open-redirect / upload / write / auth-endpoint
|
|
108
109
|
candidates — so probes get pointed at the *exact* endpoints, not fired blindly.
|
|
@@ -184,7 +185,7 @@ upload, cross-tenant BOLA, role/authz gaps).
|
|
|
184
185
|
## Tests
|
|
185
186
|
|
|
186
187
|
```bash
|
|
187
|
-
python3 -m unittest discover -s tests # stdlib only, no Noir/network —
|
|
188
|
+
python3 -m unittest discover -s tests # stdlib only, no Noir/network — 135 tests
|
|
188
189
|
```
|
|
189
190
|
|
|
190
191
|
## Releasing (maintainer)
|
|
@@ -216,7 +217,7 @@ managed-AppSync / VTL boundary**, **upload-security** + **PII-output-boundary**
|
|
|
216
217
|
de-dup + **bundled Semgrep rules**, tailored probe staging, agent briefing, traceable findings ledger
|
|
217
218
|
with **calibrated confidence (CJE — Wilson CIs)**, proof harness, test suite, **Docker bundle** (all
|
|
218
219
|
scanners + Noir, arch-aware), **dynamic phase v1** (authenticated read-only cross-tenant BOLA —
|
|
219
|
-
validated live, reproduced a hand-pentest's 14/14). Validated against the **
|
|
220
|
+
validated live, reproduced a hand-pentest's 14/14). Validated against the **REF-PENTEST pen test +
|
|
220
221
|
retest** (incl. correcting two findings the retest disproved: AppSync introspection *is* disablable
|
|
221
222
|
engine-level, and API_KEY-default is anonymous-auth, not CSWSH).
|
|
222
223
|
**Next:** dynamic write-verb BOLA + JWT/auth probes + ZAP/Nuclei two-role diff (gated, they mutate),
|
|
@@ -72,7 +72,7 @@ Then point your agent at the output: **"Read `websec-out/AGENT-BRIEFING.md` and
|
|
|
72
72
|
|
|
73
73
|
> 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.
|
|
74
74
|
|
|
75
|
-
## What it extracts (
|
|
75
|
+
## What it extracts (16 deterministic extractors, no LLM)
|
|
76
76
|
|
|
77
77
|
| | Dimension | Notable output |
|
|
78
78
|
|---|---|---|
|
|
@@ -82,15 +82,16 @@ Then point your agent at the output: **"Read `websec-out/AGENT-BRIEFING.md` and
|
|
|
82
82
|
| **authz** | access-control map | guard coverage + **write endpoints with no visible guard** + roles |
|
|
83
83
|
| tenant | multi-tenancy key candidates | the BOLA boundary, by frequency |
|
|
84
84
|
| **password_policy** | cross-route consistency **+ reuse/history** | complexity drift across routes **+ a set-password path that hashes without a reuse check** |
|
|
85
|
-
| surface |
|
|
85
|
+
| surface | 15 sink classes **+ redirect-SSRF** | user-input-gated sinks (incl. **mass-assignment via object spread**) + var-arg SSRF + error-disclosure **+ follows-redirects-without-per-hop-guard** |
|
|
86
86
|
| **upload_security** | unrestricted upload + unsafe serve | deny-list-only, stored-name-from-filename, trust-client-MIME, accept-SVG, **serve without `nosniff`** |
|
|
87
87
|
| schemas | data models + **privileged fields** | Pydantic/SQLAlchemy/Django/Prisma/Mongoose/TypeORM/Zod → `role`/`isAdmin`/`groupId` for mass-assignment targeting |
|
|
88
88
|
| iac_ci | IaC + CI/CD | GHA injection, unpinned actions, tfstate, **CDK AppSync `API_KEY` anonymous-default-auth + WAF-as-control smell** |
|
|
89
89
|
| client_exposure | browser leakage | public-var secrets by **name + value-shape (`da2-…`) + CDK build-injection**, server-secret-in-client, source maps |
|
|
90
|
-
| **client_integrity** | tamperable display + **WS auth model** |
|
|
90
|
+
| **client_integrity** | tamperable display (client trust boundary) + **WS auth model** | any security-critical sink value (address/IBAN/2FA-seed/API-key/webhook) the user reads or copies, without strict CSP / out-of-band anchor **+ client-tamper-vector, grindable-fingerprint, over-claimed-control, the CSWSH determinant** |
|
|
91
|
+
| **transport_security** | CSP + HSTS header baseline | missing/weak CSP, inline event handlers, **partial HSTS (set on /api but not the HTML page)** |
|
|
91
92
|
| **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) |
|
|
92
93
|
| graphql | GraphQL surface | introspection (**AppSync `introspectionConfig: DISABLED`-aware**) / playground / depth-limit **+ AppSync subscription-authz (cross-group BOLA)** |
|
|
93
|
-
| integrations | third-party + webhooks | webhooks
|
|
94
|
+
| integrations | third-party + webhooks **+ outbound-action endpoints** | unsigned webhooks **+ email/SMS/push handlers with no auth or IP-only rate-limit + redundant secret-fetch** |
|
|
94
95
|
|
|
95
96
|
Plus **derived targeting** — IDOR / SSRF / open-redirect / upload / write / auth-endpoint
|
|
96
97
|
candidates — so probes get pointed at the *exact* endpoints, not fired blindly.
|
|
@@ -172,7 +173,7 @@ upload, cross-tenant BOLA, role/authz gaps).
|
|
|
172
173
|
## Tests
|
|
173
174
|
|
|
174
175
|
```bash
|
|
175
|
-
python3 -m unittest discover -s tests # stdlib only, no Noir/network —
|
|
176
|
+
python3 -m unittest discover -s tests # stdlib only, no Noir/network — 135 tests
|
|
176
177
|
```
|
|
177
178
|
|
|
178
179
|
## Releasing (maintainer)
|
|
@@ -204,7 +205,7 @@ managed-AppSync / VTL boundary**, **upload-security** + **PII-output-boundary**
|
|
|
204
205
|
de-dup + **bundled Semgrep rules**, tailored probe staging, agent briefing, traceable findings ledger
|
|
205
206
|
with **calibrated confidence (CJE — Wilson CIs)**, proof harness, test suite, **Docker bundle** (all
|
|
206
207
|
scanners + Noir, arch-aware), **dynamic phase v1** (authenticated read-only cross-tenant BOLA —
|
|
207
|
-
validated live, reproduced a hand-pentest's 14/14). Validated against the **
|
|
208
|
+
validated live, reproduced a hand-pentest's 14/14). Validated against the **REF-PENTEST pen test +
|
|
208
209
|
retest** (incl. correcting two findings the retest disproved: AppSync introspection *is* disablable
|
|
209
210
|
engine-level, and API_KEY-default is anonymous-auth, not CSWSH).
|
|
210
211
|
**Next:** dynamic write-verb BOLA + JWT/auth probes + ZAP/Nuclei two-role diff (gated, they mutate),
|
|
@@ -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.6.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"
|
|
@@ -157,6 +157,7 @@ credentials** — ask the human, never fabricate, never hit production.
|
|
|
157
157
|
- **Auth scheme:** `{auth.get("scheme","?")}` (token in {auth.get("token_location","?")}) · guard files: {len(auth.get("guard_files", []))}
|
|
158
158
|
- **Route engine:** {routes.get("engine","?")} · **{routes.get('count',0)} endpoints** · by method: {routes.get("by_method", {})}
|
|
159
159
|
{("> " + routes["note"]) if routes.get("note") else ""}
|
|
160
|
+
{("> " + routes["coverage_warning"]) if routes.get("coverage_warning") else ""}
|
|
160
161
|
|
|
161
162
|
## 2. ★ Tenant boundary (confirm first — highest value, easiest to get wrong)
|
|
162
163
|
|
|
@@ -167,6 +168,7 @@ credentials** — ask the human, never fabricate, never hit production.
|
|
|
167
168
|
|
|
168
169
|
## 3. ★ Attack surface & targeting (point the probes HERE)
|
|
169
170
|
|
|
171
|
+
{"_⚠ route discovery may be INCOMPLETE (see §1) — treat empty lists below as 'couldn't map', not 'nothing there'._" if routes.get("coverage_warning") else ""}
|
|
170
172
|
{_section("IDOR / BOLA candidates — endpoints with a path/object id", tgt.get("idor_candidates"))}
|
|
171
173
|
{_section("SSRF candidates — endpoints taking a url/domain-ish param", tgt.get("ssrf_candidates"))}
|
|
172
174
|
{_section("Open-redirect candidates", tgt.get("open_redirect_candidates"))}
|
|
@@ -60,7 +60,7 @@ 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,
|
|
63
|
+
# P6 — Signing-secret integrity (forgeable JWT, REF-PENTEST #8)
|
|
64
64
|
for sd in ((facts.get("auth", {}) or {}).get("insecure_secret_defaults", []) or [])[:5]:
|
|
65
65
|
add("Signing-secret integrity", f"Given the signing-secret env var is unset, When the app boots, Then it "
|
|
66
66
|
f"FAILS CLOSED — no hard-coded fallback ({sd.get('literal')!r} in {sd.get('file')})",
|
{websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/extractors/__init__.py
RENAMED
|
@@ -24,6 +24,7 @@ from .schemas import SchemasExtractor
|
|
|
24
24
|
from .stack import StackExtractor
|
|
25
25
|
from .surface import SurfaceExtractor
|
|
26
26
|
from .tenant import TenantExtractor
|
|
27
|
+
from .transport_security import TransportSecurityExtractor
|
|
27
28
|
from .upload_security import UploadSecurityExtractor
|
|
28
29
|
|
|
29
30
|
# Order matters: stack first (others read facts['stack']); authz after routes
|
|
@@ -41,6 +42,7 @@ REGISTRY: list[Extractor] = [
|
|
|
41
42
|
IacCiExtractor(),
|
|
42
43
|
ClientExposureExtractor(),
|
|
43
44
|
ClientIntegrityExtractor(),
|
|
45
|
+
TransportSecurityExtractor(),
|
|
44
46
|
PiiExposureExtractor(),
|
|
45
47
|
GraphQLExtractor(),
|
|
46
48
|
IntegrationsExtractor(),
|
|
@@ -16,6 +16,11 @@ JWT_LIBS = re.compile(r"jsonwebtoken|\bjose\b|\bPyJWT\b|import\s+jwt\b|get_jwt_i
|
|
|
16
16
|
PASSPORT = re.compile(r"\bpassport\b|passport-jwt|passport-local")
|
|
17
17
|
SESSION = re.compile(r"express-session|cookie-session|iron-session|flask\.session|request\.session|getServerSession|getToken", re.I)
|
|
18
18
|
APIKEY = re.compile(r"x-api-key|api[_-]?key|apikey", re.I)
|
|
19
|
+
# HMAC-signed session cookies / payloads — the app authenticates by VERIFYING an HMAC (common in
|
|
20
|
+
# Cloudflare Workers / edge via crypto.subtle.sign/verify), NOT a JWT. Without this the scheme
|
|
21
|
+
# misreads as "api-key" and JWT-specific probes get staged for a no-JWT app (P2).
|
|
22
|
+
HMAC_AUTH = re.compile(r"crypto\.subtle\.(?:sign|verify)|createHmac|\bhmac\.new\b|compare_digest"
|
|
23
|
+
r"|timingSafeEqual|HMAC-SHA", re.I)
|
|
19
24
|
GUARDS = re.compile(r"requireAuth|requirePermission|requireRole|isAuthenticated|@login_required|@require|ensureAuth|withAuth|getServerSession|verifyToken|authMiddleware|@roles_required|can\(|ability\.", re.I)
|
|
20
25
|
# Cookie READ sites — the names the app pulls a token/session from. The forged-token probe
|
|
21
26
|
# forges into these (not just Authorization: Bearer) so a cookie-ONLY session app isn't a
|
|
@@ -26,9 +31,13 @@ COOKIE_READ = re.compile(
|
|
|
26
31
|
r"""|\.cookies\.([A-Za-z_][A-Za-z0-9_]{1,63})\b(?!\s*\()""") # req.cookies.X (not a .get()/.set() call)
|
|
27
32
|
_COOKIE_RESERVED = {"get", "set", "getall", "has", "delete", "clear", "tostring",
|
|
28
33
|
"foreach", "entries", "keys", "values", "size", "name", "value", "length"}
|
|
34
|
+
# Any cookie usage at all (set/read/header) — qualifies an HMAC app as cookie-SESSION vs webhook-only,
|
|
35
|
+
# even when the exact cookie name isn't captured by COOKIE_READ (e.g. a Worker parsing the Cookie header).
|
|
36
|
+
COOKIE_PRESENT = re.compile(r"set-cookie|getCookie|setCookie|req\.cookies|\.cookies\b|c\.req\.cookie"
|
|
37
|
+
r"|headers\.get\(\s*['\"]cookie|document\.cookie", re.I)
|
|
29
38
|
|
|
30
39
|
# Insecure DEFAULT signing secret — a hard-coded fallback on a secret/key var (the forgeable-JWT
|
|
31
|
-
# class,
|
|
40
|
+
# class, REF-PENTEST #8). JS/TS: `process.env.JWT_SECRET || 'dev-secret-do-not-use-in-prod'`;
|
|
32
41
|
# Python: os.environ.get('JWT_SECRET', 'dev-secret'). A quoted fallback on a *SECRET/*KEY var is
|
|
33
42
|
# almost never benign — and if it's a dev-ish placeholder AND the repo actually signs JWTs, anyone
|
|
34
43
|
# who reads the source can forge tokens for any user/role.
|
|
@@ -63,11 +72,12 @@ class AuthExtractor(Extractor):
|
|
|
63
72
|
auth_eps = (routes.get("targeting") or {}).get("auth_endpoints", [])
|
|
64
73
|
|
|
65
74
|
# scheme: framework/route signals first, then grep
|
|
66
|
-
jwt = passport = session = apikey = 0
|
|
75
|
+
jwt = passport = session = apikey = hmac = 0
|
|
67
76
|
guard_files = []
|
|
68
77
|
cookie_names: list[str] = []
|
|
69
78
|
secret_defaults: list = [] # (file, literal) hard-coded fallback signing secrets
|
|
70
79
|
jwt_sign_verify = False # does the repo actually sign/verify JWTs?
|
|
80
|
+
cookie_present = False # any cookie usage (qualifies HMAC as cookie-session)
|
|
71
81
|
for _p, rel, text in ctx.iter_code():
|
|
72
82
|
if JWT_LIBS.search(text):
|
|
73
83
|
jwt += 1
|
|
@@ -77,6 +87,8 @@ class AuthExtractor(Extractor):
|
|
|
77
87
|
session += 1
|
|
78
88
|
if APIKEY.search(text):
|
|
79
89
|
apikey += 1
|
|
90
|
+
if HMAC_AUTH.search(text):
|
|
91
|
+
hmac += 1
|
|
80
92
|
if GUARDS.search(text) and len(guard_files) < 25:
|
|
81
93
|
guard_files.append(rel)
|
|
82
94
|
if len(cookie_names) < 20:
|
|
@@ -86,13 +98,15 @@ class AuthExtractor(Extractor):
|
|
|
86
98
|
cookie_names.append(name)
|
|
87
99
|
if JWT_SIGN_VERIFY.search(text):
|
|
88
100
|
jwt_sign_verify = True
|
|
101
|
+
if not cookie_present and COOKIE_PRESENT.search(text):
|
|
102
|
+
cookie_present = True
|
|
89
103
|
if not _looks_like_example(rel):
|
|
90
104
|
for mm in SECRET_DEFAULT_JS.finditer(text):
|
|
91
105
|
secret_defaults.append((rel, mm.group(1)))
|
|
92
106
|
for mm in SECRET_DEFAULT_PY.finditer(text):
|
|
93
107
|
secret_defaults.append((rel, mm.group(1)))
|
|
94
108
|
|
|
95
|
-
# Hard-coded fallback signing secret → forgeable-JWT lead (
|
|
109
|
+
# Hard-coded fallback signing secret → forgeable-JWT lead (REF-PENTEST #8). De-dup by
|
|
96
110
|
# (file, literal); mark dev-ish placeholders. findings.py escalates dev-ish + jwt-in-use to
|
|
97
111
|
# CRITICAL; probes.stage seeds the literal into the hs256 brute-force candidate list.
|
|
98
112
|
seen_sd: set = set()
|
|
@@ -112,11 +126,16 @@ class AuthExtractor(Extractor):
|
|
|
112
126
|
# that also wires Passport for SSO must read as primary=jwt, not passport
|
|
113
127
|
# (Passport is often SSO-only). Priority: nextauth > jwt > session > passport > api-key.
|
|
114
128
|
route_count = len(routes.get("endpoints", []))
|
|
129
|
+
# an HMAC-signed cookie is a real session mechanism — rank it above api-key (an x-api-key may
|
|
130
|
+
# just be a secondary admin header), but below JWT/nextauth.
|
|
131
|
+
hmac_cookie = bool(hmac and (cookie_names or session or cookie_present))
|
|
115
132
|
detected = []
|
|
116
133
|
if nextauth:
|
|
117
134
|
detected.append("nextauth (session JWT in cookie)")
|
|
118
135
|
if jwt:
|
|
119
136
|
detected.append("jwt (bearer)")
|
|
137
|
+
if hmac_cookie:
|
|
138
|
+
detected.append("hmac-signed-cookie")
|
|
120
139
|
if session:
|
|
121
140
|
detected.append("session-cookie")
|
|
122
141
|
if passport:
|
|
@@ -124,7 +143,7 @@ class AuthExtractor(Extractor):
|
|
|
124
143
|
if apikey:
|
|
125
144
|
detected.append("api-key")
|
|
126
145
|
primary = detected[0] if detected else "unknown"
|
|
127
|
-
token_location = ("cookie" if primary.startswith("nextauth"
|
|
146
|
+
token_location = ("cookie" if primary.startswith(("nextauth", "session", "hmac"))
|
|
128
147
|
else "bearer" if primary.startswith("jwt")
|
|
129
148
|
else "header" if primary.startswith("api-key")
|
|
130
149
|
else "cookie-or-bearer" if primary.startswith("passport") else "unknown")
|
|
@@ -136,7 +155,7 @@ class AuthExtractor(Extractor):
|
|
|
136
155
|
"login_endpoints": auth_eps,
|
|
137
156
|
"cookie_names": cookie_names[:15],
|
|
138
157
|
"guard_files": guard_files,
|
|
139
|
-
"signal_counts": {"jwt": jwt, "passport": passport, "session": session, "api_key": apikey},
|
|
158
|
+
"signal_counts": {"jwt": jwt, "passport": passport, "session": session, "api_key": apikey, "hmac": hmac},
|
|
140
159
|
"insecure_secret_defaults": insecure_secret_defaults, # CRITICAL-class (forgeable JWT #8)
|
|
141
160
|
"jwt_sign_verify_present": jwt_sign_verify,
|
|
142
161
|
"route_count": route_count,
|
|
@@ -16,14 +16,14 @@ SKIP_DIRS = {".git", "node_modules", "dist", "build", ".next", ".nuxt", "venv",
|
|
|
16
16
|
".venv", "__pycache__", ".mypy_cache", ".pytest_cache", "coverage",
|
|
17
17
|
".turbo", "out", "target", ".gradle", "vendor", "site-packages",
|
|
18
18
|
".terraform", "security", ".websec-out", "websec-out", ".cache",
|
|
19
|
-
".svelte-kit", "storybook-static", ".serverless",
|
|
19
|
+
".svelte-kit", "storybook-static", ".serverless", ".aws-sam", "cdk.out", ".sst", ".amplify",
|
|
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
23
|
".java", ".php", ".prisma",
|
|
24
24
|
# Managed-cloud surfaces: AppSync GraphQL SDL (@aws_* auth directives) + VTL
|
|
25
25
|
# resolvers (where realtime/subscription authz actually lives, or is missing).
|
|
26
|
-
#
|
|
26
|
+
# REF-PENTEST #2/#5 lived in these file types — previously invisible to every
|
|
27
27
|
# iter_code()-based extractor. routes.py SPEC_PATH still splits .graphql/.gql out
|
|
28
28
|
# of the route list so SDL doesn't generate phantom endpoints.
|
|
29
29
|
".graphql", ".gql", ".vtl"}
|
{websec_validator-0.4.2 → websec_validator-0.6.0}/src/websec_validator/extractors/client_exposure.py
RENAMED
|
@@ -16,21 +16,32 @@ SECRETISH = re.compile(r"SECRET|PRIVATE|TOKEN|PASSWORD|PASSWD|API_?KEY|ACCESS_?K
|
|
|
16
16
|
SERVER_SECRET = re.compile(r"process\.env\.([A-Z0-9_]*(?:SECRET|PRIVATE|TOKEN|PASSWORD|API_?KEY|ACCESS_?KEY)[A-Z0-9_]*)")
|
|
17
17
|
|
|
18
18
|
# VALUE-aware leak detection — hardens the name-based scan above so it survives a benign rename
|
|
19
|
-
# (the
|
|
20
|
-
# We match distinctive secret SHAPES, not var names
|
|
21
|
-
# so
|
|
22
|
-
#
|
|
19
|
+
# (the REF-PENTEST #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 — CLOUD-AGNOSTIC by design (AWS + Azure + GCP +
|
|
21
|
+
# generic), so the same value-leak detector works on a Next.js-on-Vercel, an Azure SWA, or a GCP
|
|
22
|
+
# Firebase app alike. AppSync's `da2-` key has NO scanner rule at all, so we always flag it; the
|
|
23
|
+
# generic shapes (which trivy/gitleaks already catch) are only flagged when the file is
|
|
24
|
+
# client-reachable, to add the ships-to-browser angle without duplicating those scanners.
|
|
23
25
|
SECRET_SHAPES = [
|
|
26
|
+
# AWS
|
|
24
27
|
(re.compile(r"\bda2-[a-z0-9]{26}\b"), "AWS AppSync API key (da2-…)", True),
|
|
25
28
|
(re.compile(r"\bAKIA[0-9A-Z]{16}\b"), "AWS access key id (AKIA)", False),
|
|
29
|
+
# GCP / Google
|
|
26
30
|
(re.compile(r"\bAIza[0-9A-Za-z_\-]{35}\b"), "Google API key (AIza…)", False),
|
|
31
|
+
(re.compile(r"""["']type["']\s*:\s*["']service_account["']"""), "GCP service-account credential JSON", False),
|
|
32
|
+
# Azure
|
|
33
|
+
(re.compile(r"AccountKey=[A-Za-z0-9+/]{86}=="), "Azure Storage account key (AccountKey=…)", False),
|
|
34
|
+
(re.compile(r"DefaultEndpointsProtocol=https;AccountName="), "Azure Storage connection string", False),
|
|
35
|
+
(re.compile(r"[?&]sig=[A-Za-z0-9%/+]{43,}&se="), "Azure SAS token (sig=…&se=…)", False),
|
|
36
|
+
# cloud-neutral
|
|
37
|
+
(re.compile(r"-----BEGIN (?:RSA |EC )?PRIVATE KEY-----"), "Private-key PEM block (TLS / SSH / SA key)", False),
|
|
27
38
|
(re.compile(r"\bsk_live_[0-9A-Za-z]{16,}\b"), "Stripe live secret key (sk_live_…)", False),
|
|
28
39
|
(re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}\b"), "JWT (eyJ…)", False),
|
|
29
40
|
]
|
|
30
41
|
# CDK build-time injection: a CloudFormation output / SSM param / Secret wired INTO a public build
|
|
31
42
|
# var — e.g. CodeBuild `envFromCfnOutputs: { VITE_APPSYNC_API_KEY: appsyncApiKeyOutput }`. Invisible
|
|
32
43
|
# 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
|
|
44
|
+
# mechanism that shipped the AppSync key to the browser in REF-PENTEST #3).
|
|
34
45
|
CFN_TO_PUBLIC = re.compile(
|
|
35
46
|
r"(?:envFromCfnOutputs|buildEnvironment|environmentVariables|partialBuildSpec)"
|
|
36
47
|
r"[\s\S]{0,400}?((?:NEXT_PUBLIC_|VITE_|REACT_APP_|GATSBY_|EXPO_PUBLIC_)\w*)\s*[:=]\s*"
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Client-trust-boundary / tamperable-display extractor — the man-in-the-browser (MITB) class.
|
|
2
|
+
|
|
3
|
+
Generalized from the agent-wallet lesson: when an app renders a **security-critical sink value** —
|
|
4
|
+
ANY value the user ACTS ON by reading or copying, where a silent swap causes irreversible loss or
|
|
5
|
+
misdirection — that on-screen value is rewritable by code running in the victim's own browser
|
|
6
|
+
(malware, a rogue extension, a poisoned JS dependency in the app's own bundle). TLS protects the
|
|
7
|
+
wire, not the DOM.
|
|
8
|
+
|
|
9
|
+
The sink set is deliberately GENERIC and classified by BLAST RADIUS, not by app type — the pen-test
|
|
10
|
+
team's principle: detect by **data-flow role**, never by keyword/category. The same probe that finds
|
|
11
|
+
a swapped crypto address finds a swapped IBAN, a swapped 2FA seed, or a swapped webhook URL. The
|
|
12
|
+
keyword lists below are a STARTING SET, not the whole detector:
|
|
13
|
+
- money-movement : crypto/wallet address, IBAN/routing/account/SWIFT, payee/pay-to → HIGH
|
|
14
|
+
- credential : 2FA/TOTP seed, recovery/mnemonic phrase, private/API/license key → HIGH
|
|
15
|
+
- config/integrity : webhook/callback URL, DNS record, invoice payment instructions → MEDIUM
|
|
16
|
+
Severity tracks IRREVERSIBILITY; confidence stays LOW — this is an architectural "verify the
|
|
17
|
+
compensating controls" lead, never a "your app is broken" claim. No web app can make on-screen
|
|
18
|
+
display cryptographically tamper-proof; that's an inherent platform limit (it's why hardware wallets
|
|
19
|
+
exist), accepted by Coinbase/MetaMask/banks alike.
|
|
20
|
+
|
|
21
|
+
The two controls that actually move the needle:
|
|
22
|
+
Layer A (kill the SCALABLE vector): a strict Content-Security-Policy (`script-src 'self'` + a
|
|
23
|
+
nonce, no `unsafe-inline`/`unsafe-eval`) so an injected/supply-chain script can't run.
|
|
24
|
+
(The framework-agnostic CSP/HSTS *baseline* audit lives in `transport_security.py`.)
|
|
25
|
+
Layer B (anchor trust OFF the browser surface): an out-of-band verification path — emailed
|
|
26
|
+
canonical value, a short safety code / fingerprint, a server-rendered identicon, an
|
|
27
|
+
EIP-55 / IBAN checksum — so a single-surface tamper is at least *detectable*.
|
|
28
|
+
|
|
29
|
+
Also emitted here (same trust boundary):
|
|
30
|
+
- weak-fingerprint : a safety-code/fingerprint truncated to too few bits is grindable offline (#7).
|
|
31
|
+
- overclaimed-control: code or UI copy asserting a CLIENT-SIDE check is "tamper-proof" / "MitB-proof"
|
|
32
|
+
is a genuine finding — it makes teams overtrust a tripwire and under-invest in the real,
|
|
33
|
+
out-of-band/server-side control (#8).
|
|
34
|
+
- cswsh : a WebSocket authenticated via an AMBIENT COOKIE (the CSWSH determinant).
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import re
|
|
40
|
+
|
|
41
|
+
from .base import Extractor, RepoContext
|
|
42
|
+
|
|
43
|
+
# --- Security-critical sink values, classified by blast radius (severity ∝ irreversibility) ---
|
|
44
|
+
SINK_MONEY = re.compile(
|
|
45
|
+
r"\b(?:wallet|receive|receiving|deposit|recipient|payout|beneficiary|payment|destination|payee)[_-]?address\b"
|
|
46
|
+
r"|\bwalletAddress\b|\btoAddress\b|\bpayTo\b|\bpayee\b|\brouting[_-]?number\b|\baccount[_-]?number\b"
|
|
47
|
+
r"|\biban\b|\bswift[_-]?code\b|\bsort[_-]?code\b|\b0x[0-9a-fA-F]{40}\b"
|
|
48
|
+
r"|crypto.{0,12}address|blockchain.{0,12}address", re.I)
|
|
49
|
+
SINK_CREDENTIAL = re.compile(
|
|
50
|
+
r"\b(?:totp|2fa|mfa|authenticator)[_-]?(?:seed|secret|key)\b|\botpauth://"
|
|
51
|
+
r"|\b(?:recovery|seed|mnemonic)[_-]?phrase\b|\bmnemonic\b|\bprivate[_-]?key\b|\brecovery[_-]?code\b"
|
|
52
|
+
r"|\b(?:api|license|licence|access)[_-]?key\b|\bclient[_-]?secret\b", re.I)
|
|
53
|
+
SINK_CONFIG = re.compile(
|
|
54
|
+
r"\bwebhook[_-]?url\b|\bcallback[_-]?url\b|\bdns[_-]?record\b|\bnameserver\b|\bcname[_-]?record\b"
|
|
55
|
+
r"|\binvoice[\s\S]{0,18}(?:account|iban|instructions|number)\b", re.I)
|
|
56
|
+
|
|
57
|
+
# --- Sink-role signals: the value is demonstrably SEEN/COPIED/LINKED (data-flow gate) ---
|
|
58
|
+
QR_SIGNAL = re.compile(r"\bqr[\s_-]?code\b|QRCode|react-qr|qrcode\.react|toDataURL\(", re.I)
|
|
59
|
+
CLIPBOARD = re.compile(r"navigator\.clipboard|clipboard\.writeText|copyToClipboard|useCopyToClipboard"
|
|
60
|
+
r"|writeText\(|execCommand\(\s*['\"]copy")
|
|
61
|
+
HREF_SINK = re.compile(r"href=\{|href=['\"](?:tel:|mailto:|bitcoin:|ethereum:|lightning:)"
|
|
62
|
+
r"|\b(?:to|toAddress|recipient|amount|payee)\s*=\s*\{")
|
|
63
|
+
# #2 — the sink value arrives over a client-side round-trip the browser (and a MitB) can intercept,
|
|
64
|
+
# rather than being server-rendered. A newly-added client fetch for a once-server-rendered value is a
|
|
65
|
+
# regression in itself (manufactures a tamper vector).
|
|
66
|
+
CLIENT_FETCH = re.compile(r"\bfetch\(|\baxios\b|useSWR\b|useQuery\b|useLazyQuery\b|\$\.(?:ajax|get|post)\b"
|
|
67
|
+
r"|XMLHttpRequest|\.get\(['\"]/(?:api|v\d)|graphql\b", re.I)
|
|
68
|
+
|
|
69
|
+
# Layer A — strict CSP detection (kept self-contained; transport_security.py owns the baseline audit)
|
|
70
|
+
CSP_PRESENT = re.compile(r"Content-Security-Policy|contentSecurityPolicy", re.I)
|
|
71
|
+
CSP_SCRIPT_SELF = re.compile(r"script-src[^;'\"]*'self'", re.I)
|
|
72
|
+
CSP_NONCE = re.compile(r"'nonce-|nonce-\$\{|\bstrict-dynamic\b", re.I)
|
|
73
|
+
CSP_UNSAFE = re.compile(r"'unsafe-(?:inline|eval)'", re.I)
|
|
74
|
+
|
|
75
|
+
# Layer B — out-of-band trust anchor detection
|
|
76
|
+
OOB_ANCHOR = re.compile(
|
|
77
|
+
r"safety[_-]?code|safetyCode|fingerprint|identicon|blockie|jazzicon|emoji[_-]?code"
|
|
78
|
+
r"|out[_-]of[_-]band|toChecksumAddress|getAddress\(|checksumAddress|\beip[_-]?55\b|verifyAddress"
|
|
79
|
+
r"|address[_-]?verif|verif\w*[_-]?address|sendVerificationEmail|canonical[_-]?address|mod[_-]?97", re.I)
|
|
80
|
+
|
|
81
|
+
# #7 — a fingerprint / safety-code derived from a TRUNCATED hash is grindable offline. Flag a hash/HMAC
|
|
82
|
+
# sliced to a small char count (hex → 4 bits/char, so .slice(0,12) ≈ 48 bits < the 60-bit floor), or a
|
|
83
|
+
# *code variable sliced short. Heuristic robustness note, not a deterministic vuln.
|
|
84
|
+
WEAK_FINGERPRINT = re.compile(
|
|
85
|
+
r"(?:sha256|sha1|sha512|md5|createHash|createHmac|\bhmac\b|digest)\b[\s\S]{0,90}?"
|
|
86
|
+
r"\.(?:slice|substring|substr)\(\s*0\s*,\s*([1-9]|1[0-4])\b"
|
|
87
|
+
r"|(?:safety|finger|verif|short|otp)[_-]?code\b[\s\S]{0,50}?\.(?:slice|substring|substr)\(\s*0\s*,\s*([1-9]|1[0-4])\b",
|
|
88
|
+
re.I)
|
|
89
|
+
# #8 — dishonest control framing: a CLIENT-side check asserted to be unbeatable. Genuine finding.
|
|
90
|
+
OVERCLAIM = re.compile(
|
|
91
|
+
r"tamper[\s_-]?proof|tamper[\s_-]?resistant|mitb[\s_-]?proof|man-in-the-browser[\s_-]?proof"
|
|
92
|
+
r"|impossible to (?:tamper|forge|fake|modify|intercept)|cryptographically (?:guaranteed|proven|secure)"
|
|
93
|
+
r"|can(?:'|no)?t be (?:tampered|forged|faked|modified|intercepted)|unhackable|100% (?:secure|safe)", re.I)
|
|
94
|
+
|
|
95
|
+
# WebSocket / realtime auth model — the CSWSH determinant (REF-PENTEST #4). CSWSH is only
|
|
96
|
+
# exploitable when the socket authenticates via an AMBIENT COOKIE the browser auto-attaches
|
|
97
|
+
# cross-origin. A token in the connection payload / subprotocol, stored origin-scoped, is NOT
|
|
98
|
+
# exploitable (SOP blocks a cross-origin page from reading it).
|
|
99
|
+
WS_USAGE = re.compile(r"new\s+WebSocket\(|socket\.io|graphql-ws|subscriptions-transport-ws|appsync-realtime"
|
|
100
|
+
r"|\bwss?://", re.I)
|
|
101
|
+
WS_COOKIE_AUTH = re.compile(r"withCredentials\s*:\s*true|credentials\s*:\s*['\"]include['\"]"
|
|
102
|
+
r"|document\.cookie[\s\S]{0,80}?(?:socket|ws\b|websocket)", re.I)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class ClientIntegrityExtractor(Extractor):
|
|
106
|
+
name = "client_integrity"
|
|
107
|
+
category = "exposure"
|
|
108
|
+
|
|
109
|
+
def extract(self, ctx: RepoContext, facts: dict) -> dict:
|
|
110
|
+
sinks: dict[str, str] = {} # rel -> blast radius (money|credential|config)
|
|
111
|
+
qr_files, clip_files = [], []
|
|
112
|
+
csp_present = csp_self = csp_nonce = csp_unsafe = False
|
|
113
|
+
oob, weak_fp, overclaim, tamper_vectors = [], [], [], []
|
|
114
|
+
ws_usage = ws_cookie = False
|
|
115
|
+
for _p, rel, text in ctx.iter_code():
|
|
116
|
+
has_copy = bool(CLIPBOARD.search(text) or QR_SIGNAL.search(text) or HREF_SINK.search(text))
|
|
117
|
+
# genuine browser-DISPLAY surface: a frontend file by extension, an explicit client component,
|
|
118
|
+
# or a known client-framework marker — NOT a backend service/repository that merely references
|
|
119
|
+
# an `account`/`recipient` field (the real-repo FP: backend message processors, SDK models).
|
|
120
|
+
client_file = (rel.lower().endswith((".tsx", ".jsx", ".vue", ".svelte", ".astro", ".html", ".hbs"))
|
|
121
|
+
or "use client" in text[:400] or "@Component(" in text
|
|
122
|
+
or "customElements.define" in text or "LitElement" in text)
|
|
123
|
+
# money sinks are specific on a client surface; the broader credential/config set additionally
|
|
124
|
+
# requires a copy/QR/href signal so a stray `apiKey` reference isn't noise.
|
|
125
|
+
radius = None
|
|
126
|
+
if client_file and SINK_MONEY.search(text):
|
|
127
|
+
radius = "money"
|
|
128
|
+
elif client_file and has_copy and SINK_CREDENTIAL.search(text):
|
|
129
|
+
radius = "credential"
|
|
130
|
+
elif client_file and has_copy and SINK_CONFIG.search(text):
|
|
131
|
+
radius = "config"
|
|
132
|
+
if radius:
|
|
133
|
+
sinks.setdefault(rel, radius)
|
|
134
|
+
if CLIENT_FETCH.search(text): # #2 — sink fed by an interceptable client round-trip
|
|
135
|
+
tamper_vectors.append(rel)
|
|
136
|
+
|
|
137
|
+
if QR_SIGNAL.search(text) and len(qr_files) < 30:
|
|
138
|
+
qr_files.append(rel)
|
|
139
|
+
if CLIPBOARD.search(text) and len(clip_files) < 30:
|
|
140
|
+
clip_files.append(rel)
|
|
141
|
+
if CSP_PRESENT.search(text):
|
|
142
|
+
csp_present = True
|
|
143
|
+
if CSP_SCRIPT_SELF.search(text):
|
|
144
|
+
csp_self = True
|
|
145
|
+
if CSP_NONCE.search(text):
|
|
146
|
+
csp_nonce = True
|
|
147
|
+
if CSP_UNSAFE.search(text):
|
|
148
|
+
csp_unsafe = True
|
|
149
|
+
if OOB_ANCHOR.search(text) and len(oob) < 20:
|
|
150
|
+
oob.append(rel)
|
|
151
|
+
if client_file and WEAK_FINGERPRINT.search(text) and len(weak_fp) < 20:
|
|
152
|
+
weak_fp.append(rel) # client-side safety code only — a backend HMAC truncation is out of scope
|
|
153
|
+
if client_file and OVERCLAIM.search(text) and len(overclaim) < 20:
|
|
154
|
+
overclaim.append(rel)
|
|
155
|
+
if WS_USAGE.search(text):
|
|
156
|
+
ws_usage = True
|
|
157
|
+
if WS_COOKIE_AUTH.search(text):
|
|
158
|
+
ws_cookie = True
|
|
159
|
+
|
|
160
|
+
# strict = a real `script-src 'self'` (+ a nonce / strict-dynamic) with NO unsafe-inline/eval
|
|
161
|
+
strict_csp = bool(csp_present and csp_self and csp_nonce and not csp_unsafe)
|
|
162
|
+
out_of_band = bool(oob)
|
|
163
|
+
ws_cookie_auth = bool(ws_usage and ws_cookie) # the CSWSH determinant (ambient-cookie WS auth)
|
|
164
|
+
|
|
165
|
+
radii = set(sinks.values())
|
|
166
|
+
present = bool(sinks)
|
|
167
|
+
# severity tracks blast radius: a money/credential sink swap is irreversible → HIGH.
|
|
168
|
+
high_blast = bool(radii & {"money", "credential"})
|
|
169
|
+
sev_csp = "HIGH" if high_blast else "MEDIUM"
|
|
170
|
+
sev_oob = "MEDIUM" if high_blast else "LOW"
|
|
171
|
+
|
|
172
|
+
findings = []
|
|
173
|
+
if present:
|
|
174
|
+
shown = ", ".join(sorted(sinks)[:5])
|
|
175
|
+
kinds = "/".join(sorted(radii))
|
|
176
|
+
if not strict_csp:
|
|
177
|
+
why = ("no Content-Security-Policy found" if not csp_present
|
|
178
|
+
else "CSP allows 'unsafe-inline'/'unsafe-eval' in script-src" if csp_unsafe
|
|
179
|
+
else "CSP present but not a strict script-src 'self' + nonce policy")
|
|
180
|
+
findings.append({
|
|
181
|
+
"severity": sev_csp, "confidence": "LOW", "attack_class": "tamperable-display",
|
|
182
|
+
"file": sorted(sinks)[0],
|
|
183
|
+
"issue": "security-critical value rendered client-side without a strict CSP",
|
|
184
|
+
"detail": f"This app renders a {kinds}-class sink value the user reads/copies ({shown}) but "
|
|
185
|
+
f"{why}. A poisoned dependency or injected script (man-in-the-browser) can then "
|
|
186
|
+
"rewrite the displayed/copied value or swap the QR for EVERY user at once (the scalable "
|
|
187
|
+
"vector). Add Layer A: `script-src 'self'` + per-request nonce + `strict-dynamic`, no "
|
|
188
|
+
"unsafe-inline/eval, object-src 'none'. (Ship report-only first to avoid breaking SDKs, "
|
|
189
|
+
"then enforce.) Severity tracks irreversibility — a swapped money/credential value is "
|
|
190
|
+
"unrecoverable."})
|
|
191
|
+
if not out_of_band:
|
|
192
|
+
findings.append({
|
|
193
|
+
"severity": sev_oob, "confidence": "LOW", "attack_class": "tamperable-display",
|
|
194
|
+
"file": sorted(sinks)[0],
|
|
195
|
+
"issue": "no out-of-band trust anchor for the displayed security-critical value",
|
|
196
|
+
"detail": f"No second, browser-independent source of truth was found for {shown} "
|
|
197
|
+
"(emailed canonical value, a short safety code / fingerprint, a server-rendered "
|
|
198
|
+
"identicon, an EIP-55 / IBAN-mod-97 checksum). Without one, a single-surface tamper is "
|
|
199
|
+
"undetectable by the user. Add Layer B: anchor trust OFF the browser surface so the user "
|
|
200
|
+
"can cross-check. NOTE: on-screen display can never be made cryptographically "
|
|
201
|
+
"tamper-proof on the web — the goal is detectable, not impossible."})
|
|
202
|
+
|
|
203
|
+
# #2 — sink value arrives via an interceptable client round-trip (server-render or sign it)
|
|
204
|
+
if present and tamper_vectors:
|
|
205
|
+
findings.append({
|
|
206
|
+
"severity": sev_oob, "confidence": "LOW", "attack_class": "client-tamper-vector",
|
|
207
|
+
"file": sorted(set(tamper_vectors))[0],
|
|
208
|
+
"issue": "security-critical value populated by a client-side fetch (interceptable in the browser)",
|
|
209
|
+
"detail": f"The sink value in {', '.join(sorted(set(tamper_vectors))[:4])} is populated by a client-side "
|
|
210
|
+
"fetch/XHR whose response the browser — and a man-in-the-browser — can intercept and rewrite, "
|
|
211
|
+
"rather than being server-rendered. Prefer server-render; if a round-trip is unavoidable, SIGN "
|
|
212
|
+
"the payload and verify integrity, don't trust raw response fields. A NEWLY-added client "
|
|
213
|
+
"round-trip for a once-server-rendered value is itself a regression."})
|
|
214
|
+
|
|
215
|
+
# #7 — grindable fingerprint/safety-code (robustness note, only meaningful when a sink exists)
|
|
216
|
+
if present and weak_fp:
|
|
217
|
+
findings.append({
|
|
218
|
+
"severity": "LOW", "confidence": "LOW", "attack_class": "weak-fingerprint",
|
|
219
|
+
"file": sorted(set(weak_fp))[0],
|
|
220
|
+
"issue": "safety-code / fingerprint derived from a truncated hash (grindable)",
|
|
221
|
+
"detail": f"A fingerprint/safety-code in {', '.join(sorted(set(weak_fp))[:4])} is a hash/HMAC sliced "
|
|
222
|
+
"to a small character count. ~40-48 bits is brute-forceable on a commodity GPU in hours, so "
|
|
223
|
+
"an attacker can grind a tampered value that yields a MATCHING code. Target >=60 bits, kept "
|
|
224
|
+
"human-comparable (grouped base32, e.g. XXXX-XXXX-XXXX). Verify the slice length / encoding."})
|
|
225
|
+
|
|
226
|
+
# #8 — over-claimed control framing (genuine finding: it manufactures misplaced trust)
|
|
227
|
+
if overclaim:
|
|
228
|
+
findings.append({
|
|
229
|
+
"severity": "LOW", "confidence": "MEDIUM", "attack_class": "overclaimed-control",
|
|
230
|
+
"file": sorted(set(overclaim))[0],
|
|
231
|
+
"issue": "client-side check framed as tamper-proof / cryptographically guaranteed",
|
|
232
|
+
"detail": f"Code or UI copy in {', '.join(sorted(set(overclaim))[:4])} asserts a CLIENT-SIDE control "
|
|
233
|
+
"is tamper-proof / MitB-proof / cryptographically guaranteed. On the web that claim is false "
|
|
234
|
+
"(the DOM is rewritable post-TLS) and it's a real finding: it makes teams and auditors "
|
|
235
|
+
"OVERTRUST a tripwire and under-invest in the actual out-of-band / server-side control. "
|
|
236
|
+
"Scope the claim honestly ('opportunistic tamper tripwire, not a guarantee') and ensure the "
|
|
237
|
+
"trust root is out-of-band or server-side."})
|
|
238
|
+
|
|
239
|
+
# CSWSH is ONLY real when the WS auth is an ambient cookie (REF-PENTEST #4).
|
|
240
|
+
if ws_cookie_auth:
|
|
241
|
+
findings.append({
|
|
242
|
+
"severity": "MEDIUM", "confidence": "LOW", "attack_class": "cswsh",
|
|
243
|
+
"issue": "WebSocket authenticated via an ambient cookie (Cross-Site WebSocket Hijacking)",
|
|
244
|
+
"detail": "A WebSocket/realtime connection appears to authenticate via a cookie "
|
|
245
|
+
"(withCredentials / credentials:'include'), which the browser auto-attaches "
|
|
246
|
+
"cross-origin — so a page on any origin can open an authenticated socket (CSWSH, #4). "
|
|
247
|
+
"Validate the Origin on the handshake, or move the credential into the connection "
|
|
248
|
+
"payload / subprotocol and store it origin-scoped (not a cookie). If WS auth is "
|
|
249
|
+
"already a token in the payload, CSWSH is NOT exploitable."})
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
"sensitive_display": sorted(sinks),
|
|
253
|
+
"sink_blast_radius": dict(sorted(sinks.items())),
|
|
254
|
+
"websocket_auth": ("cookie (CSWSH-exposed — validate Origin)" if ws_cookie_auth
|
|
255
|
+
else "token-or-none (CSWSH not exploitable)" if ws_usage
|
|
256
|
+
else "no websocket detected"),
|
|
257
|
+
"qr_generation": sorted(set(qr_files)),
|
|
258
|
+
"clipboard_copy": sorted(set(clip_files)),
|
|
259
|
+
"strict_csp": strict_csp,
|
|
260
|
+
"csp_present": csp_present,
|
|
261
|
+
"csp_has_unsafe": csp_unsafe,
|
|
262
|
+
"out_of_band_anchor": out_of_band,
|
|
263
|
+
"anchors_found": sorted(set(oob)),
|
|
264
|
+
"weak_fingerprints": sorted(set(weak_fp)),
|
|
265
|
+
"overclaimed_controls": sorted(set(overclaim)),
|
|
266
|
+
"client_fetch_sinks": sorted(set(tamper_vectors)),
|
|
267
|
+
"findings": findings,
|
|
268
|
+
"note": (f"Renders {'/'.join(sorted(radii))}-class security-critical value(s) — review man-in-the-browser "
|
|
269
|
+
"exposure: strict CSP (kill the scalable vector) + an out-of-band anchor (make tamper "
|
|
270
|
+
"detectable). Inherent web-platform limit; treat as architectural, LOW-confidence." if present else
|
|
271
|
+
"No security-critical display values detected — MITB/tamperable-display class N/A."),
|
|
272
|
+
}
|