websec-validator 0.2.5__tar.gz → 0.2.7__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {websec_validator-0.2.5/src/websec_validator.egg-info → websec_validator-0.2.7}/PKG-INFO +1 -1
- {websec_validator-0.2.5 → websec_validator-0.2.7}/pyproject.toml +1 -1
- {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/dynamic.py +19 -12
- {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/extractors/auth.py +16 -0
- {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/findings.py +3 -1
- {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/probes.py +1 -0
- {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/scanners.py +25 -3
- {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/templates/probes/forged-token.sh +15 -3
- {websec_validator-0.2.5 → websec_validator-0.2.7/src/websec_validator.egg-info}/PKG-INFO +1 -1
- {websec_validator-0.2.5 → websec_validator-0.2.7}/tests/test_hardening.py +70 -0
- {websec_validator-0.2.5 → websec_validator-0.2.7}/LICENSE +0 -0
- {websec_validator-0.2.5 → websec_validator-0.2.7}/README.md +0 -0
- {websec_validator-0.2.5 → websec_validator-0.2.7}/setup.cfg +0 -0
- {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/__init__.py +0 -0
- {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/briefing.py +0 -0
- {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/calibration.json +0 -0
- {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/calibration.py +0 -0
- {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/cli.py +0 -0
- {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/constitution.py +0 -0
- {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/corpus.json +0 -0
- {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/extractors/__init__.py +0 -0
- {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/extractors/authz.py +0 -0
- {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/extractors/base.py +0 -0
- {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/extractors/client_exposure.py +0 -0
- {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/extractors/graphql.py +0 -0
- {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/extractors/iac_ci.py +0 -0
- {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/extractors/integrations.py +0 -0
- {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/extractors/routes.py +0 -0
- {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/extractors/schemas.py +0 -0
- {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/extractors/stack.py +0 -0
- {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/extractors/surface.py +0 -0
- {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/extractors/tenant.py +0 -0
- {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/proof.py +0 -0
- {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/recon.py +0 -0
- {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/report.py +0 -0
- {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/templates/probes/_lib.py +0 -0
- {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/templates/probes/bola-cross-tenant.sh +0 -0
- {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/templates/probes/bola-write-verbs.py +0 -0
- {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/templates/probes/compare-roles.sh +0 -0
- {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/templates/probes/dlp-bypass-offline.py +0 -0
- {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/templates/probes/hs256-brute-force.py +0 -0
- {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/templates/probes/jwt-attacks.sh +0 -0
- {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/templates/probes/mass-assignment.py +0 -0
- {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/templates/probes/race-conditions.py +0 -0
- {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/templates/probes/rate-limit-burst.sh +0 -0
- {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/templates/probes/s3-assess.sh +0 -0
- {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/templates/probes/ssrf-probes.sh +0 -0
- {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/templates/probes/unauth-baseline.sh +0 -0
- {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/templates/probes/webhook-forgery.py +0 -0
- {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/templates/reports/FINDINGS-SUMMARY.md.template +0 -0
- {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/templates/reports/access-control-matrix.md.template +0 -0
- {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/templates/reports/findings-triage.md.template +0 -0
- {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/templates/reports/pentest-handover-brief.md.template +0 -0
- {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/templates/reports/per-tool-FINDINGS.md.template +0 -0
- {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator.egg-info/SOURCES.txt +0 -0
- {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator.egg-info/dependency_links.txt +0 -0
- {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator.egg-info/entry_points.txt +0 -0
- {websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator.egg-info/top_level.txt +0 -0
- {websec_validator-0.2.5 → websec_validator-0.2.7}/tests/test_recon.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: websec-validator
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.7
|
|
4
4
|
Summary: Local-first security recon that briefs your AI coding agent: facts + tailored probe scripts, code-in / artifacts-out. No LLM, no server, no running app.
|
|
5
5
|
Author: Ricardo Accioly
|
|
6
6
|
License: MIT
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "websec-validator"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.7"
|
|
8
8
|
description = "Local-first security recon that briefs your AI coding agent: facts + tailored probe scripts, code-in / artifacts-out. No LLM, no server, no running app."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -308,19 +308,24 @@ def forged_token_bypass(target: str, facts: dict, cookie_names=None,
|
|
|
308
308
|
base_code, _ = _request(method, url, token=None, data=body)
|
|
309
309
|
if base_code not in (401, 403):
|
|
310
310
|
continue # only routes that are gated WITHOUT auth tell us anything about forgery
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
hit =
|
|
315
|
-
if
|
|
316
|
-
|
|
317
|
-
row = {"method": method, "path": path, "baseline": base_code, "forged": fcode,
|
|
318
|
-
"via": via, "verdict": "BYPASS"}
|
|
319
|
-
bypassed.append(row)
|
|
311
|
+
# Bearer first (cheapest, most universal); only forge into each known auth cookie if
|
|
312
|
+
# Bearer didn't reach the handler — short-circuits to keep request volume (and
|
|
313
|
+
# rate-limiter pressure) down. cookie_names is what catches cookie-ONLY session apps.
|
|
314
|
+
hit, bearer_code = None, _request(method, url, token=forged, data=body)[0]
|
|
315
|
+
if bearer_code in _REACHED_HANDLER:
|
|
316
|
+
hit = ("Authorization: Bearer", bearer_code)
|
|
320
317
|
else:
|
|
321
|
-
|
|
322
|
-
|
|
318
|
+
for cn in (cookie_names or []):
|
|
319
|
+
cc = _request(method, url, token=None, data=body, cookie=f"{cn}={forged}")[0]
|
|
320
|
+
if cc in _REACHED_HANDLER:
|
|
321
|
+
hit = (f"cookie:{cn}", cc)
|
|
322
|
+
break
|
|
323
|
+
via, fcode = hit if hit else ("Authorization: Bearer", bearer_code)
|
|
324
|
+
row = {"method": method, "path": path, "baseline": base_code, "forged": fcode,
|
|
325
|
+
"via": via, "verdict": "BYPASS" if hit else "rejected"}
|
|
323
326
|
results.append(row)
|
|
327
|
+
if hit:
|
|
328
|
+
bypassed.append(row)
|
|
324
329
|
|
|
325
330
|
return {
|
|
326
331
|
"target": target,
|
|
@@ -338,8 +343,10 @@ def forged_token_bypass(target: str, facts: dict, cookie_names=None,
|
|
|
338
343
|
|
|
339
344
|
def run_unauth(target: str, facts_path: Path, outdir: Path, probe_writes: bool = False) -> dict:
|
|
340
345
|
facts = json.loads(Path(facts_path).read_text())
|
|
346
|
+
cookie_names = (facts.get("auth") or {}).get("cookie_names")
|
|
341
347
|
res = {"unauth_reachability": unauth_reachability(target, facts),
|
|
342
|
-
"forged_token_bypass": forged_token_bypass(target, facts,
|
|
348
|
+
"forged_token_bypass": forged_token_bypass(target, facts, cookie_names=cookie_names,
|
|
349
|
+
probe_writes=probe_writes)}
|
|
343
350
|
if probe_writes:
|
|
344
351
|
res["write_auth_enforcement"] = write_auth_enforcement(target, facts)
|
|
345
352
|
outdir.mkdir(parents=True, exist_ok=True)
|
|
@@ -17,6 +17,15 @@ PASSPORT = re.compile(r"\bpassport\b|passport-jwt|passport-local")
|
|
|
17
17
|
SESSION = re.compile(r"express-session|cookie-session|iron-session|flask\.session|request\.session|getServerSession|getToken", re.I)
|
|
18
18
|
APIKEY = re.compile(r"x-api-key|api[_-]?key|apikey", re.I)
|
|
19
19
|
GUARDS = re.compile(r"requireAuth|requirePermission|requireRole|isAuthenticated|@login_required|@require|ensureAuth|withAuth|getServerSession|verifyToken|authMiddleware|@roles_required|can\(|ability\.", re.I)
|
|
20
|
+
# Cookie READ sites — the names the app pulls a token/session from. The forged-token probe
|
|
21
|
+
# forges into these (not just Authorization: Bearer) so a cookie-ONLY session app isn't a
|
|
22
|
+
# false negative. Covers cookies.get('X') / cookies['X'] / getCookie('X') / req.cookies.X.
|
|
23
|
+
COOKIE_READ = re.compile(
|
|
24
|
+
r"""cookies\s*(?:\.get\(|\[)\s*['"]([A-Za-z0-9_.\-]{2,64})['"]""" # cookies.get('X') / cookies['X']
|
|
25
|
+
r"""|getCookie\(\s*['"]([A-Za-z0-9_.\-]{2,64})['"]""" # getCookie('X')
|
|
26
|
+
r"""|\.cookies\.([A-Za-z_][A-Za-z0-9_]{1,63})\b(?!\s*\()""") # req.cookies.X (not a .get()/.set() call)
|
|
27
|
+
_COOKIE_RESERVED = {"get", "set", "getall", "has", "delete", "clear", "tostring",
|
|
28
|
+
"foreach", "entries", "keys", "values", "size", "name", "value", "length"}
|
|
20
29
|
|
|
21
30
|
|
|
22
31
|
class AuthExtractor(Extractor):
|
|
@@ -31,6 +40,7 @@ class AuthExtractor(Extractor):
|
|
|
31
40
|
# scheme: framework/route signals first, then grep
|
|
32
41
|
jwt = passport = session = apikey = 0
|
|
33
42
|
guard_files = []
|
|
43
|
+
cookie_names: list[str] = []
|
|
34
44
|
for _p, rel, text in ctx.iter_code():
|
|
35
45
|
if JWT_LIBS.search(text):
|
|
36
46
|
jwt += 1
|
|
@@ -42,6 +52,11 @@ class AuthExtractor(Extractor):
|
|
|
42
52
|
apikey += 1
|
|
43
53
|
if GUARDS.search(text) and len(guard_files) < 25:
|
|
44
54
|
guard_files.append(rel)
|
|
55
|
+
if len(cookie_names) < 20:
|
|
56
|
+
for m in COOKIE_READ.finditer(text):
|
|
57
|
+
name = m.group(1) or m.group(2) or m.group(3)
|
|
58
|
+
if name and name.lower() not in _COOKIE_RESERVED and name not in cookie_names:
|
|
59
|
+
cookie_names.append(name)
|
|
45
60
|
|
|
46
61
|
nextauth = "nextauth" in frameworks or any("nextauth" in e.lower() for e in auth_eps)
|
|
47
62
|
|
|
@@ -70,6 +85,7 @@ class AuthExtractor(Extractor):
|
|
|
70
85
|
"schemes_detected": detected,
|
|
71
86
|
"token_location": token_location,
|
|
72
87
|
"login_endpoints": auth_eps,
|
|
88
|
+
"cookie_names": cookie_names[:15],
|
|
73
89
|
"guard_files": guard_files,
|
|
74
90
|
"signal_counts": {"jwt": jwt, "passport": passport, "session": session, "api_key": apikey},
|
|
75
91
|
"note": "AGENT: confirm the PRIMARY auth flow + how a test token is minted before the JWT/auth "
|
|
@@ -184,7 +184,9 @@ def build_ledger(facts: dict, unified: dict | None, dynamic: dict | None = None,
|
|
|
184
184
|
cat = t.get("category", "")
|
|
185
185
|
cls = cat_to_class.get(cat, "sast")
|
|
186
186
|
sev = t.get("severity", "MEDIUM")
|
|
187
|
-
|
|
187
|
+
# Confidence follows severity for secrets/CVEs: a generic-api-key tiered down to MEDIUM
|
|
188
|
+
# (low-precision rule, bug-072) should NOT be stamped HIGH-confidence — keep P(real) honest.
|
|
189
|
+
conf = "HIGH" if (cat in ("secret", "sca") and sev in ("HIGH", "CRITICAL")) else "MEDIUM"
|
|
188
190
|
out.append(_f(t.get("title", cat), f"static-{cat}", cls, sev, conf, t.get("file", ""),
|
|
189
191
|
[{"layer": "static", "detail": f"{'+'.join(t.get('tools', []))}: {t.get('title','')}"}]))
|
|
190
192
|
|
|
@@ -111,6 +111,7 @@ def build_context(facts: dict) -> dict:
|
|
|
111
111
|
"auth": {
|
|
112
112
|
"scheme": auth.get("scheme"),
|
|
113
113
|
"token_location": auth.get("token_location"),
|
|
114
|
+
"cookie_names": auth.get("cookie_names", []),
|
|
114
115
|
"login_endpoints": tgt.get("auth_endpoints", [])[:10],
|
|
115
116
|
"how_to_authenticate": "cookie-session (e.g. NextAuth) → send the session cookie; "
|
|
116
117
|
"bearer → Authorization: Bearer <jwt>; api-key → the documented key header",
|
|
@@ -200,6 +200,23 @@ def _aws_secret_tier(secret: str, match: str):
|
|
|
200
200
|
return None, None
|
|
201
201
|
|
|
202
202
|
|
|
203
|
+
# gitleaks/trivy "generic" + entropy/keyword rules are high-recall, low-precision: they fire on
|
|
204
|
+
# public keys, wallet addresses, hashes, env-var refs and test fixtures about as often as real
|
|
205
|
+
# credentials. Tier those to MEDIUM + a verify note (NEVER hide them) so the HIGH secret tier
|
|
206
|
+
# stays trustworthy in a shareable report; specific-format rules (AKIA, private-key, GitHub/
|
|
207
|
+
# Stripe/Slack/JWT, etc.) keep HIGH. (bug-072 — dogfooding a wallet app surfaced ~20 HIGH
|
|
208
|
+
# generic-api-key FPs in committed source.)
|
|
209
|
+
_GENERIC_SECRET_RULES = {"generic-api-key", "generic-api-key-1", "generic", "api-key",
|
|
210
|
+
"secret-keyword", "high-entropy", "high-entropy-string", "entropy"}
|
|
211
|
+
_GENERIC_NOTE = ("generic/entropy match — verify it's a live credential "
|
|
212
|
+
"(often a public key, address, hash or env-ref, not a secret)")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _generic_secret(rule: str) -> bool:
|
|
216
|
+
r = (rule or "").lower()
|
|
217
|
+
return r in _GENERIC_SECRET_RULES or "generic" in r or "entropy" in r
|
|
218
|
+
|
|
219
|
+
|
|
203
220
|
def _norm_trivy(data: dict) -> list:
|
|
204
221
|
out = []
|
|
205
222
|
for res in (data.get("Results") or []):
|
|
@@ -210,11 +227,14 @@ def _norm_trivy(data: dict) -> list:
|
|
|
210
227
|
"title": f"{v.get('PkgName')} {v.get('InstalledVersion')} → {v.get('FixedVersion', '(no fix)')}",
|
|
211
228
|
"fingerprint": f"cve|{v.get('PkgName')}|{v.get('VulnerabilityID')}"})
|
|
212
229
|
for s in (res.get("Secrets") or []):
|
|
230
|
+
rid = s.get("RuleID", "")
|
|
213
231
|
sev, note = _aws_secret_tier(s.get("Match", ""), s.get("Code", "") or "")
|
|
214
|
-
|
|
232
|
+
if not sev and _generic_secret(rid):
|
|
233
|
+
sev, note = "MEDIUM", _GENERIC_NOTE
|
|
234
|
+
title = f"secret: {s.get('Title') or rid}" + (f" — {note}" if note else "")
|
|
215
235
|
out.append({"tool": "trivy", "category": "secret", "severity": sev or _sev(s.get("Severity") or "HIGH"),
|
|
216
|
-
"key":
|
|
217
|
-
"title": title, "fingerprint": f"secret|{tgt}|{
|
|
236
|
+
"key": rid, "file": tgt, "line": s.get("StartLine", 0),
|
|
237
|
+
"title": title, "fingerprint": f"secret|{tgt}|{rid}"})
|
|
218
238
|
for m in (res.get("Misconfigurations") or []):
|
|
219
239
|
out.append({"tool": "trivy", "category": "iac", "severity": _sev(m.get("Severity")),
|
|
220
240
|
"key": m.get("ID", ""), "file": tgt, "line": 0, "title": (m.get("Title") or "")[:90],
|
|
@@ -228,6 +248,8 @@ def _norm_gitleaks(data) -> list:
|
|
|
228
248
|
for x in rows:
|
|
229
249
|
f, rule = x.get("File", ""), x.get("RuleID", "")
|
|
230
250
|
sev, note = _aws_secret_tier(x.get("Secret", ""), x.get("Match", ""))
|
|
251
|
+
if not sev and _generic_secret(rule):
|
|
252
|
+
sev, note = "MEDIUM", _GENERIC_NOTE
|
|
231
253
|
title = f"secret: {(x.get('Description') or rule)[:80]}" + (f" — {note}" if note else "")
|
|
232
254
|
out.append({"tool": "gitleaks", "category": "secret", "severity": sev or "HIGH",
|
|
233
255
|
"key": rule, "file": f, "line": x.get("StartLine", 0),
|
|
@@ -26,6 +26,16 @@ def b(o): return base64.urlsafe_b64encode(json.dumps(o).encode()).rstrip(b'=').d
|
|
|
26
26
|
print(b({'alg':'RS256','typ':'JWT','kid':'forged'})+'.'+b({'sub':'websec-forged','email':'websec-forged@example.com','role':'admin','roles':['admin'],'exp':9999999999})+'.d2Vic2VjLWZvcmdlZC1zaWc')
|
|
27
27
|
")
|
|
28
28
|
|
|
29
|
+
# Auth cookie names the app reads (from recon → probe-context.json) + an optional COOKIE_NAME
|
|
30
|
+
# override. We forge into these too, not just Authorization: Bearer, so a cookie-ONLY app isn't
|
|
31
|
+
# a false negative. (portable; macOS bash 3.2 lacks `mapfile`.)
|
|
32
|
+
COOKIES=()
|
|
33
|
+
[ -n "${COOKIE_NAME:-}" ] && COOKIES+=("$COOKIE_NAME")
|
|
34
|
+
while IFS= read -r cn; do [ -n "$cn" ] && COOKIES+=("$cn"); done < <(python3 -c "
|
|
35
|
+
import json
|
|
36
|
+
for c in json.load(open('$ctx')).get('auth',{}).get('cookie_names',[]): print(c)
|
|
37
|
+
" 2>/dev/null)
|
|
38
|
+
|
|
29
39
|
# Routes to test: GET reads + GET idor/ssrf candidates (always); writes when PROBE_WRITES=1.
|
|
30
40
|
# Skip any path with an unfilled {param}. (portable; macOS bash 3.2 lacks `mapfile`.)
|
|
31
41
|
ROUTES=()
|
|
@@ -64,9 +74,11 @@ for ep in "${ROUTES[@]}"; do
|
|
|
64
74
|
if [ "$na" != "401" ] && [ "$na" != "403" ]; then skip=$((skip+1)); continue; fi # not gated unauthenticated → N/A here
|
|
65
75
|
fg=$(curl -s -o /dev/null -w '%{http_code}' -X "$method" "$BASE$path" -H "Authorization: Bearer $FORGED" ${data[@]+"${data[@]}"} --max-time 15)
|
|
66
76
|
via="Bearer"
|
|
67
|
-
if ! reached "$fg"
|
|
68
|
-
|
|
69
|
-
|
|
77
|
+
if ! reached "$fg"; then # Bearer didn't reach the handler → try forging into each known auth cookie
|
|
78
|
+
for cn in ${COOKIES[@]+"${COOKIES[@]}"}; do
|
|
79
|
+
cfg=$(curl -s -o /dev/null -w '%{http_code}' -X "$method" "$BASE$path" -H "Cookie: $cn=$FORGED" ${data[@]+"${data[@]}"} --max-time 15)
|
|
80
|
+
if reached "$cfg"; then fg="$cfg"; via="cookie:$cn"; break; fi
|
|
81
|
+
done
|
|
70
82
|
fi
|
|
71
83
|
if reached "$fg"; then
|
|
72
84
|
printf ' BYPASS %s→%s %s %s (forged token accepted via %s)\n' "$na" "$fg" "$method" "$path" "$via"; bypass=$((bypass+1))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: websec-validator
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.7
|
|
4
4
|
Summary: Local-first security recon that briefs your AI coding agent: facts + tailored probe scripts, code-in / artifacts-out. No LLM, no server, no running app.
|
|
5
5
|
Author: Ricardo Accioly
|
|
6
6
|
License: MIT
|
|
@@ -17,6 +17,8 @@ ROOT = Path(__file__).resolve().parents[1]
|
|
|
17
17
|
sys.path.insert(0, str(ROOT / "src"))
|
|
18
18
|
|
|
19
19
|
from websec_validator import dynamic, findings, probes, scanners # noqa: E402
|
|
20
|
+
from websec_validator.extractors.auth import AuthExtractor # noqa: E402
|
|
21
|
+
from websec_validator.extractors.base import RepoContext # noqa: E402
|
|
20
22
|
|
|
21
23
|
FACTS = {"routes": {"endpoints": [
|
|
22
24
|
{"method": "GET", "path": "/api/bypass"}, # gated; accepts forged token -> BYPASS
|
|
@@ -133,5 +135,73 @@ class ProbeRegistrationTests(unittest.TestCase):
|
|
|
133
135
|
self.assertEqual(ctx["endpoints"]["reads"], ["GET /api/a"])
|
|
134
136
|
|
|
135
137
|
|
|
138
|
+
class SecretPrecisionTests(unittest.TestCase):
|
|
139
|
+
"""bug-072: low-precision generic/entropy secret rules -> MEDIUM (+verify note); specific
|
|
140
|
+
rules (AKIA, private-key, …) keep HIGH. Nothing is hidden."""
|
|
141
|
+
|
|
142
|
+
def test_generic_rule_detection(self):
|
|
143
|
+
self.assertTrue(scanners._generic_secret("generic-api-key"))
|
|
144
|
+
self.assertTrue(scanners._generic_secret("high-entropy-string"))
|
|
145
|
+
self.assertFalse(scanners._generic_secret("aws-access-token"))
|
|
146
|
+
self.assertFalse(scanners._generic_secret("private-key"))
|
|
147
|
+
|
|
148
|
+
def test_gitleaks_generic_is_medium_specific_is_high(self):
|
|
149
|
+
rows = [
|
|
150
|
+
{"File": "src/lib/chains.ts", "RuleID": "generic-api-key", "Secret": "x" * 40, "Match": "x" * 40, "StartLine": 1},
|
|
151
|
+
{"File": "src/k.pem", "RuleID": "private-key", "Secret": "-----BEGIN", "Match": "-----BEGIN", "StartLine": 1},
|
|
152
|
+
{"File": "src/a.ts", "RuleID": "aws-access-token", "Secret": "AKIA" + "A" * 16, "Match": "AKIA" + "A" * 16, "StartLine": 1},
|
|
153
|
+
]
|
|
154
|
+
by = {r["key"]: r for r in scanners._norm_gitleaks(rows)}
|
|
155
|
+
self.assertEqual(by["generic-api-key"]["severity"], "MEDIUM")
|
|
156
|
+
self.assertIn("generic/entropy", by["generic-api-key"]["title"])
|
|
157
|
+
self.assertEqual(by["private-key"]["severity"], "HIGH") # specific rule untouched
|
|
158
|
+
self.assertEqual(by["aws-access-token"]["severity"], "HIGH") # AKIA via _aws_secret_tier
|
|
159
|
+
|
|
160
|
+
def test_trivy_generic_secret_is_medium(self):
|
|
161
|
+
data = {"Results": [{"Target": "src/x.ts", "Secrets": [
|
|
162
|
+
{"RuleID": "generic-api-key", "Title": "Generic API Key", "Match": "y" * 40, "StartLine": 2}]}]}
|
|
163
|
+
secs = [f for f in scanners._norm_trivy(data) if f["category"] == "secret"]
|
|
164
|
+
self.assertEqual(secs[0]["severity"], "MEDIUM")
|
|
165
|
+
|
|
166
|
+
def test_medium_secret_gets_medium_confidence_in_ledger(self):
|
|
167
|
+
unified = {"top": [{"severity": "MEDIUM", "category": "secret",
|
|
168
|
+
"title": "secret: Generic API Key — generic/entropy match", "file": "src/x.ts", "tools": ["gitleaks"]}]}
|
|
169
|
+
led = findings.build_ledger({}, unified, None, [])
|
|
170
|
+
hit = [f for f in led["findings"] if f["category"] == "static-secret"][0]
|
|
171
|
+
self.assertEqual(hit["confidence"], "MEDIUM")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class CookieCoverageTests(unittest.TestCase):
|
|
175
|
+
"""0.2.7: extract auth cookie names so the forged-token engine covers cookie-ONLY apps."""
|
|
176
|
+
|
|
177
|
+
def test_extracts_cookie_names(self):
|
|
178
|
+
with tempfile.TemporaryDirectory() as d:
|
|
179
|
+
d = Path(d)
|
|
180
|
+
(d / "auth.ts").write_text(
|
|
181
|
+
"const s = request.cookies.get('agent_wallet_session');\n"
|
|
182
|
+
"const p = req.cookies['ping_id_token'];\n"
|
|
183
|
+
"const x = getCookie('dynamic_authentication_token');\n")
|
|
184
|
+
out = AuthExtractor().extract(RepoContext(d), {"stack": {"frameworks": []}, "routes": {}})
|
|
185
|
+
names = set(out["cookie_names"])
|
|
186
|
+
self.assertIn("agent_wallet_session", names)
|
|
187
|
+
self.assertIn("ping_id_token", names)
|
|
188
|
+
self.assertIn("dynamic_authentication_token", names)
|
|
189
|
+
self.assertNotIn("get", names) # reserved method name filtered
|
|
190
|
+
|
|
191
|
+
def test_forged_bypass_detected_via_cookie(self):
|
|
192
|
+
facts = {"routes": {"endpoints": [{"method": "GET", "path": "/api/cookieonly"}]}}
|
|
193
|
+
|
|
194
|
+
def fake(method, url, token=None, timeout=20, data=None, cookie=None):
|
|
195
|
+
if token:
|
|
196
|
+
return 401, "x" # Bearer rejected
|
|
197
|
+
if cookie and "sess=" in cookie:
|
|
198
|
+
return 200, "x" # forged cookie accepted (cookie-only app)
|
|
199
|
+
return 401, "x" # no-auth baseline (gated)
|
|
200
|
+
with mock.patch.object(dynamic, "_request", fake):
|
|
201
|
+
r = dynamic.forged_token_bypass("http://t", facts, cookie_names=["sess"])
|
|
202
|
+
self.assertEqual([b["path"] for b in r["bypassed"]], ["/api/cookieonly"])
|
|
203
|
+
self.assertTrue(r["bypassed"][0]["via"].startswith("cookie:"))
|
|
204
|
+
|
|
205
|
+
|
|
136
206
|
if __name__ == "__main__":
|
|
137
207
|
unittest.main()
|
|
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.5 → websec_validator-0.2.7}/src/websec_validator/extractors/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/extractors/client_exposure.py
RENAMED
|
File without changes
|
{websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/extractors/graphql.py
RENAMED
|
File without changes
|
|
File without changes
|
{websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/extractors/integrations.py
RENAMED
|
File without changes
|
|
File without changes
|
{websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator/extractors/schemas.py
RENAMED
|
File without changes
|
|
File without changes
|
{websec_validator-0.2.5 → websec_validator-0.2.7}/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.5 → websec_validator-0.2.7}/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
|
{websec_validator-0.2.5 → websec_validator-0.2.7}/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.5 → websec_validator-0.2.7}/src/websec_validator.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{websec_validator-0.2.5 → websec_validator-0.2.7}/src/websec_validator.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|