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.
- {websec_validator-0.2.7/src/websec_validator.egg-info → websec_validator-0.2.9}/PKG-INFO +1 -1
- {websec_validator-0.2.7 → websec_validator-0.2.9}/pyproject.toml +1 -1
- {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/extractors/auth.py +8 -2
- {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/extractors/authz.py +23 -0
- websec_validator-0.2.9/src/websec_validator/extractors/tenant.py +42 -0
- {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/findings.py +11 -5
- {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/scanners.py +25 -0
- {websec_validator-0.2.7 → websec_validator-0.2.9/src/websec_validator.egg-info}/PKG-INFO +1 -1
- {websec_validator-0.2.7 → websec_validator-0.2.9}/tests/test_hardening.py +86 -0
- websec_validator-0.2.7/src/websec_validator/extractors/tenant.py +0 -33
- {websec_validator-0.2.7 → websec_validator-0.2.9}/LICENSE +0 -0
- {websec_validator-0.2.7 → websec_validator-0.2.9}/README.md +0 -0
- {websec_validator-0.2.7 → websec_validator-0.2.9}/setup.cfg +0 -0
- {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/__init__.py +0 -0
- {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/briefing.py +0 -0
- {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/calibration.json +0 -0
- {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/calibration.py +0 -0
- {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/cli.py +0 -0
- {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/constitution.py +0 -0
- {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/corpus.json +0 -0
- {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/dynamic.py +0 -0
- {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/extractors/__init__.py +0 -0
- {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/extractors/base.py +0 -0
- {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/extractors/client_exposure.py +0 -0
- {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/extractors/graphql.py +0 -0
- {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/extractors/iac_ci.py +0 -0
- {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/extractors/integrations.py +0 -0
- {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/extractors/routes.py +0 -0
- {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/extractors/schemas.py +0 -0
- {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/extractors/stack.py +0 -0
- {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/extractors/surface.py +0 -0
- {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/probes.py +0 -0
- {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/proof.py +0 -0
- {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/recon.py +0 -0
- {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/report.py +0 -0
- {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/templates/probes/_lib.py +0 -0
- {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/templates/probes/bola-cross-tenant.sh +0 -0
- {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/templates/probes/bola-write-verbs.py +0 -0
- {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/templates/probes/compare-roles.sh +0 -0
- {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/templates/probes/dlp-bypass-offline.py +0 -0
- {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/templates/probes/forged-token.sh +0 -0
- {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/templates/probes/hs256-brute-force.py +0 -0
- {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/templates/probes/jwt-attacks.sh +0 -0
- {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/templates/probes/mass-assignment.py +0 -0
- {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/templates/probes/race-conditions.py +0 -0
- {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/templates/probes/rate-limit-burst.sh +0 -0
- {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/templates/probes/s3-assess.sh +0 -0
- {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/templates/probes/ssrf-probes.sh +0 -0
- {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/templates/probes/unauth-baseline.sh +0 -0
- {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/templates/probes/webhook-forgery.py +0 -0
- {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/templates/reports/FINDINGS-SUMMARY.md.template +0 -0
- {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/templates/reports/access-control-matrix.md.template +0 -0
- {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/templates/reports/findings-triage.md.template +0 -0
- {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/templates/reports/pentest-handover-brief.md.template +0 -0
- {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/templates/reports/per-tool-FINDINGS.md.template +0 -0
- {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator.egg-info/SOURCES.txt +0 -0
- {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator.egg-info/dependency_links.txt +0 -0
- {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator.egg-info/entry_points.txt +0 -0
- {websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator.egg-info/top_level.txt +0 -0
- {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.
|
|
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
|
+
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
|
-
"
|
|
92
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/extractors/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/extractors/client_exposure.py
RENAMED
|
File without changes
|
{websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/extractors/graphql.py
RENAMED
|
File without changes
|
|
File without changes
|
{websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/extractors/integrations.py
RENAMED
|
File without changes
|
|
File without changes
|
{websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/extractors/schemas.py
RENAMED
|
File without changes
|
|
File without changes
|
{websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/extractors/surface.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/templates/probes/_lib.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator/templates/probes/s3-assess.sh
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{websec_validator-0.2.7 → websec_validator-0.2.9}/src/websec_validator.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|