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.
- {websec_validator-0.2.2/src/websec_validator.egg-info → websec_validator-0.2.3}/PKG-INFO +4 -4
- {websec_validator-0.2.2 → websec_validator-0.2.3}/README.md +3 -3
- {websec_validator-0.2.2 → websec_validator-0.2.3}/pyproject.toml +1 -1
- {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/extractors/auth.py +1 -1
- {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/probes.py +5 -0
- websec_validator-0.2.3/src/websec_validator/templates/probes/_lib.py +90 -0
- websec_validator-0.2.3/src/websec_validator/templates/probes/bola-cross-tenant.sh +48 -0
- websec_validator-0.2.3/src/websec_validator/templates/probes/bola-write-verbs.py +58 -0
- {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/templates/probes/dlp-bypass-offline.py +21 -29
- websec_validator-0.2.3/src/websec_validator/templates/probes/mass-assignment.py +60 -0
- websec_validator-0.2.3/src/websec_validator/templates/probes/race-conditions.py +97 -0
- {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/templates/probes/webhook-forgery.py +10 -13
- {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/templates/reports/FINDINGS-SUMMARY.md.template +15 -15
- {websec_validator-0.2.2 → websec_validator-0.2.3/src/websec_validator.egg-info}/PKG-INFO +4 -4
- {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator.egg-info/SOURCES.txt +1 -0
- {websec_validator-0.2.2 → websec_validator-0.2.3}/tests/test_recon.py +15 -1
- websec_validator-0.2.2/src/websec_validator/templates/probes/bola-cross-tenant.sh +0 -192
- websec_validator-0.2.2/src/websec_validator/templates/probes/bola-write-verbs.py +0 -147
- websec_validator-0.2.2/src/websec_validator/templates/probes/mass-assignment.py +0 -201
- websec_validator-0.2.2/src/websec_validator/templates/probes/race-conditions.py +0 -144
- {websec_validator-0.2.2 → websec_validator-0.2.3}/LICENSE +0 -0
- {websec_validator-0.2.2 → websec_validator-0.2.3}/setup.cfg +0 -0
- {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/__init__.py +0 -0
- {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/briefing.py +0 -0
- {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/calibration.json +0 -0
- {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/calibration.py +0 -0
- {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/cli.py +0 -0
- {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/constitution.py +0 -0
- {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/corpus.json +0 -0
- {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/dynamic.py +0 -0
- {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/extractors/__init__.py +0 -0
- {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/extractors/authz.py +0 -0
- {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/extractors/base.py +0 -0
- {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/extractors/client_exposure.py +0 -0
- {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/extractors/graphql.py +0 -0
- {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/extractors/iac_ci.py +0 -0
- {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/extractors/integrations.py +0 -0
- {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/extractors/routes.py +0 -0
- {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/extractors/schemas.py +0 -0
- {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/extractors/stack.py +0 -0
- {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/extractors/surface.py +0 -0
- {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/extractors/tenant.py +0 -0
- {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/findings.py +0 -0
- {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/proof.py +0 -0
- {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/recon.py +0 -0
- {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/report.py +0 -0
- {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/scanners.py +0 -0
- {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/templates/probes/compare-roles.sh +0 -0
- {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/templates/probes/hs256-brute-force.py +0 -0
- {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/templates/probes/jwt-attacks.sh +0 -0
- {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/templates/probes/rate-limit-burst.sh +0 -0
- {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/templates/probes/s3-assess.sh +0 -0
- {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/templates/probes/ssrf-probes.sh +0 -0
- {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/templates/probes/unauth-baseline.sh +0 -0
- {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/templates/reports/access-control-matrix.md.template +0 -0
- {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/templates/reports/findings-triage.md.template +0 -0
- {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/templates/reports/pentest-handover-brief.md.template +0 -0
- {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator/templates/reports/per-tool-FINDINGS.md.template +0 -0
- {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator.egg-info/dependency_links.txt +0 -0
- {websec_validator-0.2.2 → websec_validator-0.2.3}/src/websec_validator.egg-info/entry_points.txt +0 -0
- {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.
|
|
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
|
-
|
|
175
|
-
independently reproducing a hand-done pentest's findings (tenant boundary,
|
|
176
|
-
upload,
|
|
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
|
-
|
|
163
|
-
independently reproducing a hand-done pentest's findings (tenant boundary,
|
|
164
|
-
upload,
|
|
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.
|
|
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
|
-
# (
|
|
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,
|
|
16
|
+
import base64, json, os, re, sys, urllib.parse
|
|
17
17
|
from pathlib import Path
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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 =
|
|
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
|
-
|
|
26
|
-
|
|
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 =
|
|
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
|
|
42
|
-
|
|
43
|
-
|
|
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 =
|
|
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 `
|
|
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> | ☐ | `
|
|
11
|
-
| **Nuclei** <ver> | ☐ | `
|
|
12
|
-
| **Semgrep** <ver> | ☐ | `
|
|
13
|
-
| **Gitleaks** <ver> | ☐ | `
|
|
14
|
-
| **Trivy** <ver> | ☐ | `
|
|
15
|
-
| **ZAP** <ver> + manual probes | ☐ | `
|
|
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
|
|
52
|
+
--output-directory websec-out/scanners/prowler/
|
|
53
53
|
|
|
54
54
|
# Nuclei
|
|
55
|
-
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
|
|
60
|
-
-output
|
|
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
|
|
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
|
|
69
|
-
gitleaks git --report-format json --report-path
|
|
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
|
|
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.
|
|
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
|
-
|
|
175
|
-
independently reproducing a hand-done pentest's findings (tenant boundary,
|
|
176
|
-
upload,
|
|
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
|