websec-validator 0.2.2__tar.gz → 0.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. {websec_validator-0.2.2/src/websec_validator.egg-info → websec_validator-0.2.3}/PKG-INFO +4 -4
  2. {websec_validator-0.2.2 → websec_validator-0.2.3}/README.md +3 -3
  3. {websec_validator-0.2.2 → websec_validator-0.2.3}/pyproject.toml +1 -1
  4. {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/extractors/auth.py +1 -1
  5. {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/probes.py +5 -0
  6. websec_validator-0.2.3/src/websec_validator/templates/probes/_lib.py +90 -0
  7. websec_validator-0.2.3/src/websec_validator/templates/probes/bola-cross-tenant.sh +48 -0
  8. websec_validator-0.2.3/src/websec_validator/templates/probes/bola-write-verbs.py +58 -0
  9. {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/templates/probes/dlp-bypass-offline.py +21 -29
  10. websec_validator-0.2.3/src/websec_validator/templates/probes/mass-assignment.py +60 -0
  11. websec_validator-0.2.3/src/websec_validator/templates/probes/race-conditions.py +97 -0
  12. {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/templates/probes/webhook-forgery.py +10 -13
  13. {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/templates/reports/FINDINGS-SUMMARY.md.template +15 -15
  14. {websec_validator-0.2.2 → websec_validator-0.2.3/src/websec_validator.egg-info}/PKG-INFO +4 -4
  15. {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator.egg-info/SOURCES.txt +1 -0
  16. {websec_validator-0.2.2 → websec_validator-0.2.3}/tests/test_recon.py +15 -1
  17. websec_validator-0.2.2/src/websec_validator/templates/probes/bola-cross-tenant.sh +0 -192
  18. websec_validator-0.2.2/src/websec_validator/templates/probes/bola-write-verbs.py +0 -147
  19. websec_validator-0.2.2/src/websec_validator/templates/probes/mass-assignment.py +0 -201
  20. websec_validator-0.2.2/src/websec_validator/templates/probes/race-conditions.py +0 -144
  21. {websec_validator-0.2.2 → websec_validator-0.2.3}/LICENSE +0 -0
  22. {websec_validator-0.2.2 → websec_validator-0.2.3}/setup.cfg +0 -0
  23. {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/__init__.py +0 -0
  24. {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/briefing.py +0 -0
  25. {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/calibration.json +0 -0
  26. {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/calibration.py +0 -0
  27. {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/cli.py +0 -0
  28. {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/constitution.py +0 -0
  29. {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/corpus.json +0 -0
  30. {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/dynamic.py +0 -0
  31. {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/extractors/__init__.py +0 -0
  32. {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/extractors/authz.py +0 -0
  33. {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/extractors/base.py +0 -0
  34. {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/extractors/client_exposure.py +0 -0
  35. {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/extractors/graphql.py +0 -0
  36. {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/extractors/iac_ci.py +0 -0
  37. {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/extractors/integrations.py +0 -0
  38. {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/extractors/routes.py +0 -0
  39. {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/extractors/schemas.py +0 -0
  40. {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/extractors/stack.py +0 -0
  41. {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/extractors/surface.py +0 -0
  42. {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/extractors/tenant.py +0 -0
  43. {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/findings.py +0 -0
  44. {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/proof.py +0 -0
  45. {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/recon.py +0 -0
  46. {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/report.py +0 -0
  47. {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/scanners.py +0 -0
  48. {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/templates/probes/compare-roles.sh +0 -0
  49. {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/templates/probes/hs256-brute-force.py +0 -0
  50. {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/templates/probes/jwt-attacks.sh +0 -0
  51. {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/templates/probes/rate-limit-burst.sh +0 -0
  52. {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/templates/probes/s3-assess.sh +0 -0
  53. {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/templates/probes/ssrf-probes.sh +0 -0
  54. {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/templates/probes/unauth-baseline.sh +0 -0
  55. {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/templates/reports/access-control-matrix.md.template +0 -0
  56. {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/templates/reports/findings-triage.md.template +0 -0
  57. {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/templates/reports/pentest-handover-brief.md.template +0 -0
  58. {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/templates/reports/per-tool-FINDINGS.md.template +0 -0
  59. {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator.egg-info/dependency_links.txt +0 -0
  60. {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator.egg-info/entry_points.txt +0 -0
  61. {websec_validator-0.2.2 → websec_validator-0.2.3}/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.2.2
3
+ Version: 0.2.3
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
@@ -171,9 +171,9 @@ the next dynamic probes (explicitly gated — they mutate).
171
171
 
172
172
  ## Validated on
173
173
 
174
- HugoCross (Next.js), `wu-whatsappinbox` (106-service Express/AWS monorepo), VAmPI, NodeGoat, DVGA
175
- independently reproducing a hand-done pentest's findings (tenant boundary, SSO-endpoint SSRF, media
176
- upload, conversation-BOLA routes, roles).
174
+ A production Next.js app, a large Express/AWS monorepo, and the VAmPI / NodeGoat / DVGA vuln-app
175
+ corpus — independently reproducing a hand-done pentest's findings (tenant boundary, SSRF, file
176
+ upload, cross-tenant BOLA, role/authz gaps).
177
177
 
178
178
  ## Tests
179
179
 
@@ -159,9 +159,9 @@ the next dynamic probes (explicitly gated — they mutate).
159
159
 
160
160
  ## Validated on
161
161
 
162
- HugoCross (Next.js), `wu-whatsappinbox` (106-service Express/AWS monorepo), VAmPI, NodeGoat, DVGA
163
- independently reproducing a hand-done pentest's findings (tenant boundary, SSO-endpoint SSRF, media
164
- upload, conversation-BOLA routes, roles).
162
+ A production Next.js app, a large Express/AWS monorepo, and the VAmPI / NodeGoat / DVGA vuln-app
163
+ corpus — independently reproducing a hand-done pentest's findings (tenant boundary, SSRF, file
164
+ upload, cross-tenant BOLA, role/authz gaps).
165
165
 
166
166
  ## Tests
167
167
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "websec-validator"
7
- version = "0.2.2"
7
+ version = "0.2.3"
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"
@@ -47,7 +47,7 @@ class AuthExtractor(Extractor):
47
47
 
48
48
  # Detect ALL schemes present, then pick a primary by priority. A JWT app
49
49
  # that also wires Passport for SSO must read as primary=jwt, not passport
50
- # (the bug the WhatsApp app exposed). Priority: nextauth > jwt > session > passport > api-key.
50
+ # (Passport is often SSO-only). Priority: nextauth > jwt > session > passport > api-key.
51
51
  detected = []
52
52
  if nextauth:
53
53
  detected.append("nextauth (session JWT in cookie)")
@@ -136,6 +136,11 @@ def stage(chosen: list, outdir: Path, facts: dict | None = None) -> list:
136
136
  manifest = [{"key": "_context", "file": "probes/probe-context.json",
137
137
  "note": "the target's real routes/auth/fields — finalize the drafts against this"}]
138
138
  src_root = resources.files("websec_validator").joinpath("templates/probes")
139
+ # always ship the shared helper the Python probes import (load context + env auth)
140
+ try:
141
+ (dest / "_lib.py").write_text(src_root.joinpath("_lib.py").read_text())
142
+ except Exception:
143
+ pass
139
144
  for key in chosen:
140
145
  fname, attack, needs = PROBES[key]
141
146
  targets = (tgt.get(_TARGET_KEYS[key], []) if key in _TARGET_KEYS else [])[:15]
@@ -0,0 +1,90 @@
1
+ """Shared probe helpers — load THIS target's real surface from probe-context.json
2
+ (written by `websec run`) and auth/ids from environment variables.
3
+
4
+ Why env vars: recon gives you the real endpoints, auth scheme, and tenant key — but it
5
+ cannot mint live tokens or know real object ids. You (or your agent, against a TEST
6
+ instance) supply those:
7
+
8
+ TARGET=http://localhost:3000 # base URL (or set target_base_url in probe-context.json)
9
+ TOKEN_A=... TOKEN_B=... # bearer JWTs for two test accounts (different tenants)
10
+ COOKIE_A=... COOKIE_B=... # OR session cookies (e.g. NextAuth) instead of bearer
11
+ APIKEY=... # OR an API key
12
+ OBJ_A=... OBJ_B=... # a sample object id owned by each account/tenant
13
+ GROUP_A=... GROUP_B=... # each account's tenant/group id (defaults to OBJ_* if unset)
14
+
15
+ Run only against a TEST instance you're authorized to probe. Never production.
16
+ """
17
+ import json
18
+ import os
19
+ import subprocess
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ _HERE = Path(__file__).resolve().parent
24
+
25
+
26
+ def context() -> dict:
27
+ p = _HERE / "probe-context.json"
28
+ if not p.is_file():
29
+ sys.exit("probe-context.json not found next to this probe — run `websec run <repo>` and use "
30
+ "the probes/ it stages (probe-context.json holds this app's real routes/auth).")
31
+ return json.loads(p.read_text())
32
+
33
+
34
+ def base_url() -> str:
35
+ u = os.environ.get("TARGET") or context().get("target_base_url", "")
36
+ if not u or u.startswith("FILL"):
37
+ sys.exit("Set TARGET=http://host:port (or fill target_base_url in probe-context.json).")
38
+ return u.rstrip("/")
39
+
40
+
41
+ def auth_headers(role: str = "A") -> list:
42
+ """Auth header for a role (A/B), adapting to whatever the operator supplied."""
43
+ tok = os.environ.get(f"TOKEN_{role}")
44
+ cookie = os.environ.get(f"COOKIE_{role}")
45
+ apikey = os.environ.get("APIKEY")
46
+ if tok:
47
+ return ["-H", f"Authorization: Bearer {tok}"]
48
+ if cookie:
49
+ return ["-H", f"Cookie: {cookie}"]
50
+ if apikey:
51
+ return ["-H", f"X-API-Key: {apikey}"]
52
+ return [] # unauthenticated
53
+
54
+
55
+ def require(*names: str) -> None:
56
+ missing = [n for n in names if not os.environ.get(n)]
57
+ if missing:
58
+ sys.exit(f"This probe needs these env var(s): {', '.join(missing)}. See _lib.py for the list.")
59
+
60
+
61
+ def curl(method: str, url: str, headers=None, body=None, timeout: int = 20):
62
+ """Returns (status_code, body_text). Never raises on HTTP errors."""
63
+ cmd = ["curl", "-s", "-X", method, url, "-w", "\nHTTP_CODE:%{http_code}",
64
+ "--max-time", str(timeout)] + (headers or [])
65
+ if body is not None:
66
+ cmd += ["-H", "content-type: application/json", "-d", json.dumps(body)]
67
+ out = subprocess.run(cmd, capture_output=True, text=True).stdout
68
+ code = int(out.split("HTTP_CODE:")[-1].strip()) if "HTTP_CODE:" in out else 0
69
+ return code, out.split("\nHTTP_CODE:")[0]
70
+
71
+
72
+ def tenant_key(default: str = "groupId") -> str:
73
+ keys = context().get("tenant_keys") or []
74
+ return keys[0] if keys else default
75
+
76
+
77
+ def write_endpoints() -> list:
78
+ """[(METHOD, path), …] for this app's mutating routes, from probe-context.json."""
79
+ out = []
80
+ for ep in context().get("endpoints", {}).get("writes", []):
81
+ parts = ep.split(" ", 1)
82
+ if len(parts) == 2:
83
+ out.append((parts[0], parts[1]))
84
+ return out
85
+
86
+
87
+ def save(name: str, findings: list) -> Path:
88
+ out = _HERE / f"{name}-findings.json"
89
+ out.write_text(json.dumps(findings, indent=2) + "\n")
90
+ return out
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env bash
2
+ # BOLA / cross-tenant READ probe — FACTS-driven. Role A uses its OWN token against
3
+ # tenant B's id (and B→A), on this app's tenant-scoped routes (from probe-context.json).
4
+ # Expect 401/403/404. A 200 that returns the OTHER tenant's data = cross-tenant BOLA
5
+ # (OWASP API #1) — the thing an automated scanner can't tell from "just another 200".
6
+ #
7
+ # Env: TARGET, TOKEN_A, TOKEN_B (two accounts in DIFFERENT tenants), GROUP_A, GROUP_B
8
+ # (each account's tenant/group id). Bearer auth; cookie users: swap the -H below.
9
+ # Run only against a TEST instance you're authorized to probe.
10
+ set -uo pipefail
11
+ cd "$(dirname "$0")"
12
+ ctx=probe-context.json
13
+
14
+ BASE="${TARGET:-$(python3 -c "import json;print(json.load(open('$ctx'))['target_base_url'])" 2>/dev/null)}"
15
+ if [ -z "${BASE:-}" ] || [ "${BASE#FILL}" != "$BASE" ]; then echo "Set TARGET=http://host:port (or fill probe-context.json)"; exit 2; fi
16
+ : "${TOKEN_A:?set TOKEN_A=<jwt for an account in tenant A>}"
17
+ : "${TOKEN_B:?set TOKEN_B=<jwt for an account in a DIFFERENT tenant>}"
18
+ : "${GROUP_A:?set GROUP_A=<tenant/group id of account A>}"
19
+ : "${GROUP_B:?set GROUP_B=<tenant/group id of account B>}"
20
+
21
+ mapfile -t PATHS < <(python3 -c "
22
+ import json
23
+ c = json.load(open('$ctx'))['endpoints']
24
+ cand = c.get('idor_candidates') or [w.split(' ',1)[1] for w in c.get('writes',[]) if ' ' in w]
25
+ for p in cand:
26
+ print(p.split(' ',1)[1] if (' ' in p and p.split(' ',1)[0].isupper()) else p)
27
+ " 2>/dev/null)
28
+ [ "${#PATHS[@]}" -eq 0 ] && { echo "No tenant-scoped / IDOR-candidate routes in probe-context.json."; exit 2; }
29
+
30
+ pass=0; leak=0
31
+ attack() { # $1=token $2=target-group-id $3=label
32
+ for raw in "${PATHS[@]}"; do
33
+ path=$(python3 -c "import re,sys; print(re.sub(r'\{[^}]+\}', sys.argv[1], sys.argv[2]))" "$2" "$raw")
34
+ code=$(curl -s -o /dev/null -w '%{http_code}' -m 15 -H "Authorization: Bearer $1" "$BASE$path")
35
+ case "$code" in
36
+ 401|403|404) printf ' ok %s %-4s %s\n' "$code" "$3" "$path"; pass=$((pass+1)) ;;
37
+ 200|206) printf ' LEAK %s %-4s %s ← returned data for the OTHER tenant? verify\n' "$code" "$3" "$path"; leak=$((leak+1)) ;;
38
+ *) printf ' ?? %s %-4s %s\n' "$code" "$3" "$path" ;;
39
+ esac
40
+ done
41
+ }
42
+
43
+ echo "=== cross-tenant BOLA vs $BASE (expect 401/403/404) ==="
44
+ echo "--- A → B's tenant ($GROUP_B) ---"; attack "$TOKEN_A" "$GROUP_B" "A→B"
45
+ echo "--- B → A's tenant ($GROUP_A) ---"; attack "$TOKEN_B" "$GROUP_A" "B→A"
46
+ echo "summary: $pass blocked · $leak potential leak(s)"
47
+ [ "$leak" -gt 0 ] && echo "A 200 means the route served the OTHER tenant's id — confirm it's actually their data (not empty / your own), then debate-verify before reporting."
48
+ exit "$leak"
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env python3
2
+ """BOLA / cross-tenant WRITE probe — FACTS-driven and generic.
3
+
4
+ As role A, send each mutating verb (PUT/PATCH/POST/DELETE) at tenant B's resources
5
+ (object id OBJ_B in group/tenant GROUP_B). Expect 401/403/404. A 2xx means the
6
+ object-level authorization check is missing (BOLA — OWASP API #1). Write verbs miss
7
+ authz more often than GETs.
8
+
9
+ Endpoints come from probe-context.json (this app's real routes). Tokens + ids come
10
+ from env (see _lib.py): TARGET, TOKEN_A|COOKIE_A|APIKEY, OBJ_B, optional GROUP_B.
11
+ Bodies are minimal `{}` to limit side effects, but write verbs CAN mutate — run only
12
+ against a TEST instance you're authorized to probe.
13
+ """
14
+ import os
15
+ import re
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
20
+ import _lib # noqa: E402
21
+
22
+ BASE = _lib.base_url()
23
+ _lib.require("OBJ_B") # a real object id owned by tenant B
24
+ OBJ_B = os.environ["OBJ_B"]
25
+ GROUP_B = os.environ.get("GROUP_B", OBJ_B)
26
+ HDR_A = _lib.auth_headers("A")
27
+ if not HDR_A:
28
+ sys.exit("Supply role-A auth: TOKEN_A=<jwt> (or COOKIE_A / APIKEY). See _lib.py.")
29
+
30
+
31
+ def fill(path: str) -> str:
32
+ # tenant/group/org path param → GROUP_B; any other {param} (an id) → OBJ_B
33
+ return re.sub(r"\{([^}]+)\}",
34
+ lambda m: GROUP_B if any(t in m.group(1).lower() for t in ("group", "tenant", "org")) else OBJ_B,
35
+ path)
36
+
37
+
38
+ eps = _lib.write_endpoints()
39
+ if not eps:
40
+ sys.exit("No write endpoints in probe-context.json — nothing to probe.")
41
+
42
+ print(f"=== BOLA write-verb probe vs {BASE} (role A → tenant B's objects; expect 401/403/404) ===")
43
+ findings = []
44
+ for method, path in eps:
45
+ url = BASE + fill(path)
46
+ code, body = _lib.curl(method, url, headers=HDR_A, body={})
47
+ sev = "PASS" if code in (401, 403, 404) else ("CRITICAL" if code in (200, 201, 204) else "INVESTIGATE")
48
+ mark = {"PASS": "ok ", "CRITICAL": "BOLA", "INVESTIGATE": "?? "}[sev]
49
+ findings.append({"method": method, "path": path, "url": url, "status": code,
50
+ "severity": sev, "preview": body[:140]})
51
+ print(f" [{mark}] {sev:11} {method:6} {url} -> {code}")
52
+
53
+ crit = sum(1 for f in findings if f["severity"] == "CRITICAL")
54
+ out = _lib.save("bola-write-verbs", findings)
55
+ print(f"\n CRITICAL (BOLA confirmed)={crit} · saved {out.name}")
56
+ print(" A 2xx as role A against tenant B's object = object-level authz missing. Confirm the object")
57
+ print(" really belongs to B, then debate-verify (Advocate/Challenger/…) before reporting.")
58
+ sys.exit(1 if crit else 0)
@@ -13,34 +13,28 @@ Output: per-rule per-encoding match/bypass table.
13
13
  PRECONDITION: your project has a DLP / content-filtering feature with rules
14
14
  the admin can list via API. If not, this script is N/A.
15
15
  """
16
- import base64, json, re, subprocess, sys, urllib.parse
16
+ import base64, json, os, re, sys, urllib.parse
17
17
  from pathlib import Path
18
18
 
19
- ROOT = Path(__file__).resolve().parents[2].parent
20
- ENV = {}
21
- for line in (ROOT / 'security/zap/.env').read_text().splitlines():
22
- if '=' in line and not line.lstrip().startswith('#'):
23
- k, v = line.split('=', 1); ENV[k.strip()] = v.strip()
24
-
25
- TARGET = ENV['ZAP_TARGET']
26
- # TODO: adjust login response shape if needed.
27
- ADMIN_TOK_RESP = subprocess.run(['curl','-fsS','-X','POST',f"{TARGET}/api/auth/login",
28
- '-H','Content-Type: application/json',
29
- '-d',json.dumps({'email':ENV['ZAP_ADMIN_USER'],'password':ENV['ZAP_ADMIN_PASS']})],
30
- capture_output=True, text=True).stdout
31
- ADMIN_TOK = json.loads(ADMIN_TOK_RESP)['tokens']['accessToken']
32
-
33
- # PROJECT-SPECIFIC START
34
- # TODO: adjust the DLP-rules endpoint and the rule schema. Common shapes:
35
- # GET /api/dlp/rules -> [{id, name, pattern, action, scope}]
36
- # GET /api/policies -> [{id, regex, action}]
37
- RULES_ENDPOINT = "/api/dlp/rules"
38
- # PROJECT-SPECIFIC END
39
-
40
- rules_raw = subprocess.run(['curl','-fsS',f"{TARGET}{RULES_ENDPOINT}",
41
- '-H',f"Authorization: Bearer {ADMIN_TOK}"],
42
- capture_output=True, text=True).stdout
43
- all_rules = json.loads(rules_raw)
19
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
20
+ import _lib # noqa: E402
21
+
22
+ TARGET = _lib.base_url()
23
+ HDR = _lib.auth_headers("A") # a privileged/admin token that can list DLP rules
24
+ if not HDR:
25
+ sys.exit("Supply an admin token: TOKEN_A=<jwt> (or COOKIE_A). See _lib.py.")
26
+
27
+ # DLP-rules endpoint + rule schema vary by app. Set RULES_ENDPOINT, or edit this.
28
+ RULES_ENDPOINT = os.environ.get("RULES_ENDPOINT", "/api/dlp/rules")
29
+
30
+ code, rules_raw = _lib.curl("GET", f"{TARGET}{RULES_ENDPOINT}", headers=HDR)
31
+ if code != 200:
32
+ sys.exit(f"Couldn't fetch DLP rules from {RULES_ENDPOINT} (HTTP {code}). If this app has no "
33
+ "DLP/content-filter feature this probe is N/A; otherwise set RULES_ENDPOINT to the real path.")
34
+ try:
35
+ all_rules = json.loads(rules_raw)
36
+ except Exception:
37
+ sys.exit(f"DLP rules endpoint returned non-JSON (HTTP {code}).")
44
38
 
45
39
  # Filter to content (regex) rules, skipping any media/file-type-prefix rules
46
40
  content_rules = [r for r in all_rules if not r.get('pattern','').startswith('MEDIA_TYPE:')]
@@ -127,9 +121,7 @@ for rule in content_rules:
127
121
  'sample': encoded[:80],
128
122
  })
129
123
 
130
- out = ROOT / 'security/pentest-prep/reports/dlp-bypass/findings.json'
131
- out.parent.mkdir(parents=True, exist_ok=True)
132
- out.write_text(json.dumps(findings, indent=2))
124
+ out = _lib.save("dlp-bypass", findings)
133
125
 
134
126
  bypasses = [f for f in findings if not f['matched'] and f['encoding'] != 'plain']
135
127
  plain_misses = [f for f in findings if not f['matched'] and f['encoding'] == 'plain']
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env python3
2
+ """Mass assignment / BOPLA (OWASP API #3) — FACTS-driven.
3
+
4
+ Injects THIS app's privileged model fields (from probe-context.json → sensitive_fields,
5
+ i.e. the schema extractor's output: role/isAdmin/groupId/ownerId/…) into each write
6
+ endpoint and flags any request that's ACCEPTED (2xx). A correct server strips or rejects
7
+ server-controlled fields. Acceptance is a *lead*, not proof — re-fetch the object as the
8
+ agent to confirm the privileged field actually persisted/escalated before reporting.
9
+
10
+ Env (see _lib.py): TARGET, TOKEN_A|COOKIE_A|APIKEY, optional OBJ_A (your own object id for
11
+ self-edit paths; defaults to the literal 'me').
12
+ """
13
+ import os
14
+ import re
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
19
+ import _lib # noqa: E402
20
+
21
+ BASE = _lib.base_url()
22
+ HDR = _lib.auth_headers("A")
23
+ if not HDR:
24
+ sys.exit("Supply auth: TOKEN_A=<jwt> (or COOKIE_A / APIKEY). See _lib.py.")
25
+ OBJ_A = os.environ.get("OBJ_A", "me")
26
+ fields = _lib.context().get("sensitive_fields") or ["role", "isAdmin", "groupId", "ownerId", "permissions"]
27
+
28
+
29
+ def _val(f: str):
30
+ fl = f.lower()
31
+ if any(k in fl for k in ("isadmin", "admin", "verified", "enabled", "active")):
32
+ return True
33
+ if any(k in fl for k in ("permission", "scope", "group", "roles")):
34
+ return ["*"]
35
+ if any(k in fl for k in ("role", "status", "plan", "tier")):
36
+ return "admin"
37
+ return "websec-injected"
38
+
39
+
40
+ PAYLOAD = {f: _val(f) for f in fields}
41
+ eps = _lib.write_endpoints()
42
+ if not eps:
43
+ sys.exit("No write endpoints in probe-context.json — nothing to probe.")
44
+
45
+ print(f"=== Mass-assignment probe vs {BASE} injecting app fields: {list(PAYLOAD)} ===")
46
+ findings = []
47
+ for method, path in eps:
48
+ url = BASE + re.sub(r"\{[^}]+\}", OBJ_A, path)
49
+ code, body = _lib.curl(method, url, headers=HDR, body=dict(PAYLOAD))
50
+ sev = "SUSPECT" if code in (200, 201, 204) else ("PASS" if code in (400, 403, 422) else "INVESTIGATE")
51
+ mark = {"SUSPECT": "!!", "PASS": "ok", "INVESTIGATE": "??"}[sev]
52
+ findings.append({"method": method, "path": path, "url": url, "status": code,
53
+ "severity": sev, "preview": body[:140]})
54
+ print(f" [{mark}] {sev:11} {method:6} {url} -> {code}")
55
+
56
+ out = _lib.save("mass-assignment", findings)
57
+ sus = sum(1 for f in findings if f["severity"] == "SUSPECT")
58
+ print(f"\n SUSPECT (privileged fields accepted — verify they stuck)={sus} · saved {out.name}")
59
+ print(" Re-fetch the object as the agent to confirm the field persisted/escalated, then debate-verify.")
60
+ sys.exit(1 if sus else 0)
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Race condition probe — fires N parallel requests at race-prone endpoints
4
+ and checks if the server's invariants hold.
5
+
6
+ Common race targets:
7
+ - "claim" / "assign" endpoints — only one parallel claim should succeed
8
+ - status / state toggles — multiple parallel calls should converge
9
+ - inventory / quota decrements — should not allow over-spend
10
+ - tag/label-add endpoints — should dedupe
11
+
12
+ For each target:
13
+ - Fire PARALLEL_REQUESTS in parallel
14
+ - Count successes (200/201)
15
+ - Compare to expected_unique (usually 1 — only one assignment should win)
16
+ - If success_count > expected_unique -> race condition likely
17
+
18
+ Uses async httpx for true parallelism (synchronous loops can't trigger races).
19
+
20
+ Install: pip install httpx
21
+ """
22
+ import asyncio, httpx, json, os, re, sys
23
+ from pathlib import Path
24
+ from collections import Counter
25
+
26
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
27
+ import _lib # noqa: E402
28
+
29
+ TARGET = _lib.base_url()
30
+ _lib.require("OBJ_A") # an object id you own (the single-winner target)
31
+ OBJ_A = os.environ["OBJ_A"]
32
+ _tok, _cookie = os.environ.get("TOKEN_A"), os.environ.get("COOKIE_A")
33
+ HEADERS = {"Authorization": f"Bearer {_tok}"} if _tok else ({"Cookie": _cookie} if _cookie else {})
34
+ if not HEADERS:
35
+ sys.exit("Supply auth: TOKEN_A=<jwt> (or COOKIE_A). See _lib.py.")
36
+
37
+ PARALLEL = 50 # concurrent requests per target
38
+
39
+ # Race-prone targets = this app's mutating endpoints (from probe-context.json). For each, the
40
+ # server should keep its single-winner / converge / dedupe invariant under PARALLEL concurrency.
41
+ TARGETS = [{"name": f"{m} {p}", "method": m, "url": TARGET + re.sub(r"\{[^}]+\}", OBJ_A, p),
42
+ "payload": {}, "expected_unique": 1,
43
+ "note": "parallel fire — a single-winner/converge/dedupe invariant should hold"}
44
+ for m, p in _lib.write_endpoints()][:8]
45
+ if not TARGETS:
46
+ sys.exit("No write endpoints in probe-context.json — nothing to probe.")
47
+
48
+ async def fire(client, t):
49
+ """Single request, return (status_code, response_body_preview)"""
50
+ try:
51
+ r = await client.request(
52
+ t['method'], t['url'],
53
+ json=t['payload'],
54
+ headers=HEADERS,
55
+ timeout=30.0,
56
+ )
57
+ return (r.status_code, r.text[:120])
58
+ except Exception as e:
59
+ return (None, str(e)[:120])
60
+
61
+ async def run_target(t):
62
+ print(f" Firing {PARALLEL} parallel {t['method']} to {t['url'][len(TARGET):]}")
63
+ async with httpx.AsyncClient() as client:
64
+ results = await asyncio.gather(*[fire(client, t) for _ in range(PARALLEL)])
65
+ codes = Counter(r[0] for r in results)
66
+ success = sum(1 for r in results if r[0] and 200 <= r[0] < 300)
67
+ race_likely = success > t['expected_unique']
68
+ print(f" -> status counts: {dict(codes)}, successes: {success}, expected: {t['expected_unique']}")
69
+ if race_likely:
70
+ print(f" !! RACE CONDITION SUSPECTED -- {success} successes vs {t['expected_unique']} expected")
71
+ return {
72
+ 'name': t['name'],
73
+ 'parallel': PARALLEL,
74
+ 'status_counts': dict(codes),
75
+ 'success_count': success,
76
+ 'expected_unique': t['expected_unique'],
77
+ 'race_suspected': race_likely,
78
+ 'note': t['note'],
79
+ 'sample_responses': [r for r in results if r[0] and r[0] < 500][:3],
80
+ }
81
+
82
+ async def main():
83
+ findings = []
84
+ for t in TARGETS:
85
+ print(f"\n=== {t['name']}: {t['note']}")
86
+ f = await run_target(t)
87
+ findings.append(f)
88
+ out = _lib.save("race-conditions", findings)
89
+ crit = sum(1 for f in findings if f['race_suspected'])
90
+ print(f"\n=== Summary ===")
91
+ print(f" race suspected on {crit}/{len(findings)} endpoints")
92
+ print(f" saved to {out}")
93
+ return crit
94
+
95
+ if __name__ == '__main__':
96
+ rc = asyncio.run(main())
97
+ sys.exit(1 if rc > 0 else 0)
@@ -19,16 +19,13 @@ This probe tests:
19
19
  8. Empty body -> expect 401
20
20
  9. Wrong content-type -> expect 401
21
21
  """
22
- import json, subprocess, time, sys
22
+ import json, os, subprocess, time, sys
23
23
  from pathlib import Path
24
24
 
25
- ROOT = Path(__file__).resolve().parents[2].parent
26
- ENV = {}
27
- for line in (ROOT / 'security/zap/.env').read_text().splitlines():
28
- if '=' in line and not line.lstrip().startswith('#'):
29
- k, v = line.split('=', 1); ENV[k.strip()] = v.strip()
25
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
26
+ import _lib # noqa: E402
30
27
 
31
- TARGET = ENV['ZAP_TARGET']
28
+ TARGET = _lib.base_url()
32
29
 
33
30
  # PROJECT-SPECIFIC START
34
31
  # TODO: replace with your project's inbound-webhook path, signature header
@@ -38,9 +35,11 @@ TARGET = ENV['ZAP_TARGET']
38
35
  # Twilio: /webhooks/twilio, X-Twilio-Signature
39
36
  # GitHub: /webhooks/github, X-Hub-Signature-256
40
37
  # Custom: /webhooks/<provider>, X-Signature, X-Timestamp
41
- WEBHOOK_PATH = "/webhooks/<provider>"
42
- SIG_HEADER = "x-signature"
43
- TS_HEADER = "x-timestamp"
38
+ # set WEBHOOK_PATH / SIG_HEADER / TS_HEADER env vars, or edit these. probe-context.json
39
+ # lists this app's detected integrations/webhooks to pick the real path from.
40
+ WEBHOOK_PATH = os.environ.get("WEBHOOK_PATH", "/webhooks/<provider>")
41
+ SIG_HEADER = os.environ.get("SIG_HEADER", "x-signature")
42
+ TS_HEADER = os.environ.get("TS_HEADER", "x-timestamp")
44
43
 
45
44
  URL = f"{TARGET}{WEBHOOK_PATH}"
46
45
 
@@ -94,9 +93,7 @@ for name, headers, body, expected, reason in probes:
94
93
  'body_preview': body_text[:120],
95
94
  })
96
95
 
97
- out_p = ROOT / 'security/pentest-prep/reports/webhook-forgery/findings.json'
98
- out_p.parent.mkdir(parents=True, exist_ok=True)
99
- out_p.write_text(json.dumps(findings, indent=2))
96
+ out_p = _lib.save("webhook-forgery", findings)
100
97
 
101
98
  passed = sum(1 for f in findings if f['pass'])
102
99
  print(f"\n=== Summary ===")
@@ -1,18 +1,18 @@
1
1
  # Security tooling pass — findings summary
2
2
 
3
3
  > Date: <YYYY-MM-DD>. Tools run locally; **zero repo footprint added**.
4
- > All outputs in `security/<tool>/` (gitignored).
4
+ > All outputs in `websec-out/scanners/<tool>/` (gitignored).
5
5
 
6
6
  ## Tools run
7
7
 
8
8
  | Tool | Status | Outputs |
9
9
  |---|---|---|
10
- | **Prowler** <ver> | ☐ | `security/prowler/` |
11
- | **Nuclei** <ver> | ☐ | `security/nuclei/` |
12
- | **Semgrep** <ver> | ☐ | `security/semgrep/` |
13
- | **Gitleaks** <ver> | ☐ | `security/gitleaks/` |
14
- | **Trivy** <ver> | ☐ | `security/trivy/` |
15
- | **ZAP** <ver> + manual probes | ☐ | `security/zap/`, `security/pentest-prep/` |
10
+ | **Prowler** <ver> | ☐ | `websec-out/scanners/prowler/` |
11
+ | **Nuclei** <ver> | ☐ | `websec-out/scanners/nuclei/` |
12
+ | **Semgrep** <ver> | ☐ | `websec-out/scanners/semgrep/` |
13
+ | **Gitleaks** <ver> | ☐ | `websec-out/scanners/gitleaks/` |
14
+ | **Trivy** <ver> | ☐ | `websec-out/scanners/trivy/` |
15
+ | **ZAP** <ver> + manual probes | ☐ | `websec-out/scanners/zap/`, `websec-out/scanners/pentest-prep/` |
16
16
 
17
17
  ## Most important finding
18
18
 
@@ -49,27 +49,27 @@
49
49
  prowler aws --region us-east-1 \
50
50
  --compliance cis_2.0_aws aws_foundational_security_best_practices_aws \
51
51
  --output-formats html json-asff csv \
52
- --output-directory security/prowler/
52
+ --output-directory websec-out/scanners/prowler/
53
53
 
54
54
  # Nuclei
55
- TOKEN=$(./security/zap/run.sh --print-token)
55
+ TOKEN=$(./websec-out/scanners/zap/run.sh --print-token)
56
56
  nuclei -target "$ZAP_TARGET" -H "Authorization: Bearer $TOKEN" \
57
57
  -tags "jwt,ssrf,sqli,lfi,redirect,rce,exposure,misconfig,cve" \
58
58
  -severity medium,high,critical -rate-limit 30 -concurrency 5 \
59
- -json-export security/nuclei/nuclei-baseline.json \
60
- -output security/nuclei/nuclei-baseline.txt
59
+ -json-export websec-out/scanners/nuclei/nuclei-baseline.json \
60
+ -output websec-out/scanners/nuclei/nuclei-baseline.txt
61
61
 
62
62
  # Semgrep
63
63
  semgrep --config auto --config p/typescript --config p/javascript \
64
64
  --config p/security-audit --severity WARNING --severity ERROR \
65
- --json -o security/semgrep/semgrep-backend.json backend/src
65
+ --json -o websec-out/scanners/semgrep/semgrep-backend.json backend/src
66
66
 
67
67
  # Gitleaks
68
- gitleaks detect --source . --report-format json --report-path security/gitleaks/current.json
69
- gitleaks git --report-format json --report-path security/gitleaks/history.json
68
+ gitleaks detect --source . --report-format json --report-path websec-out/scanners/gitleaks/current.json
69
+ gitleaks git --report-format json --report-path websec-out/scanners/gitleaks/history.json
70
70
 
71
71
  # Trivy
72
72
  trivy fs --scanners vuln,secret,misconfig --severity HIGH,CRITICAL \
73
73
  --skip-dirs node_modules --skip-dirs security \
74
- --format json --output security/trivy/trivy-fs.json .
74
+ --format json --output websec-out/scanners/trivy/trivy-fs.json .
75
75
  ```
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: websec-validator
3
- Version: 0.2.2
3
+ Version: 0.2.3
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
@@ -171,9 +171,9 @@ the next dynamic probes (explicitly gated — they mutate).
171
171
 
172
172
  ## Validated on
173
173
 
174
- HugoCross (Next.js), `wu-whatsappinbox` (106-service Express/AWS monorepo), VAmPI, NodeGoat, DVGA
175
- independently reproducing a hand-done pentest's findings (tenant boundary, SSO-endpoint SSRF, media
176
- upload, conversation-BOLA routes, roles).
174
+ A production Next.js app, a large Express/AWS monorepo, and the VAmPI / NodeGoat / DVGA vuln-app
175
+ corpus — independently reproducing a hand-done pentest's findings (tenant boundary, SSRF, file
176
+ upload, cross-tenant BOLA, role/authz gaps).
177
177
 
178
178
  ## Tests
179
179
 
@@ -33,6 +33,7 @@ src/websec_validator/extractors/schemas.py
33
33
  src/websec_validator/extractors/stack.py
34
34
  src/websec_validator/extractors/surface.py
35
35
  src/websec_validator/extractors/tenant.py
36
+ src/websec_validator/templates/probes/_lib.py
36
37
  src/websec_validator/templates/probes/bola-cross-tenant.sh
37
38
  src/websec_validator/templates/probes/bola-write-verbs.py
38
39
  src/websec_validator/templates/probes/compare-roles.sh