websec-validator 0.8.0__tar.gz → 0.9.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 (81) hide show
  1. {websec_validator-0.8.0/src/websec_validator.egg-info → websec_validator-0.9.0}/PKG-INFO +9 -6
  2. {websec_validator-0.8.0 → websec_validator-0.9.0}/README.md +8 -5
  3. {websec_validator-0.8.0 → websec_validator-0.9.0}/pyproject.toml +1 -1
  4. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/briefing.py +22 -0
  5. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/dynamic.py +40 -5
  6. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/extractors/__init__.py +2 -0
  7. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/extractors/iac_ci.py +1 -1
  8. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/extractors/integrations.py +90 -2
  9. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/extractors/policy_consistency.py +6 -2
  10. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/extractors/routes.py +42 -2
  11. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/extractors/schemas.py +21 -1
  12. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/extractors/stack.py +53 -0
  13. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/extractors/tenant.py +7 -1
  14. websec_validator-0.9.0/src/websec_validator/extractors/webext.py +129 -0
  15. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/findings.py +46 -0
  16. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/probes.py +6 -0
  17. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/scanners.py +54 -5
  18. websec_validator-0.9.0/src/websec_validator/templates/probes/entitlement-abuse.sh +67 -0
  19. {websec_validator-0.8.0 → websec_validator-0.9.0/src/websec_validator.egg-info}/PKG-INFO +9 -6
  20. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator.egg-info/SOURCES.txt +3 -0
  21. websec_validator-0.9.0/tests/test_entitlement_webext.py +281 -0
  22. {websec_validator-0.8.0 → websec_validator-0.9.0}/tests/test_hardening.py +56 -0
  23. {websec_validator-0.8.0 → websec_validator-0.9.0}/tests/test_pentest_regressions.py +11 -0
  24. {websec_validator-0.8.0 → websec_validator-0.9.0}/tests/test_recon.py +15 -0
  25. {websec_validator-0.8.0 → websec_validator-0.9.0}/LICENSE +0 -0
  26. {websec_validator-0.8.0 → websec_validator-0.9.0}/setup.cfg +0 -0
  27. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/__init__.py +0 -0
  28. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/calibration.json +0 -0
  29. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/calibration.py +0 -0
  30. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/cli.py +0 -0
  31. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/constitution.py +0 -0
  32. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/corpus.json +0 -0
  33. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/extractors/auth.py +0 -0
  34. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/extractors/authz.py +0 -0
  35. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/extractors/authz_dataflow.py +0 -0
  36. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/extractors/base.py +0 -0
  37. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/extractors/client_exposure.py +0 -0
  38. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/extractors/client_integrity.py +0 -0
  39. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/extractors/crypto_usage.py +0 -0
  40. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/extractors/graphql.py +0 -0
  41. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/extractors/llm_security.py +0 -0
  42. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/extractors/pii_exposure.py +0 -0
  43. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/extractors/surface.py +0 -0
  44. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/extractors/transport_security.py +0 -0
  45. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/extractors/upload_security.py +0 -0
  46. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/proof.py +0 -0
  47. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/recon.py +0 -0
  48. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/report.py +0 -0
  49. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/rules/error-stack-disclosure.yml +0 -0
  50. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/rules/insecure-default-secret.yml +0 -0
  51. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/templates/probes/_lib.py +0 -0
  52. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/templates/probes/appsync-cswsh.sh +0 -0
  53. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/templates/probes/appsync-introspection.sh +0 -0
  54. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/templates/probes/appsync-subscription-bola.sh +0 -0
  55. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/templates/probes/bola-cross-tenant.sh +0 -0
  56. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/templates/probes/bola-write-verbs.py +0 -0
  57. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/templates/probes/client-integrity-checklist.sh +0 -0
  58. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/templates/probes/compare-roles.sh +0 -0
  59. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/templates/probes/dlp-bypass-offline.py +0 -0
  60. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/templates/probes/error-disclosure-probe.sh +0 -0
  61. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/templates/probes/forged-token.sh +0 -0
  62. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/templates/probes/hs256-brute-force.py +0 -0
  63. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/templates/probes/jwt-attacks.sh +0 -0
  64. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/templates/probes/mass-assignment.py +0 -0
  65. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/templates/probes/password-reuse.sh +0 -0
  66. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/templates/probes/pii-output-diff.sh +0 -0
  67. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/templates/probes/race-conditions.py +0 -0
  68. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/templates/probes/rate-limit-burst.sh +0 -0
  69. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/templates/probes/s3-assess.sh +0 -0
  70. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/templates/probes/ssrf-probes.sh +0 -0
  71. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/templates/probes/unauth-baseline.sh +0 -0
  72. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/templates/probes/upload-matrix.sh +0 -0
  73. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/templates/probes/webhook-forgery.py +0 -0
  74. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/templates/reports/FINDINGS-SUMMARY.md.template +0 -0
  75. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/templates/reports/access-control-matrix.md.template +0 -0
  76. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/templates/reports/findings-triage.md.template +0 -0
  77. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/templates/reports/pentest-handover-brief.md.template +0 -0
  78. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/templates/reports/per-tool-FINDINGS.md.template +0 -0
  79. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator.egg-info/dependency_links.txt +0 -0
  80. {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator.egg-info/entry_points.txt +0 -0
  81. {websec_validator-0.8.0 → websec_validator-0.9.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.8.0
3
+ Version: 0.9.0
4
4
  Summary: Defensive, local-first security recon that briefs your AI coding agent on your own codebase — read-only by default (code in, artifacts out): facts + tailored probe scripts, no LLM, no server, no running app.
5
5
  Author: Ricardo Accioly
6
6
  License: MIT
@@ -25,7 +25,7 @@ turns a repo into a precise, fact-grounded security brief an AI agent (with a hu
25
25
  can act on — an auto-filled, repo-aware version of a senior pentester's "here's what to test and
26
26
  how" handoff. How it works + the reasoning behind every check: [`docs/METHODOLOGY.md`](docs/METHODOLOGY.md).
27
27
 
28
- ## Quickstart — just point it at your repo
28
+ ## Getting Started — just point it at your repo
29
29
 
30
30
  **Simplest: tell your AI agent.** In Claude Code (or any coding agent), open your project and say:
31
31
 
@@ -89,7 +89,7 @@ Then point your agent at the output: **"Read `websec-out/AGENT-BRIEFING.md` and
89
89
 
90
90
  > That's the whole user surface: **`run`** (plus the optional, advanced **`dynamic`** live-probing step below). `recon`/`proof`/`calibrate` exist for developing the tool itself and are hidden from `--help` — you never need them.
91
91
 
92
- ## What it extracts (19 deterministic extractors, no LLM)
92
+ ## What it extracts (20 deterministic extractors, no LLM)
93
93
 
94
94
  | | Dimension | Notable output |
95
95
  |---|---|---|
@@ -193,7 +193,7 @@ upload, cross-tenant BOLA, role/authz gaps).
193
193
  ## Tests
194
194
 
195
195
  ```bash
196
- python3 -m unittest discover -s tests # stdlib only, no Noir/network — 139 tests
196
+ python3 -m unittest discover -s tests # stdlib only, no Noir/network — 232 tests
197
197
  ```
198
198
 
199
199
  ## Releasing (maintainer)
@@ -219,7 +219,10 @@ publisher** with project `websec-validator`, owner `raccioly`, repo `websec-vali
219
219
 
220
220
  ## Status / roadmap
221
221
 
222
- **Done:** 19-extractor recon (incl. an **authz-correctness data-flow extractor** — unsigned-cookie /
222
+ **Done:** 20-extractor recon (incl. a **WebExtension client-trust extractor** — client-side entitlement
223
+ gate / over-broad host permissions / `world:"MAIN"` / unvalidated external messages — a
224
+ **license/entitlement verification-trust** pass — revocation-bypass + no per-license usage cap, provider-
225
+ agnostic — **Deno/Supabase-edge + Chrome-extension** stack & route modeling, an **authz-correctness data-flow extractor** — unsigned-cookie /
223
226
  claim-keyed authz / transaction-local RLS — plus **CORS-misconfig**, **SRI**, **host-header
224
227
  open-redirect** and **SSRF-redirect-hardening** classes, schema/entity → mass-assignment targeting, the **AWS-CDK /
225
228
  managed-AppSync / VTL boundary**, **upload-security** + **PII-output-boundary** + **redirect-SSRF**
@@ -230,7 +233,7 @@ algorithms / predictable principal), **docker-compose host-takeover** + **`.gitl
230
233
  secret-suppression** audits, and a **reverse-proxy prefix-escape** detector), cross-tool de-dup +
231
234
  **bundled Semgrep rules**, **router-mount-auth modeling** (cuts the dominant Express-monorepo
232
235
  missing-auth false positive), tailored probe staging, agent briefing, traceable findings ledger with
233
- **calibrated confidence (CJE — Wilson CIs)**, proof harness, test suite (181), **Docker bundle** (all
236
+ **calibrated confidence (CJE — Wilson CIs)**, proof harness, test suite (232), **Docker bundle** (all
234
237
  scanners + Noir, arch-aware), **dynamic phase v1** (authenticated read-only cross-tenant BOLA —
235
238
  validated live, reproduced a hand-pentest's 14/14). Validated against the **REF-PENTEST pen test +
236
239
  retest** and re-validated on a large real-world LLM-agent monorepo (HIGH-finding noise 178 → 15, AI +
@@ -13,7 +13,7 @@ turns a repo into a precise, fact-grounded security brief an AI agent (with a hu
13
13
  can act on — an auto-filled, repo-aware version of a senior pentester's "here's what to test and
14
14
  how" handoff. How it works + the reasoning behind every check: [`docs/METHODOLOGY.md`](docs/METHODOLOGY.md).
15
15
 
16
- ## Quickstart — just point it at your repo
16
+ ## Getting Started — just point it at your repo
17
17
 
18
18
  **Simplest: tell your AI agent.** In Claude Code (or any coding agent), open your project and say:
19
19
 
@@ -77,7 +77,7 @@ Then point your agent at the output: **"Read `websec-out/AGENT-BRIEFING.md` and
77
77
 
78
78
  > That's the whole user surface: **`run`** (plus the optional, advanced **`dynamic`** live-probing step below). `recon`/`proof`/`calibrate` exist for developing the tool itself and are hidden from `--help` — you never need them.
79
79
 
80
- ## What it extracts (19 deterministic extractors, no LLM)
80
+ ## What it extracts (20 deterministic extractors, no LLM)
81
81
 
82
82
  | | Dimension | Notable output |
83
83
  |---|---|---|
@@ -181,7 +181,7 @@ upload, cross-tenant BOLA, role/authz gaps).
181
181
  ## Tests
182
182
 
183
183
  ```bash
184
- python3 -m unittest discover -s tests # stdlib only, no Noir/network — 139 tests
184
+ python3 -m unittest discover -s tests # stdlib only, no Noir/network — 232 tests
185
185
  ```
186
186
 
187
187
  ## Releasing (maintainer)
@@ -207,7 +207,10 @@ publisher** with project `websec-validator`, owner `raccioly`, repo `websec-vali
207
207
 
208
208
  ## Status / roadmap
209
209
 
210
- **Done:** 19-extractor recon (incl. an **authz-correctness data-flow extractor** — unsigned-cookie /
210
+ **Done:** 20-extractor recon (incl. a **WebExtension client-trust extractor** — client-side entitlement
211
+ gate / over-broad host permissions / `world:"MAIN"` / unvalidated external messages — a
212
+ **license/entitlement verification-trust** pass — revocation-bypass + no per-license usage cap, provider-
213
+ agnostic — **Deno/Supabase-edge + Chrome-extension** stack & route modeling, an **authz-correctness data-flow extractor** — unsigned-cookie /
211
214
  claim-keyed authz / transaction-local RLS — plus **CORS-misconfig**, **SRI**, **host-header
212
215
  open-redirect** and **SSRF-redirect-hardening** classes, schema/entity → mass-assignment targeting, the **AWS-CDK /
213
216
  managed-AppSync / VTL boundary**, **upload-security** + **PII-output-boundary** + **redirect-SSRF**
@@ -218,7 +221,7 @@ algorithms / predictable principal), **docker-compose host-takeover** + **`.gitl
218
221
  secret-suppression** audits, and a **reverse-proxy prefix-escape** detector), cross-tool de-dup +
219
222
  **bundled Semgrep rules**, **router-mount-auth modeling** (cuts the dominant Express-monorepo
220
223
  missing-auth false positive), tailored probe staging, agent briefing, traceable findings ledger with
221
- **calibrated confidence (CJE — Wilson CIs)**, proof harness, test suite (181), **Docker bundle** (all
224
+ **calibrated confidence (CJE — Wilson CIs)**, proof harness, test suite (232), **Docker bundle** (all
222
225
  scanners + Noir, arch-aware), **dynamic phase v1** (authenticated read-only cross-tenant BOLA —
223
226
  validated live, reproduced a hand-pentest's 14/14). Validated against the **REF-PENTEST pen test +
224
227
  retest** and re-validated on a large real-world LLM-agent monorepo (HIGH-finding noise 178 → 15, AI +
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "websec-validator"
7
- version = "0.8.0"
7
+ version = "0.9.0"
8
8
  description = "Defensive, local-first security recon that briefs your AI coding agent on your own codebase — read-only by default (code in, artifacts out): facts + tailored probe scripts, no LLM, no server, no running app."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -114,6 +114,23 @@ def render(facts: dict, scanners: dict, scan_results: list, probe_manifest: list
114
114
  wh_unverified = integ.get("webhooks_without_sig_verification", [])
115
115
  wh_line = (_section("⚠ Webhooks with NO signature-verification in their handler (verify)", wh_unverified)
116
116
  if wh_unverified else f"_{len(integ.get('webhook_endpoints', []))} webhook endpoint(s); signature code present or none found_")
117
+ # licensing / entitlement verification-trust gaps (revocation bypass + no per-license seat cap)
118
+ ent_finds = [f for f in integ.get("findings", [])
119
+ if f.get("attack_class") in ("entitlement-revocation-bypass", "missing-usage-cap")]
120
+ ent_line = (_section("⚠ License/entitlement verification gaps (verify + fix)",
121
+ [f"{f['severity']} {f['attack_class']}: {f['file']}" for f in ent_finds])
122
+ if ent_finds else "_no license/entitlement verification-trust gaps detected_")
123
+ # WebExtension client-trust surface (chrome.storage entitlement gate / host perms / world:MAIN)
124
+ wx = facts.get("webext", {})
125
+ if wx.get("is_extension"):
126
+ wx_finds = [f"{f['severity']} {f['attack_class']}: {f['file']}" for f in wx.get("findings", [])]
127
+ webext_line = (f"MV{wx.get('manifest_version')} extension · entitlement gates: "
128
+ f"{', '.join(wx.get('client_entitlement_gates', [])) or 'none'}\n"
129
+ + (_section("⚠ Extension client-trust (client storage is USER-EDITABLE — enforce paid "
130
+ "features server-side)", wx_finds)
131
+ if wx_finds else "_no extension client-trust findings_"))
132
+ else:
133
+ webext_line = "_no WebExtension manifest detected_"
117
134
 
118
135
  avail = ", ".join(s["name"] for s in scanners.get("available", [])) or "none on PATH"
119
136
  missing = "\n".join(f"- **{s['name']}** ({s['category']}) — `{s.get('install','')}`"
@@ -241,6 +258,11 @@ Production source maps exposed: {client.get("production_source_maps", False)}
241
258
  **Third-party integrations:** {integ_line}
242
259
  {wh_line}
243
260
 
261
+ **License / entitlement verification (revocation + seat-cap trust):** {ent_line}
262
+
263
+ **Browser-extension client-trust (chrome.storage entitlement gate / host perms / world:MAIN):**
264
+ {webext_line}
265
+
244
266
  **LLM / AI-agent surface (OWASP LLM Top 10 — prompt injection, insecure output, excessive agency, unbounded):**
245
267
  {llm_section}
246
268
 
@@ -73,11 +73,48 @@ def mint(cfg: dict, role: str) -> dict:
73
73
  return {"error": f"{type(e).__name__}: {e}"}
74
74
  token = _dig(d, cfg.get("token_json_path", "tokens.accessToken"))
75
75
  user = _dig(d, cfg.get("user_json_path", "user")) or {}
76
- tenants = user.get(cfg.get("tenant_field", "groupIds")) or []
77
- return {"token": token, "tenant": tenants[0] if tenants else None,
76
+ return {"token": token, "tenant": _first_tenant(user.get(cfg.get("tenant_field", "groupIds"))),
78
77
  "email": user.get("email"), "role": user.get("role")}
79
78
 
80
79
 
80
+ def _first_tenant(raw):
81
+ """The first tenant id from a field that may be a list (`groupIds: [5]`) OR a singular scalar
82
+ (`groupId: 5` / "acme"). Coerce to a list before indexing — else a scalar int crashes (`5[0]`
83
+ TypeError, uncaught) and a scalar string silently yields a single-CHARACTER tenant."""
84
+ tenants = raw if isinstance(raw, list) else ([raw] if raw is not None else [])
85
+ return tenants[0] if tenants else None
86
+
87
+
88
+ def _no_records(body: str) -> bool:
89
+ """True if a 200/206 response carries NO records (so a cross-tenant read is NOT a leak).
90
+
91
+ Parses JSON and tests emptiness STRUCTURALLY instead of string-matching a tiny allowlist
92
+ (`[]`/`{}`/`{"data":[]}`) — that allowlist misclassified common empty wrappers (`{"items":[]}`,
93
+ whitespace-formatted `{ "data": [] }`, paginated `{"data":[],"total":0}`) as LEAKs. Conservative:
94
+ anything that isn't provably empty is treated as DATA, so a real leak is never masked."""
95
+ if not body or not body.strip():
96
+ return True
97
+
98
+ def empty(x) -> bool:
99
+ if x is None or x == "" or x == []:
100
+ return True
101
+ if isinstance(x, list):
102
+ return len(x) == 0
103
+ if isinstance(x, dict):
104
+ if not x:
105
+ return True
106
+ cols = [v for v in x.values() if isinstance(v, (list, dict))]
107
+ # empty only when there's at least one data container and ALL of them are empty
108
+ # (scalar siblings like total/page/count are treated as pagination meta).
109
+ return bool(cols) and all(empty(c) for c in cols)
110
+ return False # a bare scalar payload → treat as data, not empty
111
+
112
+ try:
113
+ return empty(json.loads(body))
114
+ except Exception:
115
+ return False # opaque / non-JSON 200 content → can't prove empty; surface it as a lead
116
+
117
+
81
118
  def _tenant_only_get_endpoints(facts: dict, param: str) -> list:
82
119
  """GET endpoints whose ONLY path param is the tenant param — clean cross-tenant
83
120
  list targets that need no other fixture id."""
@@ -112,10 +149,8 @@ def cross_tenant_bola(cfg: dict, facts: dict) -> dict:
112
149
  code, body = _request("GET", url, atk["token"])
113
150
  if code in (401, 403, 404):
114
151
  verdict = "blocked"
115
- elif code in (200, 206) and body and body.strip() not in ("[]", "{}", '{"data":[]}'):
116
- verdict = "LEAK"
117
152
  elif code in (200, 206):
118
- verdict = "blocked-empty" # 200 but no cross-tenant data returned
153
+ verdict = "blocked-empty" if _no_records(body) else "LEAK" # structural, not string-match
119
154
  else:
120
155
  verdict = "investigate"
121
156
  results.append({"path": path, "direction": direction, "status": code, "verdict": verdict})
@@ -29,6 +29,7 @@ from .surface import SurfaceExtractor
29
29
  from .tenant import TenantExtractor
30
30
  from .transport_security import TransportSecurityExtractor
31
31
  from .upload_security import UploadSecurityExtractor
32
+ from .webext import WebExtExtractor
32
33
 
33
34
  # Order matters: stack first (others read facts['stack']); authz after routes
34
35
  # (reads facts['routes']).
@@ -52,6 +53,7 @@ REGISTRY: list[Extractor] = [
52
53
  LlmSecurityExtractor(),
53
54
  CryptoUsageExtractor(),
54
55
  AuthzDataflowExtractor(),
56
+ WebExtExtractor(),
55
57
  ]
56
58
 
57
59
 
@@ -102,7 +102,7 @@ class IacCiExtractor(Extractor):
102
102
  "free-text injectable); verify, low exploitability" if sha_only else "")
103
103
  findings.append({"severity": sev, "kind": "gha-script-injection", "file": rel,
104
104
  "detail": "untrusted context interpolated into a run: step — "
105
- + ", ".join("github." + c for c in contexts[:4]) + extra})
105
+ + ", ".join(contexts[:4]) + extra})
106
106
  unpinned = sorted({f"{a}@{r}" for a, r in USES.findall(text)
107
107
  if not SHA40.match(r) and not a.startswith("./")})
108
108
  if unpinned:
@@ -26,6 +26,58 @@ SIG_VERIFY = re.compile(
26
26
  r"\bsvix\b|constant_time_compare|compare_digest|verifyWebhook|webhookSecret|"
27
27
  r"(?:verif|check|validate|assert|compute|expected|valid)\w*[_-]?[Ss]ignature", re.I)
28
28
 
29
+ # --- License / subscription / payment providers called via a RAW fetch (not an npm SDK), so the
30
+ # SDK-name scan below misses them. Detected by API host so an entitlement check surfaces as a
31
+ # third-party trust boundary regardless of provider or naming. Add hosts freely — nothing here is
32
+ # specific to any one app. ---
33
+ VERIFY_PROVIDERS = {
34
+ "api.gumroad.com": "Gumroad", "api.lemonsqueezy.com": "Lemon Squeezy",
35
+ "api.paddle.com": "Paddle", "vendors.paddle.com": "Paddle", "checkout.paddle.com": "Paddle",
36
+ "sandbox-api.paddle.com": "Paddle", "api.stripe.com": "Stripe", "api.keygen.sh": "Keygen",
37
+ "api.paypal.com": "PayPal", "api-m.paypal.com": "PayPal", "api.chargebee.com": "Chargebee",
38
+ "api.fastspring.com": "FastSpring", "api.polar.sh": "Polar", "api.creem.io": "Creem",
39
+ }
40
+ # A handler that checks a paid entitlement — a license OR a subscription. Broad on purpose (any
41
+ # provider, any naming); the findings below additionally require a truthy-grant tell.
42
+ ENTITLEMENT_VERIFY = re.compile(
43
+ r"licen[sc]es?/(?:verify|validate|activate)|/v\d+/validate|verify_?licen[sc]e|verify_?key"
44
+ r"|activate_?licen[sc]e|licen[sc]e_?key|\bentitlement|subscriptions?/|/customers?/"
45
+ r"|checkout/sessions|has_?active_?subscription|is_?subscribed|verify_?subscription", re.I)
46
+ # The grant keys on a truthy success/valid/activated flag …
47
+ GRANT_ON_SUCCESS = re.compile(r"\.success\b|\.valid\b|\.activated\b|\.meta\.valid\b|['\"]valid['\"]\s*:", re.I)
48
+ # … but a SOUND check ALSO inspects revocation/validity state. PROVIDER-AGNOSTIC concept vocabulary
49
+ # (refund / chargeback / dispute / cancel / expire / revoke / suspend / a status compared to
50
+ # active|paid), matched as CODE — a property access, a quoted key/value, or a *_at/status field —
51
+ # never a bare prose word, so a comment ("no revocation check", "we still need refund handling") can't
52
+ # SUPPRESS the finding (the comment-suppression FN trap the SIG_VERIFY note above learned the hard
53
+ # way). This is intentionally not tied to any single provider's field spellings.
54
+ _REVOKE = (r"refunded|chargebacked|charged_back|disputed|revoked|revocation|suspended|voided"
55
+ r"|cancell?ed|canceled|cancel_at_period_end|inactive|past_due|unpaid")
56
+ # NOTE: expiry (`expires_at`/`ended_at`) is DELIBERATELY excluded from the bare-field match — it
57
+ # collides with a row's OWN expiry data column (a real false negative: it would mark an uncapped app
58
+ # safe). Expiry only counts when subscription-scoped (`subscription_ended_at`) or a property/quoted
59
+ # revocation value, so a plain `row.expires_at` select can't suppress the finding.
60
+ REVOCATION_CHECK = re.compile(
61
+ r"\.\s*(?:" + _REVOKE + r"|active|valid_?until|current_period_end)\b"
62
+ r"|['\"](?:" + _REVOKE + r"|active|paused|trialing)['\"]"
63
+ r"|\bsubscription_(?:cancell?ed|canceled|ended|failed|expires?)(?:_(?:at|on|date))?\b"
64
+ r"|\b(?:sub(?:scription)?_?)?status\b\s*[=!]==", re.I)
65
+ # A per-principal usage cap — seat / device / machine / activation / quota / concurrency — OR a real
66
+ # server-side use-count, named HOWEVER the app names it (provider-agnostic). Its ABSENCE (with no
67
+ # rate-limiter) is finding #1. Matched as CODE idioms — compound identifiers, a table, a comparison —
68
+ # never a bare word, so a comment ("no seat cap") can't suppress it. `uses` is excluded from the
69
+ # compound (it collides with `increment_uses_count`); server-side use-counting is caught only by the
70
+ # explicit `increment_uses_count: true` (the `"false"` opt-OUT must not read as a cap).
71
+ USAGE_CAP = re.compile(
72
+ r"(?:max|per|distinct|active|registered|allowed|remaining|current|total)[_-]?"
73
+ r"(?:seats?|devices?|machines?|activations?|installs?|sessions?)"
74
+ r"|(?:seats?|devices?|machines?|activations?|installs?|sessions?|usage)[_-]?"
75
+ r"(?:count|counts?|limit|limits?|cap|caps?|max|quota|used|remaining|left)"
76
+ r"|\b\w+[_-](?:seats?|devices?|machines?|activations?)\b"
77
+ r"|\bseats?\b\s*(?:\.\s*(?:length|count|size)|[<>]=?)"
78
+ r"|\bquota\b|\bconcurren\w*|\bsimultaneous\b"
79
+ r"|increment_uses_count['\"]?\s*[:=]\s*['\"]?true", re.I)
80
+
29
81
  SDKS = {"stripe": "Stripe", "twilio": "Twilio", "@sendgrid": "SendGrid", "messagebird": "MessageBird/Bird",
30
82
  "@slack": "Slack", "openai": "OpenAI", "@anthropic": "Anthropic", "octokit": "GitHub",
31
83
  "plaid": "Plaid", "@aws-sdk": "AWS", "aws-sdk": "AWS", "firebase": "Firebase",
@@ -84,9 +136,45 @@ class IntegrationsExtractor(Extractor):
84
136
  if not (text and SIG_VERIFY.search(text)):
85
137
  unverified.append(f"{e['method']} {e['path']} ({ctx.rel(Path(cp)) if cp else '?'})")
86
138
 
87
- # #5 outbound-action endpoints + #6 redundant secret fetches (one shared code walk)
139
+ # #5 outbound-action endpoints + #6 redundant secret fetches + entitlement-trust (one code walk)
88
140
  findings = []
141
+ providers: set = set()
89
142
  for _p, rel, text in ctx.iter_code():
143
+ hit_provider = False
144
+ for host, label in VERIFY_PROVIDERS.items():
145
+ if host in text:
146
+ providers.add(label)
147
+ hit_provider = True
148
+
149
+ # --- License / subscription entitlement verification trust (provider-agnostic) ---
150
+ if ((hit_provider or ENTITLEMENT_VERIFY.search(text)) and not SKIP_ACTION_FILE.search(rel)
151
+ and (HANDLER_CTX.search(text) or HANDLER_DIR.search(rel))):
152
+ # #2 — grants on success alone, never inspecting revocation state (HIGH confidence:
153
+ # a concrete dataflow tell). /verify returns success:true after a refund.
154
+ if GRANT_ON_SUCCESS.search(text) and not REVOCATION_CHECK.search(text):
155
+ findings.append({"severity": "HIGH", "confidence": "HIGH",
156
+ "kind": "entitlement-revocation-not-checked",
157
+ "attack_class": "entitlement-revocation-bypass", "file": rel,
158
+ "detail": f"{rel} grants access on a truthy license/subscription result "
159
+ "(success/valid/activated) but never inspects REVOCATION state "
160
+ "(refunded / chargebacked / disputed / cancelled / expired / a "
161
+ "status compared to active). Most providers still report the "
162
+ "license as valid after a refund or chargeback, so buy → use → "
163
+ "refund/chargeback → keep access works indefinitely. Inspect the "
164
+ "purchase/subscription object and reject revoked states before granting."})
165
+ # #1 — entitlement-gated feature with NO per-license seat/device cap, rate limit, or
166
+ # server-side use-count (LOW confidence: verify the control isn't in middleware).
167
+ if not RATE_LIMIT.search(text) and not USAGE_CAP.search(text):
168
+ findings.append({"severity": "MEDIUM", "confidence": "LOW",
169
+ "kind": "no-per-license-usage-cap",
170
+ "attack_class": "missing-usage-cap", "file": rel,
171
+ "detail": f"{rel} gates a server-backed feature on a valid entitlement with NO "
172
+ "per-principal seat/device/activation cap, rate limit, or server-side "
173
+ "use-count — one shared or leaked credential works from unlimited "
174
+ "devices indefinitely. Track distinct devices/seats/activations per "
175
+ "license and reject beyond the limit, or rate-limit per license "
176
+ "(never per IP alone). Verify the cap isn't applied in middleware."})
177
+
90
178
  if (SEND_CALL.search(text) and not SKIP_ACTION_FILE.search(rel)
91
179
  and (HANDLER_CTX.search(text) or HANDLER_DIR.search(rel))):
92
180
  has_auth = bool(AUTH_GUARD.search(text))
@@ -116,7 +204,7 @@ class IntegrationsExtractor(Extractor):
116
204
 
117
205
  blob = " ".join(ctx.text(p) for p in ctx.glob("**/package.json", 80)).lower()
118
206
  blob += " ".join(ctx.text(p) for p in (ctx.glob("**/requirements*.txt", 40) + ctx.glob("**/pyproject.toml", 40))).lower()
119
- detected = sorted({label for dep, label in SDKS.items() if dep.lower() in blob})
207
+ detected = sorted({label for dep, label in SDKS.items() if dep.lower() in blob} | providers)
120
208
 
121
209
  return {
122
210
  "webhook_endpoints": [f"{e['method']} {e['path']}" for e in webhook_eps],
@@ -33,8 +33,12 @@ VALIDATION_SIGNAL = re.compile(
33
33
  _LA = r"\(\?=[^)]{0,40}"
34
34
  _RE_MIN = re.compile(r"\.min\(\s*(\d{1,3})|minLength\s*[:=]\s*(\d{1,3})|@MinLength\(\s*(\d{1,3})"
35
35
  r"|\.length\s*>=?\s*(\d{1,3})|\{\s*(\d{1,3})\s*,")
36
- _RE_UPPER = re.compile(_LA + r"\[[^\]]*A-Z|minUppercase\s*:\s*[1-9]|requireUppercase\s*[:=]\s*true", re.I)
37
- _RE_LOWER = re.compile(_LA + r"\[[^\]]*a-z|minLowercase\s*:\s*[1-9]|requireLowercase\s*[:=]\s*true", re.I)
36
+ # The char-class branch must be case-SENSITIVE: under re.I a literal `A-Z` also matches `a-z`, so a
37
+ # lowercase-only rule `(?=.*[a-z])` was mis-counted as ALSO requiring uppercase — inflating the class
38
+ # set and masking real drift (a lower-only sibling looked equal to an upper+lower policy). `(?-i:...)`
39
+ # locally disables re.I for just the range token; the keyword branches keep re.I for casing tolerance.
40
+ _RE_UPPER = re.compile(_LA + r"\[[^\]]*(?-i:A-Z)|minUppercase\s*:\s*[1-9]|requireUppercase\s*[:=]\s*true", re.I)
41
+ _RE_LOWER = re.compile(_LA + r"\[[^\]]*(?-i:a-z)|minLowercase\s*:\s*[1-9]|requireLowercase\s*[:=]\s*true", re.I)
38
42
  _RE_DIGIT = re.compile(_LA + r"(?:\\d|\[[^\]]*0-9)|minNumbers\s*:\s*[1-9]|requireDigit\s*[:=]\s*true", re.I)
39
43
  _RE_SPECIAL = re.compile(_LA + r"(?:\\W|\[\^[A-Za-z0-9\\w]|\[[^\]]*[!@#$%^&*])|minSymbols\s*:\s*[1-9]"
40
44
  r"|require[_]?Symbol", re.I)
@@ -151,7 +151,30 @@ ROUTER_ON = re.compile(r"\b\w+\.on\s*\(\s*['\"]([A-Za-z]+)['\"]\s*,\s*['\"`](/[^
151
151
  HANDLER_SIG = re.compile(
152
152
  r"\(\s*req\s*,\s*res\b|\(\s*request\s*,\s*(?:res|reply|env|ctx|context)\b|\(\s*c\s*\)\s*=>"
153
153
  r"|async\s*\(\s*c\s*\)|\(\s*ctx\s*\)\s*=>|export\s+default\s*\{[^}]*\bfetch\b"
154
- r"|addEventListener\(\s*['\"]fetch['\"]", re.I)
154
+ r"|addEventListener\(\s*['\"]fetch['\"]|Deno\.serve\s*\(", re.I)
155
+
156
+ # Supabase Edge Functions: a supabase/functions/<name>/index.ts with a Deno.serve (or deno-std serve)
157
+ # handler is an HTTP endpoint at /functions/v1/<name>. Noir + the regex frameworks don't model
158
+ # Deno.serve, so without this every edge function is INVISIBLE (0 routes → no authz/tenant/probes).
159
+ _EDGE_INDEX = re.compile(r"supabase/functions/([^/]+)/index\.[tj]s$", re.I)
160
+ _DENO_METHOD = re.compile(r"req\.method\s*(?:!==?|===?)\s*['\"]([A-Za-z]+)['\"]")
161
+
162
+
163
+ def _supabase_edge_routes(ctx: RepoContext) -> list:
164
+ rows = []
165
+ for p in ctx.glob("supabase/functions/**/index.ts", 200) + ctx.glob("supabase/functions/**/index.js", 200):
166
+ text = ctx.text(p)
167
+ has_serve = "Deno.serve" in text or ("deno.land/std" in text and re.search(r"\bserve\s*\(", text))
168
+ if not has_serve:
169
+ continue
170
+ m = _EDGE_INDEX.search(ctx.rel(p).replace("\\", "/"))
171
+ if not m or m.group(1).startswith("_"): # supabase/functions/_shared → helper, not a route
172
+ continue
173
+ methods = {mm.upper() for mm in _DENO_METHOD.findall(text)} - {"OPTIONS", "HEAD"}
174
+ for method in (sorted(methods) or ["POST"]): # Supabase invokes edge functions via POST
175
+ rows.append({"method": method, "path": f"/functions/v1/{m.group(1)}", "params": [],
176
+ "technology": "supabase-edge", "code_path": ctx.rel(p), "source": "supabase-edge"})
177
+ return rows
155
178
 
156
179
 
157
180
  def _router_calls(ctx: RepoContext) -> list:
@@ -211,7 +234,11 @@ def _fallback_next_app_router(ctx: RepoContext) -> list:
211
234
 
212
235
  def _fallback_regex(ctx: RepoContext) -> list:
213
236
  rows = []
214
- flask = re.compile(r"@\w+\.route\s*\(\s*['\"]([^'\"]+)['\"](?:.*methods\s*=\s*\[([^\]]*)\])?", re.S)
237
+ # `[^)]*?` (not `.*` with re.S) keeps the optional methods= group INSIDE this one route() call:
238
+ # a greedy DOTALL `.*` reaches across the file to the LAST methods=[...], mis-assigning it to the
239
+ # first route and silently swallowing every route in between (only routes after the final
240
+ # methods=[...] survived). Staying within the call parens fixes both the mislabel and the drop.
241
+ flask = re.compile(r"@\w+\.route\s*\(\s*['\"]([^'\"]+)['\"](?:[^)]*?methods\s*=\s*\[([^\]]*)\])?")
215
242
  fastapi = re.compile(r"@\w+\.(get|post|put|patch|delete)\s*\(\s*['\"]([^'\"]+)")
216
243
  for _p, rel, text in ctx.iter_code():
217
244
  for verb, path in fastapi.findall(text):
@@ -292,6 +319,19 @@ class RoutesExtractor(Extractor):
292
319
  existing.add(k)
293
320
  routes.append(r)
294
321
 
322
+ # Supabase Edge Functions (Deno.serve) — synthesize /functions/v1/<name> routes in EVERY
323
+ # engine path (noir-found, noir-zero, noir-absent), since none of them model Deno.serve.
324
+ edge_existing = {(r["method"], r["path"]) for r in routes}
325
+ for r in _supabase_edge_routes(ctx):
326
+ if getattr(ctx, "excludes", None) and ctx._excluded(r.get("code_path", "")):
327
+ continue
328
+ k = (r["method"], r["path"])
329
+ if k not in edge_existing:
330
+ edge_existing.add(k)
331
+ routes.append(r)
332
+ if engine.startswith("noir (0 routes)"):
333
+ engine = "noir (0 routes) → supabase-edge"
334
+
295
335
  by_method: dict = {}
296
336
  by_tech: dict = {}
297
337
  for r in routes:
@@ -29,7 +29,13 @@ SENSITIVE = re.compile(
29
29
  r"^(roles?|is_?admin|admin|permissions?|scopes?|password|password_?hash|pwd|"
30
30
  r"owner|owner_?id|user_?id|group_?id|tenant_?id|org_?id|organization_?id|account_?id|"
31
31
  r"balance|credits?|is_?verified|verified|status|plan|tier|enabled|active|api_?key|"
32
- r"secret|token|email_?verified|stripe_?customer|subscription)$", re.I)
32
+ r"secret|token|email_?verified|stripe_?customer|subscription|"
33
+ # licensed/extension ownership keys — the BOLA isolation boundary for per-license/per-device apps
34
+ r"license_?hash|license_?key|licence_?key|visitor_?id|device_?id|subscription_?id|customer_?id)$", re.I)
35
+
36
+ # CREATE TABLE [IF NOT EXISTS] [schema.]<name> ( — a plain SQL schema file (not an ORM), globbed
37
+ # separately because `.sql` isn't in CODE_EXT.
38
+ SQL_TABLE = re.compile(r"CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[\"`]?(?:\w+\.)?([A-Za-z_]\w*)[\"`]?\s*\(", re.I)
33
39
 
34
40
  MODELISH_PATH = re.compile(r"/models?/|/schemas?/|/entit|\.prisma$|\.model\.|\.entity\.", re.I)
35
41
  IDENT = re.compile(r"\b([A-Za-z_]\w*)\b")
@@ -57,6 +63,20 @@ class SchemasExtractor(Extractor):
57
63
  if SENSITIVE.match(w):
58
64
  sensitive.add(w)
59
65
 
66
+ # Plain SQL schema files (schema.sql / migrations) — globbed explicitly since `.sql` isn't in
67
+ # CODE_EXT. A CREATE TABLE with a license_hash / owner column is exactly the ownership boundary
68
+ # BOLA must isolate, and it's invisible to every iter_code()-based extractor without this.
69
+ for sf in ctx.glob("**/*.sql", 60):
70
+ stext = ctx.text(sf)
71
+ srel = ctx.rel(sf)
72
+ for m in SQL_TABLE.finditer(stext):
73
+ orms.add("sql-ddl")
74
+ if len(entities) < 80:
75
+ entities.append({"name": m.group(1), "type": "sql-table", "file": srel})
76
+ for w in IDENT.findall(stext):
77
+ if SENSITIVE.match(w):
78
+ sensitive.add(w)
79
+
60
80
  # de-dup entities by (name,type)
61
81
  seen, ents = set(), []
62
82
  for e in entities:
@@ -96,6 +96,59 @@ class StackExtractor(Extractor):
96
96
  if ctx.manifest("serverless.yml") + ctx.manifest("serverless.yaml"):
97
97
  frameworks.add("serverless")
98
98
 
99
+ # --- Manifest-LESS stacks a package.json/requirements scan misses entirely.
100
+ # A browser extension + Deno/Supabase edge functions ship NO package.json, so without this
101
+ # the whole app reads as `languages: ?` — which zeroes out every downstream extractor.
102
+ # File-extension fallback + Deno/Supabase/WebExtension/SQL-schema detection restore a real
103
+ # stack model for these manifest-less stacks. ---
104
+ code_exts = {p.suffix.lower() for p in ctx.code_files}
105
+ if not langs: # nothing from manifests → infer from source
106
+ if code_exts & {".ts", ".tsx", ".mts", ".cts"}:
107
+ langs.update({"node", "typescript"})
108
+ elif code_exts & {".js", ".jsx", ".mjs", ".cjs"}:
109
+ langs.add("node")
110
+ if ".py" in code_exts:
111
+ langs.add("python")
112
+ if ".go" in code_exts:
113
+ langs.add("go")
114
+ if ".rb" in code_exts:
115
+ langs.add("ruby")
116
+
117
+ # Deno + Supabase edge functions — `Deno.serve` handlers are HTTP endpoints (routes.py maps them).
118
+ deno_sig = bool(ctx.glob("**/deno.json", 1) or ctx.glob("**/deno.jsonc", 1))
119
+ supabase_fns = bool(ctx.glob("supabase/functions/**/index.ts", 1)
120
+ or ctx.glob("supabase/functions/**/index.js", 1))
121
+ supabase_cfg = ctx.exists("supabase/config.toml") or bool(ctx.glob("supabase/**/*.sql", 1))
122
+ if not deno_sig:
123
+ for _p, _rel, text in ctx.iter_code():
124
+ if "Deno.serve" in text or "Deno.env" in text:
125
+ deno_sig = True
126
+ break
127
+ if deno_sig:
128
+ langs.update({"node", "typescript"})
129
+ frameworks.add("deno")
130
+ if supabase_fns:
131
+ frameworks.add("supabase-edge")
132
+ if supabase_fns or supabase_cfg or "@supabase/supabase-js" in node_text:
133
+ frameworks.add("supabase")
134
+ datastores.add("postgres") # Supabase is Postgres-backed
135
+
136
+ # WebExtension / Chrome extension (MV2/MV3): a manifest.json declaring manifest_version.
137
+ for mf in ctx.glob("**/manifest.json", 40):
138
+ if '"manifest_version"' in ctx.text(mf):
139
+ frameworks.add("webextension")
140
+ langs.add("node") # extension code is JS
141
+ break
142
+
143
+ # SQL schema / migration files imply a SQL datastore even with no ORM dependency.
144
+ for sf in ctx.glob("**/*.sql", 60):
145
+ stext = ctx.text(sf)
146
+ if re.search(r"\bCREATE\s+TABLE\b", stext, re.I):
147
+ datastores.add("postgres" if re.search(
148
+ r"gen_random_uuid|timestamptz|\bjsonb\b|ROW LEVEL SECURITY|CREATE POLICY", stext, re.I)
149
+ else "sql")
150
+ break
151
+
99
152
  result = {
100
153
  "languages": sorted(langs),
101
154
  "frameworks": sorted(frameworks),
@@ -11,7 +11,13 @@ from .base import Extractor, RepoContext
11
11
  TENANT_KEYS = ["groupId", "group_id", "orgId", "org_id", "organizationId",
12
12
  "tenantId", "tenant_id", "workspaceId", "workspace_id",
13
13
  "accountId", "account_id", "companyId", "company_id",
14
- "teamId", "team_id", "projectId", "project_id"]
14
+ "teamId", "team_id", "projectId", "project_id",
15
+ # per-license / per-device ownership keys — the isolation boundary for licensed and
16
+ # browser-extension apps (Gumroad/Stripe/Paddle). license_hash keying a per-user row
17
+ # (e.g. alert_configs) IS the BOLA boundary here, even though it isn't classic SaaS tenancy.
18
+ "licenseHash", "license_hash", "licenseKey", "license_key",
19
+ "visitorId", "visitor_id", "deviceId", "device_id",
20
+ "subscriptionId", "subscription_id", "customerId", "customer_id"]
15
21
 
16
22
 
17
23
  class TenantExtractor(Extractor):