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.
Files changed (75) hide show
  1. {websec_validator-0.5.0/src/websec_validator.egg-info → websec_validator-0.6.0}/PKG-INFO +3 -3
  2. {websec_validator-0.5.0 → websec_validator-0.6.0}/README.md +2 -2
  3. {websec_validator-0.5.0 → websec_validator-0.6.0}/pyproject.toml +1 -1
  4. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/briefing.py +2 -0
  5. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/extractors/auth.py +22 -3
  6. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/extractors/routes.py +63 -5
  7. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/extractors/stack.py +29 -0
  8. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/extractors/surface.py +7 -0
  9. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/extractors/transport_security.py +4 -1
  10. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/findings.py +2 -1
  11. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/probes.py +16 -3
  12. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/scanners.py +33 -0
  13. {websec_validator-0.5.0 → websec_validator-0.6.0/src/websec_validator.egg-info}/PKG-INFO +3 -3
  14. {websec_validator-0.5.0 → websec_validator-0.6.0}/tests/test_hardening.py +11 -3
  15. {websec_validator-0.5.0 → websec_validator-0.6.0}/tests/test_pentest_regressions.py +56 -0
  16. {websec_validator-0.5.0 → websec_validator-0.6.0}/tests/test_recon.py +37 -0
  17. {websec_validator-0.5.0 → websec_validator-0.6.0}/LICENSE +0 -0
  18. {websec_validator-0.5.0 → websec_validator-0.6.0}/setup.cfg +0 -0
  19. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/__init__.py +0 -0
  20. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/calibration.json +0 -0
  21. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/calibration.py +0 -0
  22. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/cli.py +0 -0
  23. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/constitution.py +0 -0
  24. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/corpus.json +0 -0
  25. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/dynamic.py +0 -0
  26. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/extractors/__init__.py +0 -0
  27. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/extractors/authz.py +0 -0
  28. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/extractors/base.py +0 -0
  29. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/extractors/client_exposure.py +0 -0
  30. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/extractors/client_integrity.py +0 -0
  31. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/extractors/graphql.py +0 -0
  32. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/extractors/iac_ci.py +0 -0
  33. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/extractors/integrations.py +0 -0
  34. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/extractors/pii_exposure.py +0 -0
  35. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/extractors/policy_consistency.py +0 -0
  36. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/extractors/schemas.py +0 -0
  37. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/extractors/tenant.py +0 -0
  38. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/extractors/upload_security.py +0 -0
  39. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/proof.py +0 -0
  40. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/recon.py +0 -0
  41. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/report.py +0 -0
  42. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/rules/error-stack-disclosure.yml +0 -0
  43. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/rules/insecure-default-secret.yml +0 -0
  44. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/templates/probes/_lib.py +0 -0
  45. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/templates/probes/appsync-cswsh.sh +0 -0
  46. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/templates/probes/appsync-introspection.sh +0 -0
  47. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/templates/probes/appsync-subscription-bola.sh +0 -0
  48. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/templates/probes/bola-cross-tenant.sh +0 -0
  49. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/templates/probes/bola-write-verbs.py +0 -0
  50. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/templates/probes/client-integrity-checklist.sh +0 -0
  51. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/templates/probes/compare-roles.sh +0 -0
  52. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/templates/probes/dlp-bypass-offline.py +0 -0
  53. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/templates/probes/error-disclosure-probe.sh +0 -0
  54. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/templates/probes/forged-token.sh +0 -0
  55. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/templates/probes/hs256-brute-force.py +0 -0
  56. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/templates/probes/jwt-attacks.sh +0 -0
  57. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/templates/probes/mass-assignment.py +0 -0
  58. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/templates/probes/password-reuse.sh +0 -0
  59. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/templates/probes/pii-output-diff.sh +0 -0
  60. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/templates/probes/race-conditions.py +0 -0
  61. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/templates/probes/rate-limit-burst.sh +0 -0
  62. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/templates/probes/s3-assess.sh +0 -0
  63. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/templates/probes/ssrf-probes.sh +0 -0
  64. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/templates/probes/unauth-baseline.sh +0 -0
  65. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/templates/probes/upload-matrix.sh +0 -0
  66. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/templates/probes/webhook-forgery.py +0 -0
  67. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/templates/reports/FINDINGS-SUMMARY.md.template +0 -0
  68. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/templates/reports/access-control-matrix.md.template +0 -0
  69. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/templates/reports/findings-triage.md.template +0 -0
  70. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/templates/reports/pentest-handover-brief.md.template +0 -0
  71. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator/templates/reports/per-tool-FINDINGS.md.template +0 -0
  72. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator.egg-info/SOURCES.txt +0 -0
  73. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator.egg-info/dependency_links.txt +0 -0
  74. {websec_validator-0.5.0 → websec_validator-0.6.0}/src/websec_validator.egg-info/entry_points.txt +0 -0
  75. {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.5.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 | 14 sink classes **+ redirect-SSRF** | user-input-gated sinks + var-arg SSRF + error-disclosure **+ follows-redirects-without-per-hop-guard** |
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 — 126 tests
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 | 14 sink classes **+ redirect-SSRF** | user-input-gated sinks + var-arg SSRF + error-disclosure **+ follows-redirects-without-per-hop-guard** |
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 — 126 tests
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.5.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") or primary.startswith("session")
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
- # ---- regex fallback (only when Noir is absent) ---------------------------------------------
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
  }
@@ -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", "cassandra"}
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
- # unauth-baseline is ALWAYS staged: it's the cheapest probe and directly exercises the
69
- # #1 lead class (missing authentication) — the one a no-creds run can confirm immediately.
70
- ALWAYS = ["unauth-baseline", "forged-token", "jwt-attacks", "hs256-brute-force", "rate-limit-burst"]
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.5.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 | 14 sink classes **+ redirect-SSRF** | user-input-gated sinks + var-arg SSRF + error-disclosure **+ follows-redirects-without-per-hop-guard** |
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 — 126 tests
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 test_forged_token_always_staged(self):
213
- self.assertIn("forged-token", probes.ALWAYS)
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({"routes": {"targeting": {}}}))
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):