websec-validator 0.2.6__tar.gz → 0.2.8__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 (59) hide show
  1. {websec_validator-0.2.6/src/websec_validator.egg-info → websec_validator-0.2.8}/PKG-INFO +1 -1
  2. {websec_validator-0.2.6 → websec_validator-0.2.8}/pyproject.toml +1 -1
  3. {websec_validator-0.2.6 → websec_validator-0.2.8}/src/websec_validator/dynamic.py +19 -12
  4. {websec_validator-0.2.6 → websec_validator-0.2.8}/src/websec_validator/extractors/auth.py +16 -0
  5. {websec_validator-0.2.6 → websec_validator-0.2.8}/src/websec_validator/probes.py +1 -0
  6. {websec_validator-0.2.6 → websec_validator-0.2.8}/src/websec_validator/scanners.py +25 -0
  7. {websec_validator-0.2.6 → websec_validator-0.2.8}/src/websec_validator/templates/probes/forged-token.sh +15 -3
  8. {websec_validator-0.2.6 → websec_validator-0.2.8/src/websec_validator.egg-info}/PKG-INFO +1 -1
  9. {websec_validator-0.2.6 → websec_validator-0.2.8}/tests/test_hardening.py +62 -0
  10. {websec_validator-0.2.6 → websec_validator-0.2.8}/LICENSE +0 -0
  11. {websec_validator-0.2.6 → websec_validator-0.2.8}/README.md +0 -0
  12. {websec_validator-0.2.6 → websec_validator-0.2.8}/setup.cfg +0 -0
  13. {websec_validator-0.2.6 → websec_validator-0.2.8}/src/websec_validator/__init__.py +0 -0
  14. {websec_validator-0.2.6 → websec_validator-0.2.8}/src/websec_validator/briefing.py +0 -0
  15. {websec_validator-0.2.6 → websec_validator-0.2.8}/src/websec_validator/calibration.json +0 -0
  16. {websec_validator-0.2.6 → websec_validator-0.2.8}/src/websec_validator/calibration.py +0 -0
  17. {websec_validator-0.2.6 → websec_validator-0.2.8}/src/websec_validator/cli.py +0 -0
  18. {websec_validator-0.2.6 → websec_validator-0.2.8}/src/websec_validator/constitution.py +0 -0
  19. {websec_validator-0.2.6 → websec_validator-0.2.8}/src/websec_validator/corpus.json +0 -0
  20. {websec_validator-0.2.6 → websec_validator-0.2.8}/src/websec_validator/extractors/__init__.py +0 -0
  21. {websec_validator-0.2.6 → websec_validator-0.2.8}/src/websec_validator/extractors/authz.py +0 -0
  22. {websec_validator-0.2.6 → websec_validator-0.2.8}/src/websec_validator/extractors/base.py +0 -0
  23. {websec_validator-0.2.6 → websec_validator-0.2.8}/src/websec_validator/extractors/client_exposure.py +0 -0
  24. {websec_validator-0.2.6 → websec_validator-0.2.8}/src/websec_validator/extractors/graphql.py +0 -0
  25. {websec_validator-0.2.6 → websec_validator-0.2.8}/src/websec_validator/extractors/iac_ci.py +0 -0
  26. {websec_validator-0.2.6 → websec_validator-0.2.8}/src/websec_validator/extractors/integrations.py +0 -0
  27. {websec_validator-0.2.6 → websec_validator-0.2.8}/src/websec_validator/extractors/routes.py +0 -0
  28. {websec_validator-0.2.6 → websec_validator-0.2.8}/src/websec_validator/extractors/schemas.py +0 -0
  29. {websec_validator-0.2.6 → websec_validator-0.2.8}/src/websec_validator/extractors/stack.py +0 -0
  30. {websec_validator-0.2.6 → websec_validator-0.2.8}/src/websec_validator/extractors/surface.py +0 -0
  31. {websec_validator-0.2.6 → websec_validator-0.2.8}/src/websec_validator/extractors/tenant.py +0 -0
  32. {websec_validator-0.2.6 → websec_validator-0.2.8}/src/websec_validator/findings.py +0 -0
  33. {websec_validator-0.2.6 → websec_validator-0.2.8}/src/websec_validator/proof.py +0 -0
  34. {websec_validator-0.2.6 → websec_validator-0.2.8}/src/websec_validator/recon.py +0 -0
  35. {websec_validator-0.2.6 → websec_validator-0.2.8}/src/websec_validator/report.py +0 -0
  36. {websec_validator-0.2.6 → websec_validator-0.2.8}/src/websec_validator/templates/probes/_lib.py +0 -0
  37. {websec_validator-0.2.6 → websec_validator-0.2.8}/src/websec_validator/templates/probes/bola-cross-tenant.sh +0 -0
  38. {websec_validator-0.2.6 → websec_validator-0.2.8}/src/websec_validator/templates/probes/bola-write-verbs.py +0 -0
  39. {websec_validator-0.2.6 → websec_validator-0.2.8}/src/websec_validator/templates/probes/compare-roles.sh +0 -0
  40. {websec_validator-0.2.6 → websec_validator-0.2.8}/src/websec_validator/templates/probes/dlp-bypass-offline.py +0 -0
  41. {websec_validator-0.2.6 → websec_validator-0.2.8}/src/websec_validator/templates/probes/hs256-brute-force.py +0 -0
  42. {websec_validator-0.2.6 → websec_validator-0.2.8}/src/websec_validator/templates/probes/jwt-attacks.sh +0 -0
  43. {websec_validator-0.2.6 → websec_validator-0.2.8}/src/websec_validator/templates/probes/mass-assignment.py +0 -0
  44. {websec_validator-0.2.6 → websec_validator-0.2.8}/src/websec_validator/templates/probes/race-conditions.py +0 -0
  45. {websec_validator-0.2.6 → websec_validator-0.2.8}/src/websec_validator/templates/probes/rate-limit-burst.sh +0 -0
  46. {websec_validator-0.2.6 → websec_validator-0.2.8}/src/websec_validator/templates/probes/s3-assess.sh +0 -0
  47. {websec_validator-0.2.6 → websec_validator-0.2.8}/src/websec_validator/templates/probes/ssrf-probes.sh +0 -0
  48. {websec_validator-0.2.6 → websec_validator-0.2.8}/src/websec_validator/templates/probes/unauth-baseline.sh +0 -0
  49. {websec_validator-0.2.6 → websec_validator-0.2.8}/src/websec_validator/templates/probes/webhook-forgery.py +0 -0
  50. {websec_validator-0.2.6 → websec_validator-0.2.8}/src/websec_validator/templates/reports/FINDINGS-SUMMARY.md.template +0 -0
  51. {websec_validator-0.2.6 → websec_validator-0.2.8}/src/websec_validator/templates/reports/access-control-matrix.md.template +0 -0
  52. {websec_validator-0.2.6 → websec_validator-0.2.8}/src/websec_validator/templates/reports/findings-triage.md.template +0 -0
  53. {websec_validator-0.2.6 → websec_validator-0.2.8}/src/websec_validator/templates/reports/pentest-handover-brief.md.template +0 -0
  54. {websec_validator-0.2.6 → websec_validator-0.2.8}/src/websec_validator/templates/reports/per-tool-FINDINGS.md.template +0 -0
  55. {websec_validator-0.2.6 → websec_validator-0.2.8}/src/websec_validator.egg-info/SOURCES.txt +0 -0
  56. {websec_validator-0.2.6 → websec_validator-0.2.8}/src/websec_validator.egg-info/dependency_links.txt +0 -0
  57. {websec_validator-0.2.6 → websec_validator-0.2.8}/src/websec_validator.egg-info/entry_points.txt +0 -0
  58. {websec_validator-0.2.6 → websec_validator-0.2.8}/src/websec_validator.egg-info/top_level.txt +0 -0
  59. {websec_validator-0.2.6 → websec_validator-0.2.8}/tests/test_recon.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: websec-validator
3
- Version: 0.2.6
3
+ Version: 0.2.8
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "websec-validator"
7
- version = "0.2.6"
7
+ version = "0.2.8"
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"
@@ -308,19 +308,24 @@ def forged_token_bypass(target: str, facts: dict, cookie_names=None,
308
308
  base_code, _ = _request(method, url, token=None, data=body)
309
309
  if base_code not in (401, 403):
310
310
  continue # only routes that are gated WITHOUT auth tell us anything about forgery
311
- attempts = [("Authorization: Bearer", _request(method, url, token=forged, data=body)[0])]
312
- for cn in cookie_names:
313
- attempts.append((f"cookie:{cn}", _request(method, url, token=None, data=body, cookie=f"{cn}={forged}")[0]))
314
- hit = next(((via, code) for via, code in attempts if code in _REACHED_HANDLER), None)
315
- if hit:
316
- via, fcode = hit
317
- row = {"method": method, "path": path, "baseline": base_code, "forged": fcode,
318
- "via": via, "verdict": "BYPASS"}
319
- bypassed.append(row)
311
+ # Bearer first (cheapest, most universal); only forge into each known auth cookie if
312
+ # Bearer didn't reach the handler — short-circuits to keep request volume (and
313
+ # rate-limiter pressure) down. cookie_names is what catches cookie-ONLY session apps.
314
+ hit, bearer_code = None, _request(method, url, token=forged, data=body)[0]
315
+ if bearer_code in _REACHED_HANDLER:
316
+ hit = ("Authorization: Bearer", bearer_code)
320
317
  else:
321
- row = {"method": method, "path": path, "baseline": base_code,
322
- "forged": attempts[0][1], "via": "Authorization: Bearer", "verdict": "rejected"}
318
+ for cn in (cookie_names or []):
319
+ cc = _request(method, url, token=None, data=body, cookie=f"{cn}={forged}")[0]
320
+ if cc in _REACHED_HANDLER:
321
+ hit = (f"cookie:{cn}", cc)
322
+ break
323
+ via, fcode = hit if hit else ("Authorization: Bearer", bearer_code)
324
+ row = {"method": method, "path": path, "baseline": base_code, "forged": fcode,
325
+ "via": via, "verdict": "BYPASS" if hit else "rejected"}
323
326
  results.append(row)
327
+ if hit:
328
+ bypassed.append(row)
324
329
 
325
330
  return {
326
331
  "target": target,
@@ -338,8 +343,10 @@ def forged_token_bypass(target: str, facts: dict, cookie_names=None,
338
343
 
339
344
  def run_unauth(target: str, facts_path: Path, outdir: Path, probe_writes: bool = False) -> dict:
340
345
  facts = json.loads(Path(facts_path).read_text())
346
+ cookie_names = (facts.get("auth") or {}).get("cookie_names")
341
347
  res = {"unauth_reachability": unauth_reachability(target, facts),
342
- "forged_token_bypass": forged_token_bypass(target, facts, probe_writes=probe_writes)}
348
+ "forged_token_bypass": forged_token_bypass(target, facts, cookie_names=cookie_names,
349
+ probe_writes=probe_writes)}
343
350
  if probe_writes:
344
351
  res["write_auth_enforcement"] = write_auth_enforcement(target, facts)
345
352
  outdir.mkdir(parents=True, exist_ok=True)
@@ -17,6 +17,15 @@ 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
19
  GUARDS = re.compile(r"requireAuth|requirePermission|requireRole|isAuthenticated|@login_required|@require|ensureAuth|withAuth|getServerSession|verifyToken|authMiddleware|@roles_required|can\(|ability\.", re.I)
20
+ # Cookie READ sites — the names the app pulls a token/session from. The forged-token probe
21
+ # forges into these (not just Authorization: Bearer) so a cookie-ONLY session app isn't a
22
+ # false negative. Covers cookies.get('X') / cookies['X'] / getCookie('X') / req.cookies.X.
23
+ COOKIE_READ = re.compile(
24
+ r"""cookies\s*(?:\.get\(|\[)\s*['"]([A-Za-z0-9_.\-]{2,64})['"]""" # cookies.get('X') / cookies['X']
25
+ r"""|getCookie\(\s*['"]([A-Za-z0-9_.\-]{2,64})['"]""" # getCookie('X')
26
+ r"""|\.cookies\.([A-Za-z_][A-Za-z0-9_]{1,63})\b(?!\s*\()""") # req.cookies.X (not a .get()/.set() call)
27
+ _COOKIE_RESERVED = {"get", "set", "getall", "has", "delete", "clear", "tostring",
28
+ "foreach", "entries", "keys", "values", "size", "name", "value", "length"}
20
29
 
21
30
 
22
31
  class AuthExtractor(Extractor):
@@ -31,6 +40,7 @@ class AuthExtractor(Extractor):
31
40
  # scheme: framework/route signals first, then grep
32
41
  jwt = passport = session = apikey = 0
33
42
  guard_files = []
43
+ cookie_names: list[str] = []
34
44
  for _p, rel, text in ctx.iter_code():
35
45
  if JWT_LIBS.search(text):
36
46
  jwt += 1
@@ -42,6 +52,11 @@ class AuthExtractor(Extractor):
42
52
  apikey += 1
43
53
  if GUARDS.search(text) and len(guard_files) < 25:
44
54
  guard_files.append(rel)
55
+ if len(cookie_names) < 20:
56
+ for m in COOKIE_READ.finditer(text):
57
+ name = m.group(1) or m.group(2) or m.group(3)
58
+ if name and name.lower() not in _COOKIE_RESERVED and name not in cookie_names:
59
+ cookie_names.append(name)
45
60
 
46
61
  nextauth = "nextauth" in frameworks or any("nextauth" in e.lower() for e in auth_eps)
47
62
 
@@ -70,6 +85,7 @@ class AuthExtractor(Extractor):
70
85
  "schemes_detected": detected,
71
86
  "token_location": token_location,
72
87
  "login_endpoints": auth_eps,
88
+ "cookie_names": cookie_names[:15],
73
89
  "guard_files": guard_files,
74
90
  "signal_counts": {"jwt": jwt, "passport": passport, "session": session, "api_key": apikey},
75
91
  "note": "AGENT: confirm the PRIMARY auth flow + how a test token is minted before the JWT/auth "
@@ -111,6 +111,7 @@ def build_context(facts: dict) -> dict:
111
111
  "auth": {
112
112
  "scheme": auth.get("scheme"),
113
113
  "token_location": auth.get("token_location"),
114
+ "cookie_names": auth.get("cookie_names", []),
114
115
  "login_endpoints": tgt.get("auth_endpoints", [])[:10],
115
116
  "how_to_authenticate": "cookie-session (e.g. NextAuth) → send the session cookie; "
116
117
  "bearer → Authorization: Bearer <jwt>; api-key → the documented key header",
@@ -217,6 +217,27 @@ def _generic_secret(rule: str) -> bool:
217
217
  return r in _GENERIC_SECRET_RULES or "generic" in r or "entropy" in r
218
218
 
219
219
 
220
+ # Secrets matched in DOCUMENTATION / EXAMPLE files are overwhelmingly placeholders, not live
221
+ # credentials — e.g. `curl -H "Authorization: Bearer <token>"` in a README/API doc, or a
222
+ # value in `.env.example`. Tier those to LOW + a verify note (still visible — a real key CAN be
223
+ # pasted into docs by mistake). Dogfooding flagged 4 HIGH curl-auth-header FPs across an API's
224
+ # README + docs/*.md (bug below).
225
+ _DOC_EXT = (".md", ".mdx", ".markdown", ".rst", ".txt", ".adoc")
226
+ _DOC_DIR_MARKERS = ("/docs/", "/doc/", "/examples/", "/example/", "/samples/", "/sample/", "/.github/")
227
+ _DOC_NAME_PREFIX = ("readme", "changelog", "contributing", "license", "authors", "history", "notice")
228
+ _EXAMPLE_SUFFIX = (".example", ".sample", ".dist", ".template", ".tmpl")
229
+ _DOC_NOTE = "in a documentation/example file — almost always a placeholder, verify before treating as real"
230
+
231
+
232
+ def _is_doc_or_example(path: str) -> bool:
233
+ p = (path or "").replace("\\", "/").lower()
234
+ base = p.rsplit("/", 1)[-1]
235
+ return (p.endswith(_DOC_EXT)
236
+ or any(m in p for m in _DOC_DIR_MARKERS)
237
+ or any(base.startswith(m) for m in _DOC_NAME_PREFIX)
238
+ or any(s in base for s in _EXAMPLE_SUFFIX))
239
+
240
+
220
241
  def _norm_trivy(data: dict) -> list:
221
242
  out = []
222
243
  for res in (data.get("Results") or []):
@@ -231,6 +252,8 @@ def _norm_trivy(data: dict) -> list:
231
252
  sev, note = _aws_secret_tier(s.get("Match", ""), s.get("Code", "") or "")
232
253
  if not sev and _generic_secret(rid):
233
254
  sev, note = "MEDIUM", _GENERIC_NOTE
255
+ if _is_doc_or_example(tgt):
256
+ sev, note = "LOW", (note + "; " if note else "") + _DOC_NOTE
234
257
  title = f"secret: {s.get('Title') or rid}" + (f" — {note}" if note else "")
235
258
  out.append({"tool": "trivy", "category": "secret", "severity": sev or _sev(s.get("Severity") or "HIGH"),
236
259
  "key": rid, "file": tgt, "line": s.get("StartLine", 0),
@@ -250,6 +273,8 @@ def _norm_gitleaks(data) -> list:
250
273
  sev, note = _aws_secret_tier(x.get("Secret", ""), x.get("Match", ""))
251
274
  if not sev and _generic_secret(rule):
252
275
  sev, note = "MEDIUM", _GENERIC_NOTE
276
+ if _is_doc_or_example(f):
277
+ sev, note = "LOW", (note + "; " if note else "") + _DOC_NOTE
253
278
  title = f"secret: {(x.get('Description') or rule)[:80]}" + (f" — {note}" if note else "")
254
279
  out.append({"tool": "gitleaks", "category": "secret", "severity": sev or "HIGH",
255
280
  "key": rule, "file": f, "line": x.get("StartLine", 0),
@@ -26,6 +26,16 @@ def b(o): return base64.urlsafe_b64encode(json.dumps(o).encode()).rstrip(b'=').d
26
26
  print(b({'alg':'RS256','typ':'JWT','kid':'forged'})+'.'+b({'sub':'websec-forged','email':'websec-forged@example.com','role':'admin','roles':['admin'],'exp':9999999999})+'.d2Vic2VjLWZvcmdlZC1zaWc')
27
27
  ")
28
28
 
29
+ # Auth cookie names the app reads (from recon → probe-context.json) + an optional COOKIE_NAME
30
+ # override. We forge into these too, not just Authorization: Bearer, so a cookie-ONLY app isn't
31
+ # a false negative. (portable; macOS bash 3.2 lacks `mapfile`.)
32
+ COOKIES=()
33
+ [ -n "${COOKIE_NAME:-}" ] && COOKIES+=("$COOKIE_NAME")
34
+ while IFS= read -r cn; do [ -n "$cn" ] && COOKIES+=("$cn"); done < <(python3 -c "
35
+ import json
36
+ for c in json.load(open('$ctx')).get('auth',{}).get('cookie_names',[]): print(c)
37
+ " 2>/dev/null)
38
+
29
39
  # Routes to test: GET reads + GET idor/ssrf candidates (always); writes when PROBE_WRITES=1.
30
40
  # Skip any path with an unfilled {param}. (portable; macOS bash 3.2 lacks `mapfile`.)
31
41
  ROUTES=()
@@ -64,9 +74,11 @@ for ep in "${ROUTES[@]}"; do
64
74
  if [ "$na" != "401" ] && [ "$na" != "403" ]; then skip=$((skip+1)); continue; fi # not gated unauthenticated → N/A here
65
75
  fg=$(curl -s -o /dev/null -w '%{http_code}' -X "$method" "$BASE$path" -H "Authorization: Bearer $FORGED" ${data[@]+"${data[@]}"} --max-time 15)
66
76
  via="Bearer"
67
- if ! reached "$fg" && [ -n "${COOKIE_NAME:-}" ]; then
68
- fg=$(curl -s -o /dev/null -w '%{http_code}' -X "$method" "$BASE$path" -H "Cookie: $COOKIE_NAME=$FORGED" ${data[@]+"${data[@]}"} --max-time 15)
69
- via="cookie:$COOKIE_NAME"
77
+ if ! reached "$fg"; then # Bearer didn't reach the handler → try forging into each known auth cookie
78
+ for cn in ${COOKIES[@]+"${COOKIES[@]}"}; do
79
+ cfg=$(curl -s -o /dev/null -w '%{http_code}' -X "$method" "$BASE$path" -H "Cookie: $cn=$FORGED" ${data[@]+"${data[@]}"} --max-time 15)
80
+ if reached "$cfg"; then fg="$cfg"; via="cookie:$cn"; break; fi
81
+ done
70
82
  fi
71
83
  if reached "$fg"; then
72
84
  printf ' BYPASS %s→%s %s %s (forged token accepted via %s)\n' "$na" "$fg" "$method" "$path" "$via"; bypass=$((bypass+1))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: websec-validator
3
- Version: 0.2.6
3
+ Version: 0.2.8
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
@@ -17,6 +17,8 @@ ROOT = Path(__file__).resolve().parents[1]
17
17
  sys.path.insert(0, str(ROOT / "src"))
18
18
 
19
19
  from websec_validator import dynamic, findings, probes, scanners # noqa: E402
20
+ from websec_validator.extractors.auth import AuthExtractor # noqa: E402
21
+ from websec_validator.extractors.base import RepoContext # noqa: E402
20
22
 
21
23
  FACTS = {"routes": {"endpoints": [
22
24
  {"method": "GET", "path": "/api/bypass"}, # gated; accepts forged token -> BYPASS
@@ -169,5 +171,65 @@ class SecretPrecisionTests(unittest.TestCase):
169
171
  self.assertEqual(hit["confidence"], "MEDIUM")
170
172
 
171
173
 
174
+ class DocExampleSecretTests(unittest.TestCase):
175
+ """0.2.8: secrets in documentation/example files (curl examples in a README, .env.example
176
+ placeholders) tier to LOW + a verify note. Real code files are untouched."""
177
+
178
+ def test_is_doc_or_example(self):
179
+ self.assertTrue(scanners._is_doc_or_example("README.md"))
180
+ self.assertTrue(scanners._is_doc_or_example("docs/API-REFERENCE.md"))
181
+ self.assertTrue(scanners._is_doc_or_example(".env.example"))
182
+ self.assertTrue(scanners._is_doc_or_example("config/settings.sample.json"))
183
+ self.assertFalse(scanners._is_doc_or_example("src/app/route.ts"))
184
+
185
+ def test_gitleaks_doc_secret_to_low_code_stays_high(self):
186
+ rows = [
187
+ {"File": "README.md", "RuleID": "curl-auth-header", "Secret": "x" * 30, "Match": "Authorization: Bearer x", "StartLine": 1},
188
+ {"File": "src/server.ts", "RuleID": "private-key", "Secret": "-----BEGIN", "Match": "-----BEGIN", "StartLine": 1},
189
+ ]
190
+ by = {r["file"]: r for r in scanners._norm_gitleaks(rows)}
191
+ self.assertEqual(by["README.md"]["severity"], "LOW")
192
+ self.assertIn("documentation/example", by["README.md"]["title"])
193
+ self.assertEqual(by["src/server.ts"]["severity"], "HIGH") # real code file untouched
194
+
195
+ def test_trivy_doc_secret_to_low(self):
196
+ data = {"Results": [{"Target": "docs/SECURITY.md", "Secrets": [
197
+ {"RuleID": "curl-auth-header", "Title": "Auth header", "Match": "Bearer x", "StartLine": 1}]}]}
198
+ secs = [f for f in scanners._norm_trivy(data) if f["category"] == "secret"]
199
+ self.assertEqual(secs[0]["severity"], "LOW")
200
+
201
+
202
+ class CookieCoverageTests(unittest.TestCase):
203
+ """0.2.7: extract auth cookie names so the forged-token engine covers cookie-ONLY apps."""
204
+
205
+ def test_extracts_cookie_names(self):
206
+ with tempfile.TemporaryDirectory() as d:
207
+ d = Path(d)
208
+ (d / "auth.ts").write_text(
209
+ "const s = request.cookies.get('agent_wallet_session');\n"
210
+ "const p = req.cookies['ping_id_token'];\n"
211
+ "const x = getCookie('dynamic_authentication_token');\n")
212
+ out = AuthExtractor().extract(RepoContext(d), {"stack": {"frameworks": []}, "routes": {}})
213
+ names = set(out["cookie_names"])
214
+ self.assertIn("agent_wallet_session", names)
215
+ self.assertIn("ping_id_token", names)
216
+ self.assertIn("dynamic_authentication_token", names)
217
+ self.assertNotIn("get", names) # reserved method name filtered
218
+
219
+ def test_forged_bypass_detected_via_cookie(self):
220
+ facts = {"routes": {"endpoints": [{"method": "GET", "path": "/api/cookieonly"}]}}
221
+
222
+ def fake(method, url, token=None, timeout=20, data=None, cookie=None):
223
+ if token:
224
+ return 401, "x" # Bearer rejected
225
+ if cookie and "sess=" in cookie:
226
+ return 200, "x" # forged cookie accepted (cookie-only app)
227
+ return 401, "x" # no-auth baseline (gated)
228
+ with mock.patch.object(dynamic, "_request", fake):
229
+ r = dynamic.forged_token_bypass("http://t", facts, cookie_names=["sess"])
230
+ self.assertEqual([b["path"] for b in r["bypassed"]], ["/api/cookieonly"])
231
+ self.assertTrue(r["bypassed"][0]["via"].startswith("cookie:"))
232
+
233
+
172
234
  if __name__ == "__main__":
173
235
  unittest.main()