websec-validator 0.2.7__tar.gz → 0.2.9__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 (60) hide show
  1. {websec_validator-0.2.7/src/websec_validator.egg-info → websec_validator-0.2.9}/PKG-INFO +1 -1
  2. {websec_validator-0.2.7 → websec_validator-0.2.9}/pyproject.toml +1 -1
  3. {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/extractors/auth.py +8 -2
  4. {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/extractors/authz.py +23 -0
  5. websec_validator-0.2.9/src/websec_validator/extractors/tenant.py +42 -0
  6. {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/findings.py +11 -5
  7. {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/scanners.py +25 -0
  8. {websec_validator-0.2.7 → websec_validator-0.2.9/src/websec_validator.egg-info}/PKG-INFO +1 -1
  9. {websec_validator-0.2.7 → websec_validator-0.2.9}/tests/test_hardening.py +86 -0
  10. websec_validator-0.2.7/src/websec_validator/extractors/tenant.py +0 -33
  11. {websec_validator-0.2.7 → websec_validator-0.2.9}/LICENSE +0 -0
  12. {websec_validator-0.2.7 → websec_validator-0.2.9}/README.md +0 -0
  13. {websec_validator-0.2.7 → websec_validator-0.2.9}/setup.cfg +0 -0
  14. {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/__init__.py +0 -0
  15. {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/briefing.py +0 -0
  16. {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/calibration.json +0 -0
  17. {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/calibration.py +0 -0
  18. {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/cli.py +0 -0
  19. {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/constitution.py +0 -0
  20. {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/corpus.json +0 -0
  21. {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/dynamic.py +0 -0
  22. {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/extractors/__init__.py +0 -0
  23. {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/extractors/base.py +0 -0
  24. {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/extractors/client_exposure.py +0 -0
  25. {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/extractors/graphql.py +0 -0
  26. {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/extractors/iac_ci.py +0 -0
  27. {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/extractors/integrations.py +0 -0
  28. {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/extractors/routes.py +0 -0
  29. {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/extractors/schemas.py +0 -0
  30. {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/extractors/stack.py +0 -0
  31. {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/extractors/surface.py +0 -0
  32. {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/probes.py +0 -0
  33. {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/proof.py +0 -0
  34. {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/recon.py +0 -0
  35. {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/report.py +0 -0
  36. {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/templates/probes/_lib.py +0 -0
  37. {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/templates/probes/bola-cross-tenant.sh +0 -0
  38. {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/templates/probes/bola-write-verbs.py +0 -0
  39. {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/templates/probes/compare-roles.sh +0 -0
  40. {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/templates/probes/dlp-bypass-offline.py +0 -0
  41. {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/templates/probes/forged-token.sh +0 -0
  42. {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/templates/probes/hs256-brute-force.py +0 -0
  43. {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/templates/probes/jwt-attacks.sh +0 -0
  44. {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/templates/probes/mass-assignment.py +0 -0
  45. {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/templates/probes/race-conditions.py +0 -0
  46. {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/templates/probes/rate-limit-burst.sh +0 -0
  47. {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/templates/probes/s3-assess.sh +0 -0
  48. {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/templates/probes/ssrf-probes.sh +0 -0
  49. {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/templates/probes/unauth-baseline.sh +0 -0
  50. {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/templates/probes/webhook-forgery.py +0 -0
  51. {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/templates/reports/FINDINGS-SUMMARY.md.template +0 -0
  52. {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/templates/reports/access-control-matrix.md.template +0 -0
  53. {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/templates/reports/findings-triage.md.template +0 -0
  54. {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/templates/reports/pentest-handover-brief.md.template +0 -0
  55. {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/templates/reports/per-tool-FINDINGS.md.template +0 -0
  56. {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator.egg-info/SOURCES.txt +0 -0
  57. {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator.egg-info/dependency_links.txt +0 -0
  58. {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator.egg-info/entry_points.txt +0 -0
  59. {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator.egg-info/top_level.txt +0 -0
  60. {websec_validator-0.2.7 → websec_validator-0.2.9}/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.7
3
+ Version: 0.2.9
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.7"
7
+ version = "0.2.9"
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"
@@ -63,6 +63,7 @@ class AuthExtractor(Extractor):
63
63
  # Detect ALL schemes present, then pick a primary by priority. A JWT app
64
64
  # that also wires Passport for SSO must read as primary=jwt, not passport
65
65
  # (Passport is often SSO-only). Priority: nextauth > jwt > session > passport > api-key.
66
+ route_count = len(routes.get("endpoints", []))
66
67
  detected = []
67
68
  if nextauth:
68
69
  detected.append("nextauth (session JWT in cookie)")
@@ -88,6 +89,11 @@ class AuthExtractor(Extractor):
88
89
  "cookie_names": cookie_names[:15],
89
90
  "guard_files": guard_files,
90
91
  "signal_counts": {"jwt": jwt, "passport": passport, "session": session, "api_key": apikey},
91
- "note": "AGENT: confirm the PRIMARY auth flow + how a test token is minted before the JWT/auth "
92
- "probes. Multiple schemes often mean primary bearer/session + secondary SSO (passport).",
92
+ "route_count": route_count,
93
+ "reliable_signal": route_count > 0 or bool(nextauth),
94
+ "note": (("⚠ No HTTP routes detected — this auth scheme is LOW-CONFIDENCE (likely a "
95
+ "library/CLI/scanner that merely mentions auth, or routes weren't parsed). "
96
+ if not (route_count > 0 or nextauth) else "")
97
+ + "AGENT: confirm the PRIMARY auth flow + how a test token is minted before the "
98
+ "JWT/auth probes. Multiple schemes often mean primary bearer/session + secondary SSO."),
93
99
  }
@@ -142,6 +142,28 @@ class AuthzExtractor(Extractor):
142
142
  for dec in sorted(set(UNSAFE_DECODER.findall(text))):
143
143
  unsafe_decoders.append({"file": rel, "decoder": dec})
144
144
 
145
+ # A guard DEFINED in a file that also calls an unsafe/unverified decoder authenticates via
146
+ # an unverified decode. Routes that call such a guard are the static "at-risk" set for the
147
+ # forged-token bypass class — the dynamic probe confirms which actually fall, but this points
148
+ # at them even with NO live target (turns the F5 hypothesis into named routes).
149
+ unverified_routes: list = []
150
+ unsafe_files = {ud["file"] for ud in unsafe_decoders}
151
+ if unsafe_files:
152
+ guard_def = re.compile(r"(?:export\s+)?(?:async\s+)?(?:function|const)\s+"
153
+ r"(require\w+|ensure\w+|\w*[Aa]uth\w*|verify\w+)\b")
154
+ unsafe_guards = set()
155
+ for _p, rel, text in ctx.iter_code():
156
+ if rel in unsafe_files:
157
+ unsafe_guards.update(g for g in guard_def.findall(text) if len(g) >= 5)
158
+ if unsafe_guards:
159
+ call = re.compile(r"\b(?:" + "|".join(re.escape(g) for g in sorted(unsafe_guards)) + r")\s*\(")
160
+ for e in endpoints:
161
+ cp = e.get("code_path", "")
162
+ t = ctx.text(Path(cp)) if cp else ""
163
+ if t and call.search(t):
164
+ unverified_routes.append(f"{e.get('method')} {e.get('path')}")
165
+ unverified_routes = sorted(set(unverified_routes))[:60]
166
+
145
167
  if global_auth:
146
168
  where = f"`{mw['file']}` (matcher {mw.get('matchers') or '—'})" if mw_auth else "`app.use(<auth>)`"
147
169
  note = (f"A GLOBAL auth middleware ({where}) was detected — most routes are protected by default. "
@@ -162,5 +184,6 @@ class AuthzExtractor(Extractor):
162
184
  "endpoint_guards": egs[:400],
163
185
  "write_endpoints_without_visible_guard": sorted(set(no_guard_writes))[:60],
164
186
  "unsafe_auth_decoders": unsafe_decoders[:30],
187
+ "unverified_signature_routes": unverified_routes,
165
188
  "note": note,
166
189
  }
@@ -0,0 +1,42 @@
1
+ """Tenant-boundary extractor — the multi-tenancy key candidates.
2
+
3
+ The single most important and easiest-to-get-wrong fact for BOLA testing. The
4
+ tool reports candidates by frequency; the agent confirms THE one with the human.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from .base import Extractor, RepoContext
10
+
11
+ TENANT_KEYS = ["groupId", "group_id", "orgId", "org_id", "organizationId",
12
+ "tenantId", "tenant_id", "workspaceId", "workspace_id",
13
+ "accountId", "account_id", "companyId", "company_id",
14
+ "teamId", "team_id", "projectId", "project_id"]
15
+
16
+
17
+ class TenantExtractor(Extractor):
18
+ name = "tenant"
19
+ category = "authz"
20
+
21
+ def extract(self, ctx: RepoContext, facts: dict) -> dict:
22
+ route_count = len((facts.get("routes") or {}).get("endpoints", []))
23
+ hits: dict = {}
24
+ files: dict = {}
25
+ for _p, rel, text in ctx.iter_code():
26
+ for key in TENANT_KEYS:
27
+ c = text.count(key)
28
+ if c:
29
+ hits[key] = hits.get(key, 0) + c
30
+ bucket = files.setdefault(key, [])
31
+ if rel not in bucket and len(bucket) < 5:
32
+ bucket.append(rel)
33
+ ranked = sorted(hits.items(), key=lambda kv: -kv[1])
34
+ return {
35
+ "candidates": [{"key": k, "occurrences": n, "files": files.get(k, [])} for k, n in ranked[:6]],
36
+ "multi_tenant_likely": bool(route_count > 0 and ranked and ranked[0][1] >= 3),
37
+ "route_count": route_count,
38
+ "note": ("AGENT: confirm with the human which key (if any) is THE tenant boundary. "
39
+ "If single-tenant, skip the cross-tenant BOLA probes."
40
+ + (" ⚠ No HTTP routes detected — a tenant key here may be a string in "
41
+ "library/scanner code, not a real boundary." if route_count == 0 else "")),
42
+ }
@@ -157,12 +157,18 @@ def build_ledger(facts: dict, unified: dict | None, dynamic: dict | None = None,
157
157
  f"(HTTP {lk.get('status')}, {lk.get('direction')})"}]))
158
158
 
159
159
  # ---- 1c. Unsafe/unverified decoder feeding an auth decision (F5) ----
160
- for ud in ((facts.get("authz", {}) or {}).get("unsafe_auth_decoders", []) or []):
160
+ _authz = facts.get("authz", {}) or {}
161
+ _uvr = _authz.get("unverified_signature_routes", []) or []
162
+ for ud in (_authz.get("unsafe_auth_decoders", []) or []):
163
+ ev = [{"layer": "recon", "detail": f"{ud.get('file')} makes an auth/identity decision AND calls "
164
+ f"{ud.get('decoder')}() — if that decodes a token/signature WITHOUT verifying it, a forged "
165
+ "value is trusted (the decodeJwtPayloadUnsafe → requireAdmin class of bug). Trace the call path."}]
166
+ if _uvr:
167
+ ev.append({"layer": "recon", "detail": f"static at-risk routes ({len(_uvr)}) — call a guard defined "
168
+ f"alongside this unverified decode: {', '.join(_uvr[:8])}{' …' if len(_uvr) > 8 else ''}. "
169
+ "Run `websec dynamic --unauth` / the forged-token probe to confirm which accept a forged token."})
161
170
  out.append(_f(f"Auth decision uses an unverified decoder: {ud.get('decoder')}", "access-control",
162
- "unsafe-auth-decoder", "HIGH", "MEDIUM", ud.get("file", ""),
163
- [{"layer": "recon", "detail": f"{ud.get('file')} makes an auth/identity decision AND calls "
164
- f"{ud.get('decoder')}() — if that decodes a token/signature WITHOUT verifying it, a forged "
165
- "value is trusted (the decodeJwtPayloadUnsafe → requireAdmin class of bug). Trace the call path."}]))
171
+ "unsafe-auth-decoder", "HIGH", "MEDIUM", ud.get("file", ""), ev))
166
172
 
167
173
  # ---- 1d. Forged-token acceptance — unverified signature, DYNAMICALLY CONFIRMED ----
168
174
  # The verdict for 1c: we presented an UNSIGNED/bogus-sig token and the route reached its
@@ -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),
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: websec-validator
3
- Version: 0.2.7
3
+ Version: 0.2.9
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
@@ -18,7 +18,9 @@ sys.path.insert(0, str(ROOT / "src"))
18
18
 
19
19
  from websec_validator import dynamic, findings, probes, scanners # noqa: E402
20
20
  from websec_validator.extractors.auth import AuthExtractor # noqa: E402
21
+ from websec_validator.extractors.authz import AuthzExtractor # noqa: E402
21
22
  from websec_validator.extractors.base import RepoContext # noqa: E402
23
+ from websec_validator.extractors.tenant import TenantExtractor # noqa: E402
22
24
 
23
25
  FACTS = {"routes": {"endpoints": [
24
26
  {"method": "GET", "path": "/api/bypass"}, # gated; accepts forged token -> BYPASS
@@ -171,6 +173,34 @@ class SecretPrecisionTests(unittest.TestCase):
171
173
  self.assertEqual(hit["confidence"], "MEDIUM")
172
174
 
173
175
 
176
+ class DocExampleSecretTests(unittest.TestCase):
177
+ """0.2.8: secrets in documentation/example files (curl examples in a README, .env.example
178
+ placeholders) tier to LOW + a verify note. Real code files are untouched."""
179
+
180
+ def test_is_doc_or_example(self):
181
+ self.assertTrue(scanners._is_doc_or_example("README.md"))
182
+ self.assertTrue(scanners._is_doc_or_example("docs/API-REFERENCE.md"))
183
+ self.assertTrue(scanners._is_doc_or_example(".env.example"))
184
+ self.assertTrue(scanners._is_doc_or_example("config/settings.sample.json"))
185
+ self.assertFalse(scanners._is_doc_or_example("src/app/route.ts"))
186
+
187
+ def test_gitleaks_doc_secret_to_low_code_stays_high(self):
188
+ rows = [
189
+ {"File": "README.md", "RuleID": "curl-auth-header", "Secret": "x" * 30, "Match": "Authorization: Bearer x", "StartLine": 1},
190
+ {"File": "src/server.ts", "RuleID": "private-key", "Secret": "-----BEGIN", "Match": "-----BEGIN", "StartLine": 1},
191
+ ]
192
+ by = {r["file"]: r for r in scanners._norm_gitleaks(rows)}
193
+ self.assertEqual(by["README.md"]["severity"], "LOW")
194
+ self.assertIn("documentation/example", by["README.md"]["title"])
195
+ self.assertEqual(by["src/server.ts"]["severity"], "HIGH") # real code file untouched
196
+
197
+ def test_trivy_doc_secret_to_low(self):
198
+ data = {"Results": [{"Target": "docs/SECURITY.md", "Secrets": [
199
+ {"RuleID": "curl-auth-header", "Title": "Auth header", "Match": "Bearer x", "StartLine": 1}]}]}
200
+ secs = [f for f in scanners._norm_trivy(data) if f["category"] == "secret"]
201
+ self.assertEqual(secs[0]["severity"], "LOW")
202
+
203
+
174
204
  class CookieCoverageTests(unittest.TestCase):
175
205
  """0.2.7: extract auth cookie names so the forged-token engine covers cookie-ONLY apps."""
176
206
 
@@ -203,5 +233,61 @@ class CookieCoverageTests(unittest.TestCase):
203
233
  self.assertTrue(r["bypassed"][0]["via"].startswith("cookie:"))
204
234
 
205
235
 
236
+ class NonWebAppFPTests(unittest.TestCase):
237
+ """0.2.9 (bug-081): on a 0-route repo (library/CLI/scanner) FLAG auth/tenant as low-confidence
238
+ + record tenant evidence files — but NEVER suppress. Suppression would be fragile (depends on
239
+ the optional noir route scanner) and could drop a real backend whose routes didn't parse."""
240
+
241
+ def test_auth_low_confidence_without_routes_but_still_detected(self):
242
+ with tempfile.TemporaryDirectory() as d:
243
+ d = Path(d)
244
+ (d / "patterns.ts").write_text("const RULE = 'express-session';\n")
245
+ out = AuthExtractor().extract(RepoContext(d), {"stack": {"frameworks": []}, "routes": {"endpoints": []}})
246
+ self.assertFalse(out["reliable_signal"]) # 0 routes, no framework -> flagged
247
+ self.assertIn("session-cookie", out["schemes_detected"]) # NOT suppressed
248
+ self.assertIn("No HTTP routes", out["note"]) # caveat surfaced
249
+
250
+ def test_auth_reliable_with_routes(self):
251
+ with tempfile.TemporaryDirectory() as d:
252
+ d = Path(d)
253
+ (d / "app.ts").write_text("const RULE = 'express-session';\n")
254
+ out = AuthExtractor().extract(RepoContext(d), {"stack": {"frameworks": []},
255
+ "routes": {"endpoints": [{"method": "GET", "path": "/x"}]}})
256
+ self.assertTrue(out["reliable_signal"])
257
+
258
+ def test_tenant_records_files_and_not_multitenant_without_routes(self):
259
+ with tempfile.TemporaryDirectory() as d:
260
+ d = Path(d)
261
+ (d / "a.ts").write_text("const x = groupId; const y = groupId; const z = groupId;\n") # x3
262
+ out = TenantExtractor().extract(RepoContext(d), {"routes": {"endpoints": []}})
263
+ gc = next(c for c in out["candidates"] if c["key"] == "groupId")
264
+ self.assertIn("a.ts", gc["files"]) # evidence recorded
265
+ self.assertFalse(out["multi_tenant_likely"]) # 0 routes -> not asserted even at >=3
266
+
267
+ def test_tenant_multitenant_with_routes(self):
268
+ with tempfile.TemporaryDirectory() as d:
269
+ d = Path(d)
270
+ (d / "a.ts").write_text("groupId groupId groupId\n") # x3
271
+ out = TenantExtractor().extract(RepoContext(d), {"routes": {"endpoints": [{"method": "GET", "path": "/x"}]}})
272
+ self.assertTrue(out["multi_tenant_likely"]) # routes + >=3 -> asserted
273
+
274
+
275
+ class StaticAtRiskRouteTests(unittest.TestCase):
276
+ """0.2.9 (B): routes calling a guard defined alongside an unverified decoder are listed
277
+ statically — the forged-token bypass set, even with no live target."""
278
+
279
+ def test_unverified_signature_routes_listed(self):
280
+ with tempfile.TemporaryDirectory() as d:
281
+ d = Path(d)
282
+ (d / "auth.ts").write_text(
283
+ "export async function requireAuth(req){ const p = decodeJwtPayloadUnsafe(t); return p; }\n")
284
+ (d / "route.ts").write_text(
285
+ "import {requireAuth} from './auth';\nexport async function GET(req){ await requireAuth(req); }\n")
286
+ facts = {"routes": {"endpoints": [
287
+ {"method": "GET", "path": "/api/x", "code_path": str(d / "route.ts")}]}}
288
+ out = AuthzExtractor().extract(RepoContext(d), facts)
289
+ self.assertIn("GET /api/x", out["unverified_signature_routes"])
290
+
291
+
206
292
  if __name__ == "__main__":
207
293
  unittest.main()
@@ -1,33 +0,0 @@
1
- """Tenant-boundary extractor — the multi-tenancy key candidates.
2
-
3
- The single most important and easiest-to-get-wrong fact for BOLA testing. The
4
- tool reports candidates by frequency; the agent confirms THE one with the human.
5
- """
6
-
7
- from __future__ import annotations
8
-
9
- from .base import Extractor, RepoContext
10
-
11
- TENANT_KEYS = ["groupId", "group_id", "orgId", "org_id", "organizationId",
12
- "tenantId", "tenant_id", "workspaceId", "workspace_id",
13
- "accountId", "account_id", "companyId", "company_id",
14
- "teamId", "team_id", "projectId", "project_id"]
15
-
16
-
17
- class TenantExtractor(Extractor):
18
- name = "tenant"
19
- category = "authz"
20
-
21
- def extract(self, ctx: RepoContext, facts: dict) -> dict:
22
- hits: dict = {}
23
- for _p, _rel, text in ctx.iter_code():
24
- for key in TENANT_KEYS:
25
- if key in text:
26
- hits[key] = hits.get(key, 0) + text.count(key)
27
- ranked = sorted(hits.items(), key=lambda kv: -kv[1])
28
- return {
29
- "candidates": [{"key": k, "occurrences": n} for k, n in ranked[:6]],
30
- "multi_tenant_likely": bool(ranked and ranked[0][1] >= 3),
31
- "note": "AGENT: confirm with the human which key (if any) is THE tenant boundary. "
32
- "If single-tenant, skip the cross-tenant BOLA probes.",
33
- }