websec-validator 0.2.6__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.6/src/websec_validator.egg-info → websec_validator-0.2.7}/PKG-INFO +1 -1
  2. {websec_validator-0.2.6 → websec_validator-0.2.7}/pyproject.toml +1 -1
  3. {websec_validator-0.2.6 → websec_validator-0.2.7}/src/websec_validator/dynamic.py +19 -12
  4. {websec_validator-0.2.6 → websec_validator-0.2.7}/src/websec_validator/extractors/auth.py +16 -0
  5. {websec_validator-0.2.6 → websec_validator-0.2.7}/src/websec_validator/probes.py +1 -0
  6. {websec_validator-0.2.6 → websec_validator-0.2.7}/src/websec_validator/templates/probes/forged-token.sh +15 -3
  7. {websec_validator-0.2.6 → websec_validator-0.2.7/src/websec_validator.egg-info}/PKG-INFO +1 -1
  8. {websec_validator-0.2.6 → websec_validator-0.2.7}/tests/test_hardening.py +34 -0
  9. {websec_validator-0.2.6 → websec_validator-0.2.7}/LICENSE +0 -0
  10. {websec_validator-0.2.6 → websec_validator-0.2.7}/README.md +0 -0
  11. {websec_validator-0.2.6 → websec_validator-0.2.7}/setup.cfg +0 -0
  12. {websec_validator-0.2.6 → websec_validator-0.2.7}/src/websec_validator/__init__.py +0 -0
  13. {websec_validator-0.2.6 → websec_validator-0.2.7}/src/websec_validator/briefing.py +0 -0
  14. {websec_validator-0.2.6 → websec_validator-0.2.7}/src/websec_validator/calibration.json +0 -0
  15. {websec_validator-0.2.6 → websec_validator-0.2.7}/src/websec_validator/calibration.py +0 -0
  16. {websec_validator-0.2.6 → websec_validator-0.2.7}/src/websec_validator/cli.py +0 -0
  17. {websec_validator-0.2.6 → websec_validator-0.2.7}/src/websec_validator/constitution.py +0 -0
  18. {websec_validator-0.2.6 → websec_validator-0.2.7}/src/websec_validator/corpus.json +0 -0
  19. {websec_validator-0.2.6 → websec_validator-0.2.7}/src/websec_validator/extractors/__init__.py +0 -0
  20. {websec_validator-0.2.6 → websec_validator-0.2.7}/src/websec_validator/extractors/authz.py +0 -0
  21. {websec_validator-0.2.6 → websec_validator-0.2.7}/src/websec_validator/extractors/base.py +0 -0
  22. {websec_validator-0.2.6 → websec_validator-0.2.7}/src/websec_validator/extractors/client_exposure.py +0 -0
  23. {websec_validator-0.2.6 → websec_validator-0.2.7}/src/websec_validator/extractors/graphql.py +0 -0
  24. {websec_validator-0.2.6 → websec_validator-0.2.7}/src/websec_validator/extractors/iac_ci.py +0 -0
  25. {websec_validator-0.2.6 → websec_validator-0.2.7}/src/websec_validator/extractors/integrations.py +0 -0
  26. {websec_validator-0.2.6 → websec_validator-0.2.7}/src/websec_validator/extractors/routes.py +0 -0
  27. {websec_validator-0.2.6 → websec_validator-0.2.7}/src/websec_validator/extractors/schemas.py +0 -0
  28. {websec_validator-0.2.6 → websec_validator-0.2.7}/src/websec_validator/extractors/stack.py +0 -0
  29. {websec_validator-0.2.6 → websec_validator-0.2.7}/src/websec_validator/extractors/surface.py +0 -0
  30. {websec_validator-0.2.6 → websec_validator-0.2.7}/src/websec_validator/extractors/tenant.py +0 -0
  31. {websec_validator-0.2.6 → websec_validator-0.2.7}/src/websec_validator/findings.py +0 -0
  32. {websec_validator-0.2.6 → websec_validator-0.2.7}/src/websec_validator/proof.py +0 -0
  33. {websec_validator-0.2.6 → websec_validator-0.2.7}/src/websec_validator/recon.py +0 -0
  34. {websec_validator-0.2.6 → websec_validator-0.2.7}/src/websec_validator/report.py +0 -0
  35. {websec_validator-0.2.6 → websec_validator-0.2.7}/src/websec_validator/scanners.py +0 -0
  36. {websec_validator-0.2.6 → websec_validator-0.2.7}/src/websec_validator/templates/probes/_lib.py +0 -0
  37. {websec_validator-0.2.6 → websec_validator-0.2.7}/src/websec_validator/templates/probes/bola-cross-tenant.sh +0 -0
  38. {websec_validator-0.2.6 → websec_validator-0.2.7}/src/websec_validator/templates/probes/bola-write-verbs.py +0 -0
  39. {websec_validator-0.2.6 → websec_validator-0.2.7}/src/websec_validator/templates/probes/compare-roles.sh +0 -0
  40. {websec_validator-0.2.6 → websec_validator-0.2.7}/src/websec_validator/templates/probes/dlp-bypass-offline.py +0 -0
  41. {websec_validator-0.2.6 → websec_validator-0.2.7}/src/websec_validator/templates/probes/hs256-brute-force.py +0 -0
  42. {websec_validator-0.2.6 → websec_validator-0.2.7}/src/websec_validator/templates/probes/jwt-attacks.sh +0 -0
  43. {websec_validator-0.2.6 → websec_validator-0.2.7}/src/websec_validator/templates/probes/mass-assignment.py +0 -0
  44. {websec_validator-0.2.6 → websec_validator-0.2.7}/src/websec_validator/templates/probes/race-conditions.py +0 -0
  45. {websec_validator-0.2.6 → websec_validator-0.2.7}/src/websec_validator/templates/probes/rate-limit-burst.sh +0 -0
  46. {websec_validator-0.2.6 → websec_validator-0.2.7}/src/websec_validator/templates/probes/s3-assess.sh +0 -0
  47. {websec_validator-0.2.6 → websec_validator-0.2.7}/src/websec_validator/templates/probes/ssrf-probes.sh +0 -0
  48. {websec_validator-0.2.6 → websec_validator-0.2.7}/src/websec_validator/templates/probes/unauth-baseline.sh +0 -0
  49. {websec_validator-0.2.6 → websec_validator-0.2.7}/src/websec_validator/templates/probes/webhook-forgery.py +0 -0
  50. {websec_validator-0.2.6 → websec_validator-0.2.7}/src/websec_validator/templates/reports/FINDINGS-SUMMARY.md.template +0 -0
  51. {websec_validator-0.2.6 → websec_validator-0.2.7}/src/websec_validator/templates/reports/access-control-matrix.md.template +0 -0
  52. {websec_validator-0.2.6 → websec_validator-0.2.7}/src/websec_validator/templates/reports/findings-triage.md.template +0 -0
  53. {websec_validator-0.2.6 → websec_validator-0.2.7}/src/websec_validator/templates/reports/pentest-handover-brief.md.template +0 -0
  54. {websec_validator-0.2.6 → websec_validator-0.2.7}/src/websec_validator/templates/reports/per-tool-FINDINGS.md.template +0 -0
  55. {websec_validator-0.2.6 → websec_validator-0.2.7}/src/websec_validator.egg-info/SOURCES.txt +0 -0
  56. {websec_validator-0.2.6 → websec_validator-0.2.7}/src/websec_validator.egg-info/dependency_links.txt +0 -0
  57. {websec_validator-0.2.6 → websec_validator-0.2.7}/src/websec_validator.egg-info/entry_points.txt +0 -0
  58. {websec_validator-0.2.6 → websec_validator-0.2.7}/src/websec_validator.egg-info/top_level.txt +0 -0
  59. {websec_validator-0.2.6 → 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.6
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.6"
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 "
@@ -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",
@@ -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.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
@@ -169,5 +171,37 @@ class SecretPrecisionTests(unittest.TestCase):
169
171
  self.assertEqual(hit["confidence"], "MEDIUM")
170
172
 
171
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
+
172
206
  if __name__ == "__main__":
173
207
  unittest.main()