websec-validator 0.5.0__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.5.0/src/websec_validator.egg-info → websec_validator-0.6.0}/PKG-INFO +3 -3
- {websec_validator-0.5.0 → websec_validator-0.6.0}/README.md +2 -2
- {websec_validator-0.5.0 → websec_validator-0.6.0}/pyproject.toml +1 -1
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/briefing.py +2 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/extractors/auth.py +22 -3
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/extractors/routes.py +63 -5
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/extractors/stack.py +29 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/extractors/surface.py +7 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/extractors/transport_security.py +4 -1
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/findings.py +2 -1
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/probes.py +16 -3
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/scanners.py +33 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0/src/websec_validator.egg-info}/PKG-INFO +3 -3
- {websec_validator-0.5.0 → websec_validator-0.6.0}/tests/test_hardening.py +11 -3
- {websec_validator-0.5.0 → websec_validator-0.6.0}/tests/test_pentest_regressions.py +56 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/tests/test_recon.py +37 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/LICENSE +0 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/setup.cfg +0 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/__init__.py +0 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/calibration.json +0 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/calibration.py +0 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/cli.py +0 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/constitution.py +0 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/corpus.json +0 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/dynamic.py +0 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/extractors/__init__.py +0 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/extractors/authz.py +0 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/extractors/base.py +0 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/extractors/client_exposure.py +0 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/extractors/client_integrity.py +0 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/extractors/graphql.py +0 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/extractors/iac_ci.py +0 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/extractors/integrations.py +0 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/extractors/pii_exposure.py +0 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/extractors/policy_consistency.py +0 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/extractors/schemas.py +0 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/extractors/tenant.py +0 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/extractors/upload_security.py +0 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/proof.py +0 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/recon.py +0 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/report.py +0 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/rules/error-stack-disclosure.yml +0 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/rules/insecure-default-secret.yml +0 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/templates/probes/_lib.py +0 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/templates/probes/appsync-cswsh.sh +0 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/templates/probes/appsync-introspection.sh +0 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/templates/probes/appsync-subscription-bola.sh +0 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/templates/probes/bola-cross-tenant.sh +0 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/templates/probes/bola-write-verbs.py +0 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/templates/probes/client-integrity-checklist.sh +0 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/templates/probes/compare-roles.sh +0 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/templates/probes/dlp-bypass-offline.py +0 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/templates/probes/error-disclosure-probe.sh +0 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/templates/probes/forged-token.sh +0 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/templates/probes/hs256-brute-force.py +0 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/templates/probes/jwt-attacks.sh +0 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/templates/probes/mass-assignment.py +0 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/templates/probes/password-reuse.sh +0 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/templates/probes/pii-output-diff.sh +0 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/templates/probes/race-conditions.py +0 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/templates/probes/rate-limit-burst.sh +0 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/templates/probes/s3-assess.sh +0 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/templates/probes/ssrf-probes.sh +0 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/templates/probes/unauth-baseline.sh +0 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/templates/probes/upload-matrix.sh +0 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/templates/probes/webhook-forgery.py +0 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/templates/reports/FINDINGS-SUMMARY.md.template +0 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/templates/reports/access-control-matrix.md.template +0 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/templates/reports/findings-triage.md.template +0 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/templates/reports/pentest-handover-brief.md.template +0 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/templates/reports/per-tool-FINDINGS.md.template +0 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator.egg-info/SOURCES.txt +0 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator.egg-info/dependency_links.txt +0 -0
- {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator.egg-info/entry_points.txt +0 -0
- {websec_validator-0.5.0 → 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
|
|
@@ -94,7 +94,7 @@ 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** |
|
|
@@ -185,7 +185,7 @@ upload, cross-tenant BOLA, role/authz gaps).
|
|
|
185
185
|
## Tests
|
|
186
186
|
|
|
187
187
|
```bash
|
|
188
|
-
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
|
|
189
189
|
```
|
|
190
190
|
|
|
191
191
|
## Releasing (maintainer)
|
|
@@ -82,7 +82,7 @@ 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** |
|
|
@@ -173,7 +173,7 @@ upload, cross-tenant BOLA, role/authz gaps).
|
|
|
173
173
|
## Tests
|
|
174
174
|
|
|
175
175
|
```bash
|
|
176
|
-
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
|
|
177
177
|
```
|
|
178
178
|
|
|
179
179
|
## Releasing (maintainer)
|
|
@@ -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"))}
|
|
@@ -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,6 +31,10 @@ 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
40
|
# class, REF-PENTEST #8). JS/TS: `process.env.JWT_SECRET || 'dev-secret-do-not-use-in-prod'`;
|
|
@@ -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,6 +98,8 @@ 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)))
|
|
@@ -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,
|
|
@@ -138,12 +138,45 @@ def _normalize_noir(eps: list) -> tuple:
|
|
|
138
138
|
return rows, spec
|
|
139
139
|
|
|
140
140
|
|
|
141
|
-
# ----
|
|
141
|
+
# ---- generic router-call heuristic (ALWAYS runs — SUPPLEMENTS Noir + powers the fallback) ----
|
|
142
|
+
# Noir collapses hand-rolled routers (itty-router / Hono / Cloudflare Workers / custom dispatch) to
|
|
143
|
+
# ~1 endpoint; this fills the gap. Matches `<obj>.<verb>('/path', handler)` for ANY object name, and
|
|
144
|
+
# Hono's `<obj>.on('METHOD','/path')`. The leading-'/' on the path is the FP guard — so `arr.get('x')`
|
|
145
|
+
# / `el.on('click')` / `cache.get('k')` are NOT mistaken for routes.
|
|
146
|
+
ROUTER_CALL = re.compile(
|
|
147
|
+
r"\b\w+\.(get|post|put|patch|delete|head|options|all)\s*\(\s*['\"`](/[^'\"`]*)['\"`]", re.I)
|
|
148
|
+
ROUTER_ON = re.compile(r"\b\w+\.on\s*\(\s*['\"]([A-Za-z]+)['\"]\s*,\s*['\"`](/[^'\"`]*)['\"`]")
|
|
149
|
+
# "handler-ish" signatures — ONLY for the coverage-confidence warning (handlers >> routes ⇒ discovery
|
|
150
|
+
# likely incomplete). Express (req,res), Hono/itty (c)=>, Workers fetch entrypoints.
|
|
151
|
+
HANDLER_SIG = re.compile(
|
|
152
|
+
r"\(\s*req\s*,\s*res\b|\(\s*request\s*,\s*(?:res|reply|env|ctx|context)\b|\(\s*c\s*\)\s*=>"
|
|
153
|
+
r"|async\s*\(\s*c\s*\)|\(\s*ctx\s*\)\s*=>|export\s+default\s*\{[^}]*\bfetch\b"
|
|
154
|
+
r"|addEventListener\(\s*['\"]fetch['\"]", re.I)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _router_calls(ctx: RepoContext) -> list:
|
|
158
|
+
rows = []
|
|
159
|
+
for _p, rel, text in ctx.iter_code():
|
|
160
|
+
for verb, path in ROUTER_CALL.findall(text):
|
|
161
|
+
rows.append({"method": "ANY" if verb.lower() == "all" else verb.upper(), "path": path,
|
|
162
|
+
"params": [], "technology": "router", "code_path": rel, "source": "router-heuristic"})
|
|
163
|
+
for method, path in ROUTER_ON.findall(text):
|
|
164
|
+
rows.append({"method": method.upper(), "path": path, "params": [],
|
|
165
|
+
"technology": "router", "code_path": rel, "source": "router-heuristic"})
|
|
166
|
+
return rows
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _handler_signal_count(ctx: RepoContext) -> int:
|
|
170
|
+
return sum(len(HANDLER_SIG.findall(text)) for _p, _rel, text in ctx.iter_code())
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# ---- regex fallback (Noir absent — decorator/file frameworks the heuristic doesn't cover) ----
|
|
142
174
|
|
|
143
175
|
def _fallback(ctx: RepoContext) -> list:
|
|
144
176
|
rows = []
|
|
145
177
|
rows += _fallback_next_app_router(ctx)
|
|
146
178
|
rows += _fallback_regex(ctx)
|
|
179
|
+
rows += _router_calls(ctx) # generic router calls (Express/itty/Hono/Workers)
|
|
147
180
|
# clean + filter noise + de-dup on (method, path)
|
|
148
181
|
seen, out = set(), []
|
|
149
182
|
for r in rows:
|
|
@@ -178,13 +211,9 @@ def _fallback_next_app_router(ctx: RepoContext) -> list:
|
|
|
178
211
|
|
|
179
212
|
def _fallback_regex(ctx: RepoContext) -> list:
|
|
180
213
|
rows = []
|
|
181
|
-
express = re.compile(r"\b(?:router|app)\.(get|post|put|patch|delete)\s*\(\s*['\"`]([^'\"`]+)")
|
|
182
214
|
flask = re.compile(r"@\w+\.route\s*\(\s*['\"]([^'\"]+)['\"](?:.*methods\s*=\s*\[([^\]]*)\])?", re.S)
|
|
183
215
|
fastapi = re.compile(r"@\w+\.(get|post|put|patch|delete)\s*\(\s*['\"]([^'\"]+)")
|
|
184
216
|
for _p, rel, text in ctx.iter_code():
|
|
185
|
-
for verb, path in express.findall(text):
|
|
186
|
-
rows.append({"method": verb.upper(), "path": path, "params": [],
|
|
187
|
-
"technology": "express", "code_path": rel, "source": "fallback"})
|
|
188
217
|
for verb, path in fastapi.findall(text):
|
|
189
218
|
rows.append({"method": verb.upper(), "path": path, "params": [],
|
|
190
219
|
"technology": "fastapi", "code_path": rel, "source": "fallback"})
|
|
@@ -245,11 +274,38 @@ class RoutesExtractor(Extractor):
|
|
|
245
274
|
# SKIP_DIRS is matched RELATIVE to the scan root (a skip-named ANCESTOR must not nuke
|
|
246
275
|
# the whole route list).
|
|
247
276
|
routes = [r for r in routes if not _in_skip_dir(r.get("code_path", ""), ctx.root)]
|
|
277
|
+
|
|
278
|
+
# P1: when NOIR produced the routes, SUPPLEMENT with the generic router-call heuristic — Noir
|
|
279
|
+
# collapses hand-rolled routers (itty/Hono/Workers) to ~1 endpoint, so without this the entire
|
|
280
|
+
# dynamic half no-ops. (The fallback path already includes the heuristic.) Dedupe on (method,path).
|
|
281
|
+
if eps:
|
|
282
|
+
existing = {(r["method"], r["path"]) for r in routes}
|
|
283
|
+
_excl = getattr(ctx, "excludes", None)
|
|
284
|
+
for r in _router_calls(ctx):
|
|
285
|
+
r["path"] = _clean_path(r["path"])
|
|
286
|
+
if _is_noise(r["path"]) or _in_skip_dir(r.get("code_path", ""), ctx.root):
|
|
287
|
+
continue
|
|
288
|
+
if _excl and ctx._excluded(r.get("code_path", "")):
|
|
289
|
+
continue
|
|
290
|
+
k = (r["method"], r["path"])
|
|
291
|
+
if k not in existing:
|
|
292
|
+
existing.add(k)
|
|
293
|
+
routes.append(r)
|
|
294
|
+
|
|
248
295
|
by_method: dict = {}
|
|
249
296
|
by_tech: dict = {}
|
|
250
297
|
for r in routes:
|
|
251
298
|
by_method[r["method"]] = by_method.get(r["method"], 0) + 1
|
|
252
299
|
by_tech[r["technology"]] = by_tech.get(r["technology"], 0) + 1
|
|
300
|
+
# P1: surface-coverage confidence — many handler-ish fns but few routes ⇒ discovery likely
|
|
301
|
+
# INCOMPLETE (hand-rolled / dynamic routing). An empty §3 then means "unmapped", not "clean".
|
|
302
|
+
handler_sigs = _handler_signal_count(ctx)
|
|
303
|
+
coverage_warning = None
|
|
304
|
+
if handler_sigs >= 5 and len(routes) <= handler_sigs // 3:
|
|
305
|
+
coverage_warning = (f"⚠ surface-coverage: mapped {len(routes)} route(s) but found ~{handler_sigs} "
|
|
306
|
+
"handler-ish function(s) — route discovery is likely INCOMPLETE (a hand-rolled or "
|
|
307
|
+
"dynamic router the engine can't follow). Treat empty §3 candidate lists as "
|
|
308
|
+
"\"couldn't map\", NOT \"nothing there\" — map routes by hand before trusting the probes.")
|
|
253
309
|
out = {
|
|
254
310
|
"engine": engine,
|
|
255
311
|
"count": len(routes),
|
|
@@ -257,6 +313,8 @@ class RoutesExtractor(Extractor):
|
|
|
257
313
|
"by_technology": by_tech,
|
|
258
314
|
"endpoints": routes,
|
|
259
315
|
"targeting": _derive(routes),
|
|
316
|
+
"handler_signals": handler_sigs,
|
|
317
|
+
"coverage_warning": coverage_warning,
|
|
260
318
|
}
|
|
261
319
|
if spec_derived:
|
|
262
320
|
from collections import Counter
|
|
@@ -8,6 +8,8 @@ result is stashed on ctx.stack for later extractors.
|
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
+
import re
|
|
12
|
+
|
|
11
13
|
from .base import Extractor, RepoContext
|
|
12
14
|
|
|
13
15
|
NODE_FRAMEWORKS = {"express": "express", "fastify": "fastify", "koa": "koa",
|
|
@@ -68,11 +70,38 @@ class StackExtractor(Extractor):
|
|
|
68
70
|
if ctx.glob("**/Gemfile", 1):
|
|
69
71
|
langs.add("ruby")
|
|
70
72
|
|
|
73
|
+
# P5: managed-platform config (wrangler / vercel / netlify / serverless) declares the framework
|
|
74
|
+
# + datastore + cron surface that package.json deps don't reveal — so a KV/Workers app no
|
|
75
|
+
# longer reads as `datastores: ?` (which down-ranks SQLi noise) and the cron surface is shown.
|
|
76
|
+
cron_triggers: list = []
|
|
77
|
+
wrangler = ctx.manifest("wrangler.jsonc") + ctx.manifest("wrangler.toml") + ctx.manifest("wrangler.json")
|
|
78
|
+
if wrangler:
|
|
79
|
+
frameworks.add("cloudflare-workers")
|
|
80
|
+
if re.search(r"kv_namespaces|KVNamespace", wrangler, re.I):
|
|
81
|
+
datastores.add("cloudflare-kv")
|
|
82
|
+
if re.search(r"d1_databases|D1Database", wrangler, re.I):
|
|
83
|
+
datastores.add("sqlite") # D1 is SQLite-backed
|
|
84
|
+
if re.search(r"r2_buckets|R2Bucket", wrangler, re.I):
|
|
85
|
+
datastores.add("r2-object-store")
|
|
86
|
+
if re.search(r"durable_objects|DurableObjectNamespace", wrangler, re.I):
|
|
87
|
+
datastores.add("durable-objects")
|
|
88
|
+
for mm in re.finditer(r"crons?\s*[=:]\s*\[([^\]]*)\]", wrangler):
|
|
89
|
+
cron_triggers += re.findall(r"['\"]([^'\"]+)['\"]", mm.group(1))
|
|
90
|
+
vercel = ctx.manifest("vercel.json")
|
|
91
|
+
if vercel:
|
|
92
|
+
frameworks.add("vercel")
|
|
93
|
+
cron_triggers += re.findall(r'"schedule"\s*:\s*"([^"]+)"', vercel)
|
|
94
|
+
if ctx.manifest("netlify.toml"):
|
|
95
|
+
frameworks.add("netlify")
|
|
96
|
+
if ctx.manifest("serverless.yml") + ctx.manifest("serverless.yaml"):
|
|
97
|
+
frameworks.add("serverless")
|
|
98
|
+
|
|
71
99
|
result = {
|
|
72
100
|
"languages": sorted(langs),
|
|
73
101
|
"frameworks": sorted(frameworks),
|
|
74
102
|
"package_managers": sorted(managers),
|
|
75
103
|
"datastores": sorted(datastores),
|
|
104
|
+
"cron_triggers": sorted(set(cron_triggers)),
|
|
76
105
|
"monorepo": len(pkgs) > 1 or ctx.exists("pnpm-workspace.yaml", "lerna.json", "nx.json", "turbo.json"),
|
|
77
106
|
"services": len(pkgs),
|
|
78
107
|
}
|
{websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/extractors/surface.py
RENAMED
|
@@ -44,6 +44,13 @@ SINKS = {
|
|
|
44
44
|
r"libxmljs\.parseXml\s*\(|lxml\.etree\.(?:parse|fromstring|XML)\s*\(|xml\.etree\.ElementTree\.(?:parse|fromstring)\s*\(|new\s+DOMParser")),
|
|
45
45
|
"prototype-pollution": ("mass-assignment", None, re.compile(
|
|
46
46
|
r"(?:_\.merge|_\.mergeWith|_\.defaultsDeep|Object\.assign)\s*\([^)]*(?:req\.|request\.)|\.update\s*\([^)]*request\.(?:json|get_json|form)")),
|
|
47
|
+
# Mass-assignment via object SPREAD — `{...record, ...req.body}` lets the client overwrite ANY
|
|
48
|
+
# field of the persisted record (role/tier/isAdmin → privilege escalation; the tier-downgrade
|
|
49
|
+
# class). User-input marker is inline → no gate. Precise double-spread/Object.assign forms only.
|
|
50
|
+
"mass-assignment": ("mass-assignment", None, re.compile(
|
|
51
|
+
r"\{\s*\.\.\.[\w.$]+\s*,\s*\.\.\.(?:(?:req\w*|request|ctx|c)\.(?:body|json|payload|data|params|query)"
|
|
52
|
+
r"|body|reqBody|requestBody|userInput|updates|patch)\b"
|
|
53
|
+
r"|Object\.assign\s*\(\s*[\w.$]+\s*,\s*(?:req|request|ctx|c)\.(?:body|json|payload|data)\b")),
|
|
47
54
|
"redos": ("ssrf-probes", None, re.compile(
|
|
48
55
|
r"new\s+RegExp\s*\([^)]*(?:req\.|request\.|\+)|re\.(?:compile|match|search|fullmatch)\s*\([^,)]*(?:request\.|f['\"])")),
|
|
49
56
|
"eval-injection": ("bola-write-verbs", None, re.compile(
|
|
@@ -37,6 +37,9 @@ HSTS_PRELOAD = re.compile(r"\bpreload\b", re.I)
|
|
|
37
37
|
# spot the "HSTS on /api but not the page" partial-coverage smell — heuristic, framed as "verify".
|
|
38
38
|
API_SCOPED = re.compile(r"(?:^|/)(?:api|routes?|server|lambda|handler|functions?|controllers?)(?:/|\.|$)", re.I)
|
|
39
39
|
HTML_SURFACE = re.compile(r"\.(?:html|tsx|jsx|vue|svelte|astro)$|_document|app/layout|index\.html", re.I)
|
|
40
|
+
# HTML built/served in CODE (a Worker / server-rendered app emitting template-literal HTML) — so CSP
|
|
41
|
+
# applies even with no frontend framework. This is the gap that missed a Cloudflare Worker's CSP.
|
|
42
|
+
HTML_CONTENT = re.compile(r"<!DOCTYPE\s+html|<html[\s>]|text/html|res\.send\(\s*[`'\"]\s*<|c\.html\(", re.I)
|
|
40
43
|
FRONTEND_FW = {"react", "next", "nextjs", "vue", "nuxt", "svelte", "sveltekit", "angular", "astro", "remix", "solid"}
|
|
41
44
|
|
|
42
45
|
|
|
@@ -60,7 +63,7 @@ class TransportSecurityExtractor(Extractor):
|
|
|
60
63
|
"netlify.toml", "public/_headers", "static/_headers", "nginx.conf"))
|
|
61
64
|
|
|
62
65
|
for _p, rel, text in ctx.iter_code():
|
|
63
|
-
if HTML_SURFACE.search(rel):
|
|
66
|
+
if HTML_SURFACE.search(rel) or HTML_CONTENT.search(text):
|
|
64
67
|
html_surface = True
|
|
65
68
|
blob = text
|
|
66
69
|
if CSP_ANY.search(blob):
|
|
@@ -319,7 +319,8 @@ def build_ledger(facts: dict, unified: dict | None, dynamic: dict | None = None,
|
|
|
319
319
|
# On a purely-NoSQL datastore, classic SQL-injection alerts are almost always FPs —
|
|
320
320
|
# down-rank them (the inflation the field test flagged) rather than ranking them MEDIUM.
|
|
321
321
|
_ds = {d.lower() for d in (facts.get("stack", {}).get("datastores") or [])}
|
|
322
|
-
_nosql = {"dynamodb", "dynamo", "mongodb", "mongo", "firestore", "cosmos", "cosmosdb", "couchdb",
|
|
322
|
+
_nosql = {"dynamodb", "dynamo", "mongodb", "mongo", "firestore", "cosmos", "cosmosdb", "couchdb",
|
|
323
|
+
"cassandra", "cloudflare-kv", "kv", "durable-objects", "r2-object-store", "redis"}
|
|
323
324
|
# Include the ORM-ish labels stack.py actually emits (prisma(sql)/sql-orm) — and treat any label
|
|
324
325
|
# CONTAINING "sql" (but not "nosql") as SQL — so a SQL-ORM app + Mongo isn't misread as nosql-only
|
|
325
326
|
# and its SQLi findings wrongly down-ranked.
|
|
@@ -65,9 +65,11 @@ PROBES = {
|
|
|
65
65
|
"a TEST account token + CURRENT_PW + the set-password path(s)"),
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
|
|
68
|
+
# Truly universal probes — cheapest, exercise the #1 lead class (missing authentication) + rate
|
|
69
|
+
# limits on any HTTP app. The AUTH-SCHEME-SPECIFIC probes (forged-token / jwt-attacks / hs256) are
|
|
70
|
+
# staged in applicable() ONLY when the detected scheme justifies them (P2 — JWT probes on a no-JWT
|
|
71
|
+
# app are noise that erodes trust).
|
|
72
|
+
ALWAYS = ["unauth-baseline", "rate-limit-burst"]
|
|
71
73
|
|
|
72
74
|
# which targeting bucket each probe should be pointed at (for the manifest's real targets)
|
|
73
75
|
_TARGET_KEYS = {
|
|
@@ -114,6 +116,17 @@ def applicable(facts: dict) -> list:
|
|
|
114
116
|
targeting = (facts.get("routes") or {}).get("targeting", {})
|
|
115
117
|
tenant = (facts.get("tenant") or {}).get("candidates")
|
|
116
118
|
|
|
119
|
+
# P2: stage AUTH probes by the DETECTED scheme. forged-token forges an unsigned token into the
|
|
120
|
+
# bearer OR the signed cookie (fits JWT and HMAC-cookie apps); jwt-attacks/hs256 are JWT-specific
|
|
121
|
+
# and must NOT be staged for a no-JWT app.
|
|
122
|
+
auth = facts.get("auth") or {}
|
|
123
|
+
sc = auth.get("signal_counts") or {}
|
|
124
|
+
if (sc.get("jwt") or sc.get("session") or sc.get("hmac") or auth.get("cookie_names")
|
|
125
|
+
or str(auth.get("scheme", "")).startswith(("nextauth", "jwt", "hmac", "session", "passport"))):
|
|
126
|
+
chosen += ["forged-token"]
|
|
127
|
+
if sc.get("jwt") or auth.get("jwt_sign_verify_present"):
|
|
128
|
+
chosen += ["jwt-attacks", "hs256-brute-force"]
|
|
129
|
+
|
|
117
130
|
if targeting.get("write_endpoints"):
|
|
118
131
|
chosen += ["mass-assignment"]
|
|
119
132
|
if tenant:
|
|
@@ -221,6 +221,35 @@ def _aws_secret_tier(secret: str, match: str):
|
|
|
221
221
|
return None, None
|
|
222
222
|
|
|
223
223
|
|
|
224
|
+
# P4: name a secret by its provider PREFIX (so triage isn't "generic, verify") and tier by whether
|
|
225
|
+
# the prefix denotes a real SECRET. A NAMED live-provider secret is HIGH, not MEDIUM-generic — and the
|
|
226
|
+
# remediation ORDER matters: rotate FIRST (gitignoring or deleting a committed key does NOT scrub it
|
|
227
|
+
# from pushed history — anyone who cloned still has it). Prefix-keyed → cloud-agnostic.
|
|
228
|
+
_ROTATE = " — ROTATE at the provider FIRST; deleting/gitignoring a committed key does NOT scrub pushed history (use BFG/git-filter-repo after rotating)."
|
|
229
|
+
_PROVIDER_PREFIXES = [
|
|
230
|
+
(re.compile(r"\bwhsec_[A-Za-z0-9]{16,}"), "HIGH", "Stripe/svix webhook signing secret (whsec_)"),
|
|
231
|
+
(re.compile(r"\bsk_live_[A-Za-z0-9]{16,}"), "HIGH", "Stripe LIVE secret key (sk_live_)"),
|
|
232
|
+
(re.compile(r"\brk_live_[A-Za-z0-9]{16,}"), "HIGH", "Stripe restricted LIVE key (rk_live_)"),
|
|
233
|
+
(re.compile(r"\bsk_test_[A-Za-z0-9]{16,}"), "LOW", "Stripe TEST secret key (sk_test_ — sandbox)"),
|
|
234
|
+
(re.compile(r"\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{30,}|\bgithub_pat_[A-Za-z0-9_]{40,}"), "HIGH", "GitHub token (gh*_/github_pat_)"),
|
|
235
|
+
(re.compile(r"\bglpat-[A-Za-z0-9_\-]{20,}"), "HIGH", "GitLab personal access token (glpat-)"),
|
|
236
|
+
(re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}"), "HIGH", "Slack token (xox*-)"),
|
|
237
|
+
(re.compile(r"\bSG\.[A-Za-z0-9_\-]{16,}\.[A-Za-z0-9_\-]{16,}"), "HIGH", "SendGrid API key (SG.)"),
|
|
238
|
+
(re.compile(r"\bnpm_[A-Za-z0-9]{36}\b"), "HIGH", "npm access token (npm_)"),
|
|
239
|
+
(re.compile(r"\bdop_v1_[a-f0-9]{64}\b"), "HIGH", "DigitalOcean token (dop_v1_)"),
|
|
240
|
+
(re.compile(r"\bshpat_[a-fA-F0-9]{32}\b"), "HIGH", "Shopify access token (shpat_)"),
|
|
241
|
+
(re.compile(r"\bAIza[0-9A-Za-z_\-]{35}\b"), "MEDIUM", "Google API key (AIza — often domain/API-restricted; verify scope)"),
|
|
242
|
+
(re.compile(r"\bpk_live_[A-Za-z0-9]{16,}"), "LOW", "publishable key (pk_live_ — usually PUBLIC by design; verify with the provider)"),
|
|
243
|
+
]
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _provider_secret_tier(blob: str):
|
|
247
|
+
for rx, sev, note in _PROVIDER_PREFIXES:
|
|
248
|
+
if rx.search(blob or ""):
|
|
249
|
+
return sev, note + (_ROTATE if sev in ("HIGH", "MEDIUM") else "")
|
|
250
|
+
return None, None
|
|
251
|
+
|
|
252
|
+
|
|
224
253
|
# gitleaks/trivy "generic" + entropy/keyword rules are high-recall, low-precision: they fire on
|
|
225
254
|
# public keys, wallet addresses, hashes, env-var refs and test fixtures about as often as real
|
|
226
255
|
# credentials. Tier those to MEDIUM + a verify note (NEVER hide them) so the HIGH secret tier
|
|
@@ -271,6 +300,8 @@ def _norm_trivy(data: dict) -> list:
|
|
|
271
300
|
for s in (res.get("Secrets") or []):
|
|
272
301
|
rid = s.get("RuleID", "")
|
|
273
302
|
sev, note = _aws_secret_tier(s.get("Match", ""), s.get("Code", "") or "")
|
|
303
|
+
if not sev:
|
|
304
|
+
sev, note = _provider_secret_tier(f"{s.get('Match','')} {s.get('Code','') or ''}")
|
|
274
305
|
if not sev and _generic_secret(rid):
|
|
275
306
|
sev, note = "MEDIUM", _GENERIC_NOTE
|
|
276
307
|
if _is_doc_or_example(tgt):
|
|
@@ -292,6 +323,8 @@ def _norm_gitleaks(data) -> list:
|
|
|
292
323
|
for x in rows:
|
|
293
324
|
f, rule = x.get("File", ""), x.get("RuleID", "")
|
|
294
325
|
sev, note = _aws_secret_tier(x.get("Secret", ""), x.get("Match", ""))
|
|
326
|
+
if not sev:
|
|
327
|
+
sev, note = _provider_secret_tier(f"{x.get('Secret','')} {x.get('Match','')}")
|
|
295
328
|
if not sev and _generic_secret(rule):
|
|
296
329
|
sev, note = "MEDIUM", _GENERIC_NOTE
|
|
297
330
|
if _is_doc_or_example(f):
|
|
@@ -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
|
|
@@ -94,7 +94,7 @@ 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** |
|
|
@@ -185,7 +185,7 @@ upload, cross-tenant BOLA, role/authz gaps).
|
|
|
185
185
|
## Tests
|
|
186
186
|
|
|
187
187
|
```bash
|
|
188
|
-
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
|
|
189
189
|
```
|
|
190
190
|
|
|
191
191
|
## Releasing (maintainer)
|
|
@@ -209,10 +209,18 @@ class WriteAuthEnforcement500Tests(unittest.TestCase):
|
|
|
209
209
|
|
|
210
210
|
|
|
211
211
|
class ProbeRegistrationTests(unittest.TestCase):
|
|
212
|
-
def
|
|
213
|
-
|
|
212
|
+
def test_auth_probes_gated_by_scheme(self):
|
|
213
|
+
# P2: forged-token forges into bearer OR signed cookie → staged for token/cookie auth, NOT
|
|
214
|
+
# for an app with no detected auth scheme. JWT-specific probes need actual JWT presence.
|
|
214
215
|
self.assertIn("forged-token", probes.PROBES)
|
|
215
|
-
self.assertIn("forged-token", probes.applicable({"
|
|
216
|
+
self.assertIn("forged-token", probes.applicable({"auth": {"signal_counts": {"jwt": 2}}}))
|
|
217
|
+
self.assertIn("forged-token", probes.applicable(
|
|
218
|
+
{"auth": {"scheme": "hmac-signed-cookie", "signal_counts": {"hmac": 3}}}))
|
|
219
|
+
self.assertNotIn("forged-token", probes.applicable({"routes": {"targeting": {}}}))
|
|
220
|
+
# jwt-attacks / hs256 only when JWT is actually present (not for an HMAC-cookie app)
|
|
221
|
+
self.assertNotIn("jwt-attacks", probes.applicable(
|
|
222
|
+
{"auth": {"signal_counts": {"hmac": 3}, "cookie_names": ["sid"]}}))
|
|
223
|
+
self.assertIn("hs256-brute-force", probes.applicable({"auth": {"signal_counts": {"jwt": 2}}}))
|
|
216
224
|
|
|
217
225
|
def test_context_has_reads(self):
|
|
218
226
|
ctx = probes.build_context({"routes": {"endpoints": [
|
|
@@ -558,5 +558,61 @@ class FalsePositiveRegressionTests(unittest.TestCase):
|
|
|
558
558
|
self.assertEqual(ctv["file"], "app/Pay.tsx")
|
|
559
559
|
|
|
560
560
|
|
|
561
|
+
class AuthSchemeTests(unittest.TestCase):
|
|
562
|
+
"""P2: HMAC-signed cookies must not misread as api-key (which staged JWT probes for a no-JWT app)."""
|
|
563
|
+
|
|
564
|
+
def test_hmac_cookie_not_mislabeled_apikey(self):
|
|
565
|
+
out = AuthExtractor().extract(repo({"src/auth.ts":
|
|
566
|
+
"const sig = await crypto.subtle.sign('HMAC', key, data);\n"
|
|
567
|
+
"const c = getCookie(req, 'session');\nres.headers.set('Set-Cookie', cookie);\n"
|
|
568
|
+
"const k = req.headers.get('x-api-key');\n"}), {})
|
|
569
|
+
self.assertEqual(out["scheme"], "hmac-signed-cookie")
|
|
570
|
+
self.assertEqual(out["token_location"], "cookie")
|
|
571
|
+
self.assertEqual(out["signal_counts"]["hmac"], 1)
|
|
572
|
+
|
|
573
|
+
def test_jwt_app_stays_jwt(self):
|
|
574
|
+
out = AuthExtractor().extract(repo({"src/a.ts": "import jwt from 'jsonwebtoken';\njwt.verify(t, S);\n"}), {})
|
|
575
|
+
self.assertTrue(out["scheme"].startswith("jwt"))
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
class StaticCheckP3Tests(unittest.TestCase):
|
|
579
|
+
"""P3: CSP linter fires for SERVER-RENDERED / template-literal HTML (not just frontend frameworks);
|
|
580
|
+
mass-assignment via object spread `{...record, ...req.body}`."""
|
|
581
|
+
|
|
582
|
+
def test_csp_fires_for_worker_template_html(self):
|
|
583
|
+
out = TransportSecurityExtractor().extract(repo({"src/index.ts":
|
|
584
|
+
"const html = `<!DOCTYPE html><html><body>hi</body></html>`;\n"
|
|
585
|
+
"return new Response(html, {headers: {'content-type': 'text/html',\n"
|
|
586
|
+
" 'Content-Security-Policy': \"script-src 'self' 'unsafe-inline'\"}});\n"}), {})
|
|
587
|
+
self.assertTrue(out["html_surface"]) # template HTML counts — no framework needed
|
|
588
|
+
self.assertTrue(any(f["attack_class"] == "missing-csp" for f in out["findings"]))
|
|
589
|
+
|
|
590
|
+
def test_mass_assignment_via_spread(self):
|
|
591
|
+
out = SurfaceExtractor().extract(repo({"src/h.ts":
|
|
592
|
+
"const saved = {...record, ...req.body};\nawait db.put(saved);\n"}), {})
|
|
593
|
+
self.assertIn("mass-assignment", out["sinks"])
|
|
594
|
+
# benign spreads don't fire
|
|
595
|
+
clean = SurfaceExtractor().extract(repo({"src/h.ts": "const s = {...defaults, ...config};\n"}), {})
|
|
596
|
+
self.assertNotIn("mass-assignment", clean["sinks"])
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
class ProviderSecretTierTests(unittest.TestCase):
|
|
600
|
+
"""P4: name a secret by provider PREFIX (HIGH + rotation order), not 'MEDIUM generic, verify'."""
|
|
601
|
+
|
|
602
|
+
def test_provider_prefixes_tiered_and_named(self):
|
|
603
|
+
self.assertEqual(scanners._provider_secret_tier("whsec_" + "a" * 20)[0], "HIGH")
|
|
604
|
+
self.assertEqual(scanners._provider_secret_tier("sk_live_" + "a" * 20)[0], "HIGH")
|
|
605
|
+
self.assertEqual(scanners._provider_secret_tier("sk_test_" + "a" * 20)[0], "LOW") # sandbox
|
|
606
|
+
self.assertEqual(scanners._provider_secret_tier("pk_live_" + "a" * 20)[0], "LOW") # public by design
|
|
607
|
+
self.assertIsNone(scanners._provider_secret_tier("just-random-entropy")[0])
|
|
608
|
+
|
|
609
|
+
def test_named_provider_key_is_high_not_generic_via_gitleaks(self):
|
|
610
|
+
row = scanners._norm_gitleaks({"findings": [{"File": "cfg.ts", "RuleID": "generic-api-key",
|
|
611
|
+
"Secret": "sk_live_" + "a" * 20, "Match": "sk_live_...", "StartLine": 1}]})[0]
|
|
612
|
+
self.assertEqual(row["severity"], "HIGH")
|
|
613
|
+
self.assertIn("Stripe", row["title"])
|
|
614
|
+
self.assertIn("ROTATE", row["title"])
|
|
615
|
+
|
|
616
|
+
|
|
561
617
|
if __name__ == "__main__":
|
|
562
618
|
unittest.main()
|
|
@@ -45,6 +45,15 @@ class StackTests(unittest.TestCase):
|
|
|
45
45
|
self.assertIn("python", f["languages"])
|
|
46
46
|
self.assertIn("flask", f["frameworks"])
|
|
47
47
|
|
|
48
|
+
def test_wrangler_config_detects_kv_and_workers(self):
|
|
49
|
+
# P5: managed-platform config reveals framework + datastore that deps don't (KV ⇒ down-rank SQLi).
|
|
50
|
+
d = Path(tempfile.mkdtemp())
|
|
51
|
+
(d / "wrangler.jsonc").write_text('{ "name": "x", "kv_namespaces": [{"binding": "CACHE"}] }\n')
|
|
52
|
+
(d / "src.ts").write_text("export default { fetch() {} };\n")
|
|
53
|
+
out = StackExtractor().extract(RepoContext(d), {})
|
|
54
|
+
self.assertIn("cloudflare-workers", out["frameworks"])
|
|
55
|
+
self.assertIn("cloudflare-kv", out["datastores"])
|
|
56
|
+
|
|
48
57
|
|
|
49
58
|
class AuthTests(unittest.TestCase):
|
|
50
59
|
def test_python_jwt(self):
|
|
@@ -364,6 +373,34 @@ class RouteUnitTests(unittest.TestCase):
|
|
|
364
373
|
self.assertIn(("GET", "/api/users/{id}"), paths)
|
|
365
374
|
self.assertIn(("POST", "/api/groups/{groupId}/items"), paths)
|
|
366
375
|
|
|
376
|
+
def test_router_heuristic_catches_modern_routers_and_guards_fps(self):
|
|
377
|
+
# P1: generic router-call heuristic — hand-rolled / itty / Hono / Workers, ANY object name.
|
|
378
|
+
d = Path(tempfile.mkdtemp())
|
|
379
|
+
(d / "index.ts").write_text(
|
|
380
|
+
"router.get('/api/health', h);\n"
|
|
381
|
+
"api.post('/api/todos/:id', h);\n"
|
|
382
|
+
"r.delete('/api/items/:id', h);\n"
|
|
383
|
+
"app.on('PUT', '/api/users/:id', h);\n"
|
|
384
|
+
"const cache = new Map(); cache.get('lookup-key');\n" # FP guard: no leading slash
|
|
385
|
+
"btn.on('click', fn);\n") # FP guard: .on(non-method, non-path)
|
|
386
|
+
paths = {(r["method"], r["path"]) for r in routes._router_calls(RepoContext(d))}
|
|
387
|
+
self.assertIn(("GET", "/api/health"), paths)
|
|
388
|
+
self.assertIn(("POST", "/api/todos/:id"), paths)
|
|
389
|
+
self.assertIn(("DELETE", "/api/items/:id"), paths)
|
|
390
|
+
self.assertIn(("PUT", "/api/users/:id"), paths)
|
|
391
|
+
self.assertNotIn(("GET", "lookup-key"), paths) # cache.get('lookup-key') is not a route
|
|
392
|
+
self.assertFalse(any("click" in p for _, p in paths)) # btn.on('click') is not a route
|
|
393
|
+
|
|
394
|
+
def test_coverage_warning_when_handlers_outnumber_routes(self):
|
|
395
|
+
# P1: many handler-ish fns but ~0 mapped routes ⇒ coverage warning (the hand-rolled-router failure mode).
|
|
396
|
+
d = Path(tempfile.mkdtemp())
|
|
397
|
+
(d / "w.ts").write_text(
|
|
398
|
+
"export default { fetch(request, env, ctx) { return dispatch(request); } };\n"
|
|
399
|
+
+ "".join(f"const h{i} = (req, res) => res.end();\n" for i in range(8)))
|
|
400
|
+
out = routes.RoutesExtractor().extract(RepoContext(d), {})
|
|
401
|
+
self.assertIsNotNone(out["coverage_warning"])
|
|
402
|
+
self.assertIn("INCOMPLETE", out["coverage_warning"])
|
|
403
|
+
|
|
367
404
|
|
|
368
405
|
class DedupTests(unittest.TestCase):
|
|
369
406
|
def test_within_tool_dedup_and_counts(self):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/extractors/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/extractors/client_exposure.py
RENAMED
|
File without changes
|
|
File without changes
|
{websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/extractors/graphql.py
RENAMED
|
File without changes
|
|
File without changes
|
{websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/extractors/integrations.py
RENAMED
|
File without changes
|
{websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/extractors/pii_exposure.py
RENAMED
|
File without changes
|
|
File without changes
|
{websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/extractors/schemas.py
RENAMED
|
File without changes
|
|
File without changes
|
{websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/extractors/upload_security.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/templates/probes/_lib.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/templates/probes/s3-assess.sh
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator.egg-info/top_level.txt
RENAMED
|
File without changes
|