websec-validator 0.7.0__tar.gz → 0.8.1__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.7.0/src/websec_validator.egg-info → websec_validator-0.8.1}/PKG-INFO +11 -8
- {websec_validator-0.7.0 → websec_validator-0.8.1}/README.md +10 -7
- {websec_validator-0.7.0 → websec_validator-0.8.1}/pyproject.toml +1 -1
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/dynamic.py +40 -5
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/extractors/__init__.py +2 -0
- websec_validator-0.8.1/src/websec_validator/extractors/authz_dataflow.py +110 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/extractors/iac_ci.py +1 -1
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/extractors/policy_consistency.py +6 -2
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/extractors/routes.py +5 -1
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/extractors/surface.py +33 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/extractors/transport_security.py +68 -2
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/findings.py +60 -3
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/scanners.py +54 -5
- {websec_validator-0.7.0 → websec_validator-0.8.1/src/websec_validator.egg-info}/PKG-INFO +11 -8
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator.egg-info/SOURCES.txt +1 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/tests/test_hardening.py +56 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/tests/test_pentest_regressions.py +11 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/tests/test_recon.py +129 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/LICENSE +0 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/setup.cfg +0 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/__init__.py +0 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/briefing.py +0 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/calibration.json +0 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/calibration.py +0 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/cli.py +0 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/constitution.py +0 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/corpus.json +0 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/extractors/auth.py +0 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/extractors/authz.py +0 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/extractors/base.py +0 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/extractors/client_exposure.py +0 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/extractors/client_integrity.py +0 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/extractors/crypto_usage.py +0 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/extractors/graphql.py +0 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/extractors/integrations.py +0 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/extractors/llm_security.py +0 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/extractors/pii_exposure.py +0 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/extractors/schemas.py +0 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/extractors/stack.py +0 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/extractors/tenant.py +0 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/extractors/upload_security.py +0 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/probes.py +0 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/proof.py +0 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/recon.py +0 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/report.py +0 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/rules/error-stack-disclosure.yml +0 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/rules/insecure-default-secret.yml +0 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/templates/probes/_lib.py +0 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/templates/probes/appsync-cswsh.sh +0 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/templates/probes/appsync-introspection.sh +0 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/templates/probes/appsync-subscription-bola.sh +0 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/templates/probes/bola-cross-tenant.sh +0 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/templates/probes/bola-write-verbs.py +0 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/templates/probes/client-integrity-checklist.sh +0 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/templates/probes/compare-roles.sh +0 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/templates/probes/dlp-bypass-offline.py +0 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/templates/probes/error-disclosure-probe.sh +0 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/templates/probes/forged-token.sh +0 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/templates/probes/hs256-brute-force.py +0 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/templates/probes/jwt-attacks.sh +0 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/templates/probes/mass-assignment.py +0 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/templates/probes/password-reuse.sh +0 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/templates/probes/pii-output-diff.sh +0 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/templates/probes/race-conditions.py +0 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/templates/probes/rate-limit-burst.sh +0 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/templates/probes/s3-assess.sh +0 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/templates/probes/ssrf-probes.sh +0 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/templates/probes/unauth-baseline.sh +0 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/templates/probes/upload-matrix.sh +0 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/templates/probes/webhook-forgery.py +0 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/templates/reports/FINDINGS-SUMMARY.md.template +0 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/templates/reports/access-control-matrix.md.template +0 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/templates/reports/findings-triage.md.template +0 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/templates/reports/pentest-handover-brief.md.template +0 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/templates/reports/per-tool-FINDINGS.md.template +0 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator.egg-info/dependency_links.txt +0 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator.egg-info/entry_points.txt +0 -0
- {websec_validator-0.7.0 → websec_validator-0.8.1}/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.8.1
|
|
4
4
|
Summary: Defensive, local-first security recon that briefs your AI coding agent on your own codebase — read-only by default (code in, artifacts out): facts + tailored probe scripts, no LLM, no server, no running app.
|
|
5
5
|
Author: Ricardo Accioly
|
|
6
6
|
License: MIT
|
|
@@ -25,7 +25,7 @@ turns a repo into a precise, fact-grounded security brief an AI agent (with a hu
|
|
|
25
25
|
can act on — an auto-filled, repo-aware version of a senior pentester's "here's what to test and
|
|
26
26
|
how" handoff. How it works + the reasoning behind every check: [`docs/METHODOLOGY.md`](docs/METHODOLOGY.md).
|
|
27
27
|
|
|
28
|
-
##
|
|
28
|
+
## Getting Started — just point it at your repo
|
|
29
29
|
|
|
30
30
|
**Simplest: tell your AI agent.** In Claude Code (or any coding agent), open your project and say:
|
|
31
31
|
|
|
@@ -89,23 +89,24 @@ Then point your agent at the output: **"Read `websec-out/AGENT-BRIEFING.md` and
|
|
|
89
89
|
|
|
90
90
|
> 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.
|
|
91
91
|
|
|
92
|
-
## What it extracts (
|
|
92
|
+
## What it extracts (19 deterministic extractors, no LLM)
|
|
93
93
|
|
|
94
94
|
| | Dimension | Notable output |
|
|
95
95
|
|---|---|---|
|
|
96
96
|
| stack | languages, frameworks, datastores | monorepo-aware (aggregates every manifest) |
|
|
97
97
|
| routes | every endpoint via **OWASP Noir** | method · path · typed params · code path |
|
|
98
98
|
| auth | scheme + login surface + **insecure-default signing secrets** | multi-scheme; flags a hard-coded `JWT_SECRET \|\| 'dev-secret'` fallback (forgeable JWT) |
|
|
99
|
-
| **authz** | access-control map | guard coverage + **write endpoints with no visible guard** + roles |
|
|
99
|
+
| **authz** | access-control map | guard coverage (incl. **router-mount auth**) + **write endpoints with no visible guard** + roles |
|
|
100
|
+
| **authz_dataflow** | authz *correctness* (does the guard trust the right thing?) | **unsigned-cookie authorization** · **claim-keyed authz** (user-influenceable JWT claim) · **transaction-local RLS context** (resets before the query) |
|
|
100
101
|
| tenant | multi-tenancy key candidates | the BOLA boundary, by frequency |
|
|
101
102
|
| **password_policy** | cross-route consistency **+ reuse/history** | complexity drift across routes **+ a set-password path that hashes without a reuse check** |
|
|
102
|
-
| surface | 15 sink classes **+ redirect-SSRF** | user-input-gated sinks (incl. **mass-assignment via object spread**) + var-arg SSRF + error-disclosure
|
|
103
|
+
| 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 **+ reverse-proxy prefix-escape + host-header open-redirect + SSRF-redirect-hardening** |
|
|
103
104
|
| **upload_security** | unrestricted upload + unsafe serve | deny-list-only, stored-name-from-filename, trust-client-MIME, accept-SVG, **serve without `nosniff`** |
|
|
104
105
|
| schemas | data models + **privileged fields** | Pydantic/SQLAlchemy/Django/Prisma/Mongoose/TypeORM/Zod → `role`/`isAdmin`/`groupId` for mass-assignment targeting |
|
|
105
106
|
| iac_ci | IaC + CI/CD | GHA injection (**run:-position-aware**), unpinned actions, tfstate, CDK AppSync `API_KEY` anonymous-default-auth, **docker-compose host-takeover (docker.sock / pid:host / privileged) + `.gitleaksignore` secret-suppression audit** |
|
|
106
107
|
| client_exposure | browser leakage | public-var secrets by **name + value-shape (`da2-…`) + CDK build-injection**, server-secret-in-client, source maps |
|
|
107
108
|
| **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** |
|
|
108
|
-
| **transport_security** | CSP + HSTS
|
|
109
|
+
| **transport_security** | CSP + HSTS + **CORS** + **SRI** baseline | missing/weak CSP, inline event handlers, partial HSTS, **CORS reflect-origin+credentials, external script without SRI, monorepo `next.config` header gap** |
|
|
109
110
|
| **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) |
|
|
110
111
|
| graphql | GraphQL surface | introspection (**AppSync `introspectionConfig: DISABLED`-aware**) / playground / depth-limit **+ AppSync subscription-authz (cross-group BOLA)** |
|
|
111
112
|
| integrations | third-party + webhooks **+ outbound-action endpoints** | unsigned webhooks **+ email/SMS/push handlers with no auth or IP-only rate-limit + redundant secret-fetch** |
|
|
@@ -192,7 +193,7 @@ upload, cross-tenant BOLA, role/authz gaps).
|
|
|
192
193
|
## Tests
|
|
193
194
|
|
|
194
195
|
```bash
|
|
195
|
-
python3 -m unittest discover -s tests # stdlib only, no Noir/network —
|
|
196
|
+
python3 -m unittest discover -s tests # stdlib only, no Noir/network — 203 tests
|
|
196
197
|
```
|
|
197
198
|
|
|
198
199
|
## Releasing (maintainer)
|
|
@@ -218,7 +219,9 @@ publisher** with project `websec-validator`, owner `raccioly`, repo `websec-vali
|
|
|
218
219
|
|
|
219
220
|
## Status / roadmap
|
|
220
221
|
|
|
221
|
-
**Done:**
|
|
222
|
+
**Done:** 19-extractor recon (incl. an **authz-correctness data-flow extractor** — unsigned-cookie /
|
|
223
|
+
claim-keyed authz / transaction-local RLS — plus **CORS-misconfig**, **SRI**, **host-header
|
|
224
|
+
open-redirect** and **SSRF-redirect-hardening** classes, schema/entity → mass-assignment targeting, the **AWS-CDK /
|
|
222
225
|
managed-AppSync / VTL boundary**, **upload-security** + **PII-output-boundary** + **redirect-SSRF**
|
|
223
226
|
+ **password-reuse** classes, a **man-in-the-browser / tamperable-display** class, an **LLM / AI-agent
|
|
224
227
|
extractor** (OWASP LLM Top 10 — prompt injection / insecure output / excessive agency / unbounded
|
|
@@ -13,7 +13,7 @@ turns a repo into a precise, fact-grounded security brief an AI agent (with a hu
|
|
|
13
13
|
can act on — an auto-filled, repo-aware version of a senior pentester's "here's what to test and
|
|
14
14
|
how" handoff. How it works + the reasoning behind every check: [`docs/METHODOLOGY.md`](docs/METHODOLOGY.md).
|
|
15
15
|
|
|
16
|
-
##
|
|
16
|
+
## Getting Started — just point it at your repo
|
|
17
17
|
|
|
18
18
|
**Simplest: tell your AI agent.** In Claude Code (or any coding agent), open your project and say:
|
|
19
19
|
|
|
@@ -77,23 +77,24 @@ Then point your agent at the output: **"Read `websec-out/AGENT-BRIEFING.md` and
|
|
|
77
77
|
|
|
78
78
|
> 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.
|
|
79
79
|
|
|
80
|
-
## What it extracts (
|
|
80
|
+
## What it extracts (19 deterministic extractors, no LLM)
|
|
81
81
|
|
|
82
82
|
| | Dimension | Notable output |
|
|
83
83
|
|---|---|---|
|
|
84
84
|
| stack | languages, frameworks, datastores | monorepo-aware (aggregates every manifest) |
|
|
85
85
|
| routes | every endpoint via **OWASP Noir** | method · path · typed params · code path |
|
|
86
86
|
| auth | scheme + login surface + **insecure-default signing secrets** | multi-scheme; flags a hard-coded `JWT_SECRET \|\| 'dev-secret'` fallback (forgeable JWT) |
|
|
87
|
-
| **authz** | access-control map | guard coverage + **write endpoints with no visible guard** + roles |
|
|
87
|
+
| **authz** | access-control map | guard coverage (incl. **router-mount auth**) + **write endpoints with no visible guard** + roles |
|
|
88
|
+
| **authz_dataflow** | authz *correctness* (does the guard trust the right thing?) | **unsigned-cookie authorization** · **claim-keyed authz** (user-influenceable JWT claim) · **transaction-local RLS context** (resets before the query) |
|
|
88
89
|
| tenant | multi-tenancy key candidates | the BOLA boundary, by frequency |
|
|
89
90
|
| **password_policy** | cross-route consistency **+ reuse/history** | complexity drift across routes **+ a set-password path that hashes without a reuse check** |
|
|
90
|
-
| surface | 15 sink classes **+ redirect-SSRF** | user-input-gated sinks (incl. **mass-assignment via object spread**) + var-arg SSRF + error-disclosure
|
|
91
|
+
| 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 **+ reverse-proxy prefix-escape + host-header open-redirect + SSRF-redirect-hardening** |
|
|
91
92
|
| **upload_security** | unrestricted upload + unsafe serve | deny-list-only, stored-name-from-filename, trust-client-MIME, accept-SVG, **serve without `nosniff`** |
|
|
92
93
|
| schemas | data models + **privileged fields** | Pydantic/SQLAlchemy/Django/Prisma/Mongoose/TypeORM/Zod → `role`/`isAdmin`/`groupId` for mass-assignment targeting |
|
|
93
94
|
| iac_ci | IaC + CI/CD | GHA injection (**run:-position-aware**), unpinned actions, tfstate, CDK AppSync `API_KEY` anonymous-default-auth, **docker-compose host-takeover (docker.sock / pid:host / privileged) + `.gitleaksignore` secret-suppression audit** |
|
|
94
95
|
| client_exposure | browser leakage | public-var secrets by **name + value-shape (`da2-…`) + CDK build-injection**, server-secret-in-client, source maps |
|
|
95
96
|
| **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** |
|
|
96
|
-
| **transport_security** | CSP + HSTS
|
|
97
|
+
| **transport_security** | CSP + HSTS + **CORS** + **SRI** baseline | missing/weak CSP, inline event handlers, partial HSTS, **CORS reflect-origin+credentials, external script without SRI, monorepo `next.config` header gap** |
|
|
97
98
|
| **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) |
|
|
98
99
|
| graphql | GraphQL surface | introspection (**AppSync `introspectionConfig: DISABLED`-aware**) / playground / depth-limit **+ AppSync subscription-authz (cross-group BOLA)** |
|
|
99
100
|
| integrations | third-party + webhooks **+ outbound-action endpoints** | unsigned webhooks **+ email/SMS/push handlers with no auth or IP-only rate-limit + redundant secret-fetch** |
|
|
@@ -180,7 +181,7 @@ upload, cross-tenant BOLA, role/authz gaps).
|
|
|
180
181
|
## Tests
|
|
181
182
|
|
|
182
183
|
```bash
|
|
183
|
-
python3 -m unittest discover -s tests # stdlib only, no Noir/network —
|
|
184
|
+
python3 -m unittest discover -s tests # stdlib only, no Noir/network — 203 tests
|
|
184
185
|
```
|
|
185
186
|
|
|
186
187
|
## Releasing (maintainer)
|
|
@@ -206,7 +207,9 @@ publisher** with project `websec-validator`, owner `raccioly`, repo `websec-vali
|
|
|
206
207
|
|
|
207
208
|
## Status / roadmap
|
|
208
209
|
|
|
209
|
-
**Done:**
|
|
210
|
+
**Done:** 19-extractor recon (incl. an **authz-correctness data-flow extractor** — unsigned-cookie /
|
|
211
|
+
claim-keyed authz / transaction-local RLS — plus **CORS-misconfig**, **SRI**, **host-header
|
|
212
|
+
open-redirect** and **SSRF-redirect-hardening** classes, schema/entity → mass-assignment targeting, the **AWS-CDK /
|
|
210
213
|
managed-AppSync / VTL boundary**, **upload-security** + **PII-output-boundary** + **redirect-SSRF**
|
|
211
214
|
+ **password-reuse** classes, a **man-in-the-browser / tamperable-display** class, an **LLM / AI-agent
|
|
212
215
|
extractor** (OWASP LLM Top 10 — prompt injection / insecure output / excessive agency / unbounded
|
|
@@ -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.8.1"
|
|
8
8
|
description = "Defensive, local-first security recon that briefs your AI coding agent on your own codebase — read-only by default (code in, artifacts out): facts + tailored probe scripts, no LLM, no server, no running app."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -73,11 +73,48 @@ def mint(cfg: dict, role: str) -> dict:
|
|
|
73
73
|
return {"error": f"{type(e).__name__}: {e}"}
|
|
74
74
|
token = _dig(d, cfg.get("token_json_path", "tokens.accessToken"))
|
|
75
75
|
user = _dig(d, cfg.get("user_json_path", "user")) or {}
|
|
76
|
-
|
|
77
|
-
return {"token": token, "tenant": tenants[0] if tenants else None,
|
|
76
|
+
return {"token": token, "tenant": _first_tenant(user.get(cfg.get("tenant_field", "groupIds"))),
|
|
78
77
|
"email": user.get("email"), "role": user.get("role")}
|
|
79
78
|
|
|
80
79
|
|
|
80
|
+
def _first_tenant(raw):
|
|
81
|
+
"""The first tenant id from a field that may be a list (`groupIds: [5]`) OR a singular scalar
|
|
82
|
+
(`groupId: 5` / "acme"). Coerce to a list before indexing — else a scalar int crashes (`5[0]`
|
|
83
|
+
TypeError, uncaught) and a scalar string silently yields a single-CHARACTER tenant."""
|
|
84
|
+
tenants = raw if isinstance(raw, list) else ([raw] if raw is not None else [])
|
|
85
|
+
return tenants[0] if tenants else None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _no_records(body: str) -> bool:
|
|
89
|
+
"""True if a 200/206 response carries NO records (so a cross-tenant read is NOT a leak).
|
|
90
|
+
|
|
91
|
+
Parses JSON and tests emptiness STRUCTURALLY instead of string-matching a tiny allowlist
|
|
92
|
+
(`[]`/`{}`/`{"data":[]}`) — that allowlist misclassified common empty wrappers (`{"items":[]}`,
|
|
93
|
+
whitespace-formatted `{ "data": [] }`, paginated `{"data":[],"total":0}`) as LEAKs. Conservative:
|
|
94
|
+
anything that isn't provably empty is treated as DATA, so a real leak is never masked."""
|
|
95
|
+
if not body or not body.strip():
|
|
96
|
+
return True
|
|
97
|
+
|
|
98
|
+
def empty(x) -> bool:
|
|
99
|
+
if x is None or x == "" or x == []:
|
|
100
|
+
return True
|
|
101
|
+
if isinstance(x, list):
|
|
102
|
+
return len(x) == 0
|
|
103
|
+
if isinstance(x, dict):
|
|
104
|
+
if not x:
|
|
105
|
+
return True
|
|
106
|
+
cols = [v for v in x.values() if isinstance(v, (list, dict))]
|
|
107
|
+
# empty only when there's at least one data container and ALL of them are empty
|
|
108
|
+
# (scalar siblings like total/page/count are treated as pagination meta).
|
|
109
|
+
return bool(cols) and all(empty(c) for c in cols)
|
|
110
|
+
return False # a bare scalar payload → treat as data, not empty
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
return empty(json.loads(body))
|
|
114
|
+
except Exception:
|
|
115
|
+
return False # opaque / non-JSON 200 content → can't prove empty; surface it as a lead
|
|
116
|
+
|
|
117
|
+
|
|
81
118
|
def _tenant_only_get_endpoints(facts: dict, param: str) -> list:
|
|
82
119
|
"""GET endpoints whose ONLY path param is the tenant param — clean cross-tenant
|
|
83
120
|
list targets that need no other fixture id."""
|
|
@@ -112,10 +149,8 @@ def cross_tenant_bola(cfg: dict, facts: dict) -> dict:
|
|
|
112
149
|
code, body = _request("GET", url, atk["token"])
|
|
113
150
|
if code in (401, 403, 404):
|
|
114
151
|
verdict = "blocked"
|
|
115
|
-
elif code in (200, 206) and body and body.strip() not in ("[]", "{}", '{"data":[]}'):
|
|
116
|
-
verdict = "LEAK"
|
|
117
152
|
elif code in (200, 206):
|
|
118
|
-
verdict = "blocked-empty"
|
|
153
|
+
verdict = "blocked-empty" if _no_records(body) else "LEAK" # structural, not string-match
|
|
119
154
|
else:
|
|
120
155
|
verdict = "investigate"
|
|
121
156
|
results.append({"path": path, "direction": direction, "status": code, "verdict": verdict})
|
{websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/extractors/__init__.py
RENAMED
|
@@ -11,6 +11,7 @@ from pathlib import Path
|
|
|
11
11
|
|
|
12
12
|
from .auth import AuthExtractor
|
|
13
13
|
from .authz import AuthzExtractor
|
|
14
|
+
from .authz_dataflow import AuthzDataflowExtractor
|
|
14
15
|
from .base import MAX_FILES, Extractor, RepoContext
|
|
15
16
|
from .client_exposure import ClientExposureExtractor
|
|
16
17
|
from .client_integrity import ClientIntegrityExtractor
|
|
@@ -50,6 +51,7 @@ REGISTRY: list[Extractor] = [
|
|
|
50
51
|
IntegrationsExtractor(),
|
|
51
52
|
LlmSecurityExtractor(),
|
|
52
53
|
CryptoUsageExtractor(),
|
|
54
|
+
AuthzDataflowExtractor(),
|
|
53
55
|
]
|
|
54
56
|
|
|
55
57
|
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Authorization data-flow extractor — authz CORRECTNESS, not just presence.
|
|
2
|
+
|
|
3
|
+
`authz.py` answers "is there a guard on this route?". This answers the next question — "does the guard
|
|
4
|
+
trust the right thing?" — for three real broken-access-control patterns the route-level model can't see:
|
|
5
|
+
|
|
6
|
+
- **Unsigned-cookie authorization (CWE-565/602)** — an access decision keyed on a client-settable,
|
|
7
|
+
unsigned cookie (`twin-access-level`, `*role*`, `*allowed*`). httpOnly stops JS reads but the user
|
|
8
|
+
still controls their own cookie jar (devtools/curl), so the gate is forgeable.
|
|
9
|
+
- **Claim → authz decision (CWE-639/807)** — an authorization check that compares a profile-ish JWT
|
|
10
|
+
BODY claim (office/role/group/tenant) the user may influence, instead of a server-resolved record.
|
|
11
|
+
- **Transaction-local RLS context (CWE-1188)** — `set_config('app.*', x, true)` (is_local=true)
|
|
12
|
+
emitted at autocommit (no surrounding transaction): the RLS principal resets before the handler's
|
|
13
|
+
query runs, so row-level security evaluates with an EMPTY context — defense-in-depth theater.
|
|
14
|
+
|
|
15
|
+
All are file-level co-occurrence heuristics, server-side + test-excluded, framed as leads to verify —
|
|
16
|
+
a static scan can't prove the cookie is the one that gates, only point the agent at the file.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import re
|
|
22
|
+
|
|
23
|
+
from .base import Extractor, RepoContext, is_client_file, is_test_file
|
|
24
|
+
|
|
25
|
+
# --- unsigned-cookie authorization ---
|
|
26
|
+
COOKIE_AUTHZ_READ = re.compile(
|
|
27
|
+
r"(?:cookies?\.get|req\.cookies|getCookie|cookieStore\.get)\s*\(?\s*['\"]"
|
|
28
|
+
r"([\w-]*(?:access|role|admin|allow|auth|tier|perm|priv|level|scope|tenant|clerk-user)[\w-]*)['\"]", re.I)
|
|
29
|
+
AUTHZ_GATE = re.compile(
|
|
30
|
+
r"\b403\b|status\s*\(\s*40[13]|\.redirect\s*\([^)]*(?:login|signin|default|unauthorized|forbidden)"
|
|
31
|
+
r"|[!=]==\s*['\"](?:full|admin|allowed|true|owner)|normalizeAccess|accessLevel|hasAccess|requireFull|denyAccess", re.I)
|
|
32
|
+
COOKIE_VERIFY = re.compile(
|
|
33
|
+
r"jwtVerify|jwt\.verify|verifyToken|verifyCookie|\bhmac\b|timingSafeEqual|unseal|\bdecrypt\b"
|
|
34
|
+
r"|signed\s*:\s*true|cookie-signature|verifySignature|getServerSession", re.I)
|
|
35
|
+
|
|
36
|
+
# --- claim → authz decision ---
|
|
37
|
+
AUTHZ_FN = re.compile(
|
|
38
|
+
r"function\s+(?:can|authorize|check|assert)\w*|(?:const|let)\s+(?:can|authorize|check)\w*\s*=\s*"
|
|
39
|
+
r"|\bcan[A-Z]\w*\s*\(|\bauthoriz\w*\s*\(|visibilit|buildVisibility|accessControl|ACL\b", re.I)
|
|
40
|
+
# a profile-ish claim taken from the JWT BODY / request identity (vs a server-resolved record)
|
|
41
|
+
CLAIM_SRC = re.compile(
|
|
42
|
+
r"jwtPayload\s*\[\s*['\"](?:office|role|roles|group|org|department|tenant|plan|tier|scope)"
|
|
43
|
+
r"|payload\.(?:office|role|group|org|department|tenant)\b"
|
|
44
|
+
r"|claims?\s*[\[.]\s*['\"]?(?:office|role|group|org|department|tenant)"
|
|
45
|
+
r"|\.tenant(?:Office|Role|Group|Plan)\b|req\.(?:user|auth)\.(?:office|role|group|org|department)\b", re.I)
|
|
46
|
+
CLAIM_COMPARE = re.compile(r"[!=]==|\.(?:includes|has|some)\s*\(|\bin\s+\[", re.I)
|
|
47
|
+
|
|
48
|
+
# --- transaction-local RLS context ---
|
|
49
|
+
SET_CONFIG_LOCAL = re.compile(r"set_config\s*\(\s*['\"]app\.[\w.]+['\"]\s*,[^,()]+,\s*true\s*\)", re.I)
|
|
50
|
+
IN_TRANSACTION = re.compile(
|
|
51
|
+
r"\bBEGIN\b|\bSTART\s+TRANSACTION\b|\.transaction\s*\(|withTransaction|db\.transaction|\btx\b\.|\btrx\b\.|\bBEGIN;|"
|
|
52
|
+
r"transaction\s*\(\s*async", re.I)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class AuthzDataflowExtractor(Extractor):
|
|
56
|
+
name = "authz_dataflow"
|
|
57
|
+
category = "authz"
|
|
58
|
+
|
|
59
|
+
def extract(self, ctx: RepoContext, facts: dict) -> dict:
|
|
60
|
+
findings: list = []
|
|
61
|
+
seen: set = set()
|
|
62
|
+
|
|
63
|
+
def add(sev, kind, attack, rel, detail):
|
|
64
|
+
if (kind, rel) in seen:
|
|
65
|
+
return
|
|
66
|
+
seen.add((kind, rel))
|
|
67
|
+
findings.append({"severity": sev, "kind": kind, "attack_class": attack,
|
|
68
|
+
"file": rel, "detail": detail})
|
|
69
|
+
|
|
70
|
+
for _p, rel, text in ctx.iter_code():
|
|
71
|
+
if is_test_file(rel) or is_client_file(rel, text):
|
|
72
|
+
continue
|
|
73
|
+
|
|
74
|
+
# 1. authorization decision keyed on an unsigned cookie
|
|
75
|
+
m = COOKIE_AUTHZ_READ.search(text)
|
|
76
|
+
if m and AUTHZ_GATE.search(text) and not COOKIE_VERIFY.search(text):
|
|
77
|
+
add("MEDIUM", "unsigned-cookie-authz", "cookie-authz", rel,
|
|
78
|
+
f"An access decision appears to be keyed on a client-settable cookie "
|
|
79
|
+
f"(`{m.group(1)}`) with no signature/JWT/HMAC verification in this file. httpOnly only "
|
|
80
|
+
"blocks JS reads — the user still controls their own cookie jar, so they can forge the "
|
|
81
|
+
"value and pass the gate (CWE-565/602). Bind access to a signed value (read it from the "
|
|
82
|
+
"verified session JWT, or HMAC the cookie) and re-derive server-side.")
|
|
83
|
+
|
|
84
|
+
# 2. authz decision comparing a user-influenceable JWT claim
|
|
85
|
+
if AUTHZ_FN.search(text) and CLAIM_SRC.search(text) and CLAIM_COMPARE.search(text):
|
|
86
|
+
add("LOW", "claim-based-authz", "claim-authz", rel,
|
|
87
|
+
"An authorization check appears to compare a profile-ish JWT BODY claim "
|
|
88
|
+
"(office/role/group/tenant) that the user may be able to influence, rather than a "
|
|
89
|
+
"value resolved from the authenticated record (CWE-639/807). VERIFY the claim is "
|
|
90
|
+
"server-resolved and non-user-editable; otherwise resolve it from the tenant/user row "
|
|
91
|
+
"inside the request, and never write a client-asserted claim back as the record of truth.")
|
|
92
|
+
|
|
93
|
+
# 3. transaction-local RLS context set outside a transaction (resets before the query)
|
|
94
|
+
if SET_CONFIG_LOCAL.search(text) and not IN_TRANSACTION.search(text):
|
|
95
|
+
add("MEDIUM", "rls-context-no-transaction", "rls-context", rel,
|
|
96
|
+
"`set_config('app.*', …, true)` sets the RLS principal TRANSACTION-LOCALLY "
|
|
97
|
+
"(is_local=true) but there's no surrounding transaction in this file — at autocommit "
|
|
98
|
+
"the setting resets the instant that one statement commits, before any handler query "
|
|
99
|
+
"runs, so RLS evaluates with an EMPTY context (CWE-1188, defense-in-depth theater). "
|
|
100
|
+
"Set it INSIDE the transaction that runs the tenant-scoped query, on the SAME "
|
|
101
|
+
"connection the handler uses (or use is_local=false on a dedicated per-request connection).")
|
|
102
|
+
|
|
103
|
+
by_sev: dict = {}
|
|
104
|
+
for f in findings:
|
|
105
|
+
by_sev[f["severity"]] = by_sev.get(f["severity"], 0) + 1
|
|
106
|
+
return {"findings": findings, "by_severity": by_sev,
|
|
107
|
+
"note": (f"{len(findings)} authz-correctness lead(s) — these check whether a guard trusts the "
|
|
108
|
+
"RIGHT thing (signed cookie / server-resolved claim / live RLS context), not just whether "
|
|
109
|
+
"a guard exists. Verify each against the data flow.") if findings
|
|
110
|
+
else "No unsigned-cookie / claim-keyed-authz / transaction-local-RLS tells found."}
|
|
@@ -102,7 +102,7 @@ class IacCiExtractor(Extractor):
|
|
|
102
102
|
"free-text injectable); verify, low exploitability" if sha_only else "")
|
|
103
103
|
findings.append({"severity": sev, "kind": "gha-script-injection", "file": rel,
|
|
104
104
|
"detail": "untrusted context interpolated into a run: step — "
|
|
105
|
-
+ ", ".join(
|
|
105
|
+
+ ", ".join(contexts[:4]) + extra})
|
|
106
106
|
unpinned = sorted({f"{a}@{r}" for a, r in USES.findall(text)
|
|
107
107
|
if not SHA40.match(r) and not a.startswith("./")})
|
|
108
108
|
if unpinned:
|
|
@@ -33,8 +33,12 @@ VALIDATION_SIGNAL = re.compile(
|
|
|
33
33
|
_LA = r"\(\?=[^)]{0,40}"
|
|
34
34
|
_RE_MIN = re.compile(r"\.min\(\s*(\d{1,3})|minLength\s*[:=]\s*(\d{1,3})|@MinLength\(\s*(\d{1,3})"
|
|
35
35
|
r"|\.length\s*>=?\s*(\d{1,3})|\{\s*(\d{1,3})\s*,")
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
# The char-class branch must be case-SENSITIVE: under re.I a literal `A-Z` also matches `a-z`, so a
|
|
37
|
+
# lowercase-only rule `(?=.*[a-z])` was mis-counted as ALSO requiring uppercase — inflating the class
|
|
38
|
+
# set and masking real drift (a lower-only sibling looked equal to an upper+lower policy). `(?-i:...)`
|
|
39
|
+
# locally disables re.I for just the range token; the keyword branches keep re.I for casing tolerance.
|
|
40
|
+
_RE_UPPER = re.compile(_LA + r"\[[^\]]*(?-i:A-Z)|minUppercase\s*:\s*[1-9]|requireUppercase\s*[:=]\s*true", re.I)
|
|
41
|
+
_RE_LOWER = re.compile(_LA + r"\[[^\]]*(?-i:a-z)|minLowercase\s*:\s*[1-9]|requireLowercase\s*[:=]\s*true", re.I)
|
|
38
42
|
_RE_DIGIT = re.compile(_LA + r"(?:\\d|\[[^\]]*0-9)|minNumbers\s*:\s*[1-9]|requireDigit\s*[:=]\s*true", re.I)
|
|
39
43
|
_RE_SPECIAL = re.compile(_LA + r"(?:\\W|\[\^[A-Za-z0-9\\w]|\[[^\]]*[!@#$%^&*])|minSymbols\s*:\s*[1-9]"
|
|
40
44
|
r"|require[_]?Symbol", re.I)
|
|
@@ -211,7 +211,11 @@ def _fallback_next_app_router(ctx: RepoContext) -> list:
|
|
|
211
211
|
|
|
212
212
|
def _fallback_regex(ctx: RepoContext) -> list:
|
|
213
213
|
rows = []
|
|
214
|
-
|
|
214
|
+
# `[^)]*?` (not `.*` with re.S) keeps the optional methods= group INSIDE this one route() call:
|
|
215
|
+
# a greedy DOTALL `.*` reaches across the file to the LAST methods=[...], mis-assigning it to the
|
|
216
|
+
# first route and silently swallowing every route in between (only routes after the final
|
|
217
|
+
# methods=[...] survived). Staying within the call parens fixes both the mislabel and the drop.
|
|
218
|
+
flask = re.compile(r"@\w+\.route\s*\(\s*['\"]([^'\"]+)['\"](?:[^)]*?methods\s*=\s*\[([^\]]*)\])?")
|
|
215
219
|
fastapi = re.compile(r"@\w+\.(get|post|put|patch|delete)\s*\(\s*['\"]([^'\"]+)")
|
|
216
220
|
for _p, rel, text in ctx.iter_code():
|
|
217
221
|
for verb, path in fastapi.findall(text):
|
{websec_validator-0.7.0 → websec_validator-0.8.1}/src/websec_validator/extractors/surface.py
RENAMED
|
@@ -108,6 +108,24 @@ DOTSEG_GUARD = re.compile(
|
|
|
108
108
|
r"includes\s*\(\s*['\"]\.\.|===\s*['\"]\.\.['\"]|['\"]\.\.['\"]\s*\)|%2e|%2f|decodeURIComponent"
|
|
109
109
|
r"|\bnormalize\b|startsWith\s*\(\s*['\"]/[\w]|sanitiz|assertPath|safeJoin|\bresolve\b[^;]{0,40}startsWith", re.I)
|
|
110
110
|
|
|
111
|
+
# Host-header → redirect (open redirect / cache poisoning, CWE-601): a redirect Location/origin built
|
|
112
|
+
# from the attacker-controllable Host / X-Forwarded-Host header with no host allow-list comparison.
|
|
113
|
+
HOST_HEADER_READ = re.compile(
|
|
114
|
+
r"(?:req|request|ctx)\.(?:headers?\s*[\[.]|get\s*\(\s*)['\"]?(?:x-forwarded-host|forwarded|host)\b"
|
|
115
|
+
r"|headers\.get\s*\(\s*['\"](?:x-forwarded-host|host|forwarded)", re.I)
|
|
116
|
+
REDIRECT_SINK = re.compile(
|
|
117
|
+
r"\.redirect\s*\(|NextResponse\.redirect|res\.setHeader\s*\(\s*['\"]Location|['\"]Location['\"]\s*[:,]|sendRedirect", re.I)
|
|
118
|
+
HOST_ALLOWLIST = re.compile(
|
|
119
|
+
r"allowedHosts?|allow[_-]?list|publicOrigin|publicWebOrigin|trustedHosts?|isAllowedHost|whitelist|brandfolderHostnames", re.I)
|
|
120
|
+
# SSRF-hardening: an outbound client DELIBERATELY following redirects with no host allow-list /
|
|
121
|
+
# private-range deny — a 30x to an internal/metadata host is then fetched server-side (CWE-918),
|
|
122
|
+
# independent of whether the INITIAL url is user-tainted. Reaches worker/job scripts the route scan misses.
|
|
123
|
+
FOLLOW_REDIR = re.compile(r"follow_redirects\s*=\s*True|allow_redirects\s*=\s*True|maxRedirects\s*:\s*[1-9]", re.I)
|
|
124
|
+
OUTBOUND_CLIENT = re.compile(r"\b(?:requests\.\w+|httpx\.\w+|\bfetch\s*\(|axios|got\s*\(|node-fetch|urllib\.request)", re.I)
|
|
125
|
+
SSRF_PRIVATE_GUARD = re.compile(
|
|
126
|
+
r"allow_redirects\s*=\s*False|follow_redirects\s*=\s*False|maxRedirects\s*:\s*0|169\.254|RFC1918|is_private|"
|
|
127
|
+
r"ip_address|private_?range|block.?(?:internal|private)|allowedHosts?|allow[_-]?list", re.I)
|
|
128
|
+
|
|
111
129
|
|
|
112
130
|
class SurfaceExtractor(Extractor):
|
|
113
131
|
name = "surface"
|
|
@@ -122,6 +140,8 @@ class SurfaceExtractor(Extractor):
|
|
|
122
140
|
counts: dict = {k: 0 for k in SINKS}
|
|
123
141
|
ssrf_redirect: list = [] # SSRF sink in a file with NO per-hop redirect guard (#1)
|
|
124
142
|
proxy_escape: list = [] # reverse-proxy prefix-escape (confined-deputy)
|
|
143
|
+
host_redirect: list = [] # redirect built from the Host/X-Forwarded-Host header (open redirect)
|
|
144
|
+
follows_redirect: list = [] # outbound client follows redirects with no allow-list (SSRF-hardening)
|
|
125
145
|
for _p, rel, text in ctx.iter_code():
|
|
126
146
|
# Test fixtures are never a runtime sink; SSRF + outbound-HTTP are SERVER-ONLY classes,
|
|
127
147
|
# so client (.tsx/'use client') and build/CLI scripts can't host them (validated: these
|
|
@@ -153,6 +173,17 @@ class SurfaceExtractor(Extractor):
|
|
|
153
173
|
if (len(proxy_escape) < 30 and not nonserver and PROXY_JOIN.search(text)
|
|
154
174
|
and PROXY_FETCH.search(text) and not DOTSEG_GUARD.search(text)):
|
|
155
175
|
proxy_escape.append(rel)
|
|
176
|
+
# host-header → redirect (open redirect via Host/X-Forwarded-Host). Server-only; needs a
|
|
177
|
+
# redirect sink + a host-header read + no host allow-list in the file.
|
|
178
|
+
if (len(host_redirect) < 30 and not nonserver and HOST_HEADER_READ.search(text)
|
|
179
|
+
and REDIRECT_SINK.search(text) and not HOST_ALLOWLIST.search(text)):
|
|
180
|
+
host_redirect.append(rel)
|
|
181
|
+
# SSRF-hardening: follows redirects with no allow-list / private-range deny. Worker/job
|
|
182
|
+
# SCRIPTS are in scope here (they fetch server-side), so only client + tests are excluded.
|
|
183
|
+
if (len(follows_redirect) < 30 and not is_client_file(rel, text)
|
|
184
|
+
and FOLLOW_REDIR.search(text) and OUTBOUND_CLIENT.search(text)
|
|
185
|
+
and not SSRF_PRIVATE_GUARD.search(text)):
|
|
186
|
+
follows_redirect.append(rel)
|
|
156
187
|
|
|
157
188
|
sinks = {k: {"probe": SINKS[k][0], "count": counts[k], "files": found[k]}
|
|
158
189
|
for k in SINKS if counts[k]}
|
|
@@ -161,6 +192,8 @@ class SurfaceExtractor(Extractor):
|
|
|
161
192
|
"sink_counts": {k: counts[k] for k in SINKS if counts[k]},
|
|
162
193
|
"ssrf_redirect_unguarded": ssrf_redirect, # validate EVERY hop, not just the input URL (#1)
|
|
163
194
|
"proxy_prefix_escape": proxy_escape, # confined-deputy via `..` in catch-all path
|
|
195
|
+
"host_header_redirect": host_redirect, # open redirect via Host/X-Forwarded-Host
|
|
196
|
+
"follows_redirect_no_allowlist": follows_redirect, # SSRF-hardening (redirect-to-internal)
|
|
164
197
|
"datastore_class": ("sql" if has_sql else ("nosql" if has_nosql else "unknown")),
|
|
165
198
|
"note": "Each sink hit is user-input-gated (req./request./concat/interp), so these are "
|
|
166
199
|
"higher-confidence leads. Cross-reference the files with routes.targeting to pick "
|
|
@@ -19,7 +19,7 @@ from __future__ import annotations
|
|
|
19
19
|
|
|
20
20
|
import re
|
|
21
21
|
|
|
22
|
-
from .base import Extractor, RepoContext
|
|
22
|
+
from .base import Extractor, RepoContext, is_script_file, is_test_file
|
|
23
23
|
|
|
24
24
|
CSP_ANY = re.compile(r"Content-Security-Policy|contentSecurityPolicy|helmet[\s\S]{0,40}?\bcsp\b"
|
|
25
25
|
r"|useCspNonce|cspDirectives", re.I)
|
|
@@ -54,6 +54,22 @@ CK_SECURE = re.compile(
|
|
|
54
54
|
r"[A-Za-z_$][\w$.]*\s*[=!]==|[A-Za-z_$][\w$.]*\s*\?|[A-Za-z_$][\w$.]*\([^)]*\))", re.I)
|
|
55
55
|
CK_SAMESITE = re.compile(r"samesite", re.I)
|
|
56
56
|
|
|
57
|
+
# CORS misconfiguration — the high-impact form is an Allow-Origin that REFLECTS the request Origin (or
|
|
58
|
+
# `*`) TOGETHER with Allow-Credentials:true, which lets any site read authenticated responses.
|
|
59
|
+
CORS_REFLECT = re.compile(
|
|
60
|
+
r"Access-Control-Allow-Origin['\"]?\s*[,:][^,\n)]{0,60}(?:req\.|request\.|headers?\.origin|get\s*\(\s*['\"]origin|\borigin\b)"
|
|
61
|
+
r"|cors\s*\(\s*\{[^}]*origin\s*:\s*true|origin\s*:\s*(?:true|function|\(origin)|reflectOrigin|originReflect", re.I)
|
|
62
|
+
CORS_WILDCARD = re.compile(r"Access-Control-Allow-Origin['\"]?\s*[,:]\s*['\"]\*['\"]|\borigin\s*:\s*['\"]\*['\"]", re.I)
|
|
63
|
+
CORS_CREDS = re.compile(r"Access-Control-Allow-Credentials['\"]?\s*[,:]\s*['\"]?true|credentials\s*:\s*true", re.I)
|
|
64
|
+
# an external <script src="https://…"> with no Subresource-Integrity (supply-chain: a CDN compromise
|
|
65
|
+
# runs arbitrary JS in your origin). Only meaningful in code that emits HTML.
|
|
66
|
+
EXT_SCRIPT = re.compile(r"<script\b[^>]*\ssrc\s*=\s*['\"]https?://[^'\"]+['\"][^>]*>", re.I)
|
|
67
|
+
SRI_OK = re.compile(r"\bintegrity\s*=", re.I)
|
|
68
|
+
# a Next.js config that defines security headers via headers()
|
|
69
|
+
NEXT_HEADERS_FN = re.compile(r"async\s+headers\s*\(|\bheaders\s*\(\s*\)\s*\{|key\s*:\s*['\"](?:Content-Security-Policy|X-Frame-Options|Strict-Transport-Security|X-Content-Type-Options)['\"]", re.I)
|
|
70
|
+
NEXT_CSP = re.compile(r"Content-Security-Policy", re.I)
|
|
71
|
+
NEXT_XFO = re.compile(r"X-Frame-Options|frame-ancestors", re.I)
|
|
72
|
+
|
|
57
73
|
|
|
58
74
|
class TransportSecurityExtractor(Extractor):
|
|
59
75
|
name = "transport_security"
|
|
@@ -69,6 +85,7 @@ class TransportSecurityExtractor(Extractor):
|
|
|
69
85
|
inline_handlers = []
|
|
70
86
|
sets_cookie = ck_httponly = ck_secure = ck_samesite = False
|
|
71
87
|
hsts_files, hsts_api_only, hsts_html = [], True, False
|
|
88
|
+
extra_findings: list = [] # CORS / SRI / next-config — emitted alongside the CSP/HSTS set
|
|
72
89
|
|
|
73
90
|
# config manifests carry headers too (next.config, vercel.json, netlify.toml, _headers)
|
|
74
91
|
manifests = "\n".join(ctx.manifest(n) for n in
|
|
@@ -76,9 +93,37 @@ class TransportSecurityExtractor(Extractor):
|
|
|
76
93
|
"netlify.toml", "public/_headers", "static/_headers", "nginx.conf"))
|
|
77
94
|
|
|
78
95
|
for _p, rel, text in ctx.iter_code():
|
|
96
|
+
if is_test_file(rel) or is_script_file(rel):
|
|
97
|
+
continue
|
|
79
98
|
if HTML_SURFACE.search(rel) or HTML_CONTENT.search(text):
|
|
80
99
|
html_surface = True
|
|
81
100
|
blob = text
|
|
101
|
+
# CORS misconfig — reflected/wildcard Allow-Origin together with credentials = any site
|
|
102
|
+
# reads authed responses (CWE-942). Server-side only.
|
|
103
|
+
if CORS_CREDS.search(blob) and (CORS_REFLECT.search(blob) or CORS_WILDCARD.search(blob)):
|
|
104
|
+
extra_findings.append({"severity": "HIGH", "kind": "cors-credentials-any-origin",
|
|
105
|
+
"attack_class": "cors-misconfig", "file": rel,
|
|
106
|
+
"detail": "CORS reflects the request Origin (or uses `*`) AND sets "
|
|
107
|
+
"Allow-Credentials:true — any website can make credentialed cross-origin "
|
|
108
|
+
"requests and READ the authenticated responses (CWE-942). Allow-list exact "
|
|
109
|
+
"trusted origins; never reflect Origin or use `*` when credentials are on."})
|
|
110
|
+
elif CORS_REFLECT.search(blob):
|
|
111
|
+
extra_findings.append({"severity": "MEDIUM", "kind": "cors-reflects-origin",
|
|
112
|
+
"attack_class": "cors-misconfig", "file": rel,
|
|
113
|
+
"detail": "CORS appears to reflect the request Origin (echo-back / `origin:true`) "
|
|
114
|
+
"rather than allow-listing exact origins. Safe only without credentials and with a "
|
|
115
|
+
"strict allow-list — verify it can't be turned into a credentialed cross-origin read."})
|
|
116
|
+
# external script with no SRI, in code that emits HTML
|
|
117
|
+
if (HTML_CONTENT.search(blob) or HTML_SURFACE.search(rel)):
|
|
118
|
+
for m in EXT_SCRIPT.finditer(blob):
|
|
119
|
+
if not SRI_OK.search(m.group(0)):
|
|
120
|
+
extra_findings.append({"severity": "MEDIUM", "kind": "external-script-no-sri",
|
|
121
|
+
"attack_class": "subresource-integrity", "file": rel,
|
|
122
|
+
"detail": "An external <script src=\"https://…\"> is loaded with no "
|
|
123
|
+
"Subresource-Integrity (`integrity=`) hash / version pin — a CDN or "
|
|
124
|
+
"package compromise runs arbitrary JS in this origin (CWE-829). Pin the "
|
|
125
|
+
"version + add an SRI hash + `crossorigin`, or self-host the bundle."})
|
|
126
|
+
break
|
|
82
127
|
if CSP_ANY.search(blob):
|
|
83
128
|
csp_present = True
|
|
84
129
|
if CSP_SCRIPT_SELF.search(blob):
|
|
@@ -121,9 +166,30 @@ class TransportSecurityExtractor(Extractor):
|
|
|
121
166
|
if HSTS_PRELOAD.search(manifests):
|
|
122
167
|
hsts_preload = True
|
|
123
168
|
|
|
169
|
+
# Monorepo-aware Next.js config header gap — `transport_security` previously only read the
|
|
170
|
+
# ROOT next.config via manifests, so a `packages/web/next.config.ts` was invisible. Glob every
|
|
171
|
+
# next.config.* and flag one with no security-header block (CSP + X-Frame-Options).
|
|
172
|
+
for nc in (ctx.glob("**/next.config.js") + ctx.glob("**/next.config.mjs")
|
|
173
|
+
+ ctx.glob("**/next.config.ts")):
|
|
174
|
+
rel, txt = ctx.rel(nc), ctx.text(nc)
|
|
175
|
+
if not NEXT_HEADERS_FN.search(txt):
|
|
176
|
+
extra_findings.append({"severity": "MEDIUM", "kind": "nextjs-no-security-headers",
|
|
177
|
+
"attack_class": "missing-csp", "file": rel,
|
|
178
|
+
"detail": "This Next.js config defines no security-header block (`headers()`) — "
|
|
179
|
+
"so no app-wide Content-Security-Policy, X-Frame-Options/frame-ancestors, HSTS, "
|
|
180
|
+
"X-Content-Type-Options, or Referrer-Policy unless an upstream edge sets them. Add "
|
|
181
|
+
"a `headers()` matcher (start CSP report-only) or document that nginx/CDN owns them."})
|
|
182
|
+
elif not (NEXT_CSP.search(txt) and NEXT_XFO.search(txt)):
|
|
183
|
+
miss = ", ".join(n for n, ok in (("CSP", NEXT_CSP.search(txt)),
|
|
184
|
+
("X-Frame-Options/frame-ancestors", NEXT_XFO.search(txt))) if not ok)
|
|
185
|
+
extra_findings.append({"severity": "LOW", "kind": "nextjs-partial-security-headers",
|
|
186
|
+
"attack_class": "missing-csp", "file": rel,
|
|
187
|
+
"detail": f"Next.js config has a headers() block but is missing {miss}. Add the "
|
|
188
|
+
"clickjacking/XSS-defense headers (verify against the live response if the edge sets some)."})
|
|
189
|
+
|
|
124
190
|
strict_csp = bool(csp_present and csp_self and csp_nonce and not csp_unsafe)
|
|
125
191
|
web_surface = html_surface or has_routes
|
|
126
|
-
findings =
|
|
192
|
+
findings = list(extra_findings)
|
|
127
193
|
|
|
128
194
|
if html_surface:
|
|
129
195
|
if not csp_present:
|