websec-validator 0.2.5__tar.gz → 0.2.7__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.5/src/websec_validator.egg-info → websec_validator-0.2.7}/PKG-INFO +1 -1
  2. {websec_validator-0.2.5 → websec_validator-0.2.7}/pyproject.toml +1 -1
  3. {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/dynamic.py +19 -12
  4. {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/extractors/auth.py +16 -0
  5. {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/findings.py +3 -1
  6. {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/probes.py +1 -0
  7. {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/scanners.py +25 -3
  8. {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/templates/probes/forged-token.sh +15 -3
  9. {websec_validator-0.2.5 → websec_validator-0.2.7/src/websec_validator.egg-info}/PKG-INFO +1 -1
  10. {websec_validator-0.2.5 → websec_validator-0.2.7}/tests/test_hardening.py +70 -0
  11. {websec_validator-0.2.5 → websec_validator-0.2.7}/LICENSE +0 -0
  12. {websec_validator-0.2.5 → websec_validator-0.2.7}/README.md +0 -0
  13. {websec_validator-0.2.5 → websec_validator-0.2.7}/setup.cfg +0 -0
  14. {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/__init__.py +0 -0
  15. {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/briefing.py +0 -0
  16. {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/calibration.json +0 -0
  17. {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/calibration.py +0 -0
  18. {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/cli.py +0 -0
  19. {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/constitution.py +0 -0
  20. {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/corpus.json +0 -0
  21. {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/extractors/__init__.py +0 -0
  22. {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/extractors/authz.py +0 -0
  23. {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/extractors/base.py +0 -0
  24. {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/extractors/client_exposure.py +0 -0
  25. {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/extractors/graphql.py +0 -0
  26. {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/extractors/iac_ci.py +0 -0
  27. {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/extractors/integrations.py +0 -0
  28. {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/extractors/routes.py +0 -0
  29. {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/extractors/schemas.py +0 -0
  30. {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/extractors/stack.py +0 -0
  31. {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/extractors/surface.py +0 -0
  32. {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/extractors/tenant.py +0 -0
  33. {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/proof.py +0 -0
  34. {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/recon.py +0 -0
  35. {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/report.py +0 -0
  36. {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/templates/probes/_lib.py +0 -0
  37. {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/templates/probes/bola-cross-tenant.sh +0 -0
  38. {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/templates/probes/bola-write-verbs.py +0 -0
  39. {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/templates/probes/compare-roles.sh +0 -0
  40. {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/templates/probes/dlp-bypass-offline.py +0 -0
  41. {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/templates/probes/hs256-brute-force.py +0 -0
  42. {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/templates/probes/jwt-attacks.sh +0 -0
  43. {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/templates/probes/mass-assignment.py +0 -0
  44. {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/templates/probes/race-conditions.py +0 -0
  45. {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/templates/probes/rate-limit-burst.sh +0 -0
  46. {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/templates/probes/s3-assess.sh +0 -0
  47. {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/templates/probes/ssrf-probes.sh +0 -0
  48. {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/templates/probes/unauth-baseline.sh +0 -0
  49. {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/templates/probes/webhook-forgery.py +0 -0
  50. {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/templates/reports/FINDINGS-SUMMARY.md.template +0 -0
  51. {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/templates/reports/access-control-matrix.md.template +0 -0
  52. {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/templates/reports/findings-triage.md.template +0 -0
  53. {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/templates/reports/pentest-handover-brief.md.template +0 -0
  54. {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/templates/reports/per-tool-FINDINGS.md.template +0 -0
  55. {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator.egg-info/SOURCES.txt +0 -0
  56. {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator.egg-info/dependency_links.txt +0 -0
  57. {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator.egg-info/entry_points.txt +0 -0
  58. {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator.egg-info/top_level.txt +0 -0
  59. {websec_validator-0.2.5 → websec_validator-0.2.7}/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.5
3
+ Version: 0.2.7
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.5"
7
+ version = "0.2.7"
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 "
@@ -184,7 +184,9 @@ def build_ledger(facts: dict, unified: dict | None, dynamic: dict | None = None,
184
184
  cat = t.get("category", "")
185
185
  cls = cat_to_class.get(cat, "sast")
186
186
  sev = t.get("severity", "MEDIUM")
187
- conf = "HIGH" if cat in ("secret",) or (cat == "sca" and sev in ("HIGH", "CRITICAL")) else "MEDIUM"
187
+ # Confidence follows severity for secrets/CVEs: a generic-api-key tiered down to MEDIUM
188
+ # (low-precision rule, bug-072) should NOT be stamped HIGH-confidence — keep P(real) honest.
189
+ conf = "HIGH" if (cat in ("secret", "sca") and sev in ("HIGH", "CRITICAL")) else "MEDIUM"
188
190
  out.append(_f(t.get("title", cat), f"static-{cat}", cls, sev, conf, t.get("file", ""),
189
191
  [{"layer": "static", "detail": f"{'+'.join(t.get('tools', []))}: {t.get('title','')}"}]))
190
192
 
@@ -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",
@@ -200,6 +200,23 @@ def _aws_secret_tier(secret: str, match: str):
200
200
  return None, None
201
201
 
202
202
 
203
+ # gitleaks/trivy "generic" + entropy/keyword rules are high-recall, low-precision: they fire on
204
+ # public keys, wallet addresses, hashes, env-var refs and test fixtures about as often as real
205
+ # credentials. Tier those to MEDIUM + a verify note (NEVER hide them) so the HIGH secret tier
206
+ # stays trustworthy in a shareable report; specific-format rules (AKIA, private-key, GitHub/
207
+ # Stripe/Slack/JWT, etc.) keep HIGH. (bug-072 — dogfooding a wallet app surfaced ~20 HIGH
208
+ # generic-api-key FPs in committed source.)
209
+ _GENERIC_SECRET_RULES = {"generic-api-key", "generic-api-key-1", "generic", "api-key",
210
+ "secret-keyword", "high-entropy", "high-entropy-string", "entropy"}
211
+ _GENERIC_NOTE = ("generic/entropy match — verify it's a live credential "
212
+ "(often a public key, address, hash or env-ref, not a secret)")
213
+
214
+
215
+ def _generic_secret(rule: str) -> bool:
216
+ r = (rule or "").lower()
217
+ return r in _GENERIC_SECRET_RULES or "generic" in r or "entropy" in r
218
+
219
+
203
220
  def _norm_trivy(data: dict) -> list:
204
221
  out = []
205
222
  for res in (data.get("Results") or []):
@@ -210,11 +227,14 @@ def _norm_trivy(data: dict) -> list:
210
227
  "title": f"{v.get('PkgName')} {v.get('InstalledVersion')} → {v.get('FixedVersion', '(no fix)')}",
211
228
  "fingerprint": f"cve|{v.get('PkgName')}|{v.get('VulnerabilityID')}"})
212
229
  for s in (res.get("Secrets") or []):
230
+ rid = s.get("RuleID", "")
213
231
  sev, note = _aws_secret_tier(s.get("Match", ""), s.get("Code", "") or "")
214
- title = f"secret: {s.get('Title') or s.get('RuleID')}" + (f" — {note}" if note else "")
232
+ if not sev and _generic_secret(rid):
233
+ sev, note = "MEDIUM", _GENERIC_NOTE
234
+ title = f"secret: {s.get('Title') or rid}" + (f" — {note}" if note else "")
215
235
  out.append({"tool": "trivy", "category": "secret", "severity": sev or _sev(s.get("Severity") or "HIGH"),
216
- "key": s.get("RuleID", ""), "file": tgt, "line": s.get("StartLine", 0),
217
- "title": title, "fingerprint": f"secret|{tgt}|{s.get('RuleID')}"})
236
+ "key": rid, "file": tgt, "line": s.get("StartLine", 0),
237
+ "title": title, "fingerprint": f"secret|{tgt}|{rid}"})
218
238
  for m in (res.get("Misconfigurations") or []):
219
239
  out.append({"tool": "trivy", "category": "iac", "severity": _sev(m.get("Severity")),
220
240
  "key": m.get("ID", ""), "file": tgt, "line": 0, "title": (m.get("Title") or "")[:90],
@@ -228,6 +248,8 @@ def _norm_gitleaks(data) -> list:
228
248
  for x in rows:
229
249
  f, rule = x.get("File", ""), x.get("RuleID", "")
230
250
  sev, note = _aws_secret_tier(x.get("Secret", ""), x.get("Match", ""))
251
+ if not sev and _generic_secret(rule):
252
+ sev, note = "MEDIUM", _GENERIC_NOTE
231
253
  title = f"secret: {(x.get('Description') or rule)[:80]}" + (f" — {note}" if note else "")
232
254
  out.append({"tool": "gitleaks", "category": "secret", "severity": sev or "HIGH",
233
255
  "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.5
3
+ Version: 0.2.7
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
@@ -133,5 +135,73 @@ class ProbeRegistrationTests(unittest.TestCase):
133
135
  self.assertEqual(ctx["endpoints"]["reads"], ["GET /api/a"])
134
136
 
135
137
 
138
+ class SecretPrecisionTests(unittest.TestCase):
139
+ """bug-072: low-precision generic/entropy secret rules -> MEDIUM (+verify note); specific
140
+ rules (AKIA, private-key, …) keep HIGH. Nothing is hidden."""
141
+
142
+ def test_generic_rule_detection(self):
143
+ self.assertTrue(scanners._generic_secret("generic-api-key"))
144
+ self.assertTrue(scanners._generic_secret("high-entropy-string"))
145
+ self.assertFalse(scanners._generic_secret("aws-access-token"))
146
+ self.assertFalse(scanners._generic_secret("private-key"))
147
+
148
+ def test_gitleaks_generic_is_medium_specific_is_high(self):
149
+ rows = [
150
+ {"File": "src/lib/chains.ts", "RuleID": "generic-api-key", "Secret": "x" * 40, "Match": "x" * 40, "StartLine": 1},
151
+ {"File": "src/k.pem", "RuleID": "private-key", "Secret": "-----BEGIN", "Match": "-----BEGIN", "StartLine": 1},
152
+ {"File": "src/a.ts", "RuleID": "aws-access-token", "Secret": "AKIA" + "A" * 16, "Match": "AKIA" + "A" * 16, "StartLine": 1},
153
+ ]
154
+ by = {r["key"]: r for r in scanners._norm_gitleaks(rows)}
155
+ self.assertEqual(by["generic-api-key"]["severity"], "MEDIUM")
156
+ self.assertIn("generic/entropy", by["generic-api-key"]["title"])
157
+ self.assertEqual(by["private-key"]["severity"], "HIGH") # specific rule untouched
158
+ self.assertEqual(by["aws-access-token"]["severity"], "HIGH") # AKIA via _aws_secret_tier
159
+
160
+ def test_trivy_generic_secret_is_medium(self):
161
+ data = {"Results": [{"Target": "src/x.ts", "Secrets": [
162
+ {"RuleID": "generic-api-key", "Title": "Generic API Key", "Match": "y" * 40, "StartLine": 2}]}]}
163
+ secs = [f for f in scanners._norm_trivy(data) if f["category"] == "secret"]
164
+ self.assertEqual(secs[0]["severity"], "MEDIUM")
165
+
166
+ def test_medium_secret_gets_medium_confidence_in_ledger(self):
167
+ unified = {"top": [{"severity": "MEDIUM", "category": "secret",
168
+ "title": "secret: Generic API Key — generic/entropy match", "file": "src/x.ts", "tools": ["gitleaks"]}]}
169
+ led = findings.build_ledger({}, unified, None, [])
170
+ hit = [f for f in led["findings"] if f["category"] == "static-secret"][0]
171
+ self.assertEqual(hit["confidence"], "MEDIUM")
172
+
173
+
174
+ class CookieCoverageTests(unittest.TestCase):
175
+ """0.2.7: extract auth cookie names so the forged-token engine covers cookie-ONLY apps."""
176
+
177
+ def test_extracts_cookie_names(self):
178
+ with tempfile.TemporaryDirectory() as d:
179
+ d = Path(d)
180
+ (d / "auth.ts").write_text(
181
+ "const s = request.cookies.get('agent_wallet_session');\n"
182
+ "const p = req.cookies['ping_id_token'];\n"
183
+ "const x = getCookie('dynamic_authentication_token');\n")
184
+ out = AuthExtractor().extract(RepoContext(d), {"stack": {"frameworks": []}, "routes": {}})
185
+ names = set(out["cookie_names"])
186
+ self.assertIn("agent_wallet_session", names)
187
+ self.assertIn("ping_id_token", names)
188
+ self.assertIn("dynamic_authentication_token", names)
189
+ self.assertNotIn("get", names) # reserved method name filtered
190
+
191
+ def test_forged_bypass_detected_via_cookie(self):
192
+ facts = {"routes": {"endpoints": [{"method": "GET", "path": "/api/cookieonly"}]}}
193
+
194
+ def fake(method, url, token=None, timeout=20, data=None, cookie=None):
195
+ if token:
196
+ return 401, "x" # Bearer rejected
197
+ if cookie and "sess=" in cookie:
198
+ return 200, "x" # forged cookie accepted (cookie-only app)
199
+ return 401, "x" # no-auth baseline (gated)
200
+ with mock.patch.object(dynamic, "_request", fake):
201
+ r = dynamic.forged_token_bypass("http://t", facts, cookie_names=["sess"])
202
+ self.assertEqual([b["path"] for b in r["bypassed"]], ["/api/cookieonly"])
203
+ self.assertTrue(r["bypassed"][0]["via"].startswith("cookie:"))
204
+
205
+
136
206
  if __name__ == "__main__":
137
207
  unittest.main()