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.
- {websec_validator-0.8.0/src/websec_validator.egg-info → websec_validator-0.9.0}/PKG-INFO +9 -6
- {websec_validator-0.8.0 → websec_validator-0.9.0}/README.md +8 -5
- {websec_validator-0.8.0 → websec_validator-0.9.0}/pyproject.toml +1 -1
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/briefing.py +22 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/dynamic.py +40 -5
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/extractors/__init__.py +2 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/extractors/iac_ci.py +1 -1
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/extractors/integrations.py +90 -2
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/extractors/policy_consistency.py +6 -2
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/extractors/routes.py +42 -2
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/extractors/schemas.py +21 -1
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/extractors/stack.py +53 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/extractors/tenant.py +7 -1
- websec_validator-0.9.0/src/websec_validator/extractors/webext.py +129 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/findings.py +46 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/probes.py +6 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/scanners.py +54 -5
- websec_validator-0.9.0/src/websec_validator/templates/probes/entitlement-abuse.sh +67 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0/src/websec_validator.egg-info}/PKG-INFO +9 -6
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator.egg-info/SOURCES.txt +3 -0
- websec_validator-0.9.0/tests/test_entitlement_webext.py +281 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/tests/test_hardening.py +56 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/tests/test_pentest_regressions.py +11 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/tests/test_recon.py +15 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/LICENSE +0 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/setup.cfg +0 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/__init__.py +0 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/calibration.json +0 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/calibration.py +0 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/cli.py +0 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/constitution.py +0 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/corpus.json +0 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/extractors/auth.py +0 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/extractors/authz.py +0 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/extractors/authz_dataflow.py +0 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/extractors/base.py +0 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/extractors/client_exposure.py +0 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/extractors/client_integrity.py +0 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/extractors/crypto_usage.py +0 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/extractors/graphql.py +0 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/extractors/llm_security.py +0 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/extractors/pii_exposure.py +0 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/extractors/surface.py +0 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/extractors/transport_security.py +0 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/extractors/upload_security.py +0 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/proof.py +0 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/recon.py +0 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/report.py +0 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/rules/error-stack-disclosure.yml +0 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/rules/insecure-default-secret.yml +0 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/templates/probes/_lib.py +0 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/templates/probes/appsync-cswsh.sh +0 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/templates/probes/appsync-introspection.sh +0 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/templates/probes/appsync-subscription-bola.sh +0 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/templates/probes/bola-cross-tenant.sh +0 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/templates/probes/bola-write-verbs.py +0 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/templates/probes/client-integrity-checklist.sh +0 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/templates/probes/compare-roles.sh +0 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/templates/probes/dlp-bypass-offline.py +0 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/templates/probes/error-disclosure-probe.sh +0 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/templates/probes/forged-token.sh +0 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/templates/probes/hs256-brute-force.py +0 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/templates/probes/jwt-attacks.sh +0 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/templates/probes/mass-assignment.py +0 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/templates/probes/password-reuse.sh +0 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/templates/probes/pii-output-diff.sh +0 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/templates/probes/race-conditions.py +0 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/templates/probes/rate-limit-burst.sh +0 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/templates/probes/s3-assess.sh +0 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/templates/probes/ssrf-probes.sh +0 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/templates/probes/unauth-baseline.sh +0 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/templates/probes/upload-matrix.sh +0 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/templates/probes/webhook-forgery.py +0 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/templates/reports/FINDINGS-SUMMARY.md.template +0 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/templates/reports/access-control-matrix.md.template +0 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/templates/reports/findings-triage.md.template +0 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/templates/reports/pentest-handover-brief.md.template +0 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/templates/reports/per-tool-FINDINGS.md.template +0 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator.egg-info/dependency_links.txt +0 -0
- {websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator.egg-info/entry_points.txt +0 -0
- {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.
|
|
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
|
-
##
|
|
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 (
|
|
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 —
|
|
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:**
|
|
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 (
|
|
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
|
-
##
|
|
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 (
|
|
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 —
|
|
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:**
|
|
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 (
|
|
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.
|
|
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
|
-
|
|
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"
|
|
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})
|
{websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/extractors/__init__.py
RENAMED
|
@@ -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(
|
|
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:
|
{websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/extractors/integrations.py
RENAMED
|
@@ -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
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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:
|
{websec_validator-0.8.0 → websec_validator-0.9.0}/src/websec_validator/extractors/schemas.py
RENAMED
|
@@ -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
|
|
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):
|