websec-validator 0.2.0__py3-none-any.whl
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/__init__.py +14 -0
- websec_validator/briefing.py +218 -0
- websec_validator/calibration.json +75 -0
- websec_validator/calibration.py +226 -0
- websec_validator/cli.py +395 -0
- websec_validator/constitution.py +81 -0
- websec_validator/corpus.json +49 -0
- websec_validator/dynamic.py +249 -0
- websec_validator/extractors/__init__.py +56 -0
- websec_validator/extractors/auth.py +77 -0
- websec_validator/extractors/authz.py +130 -0
- websec_validator/extractors/base.py +101 -0
- websec_validator/extractors/client_exposure.py +48 -0
- websec_validator/extractors/graphql.py +71 -0
- websec_validator/extractors/iac_ci.py +65 -0
- websec_validator/extractors/integrations.py +55 -0
- websec_validator/extractors/routes.py +215 -0
- websec_validator/extractors/schemas.py +75 -0
- websec_validator/extractors/stack.py +80 -0
- websec_validator/extractors/surface.py +86 -0
- websec_validator/extractors/tenant.py +33 -0
- websec_validator/findings.py +199 -0
- websec_validator/probes.py +79 -0
- websec_validator/proof.py +96 -0
- websec_validator/recon.py +28 -0
- websec_validator/report.py +114 -0
- websec_validator/scanners.py +248 -0
- websec_validator/templates/probes/bola-cross-tenant.sh +192 -0
- websec_validator/templates/probes/bola-write-verbs.py +147 -0
- websec_validator/templates/probes/compare-roles.sh +69 -0
- websec_validator/templates/probes/dlp-bypass-offline.py +149 -0
- websec_validator/templates/probes/hs256-brute-force.py +90 -0
- websec_validator/templates/probes/jwt-attacks.sh +161 -0
- websec_validator/templates/probes/mass-assignment.py +201 -0
- websec_validator/templates/probes/race-conditions.py +144 -0
- websec_validator/templates/probes/rate-limit-burst.sh +136 -0
- websec_validator/templates/probes/s3-assess.sh +120 -0
- websec_validator/templates/probes/ssrf-probes.sh +189 -0
- websec_validator/templates/probes/webhook-forgery.py +113 -0
- websec_validator/templates/reports/FINDINGS-SUMMARY.md.template +75 -0
- websec_validator/templates/reports/access-control-matrix.md.template +65 -0
- websec_validator/templates/reports/findings-triage.md.template +28 -0
- websec_validator/templates/reports/pentest-handover-brief.md.template +121 -0
- websec_validator/templates/reports/per-tool-FINDINGS.md.template +37 -0
- websec_validator-0.2.0.dist-info/METADATA +232 -0
- websec_validator-0.2.0.dist-info/RECORD +50 -0
- websec_validator-0.2.0.dist-info/WHEEL +5 -0
- websec_validator-0.2.0.dist-info/entry_points.txt +2 -0
- websec_validator-0.2.0.dist-info/licenses/LICENSE +21 -0
- websec_validator-0.2.0.dist-info/top_level.txt +1 -0
websec_validator/cli.py
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
"""websec — CLI entry point.
|
|
2
|
+
|
|
3
|
+
Commands:
|
|
4
|
+
websec run <repo> [--scan] [--out DIR] full pipeline → FACTS.json + AGENT-BRIEFING.md + probes/
|
|
5
|
+
websec recon <repo> [--out DIR] recon only → FACTS.json
|
|
6
|
+
websec doctor [<repo>] show which scanners are present / missing
|
|
7
|
+
|
|
8
|
+
Code-in, artifacts-out. No LLM, no server, no running app. Point your AI coding
|
|
9
|
+
agent at the generated AGENT-BRIEFING.md.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import json
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from . import (__version__, briefing, calibration, constitution, dynamic, findings, probes, proof,
|
|
20
|
+
recon, report, scanners)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _resolve_target(raw: str) -> Path:
|
|
24
|
+
p = Path(raw).expanduser().resolve()
|
|
25
|
+
if not p.is_dir():
|
|
26
|
+
sys.exit(f"error: target is not a directory: {p}")
|
|
27
|
+
return p
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _default_out(target: Path, out: str | None) -> Path:
|
|
31
|
+
d = Path(out).expanduser().resolve() if out else Path.cwd() / "websec-out"
|
|
32
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
return d
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _new_run_dir(out: str | None) -> tuple:
|
|
37
|
+
"""Create an immutable timestamped run dir and point `latest` at it. Returns (run_dir, ts).
|
|
38
|
+
Every run is preserved — nothing is overwritten."""
|
|
39
|
+
import datetime
|
|
40
|
+
base = Path(out).expanduser().resolve() if out else Path.cwd() / "websec-out"
|
|
41
|
+
ts = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
42
|
+
run = base / "runs" / ts
|
|
43
|
+
run.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
latest = base / "latest"
|
|
45
|
+
try:
|
|
46
|
+
if latest.is_symlink() or latest.exists():
|
|
47
|
+
latest.unlink()
|
|
48
|
+
latest.symlink_to(Path("runs") / ts, target_is_directory=True)
|
|
49
|
+
except Exception:
|
|
50
|
+
pass
|
|
51
|
+
return run, ts
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def cmd_doctor(args) -> int:
|
|
55
|
+
target = _resolve_target(args.target) if args.target else None
|
|
56
|
+
langs = recon.detect_stack(target)["languages"] if target else None
|
|
57
|
+
det = scanners.detect(langs)
|
|
58
|
+
print(f"websec-validator v{__version__} — scanner check"
|
|
59
|
+
+ (f" (stack: {', '.join(langs) or 'unknown'})" if langs else ""))
|
|
60
|
+
print("\n available:")
|
|
61
|
+
for s in det["available"]:
|
|
62
|
+
print(f" ✓ {s['name']:20} {s['category']}")
|
|
63
|
+
if not det["available"]:
|
|
64
|
+
print(" (none on PATH)")
|
|
65
|
+
print("\n missing (optional — install for fuller coverage):")
|
|
66
|
+
for s in det["missing"]:
|
|
67
|
+
print(f" · {s['name']:20} {s['category']:8} {s.get('install','')}")
|
|
68
|
+
print("\n Docker:", "present" if _which("docker") else "not found "
|
|
69
|
+
"(used for reproducible scanner runs in a future release)")
|
|
70
|
+
return 0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def cmd_recon(args) -> int:
|
|
74
|
+
target = _resolve_target(args.target)
|
|
75
|
+
out = _default_out(target, args.out)
|
|
76
|
+
facts = recon.build_facts(target, __version__)
|
|
77
|
+
recon.write_facts(facts, out / "FACTS.json")
|
|
78
|
+
print(f"✓ FACTS.json → {out / 'FACTS.json'}")
|
|
79
|
+
_print_facts_summary(facts)
|
|
80
|
+
return 0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def cmd_run(args) -> int:
|
|
84
|
+
target = _resolve_target(args.target)
|
|
85
|
+
out, ts = _new_run_dir(args.out)
|
|
86
|
+
|
|
87
|
+
print(f"websec-validator v{__version__} · target: {target} · run {ts}\n")
|
|
88
|
+
|
|
89
|
+
# 1. recon
|
|
90
|
+
facts = recon.build_facts(target, __version__)
|
|
91
|
+
recon.write_facts(facts, out / "FACTS.json")
|
|
92
|
+
langs = facts["stack"]["languages"]
|
|
93
|
+
_print_facts_summary(facts)
|
|
94
|
+
|
|
95
|
+
# 2. scanners: detect, optionally run
|
|
96
|
+
det = scanners.detect(langs)
|
|
97
|
+
scan_results = []
|
|
98
|
+
unified = None
|
|
99
|
+
if args.scan:
|
|
100
|
+
print("\n running available static scanners (read-only)…")
|
|
101
|
+
scan_results = scanners.run_available(target, out, langs)
|
|
102
|
+
for r in scan_results:
|
|
103
|
+
tag = r.get("findings", r.get("status", "?"))
|
|
104
|
+
print(f" {r['name']}: {tag}")
|
|
105
|
+
unified = scanners.normalize_findings(scan_results, out)
|
|
106
|
+
print(f" → {unified['total']} de-duplicated findings "
|
|
107
|
+
f"({unified['cross_tool_or_dup_merged']} merged) · {unified['by_severity']}")
|
|
108
|
+
else:
|
|
109
|
+
print(f"\n scanners available: {', '.join(s['name'] for s in det['available']) or 'none'}"
|
|
110
|
+
" (add --scan to execute them)")
|
|
111
|
+
|
|
112
|
+
# 3. probes: choose + stage
|
|
113
|
+
chosen = probes.applicable(facts)
|
|
114
|
+
manifest = probes.stage(chosen, out)
|
|
115
|
+
print(f"\n staged {len([m for m in manifest if 'attack_class' in m])} tailored probe template(s) → {out / 'probes'}")
|
|
116
|
+
|
|
117
|
+
# 4. traceable findings ledger (recon + static; dynamic merges in via `websec dynamic`)
|
|
118
|
+
suppressions = findings.load_suppressions(target)
|
|
119
|
+
ledger = findings.build_ledger(facts, unified, None, suppressions)
|
|
120
|
+
(out / "findings-ledger.json").write_text(json.dumps(ledger, indent=2))
|
|
121
|
+
(out / "CONSTITUTION.md").write_text(constitution.render(constitution.build(facts, ledger)))
|
|
122
|
+
if ledger["total"]:
|
|
123
|
+
print(f"\n ledger: {ledger['total']} finding(s) · {ledger['by_severity']} · confidence {ledger['by_confidence']}"
|
|
124
|
+
+ (f" · {ledger['suppressed']} suppressed" if ledger["suppressed"] else ""))
|
|
125
|
+
|
|
126
|
+
# 5. briefing + comprehensive REPORT.md (immutable run record)
|
|
127
|
+
(out / "AGENT-BRIEFING.md").write_text(briefing.render(facts, det, scan_results, manifest, unified))
|
|
128
|
+
(out / "REPORT.md").write_text(report.render(facts, det, scan_results, unified, manifest, ts, ledger))
|
|
129
|
+
(out / "manifest.json").write_text(json.dumps(
|
|
130
|
+
{"facts": "FACTS.json", "scanners": det, "scan_results": scan_results,
|
|
131
|
+
"findings_summary": unified, "ledger": {"total": ledger["total"], "by_severity": ledger["by_severity"]},
|
|
132
|
+
"probes": manifest, "timestamp": ts}, indent=2))
|
|
133
|
+
|
|
134
|
+
print(f"\n✓ run {ts} saved (immutable — nothing overwritten):\n {out}")
|
|
135
|
+
print(" REPORT.md — full historical record")
|
|
136
|
+
print(" AGENT-BRIEFING.md — hand this to your AI coding agent")
|
|
137
|
+
print(f" latest → {out.parent.parent / 'latest'} · add `websec-out/` to .gitignore")
|
|
138
|
+
return 0
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def cmd_dynamic(args) -> int:
|
|
142
|
+
base = Path(args.out).expanduser().resolve() if args.out else Path.cwd() / "websec-out"
|
|
143
|
+
# resolve BEFORE _new_run_dir repoints `latest` (else the symlink moves under us)
|
|
144
|
+
facts_path = (Path(args.facts).expanduser() if args.facts else base / "latest" / "FACTS.json").resolve()
|
|
145
|
+
if not facts_path.is_file():
|
|
146
|
+
sys.exit(f"error: FACTS.json not found at {facts_path} — run `websec run <repo>` first (or pass --facts)")
|
|
147
|
+
out, ts = _new_run_dir(args.out)
|
|
148
|
+
dyn: dict = {}
|
|
149
|
+
|
|
150
|
+
if args.unauth:
|
|
151
|
+
if not args.target:
|
|
152
|
+
sys.exit("error: --unauth requires --target")
|
|
153
|
+
if args.probe_writes and not dynamic.is_localhost(args.target):
|
|
154
|
+
sys.exit("error: --probe-writes is localhost-only (it sends write verbs) — point --target at your sandbox")
|
|
155
|
+
print(f"websec dynamic — STRICT read-only · UNAUTHENTICATED · GET-only · run {ts}\n")
|
|
156
|
+
dyn = dynamic.run_unauth(args.target, facts_path, out, probe_writes=args.probe_writes)
|
|
157
|
+
u = dyn["unauth_reachability"]
|
|
158
|
+
print(f" target: {u['target']} · → {u['summary']}")
|
|
159
|
+
for r in u["results"]:
|
|
160
|
+
mark = "🔓" if r["verdict"] == "OPEN-no-auth" else (" ·" if r["verdict"] == "protected" else " ")
|
|
161
|
+
print(f" {mark} {str(r['status']):>4} {r['verdict']:26} {r['path']}")
|
|
162
|
+
if args.probe_writes:
|
|
163
|
+
w = dyn["write_auth_enforcement"]
|
|
164
|
+
print(f"\n write-verb auth enforcement → {w['summary']}")
|
|
165
|
+
for r in w["results"]:
|
|
166
|
+
mark = "🔓" if r["verdict"] != "auth-enforced" and not r["verdict"].startswith("http-") else " ·"
|
|
167
|
+
print(f" {mark} {str(r['status']):>4} {r['verdict']:42} {r['method']} {r['path']}")
|
|
168
|
+
elif args.config:
|
|
169
|
+
cfg = Path(args.config).expanduser().resolve()
|
|
170
|
+
if not cfg.is_file():
|
|
171
|
+
sys.exit(f"error: config not found: {cfg}")
|
|
172
|
+
print(f"websec dynamic — authenticated cross-tenant BOLA (read-only) · run {ts}\n")
|
|
173
|
+
dyn = dynamic.run_dynamic(cfg, facts_path, out)
|
|
174
|
+
ct = dyn.get("cross_tenant_bola", {})
|
|
175
|
+
if ct.get("error"):
|
|
176
|
+
print(" ERROR:", ct["error"])
|
|
177
|
+
return 1
|
|
178
|
+
print(f" agentA {ct['agentA']['email']} (tenant {ct['agentA']['tenant']}) · "
|
|
179
|
+
f"agentB {ct['agentB']['email']} (tenant {ct['agentB']['tenant']})")
|
|
180
|
+
print(f" → {ct['summary']}")
|
|
181
|
+
for lk in ct.get("leaks", []):
|
|
182
|
+
print(f" 🚨 LEAK {lk['direction']} {lk['path']} → HTTP {lk['status']}")
|
|
183
|
+
else:
|
|
184
|
+
sys.exit("error: provide --config (authenticated cross-tenant) OR --unauth --target (read-only)")
|
|
185
|
+
|
|
186
|
+
# merge dynamic evidence into the traceable ledger + write the immutable run report
|
|
187
|
+
facts_dict = json.loads(facts_path.read_text())
|
|
188
|
+
ledger = findings.build_ledger(facts_dict, None, dyn,
|
|
189
|
+
findings.load_suppressions(Path(facts_dict.get("target", "."))))
|
|
190
|
+
(out / "findings-ledger.json").write_text(json.dumps(ledger, indent=2))
|
|
191
|
+
(out / "CONSTITUTION.md").write_text(constitution.render(constitution.build(facts_dict, ledger)))
|
|
192
|
+
(out / "REPORT.md").write_text(
|
|
193
|
+
report.render(facts_dict, {"available": [], "missing": []}, [], None, [], ts, ledger))
|
|
194
|
+
print(f"\n ledger: {ledger['total']} finding(s) · {ledger['by_severity']} · confidence {ledger['by_confidence']}")
|
|
195
|
+
|
|
196
|
+
# self-improving calibration: dynamic is an oracle — fold this run's CONFIRMED results
|
|
197
|
+
# (executed-unauth / auth-enforced / cross-tenant leak) into the user-global local overlay
|
|
198
|
+
samples = calibration.samples_from_dynamic(dyn)
|
|
199
|
+
rec = calibration.record_samples(samples) if samples else None
|
|
200
|
+
if rec:
|
|
201
|
+
nr = sum(1 for s in samples if s["is_real"])
|
|
202
|
+
print(f" calibration: folded {len(samples)} confirmed sample(s) ({nr} real / {len(samples) - nr} FP) "
|
|
203
|
+
f"into your local overlay → {rec['meta']['samples']} total; confidence now personalizes to your apps")
|
|
204
|
+
|
|
205
|
+
print(f" ✓ run {ts} saved (immutable): {out}")
|
|
206
|
+
return 1 if ledger["by_severity"].get("CRITICAL") else 0
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def cmd_proof(args) -> int:
|
|
210
|
+
from importlib import resources
|
|
211
|
+
corpus_path = (Path(args.corpus).expanduser().resolve() if args.corpus
|
|
212
|
+
else Path(str(resources.files("websec_validator").joinpath("corpus.json"))))
|
|
213
|
+
workdir = (Path(args.workdir).expanduser().resolve() if args.workdir
|
|
214
|
+
else Path.home() / ".cache" / "websec-corpus")
|
|
215
|
+
print(f"websec proof — recon coverage vs vuln-app corpus\n corpus: {corpus_path}\n workdir: {workdir}\n")
|
|
216
|
+
res = proof.run_proof(corpus_path, workdir)
|
|
217
|
+
for r in res["results"]:
|
|
218
|
+
if r.get("score") is None:
|
|
219
|
+
print(f" {r['name']:12} — {r.get('status', 'no checks')}")
|
|
220
|
+
continue
|
|
221
|
+
print(f" {r['name']:12} {r['passed']}/{r['total']} checks · {r.get('endpoints', '?')} endpoints · {r.get('vulns', '')[:55]}")
|
|
222
|
+
for c in r.get("checks", []):
|
|
223
|
+
print(f" {'✓' if c['pass'] else '✗'} {c['check']:22} got={c['got']}")
|
|
224
|
+
agg = res["aggregate"]
|
|
225
|
+
print(f"\n OVERALL recon coverage: {agg.get('overall_coverage')} "
|
|
226
|
+
f"({agg['checks_passed']}/{agg['checks_total']} checks, {agg['apps']} apps)")
|
|
227
|
+
print(" NOTE: PROXY metric (does recon surface the known-vuln surface?). The full agent-lift")
|
|
228
|
+
print(" kill-criterion is the manual A/B in corpus/PROOF-PROTOCOL.md.")
|
|
229
|
+
return 0
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def cmd_calibrate(args) -> int:
|
|
233
|
+
"""Fit confidence calibration: run the recon ledger against the labeled vuln corpus,
|
|
234
|
+
measure how often each (attack_class, label) bucket is a real documented vuln, and
|
|
235
|
+
write calibration.json (shipped + applied at runtime by findings.build_ledger)."""
|
|
236
|
+
from importlib import resources
|
|
237
|
+
|
|
238
|
+
# --ingest: fold a hand-labeled findings file into your LOCAL overlay (the manual real-repo path)
|
|
239
|
+
if getattr(args, "ingest", None):
|
|
240
|
+
src = Path(args.ingest).expanduser().resolve()
|
|
241
|
+
if not src.is_file():
|
|
242
|
+
sys.exit(f"error: --ingest file not found: {src}")
|
|
243
|
+
data = json.loads(src.read_text())
|
|
244
|
+
rows = data.get("findings", data) if isinstance(data, dict) else data
|
|
245
|
+
labeled = [{"attack_class": r.get("attack_class", ""), "confidence": r.get("confidence", "MEDIUM"),
|
|
246
|
+
"is_real": bool(r.get("is_real"))} for r in rows]
|
|
247
|
+
rec = calibration.record_samples(labeled)
|
|
248
|
+
if not rec:
|
|
249
|
+
sys.exit("error: nothing ingested (empty file, or local overlay not writable)")
|
|
250
|
+
nr = sum(1 for s in labeled if s["is_real"])
|
|
251
|
+
print(f"websec calibrate --ingest: folded {len(labeled)} hand-labeled sample(s) "
|
|
252
|
+
f"({nr} real / {len(labeled) - nr} FP) into {calibration.LOCAL_PATH} → {rec['meta']['samples']} total.")
|
|
253
|
+
return 0
|
|
254
|
+
|
|
255
|
+
corpus_path = (Path(args.corpus).expanduser().resolve() if args.corpus
|
|
256
|
+
else Path(str(resources.files("websec_validator").joinpath("corpus.json"))))
|
|
257
|
+
workdir = (Path(args.workdir).expanduser().resolve() if args.workdir
|
|
258
|
+
else Path.home() / ".cache" / "websec-corpus")
|
|
259
|
+
out_path = (Path(args.out).expanduser().resolve() if args.out
|
|
260
|
+
else Path(calibration.__file__).resolve().parent / "calibration.json")
|
|
261
|
+
corpus = json.loads(corpus_path.read_text())
|
|
262
|
+
workdir.mkdir(parents=True, exist_ok=True)
|
|
263
|
+
print("websec calibrate — fitting confidence against the labeled vuln corpus")
|
|
264
|
+
print(f" corpus: {corpus_path}\n workdir: {workdir}\n out: {out_path}\n")
|
|
265
|
+
|
|
266
|
+
labeled, used = [], []
|
|
267
|
+
for entry in corpus:
|
|
268
|
+
truth = entry.get("truth")
|
|
269
|
+
if not truth:
|
|
270
|
+
print(f" {entry['name']:12} — no truth block, skipped")
|
|
271
|
+
continue
|
|
272
|
+
repo = proof._ensure_repo(entry, workdir)
|
|
273
|
+
if not repo:
|
|
274
|
+
print(f" {entry['name']:12} — unavailable (clone failed / no local_path)")
|
|
275
|
+
continue
|
|
276
|
+
try:
|
|
277
|
+
facts = recon.build_facts(repo, __version__)
|
|
278
|
+
ledger = findings.build_ledger(facts, None, None, [])
|
|
279
|
+
except Exception as e:
|
|
280
|
+
print(f" {entry['name']:12} — recon/ledger error: {e}")
|
|
281
|
+
continue
|
|
282
|
+
n_real = 0
|
|
283
|
+
for f in ledger["findings"]:
|
|
284
|
+
real = calibration.is_real(f.get("attack_class", ""), f.get("location", ""), truth)
|
|
285
|
+
labeled.append({"attack_class": f.get("attack_class", ""),
|
|
286
|
+
"confidence": f["confidence"], "is_real": real})
|
|
287
|
+
n_real += int(real)
|
|
288
|
+
used.append(entry["name"])
|
|
289
|
+
print(f" {entry['name']:12} {len(ledger['findings'])} findings · {n_real} matched a documented vuln")
|
|
290
|
+
|
|
291
|
+
if not labeled:
|
|
292
|
+
print("\n no labeled findings produced — is the corpus cloned? (needs network on first run)")
|
|
293
|
+
return 1
|
|
294
|
+
|
|
295
|
+
researched = {t.get("class") for entry in corpus for t in (entry.get("truth") or [])}
|
|
296
|
+
table = calibration.fit(labeled, used, researched)
|
|
297
|
+
out_path.write_text(json.dumps(table, indent=2) + "\n")
|
|
298
|
+
print(f"\n fitted {table['meta']['n_total']} findings across {len(used)} app(s) → {out_path}")
|
|
299
|
+
for k, v in table["by_label"].items():
|
|
300
|
+
print(f" {k:7} {v['k']}/{v['n']} real · p={v['p']} · 95% CI {v['ci']}")
|
|
301
|
+
print(f"\n NOTE: {table['meta']['caveat']}.")
|
|
302
|
+
print(" Per-finding estimates carry n + basis; wide CI / basis=prior ⇒ trust the debate, not the number.")
|
|
303
|
+
return 0
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _which(b):
|
|
307
|
+
import shutil
|
|
308
|
+
return shutil.which(b)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _print_facts_summary(facts: dict) -> None:
|
|
312
|
+
st = facts.get("stack", {})
|
|
313
|
+
rt = facts.get("routes", {})
|
|
314
|
+
tg = rt.get("targeting", {})
|
|
315
|
+
print(f" stack: {', '.join(st.get('languages', [])) or '?'} · "
|
|
316
|
+
f"frameworks: {', '.join(st.get('frameworks', [])) or '?'} · "
|
|
317
|
+
f"datastores: {', '.join(st.get('datastores', [])) or '?'}")
|
|
318
|
+
print(f" auth: {facts.get('auth', {}).get('scheme', '?')}")
|
|
319
|
+
tc = facts.get("tenant", {}).get("candidates", [])
|
|
320
|
+
print(f" tenant?: {', '.join(t['key'] for t in tc) or 'none detected'}"
|
|
321
|
+
+ (" ← confirm THE boundary" if tc else ""))
|
|
322
|
+
print(f" routes: {rt.get('count', 0)} endpoints via {rt.get('engine', '?').split(' ')[0]}")
|
|
323
|
+
print(f" targets: IDOR={len(tg.get('idor_candidates', []))} "
|
|
324
|
+
f"SSRF={len(tg.get('ssrf_candidates', []))} "
|
|
325
|
+
f"upload={len(tg.get('upload_candidates', []))} "
|
|
326
|
+
f"writes={len(tg.get('write_endpoints', []))}")
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
330
|
+
p = argparse.ArgumentParser(prog="websec",
|
|
331
|
+
description="Local-first security recon that briefs your AI coding agent.")
|
|
332
|
+
p.add_argument("--version", action="version", version=f"websec-validator {__version__}")
|
|
333
|
+
# metavar lists only the user-facing commands; recon/proof/calibrate still work but are
|
|
334
|
+
# omitted (they get no `help=`, so argparse leaves them out of the listing entirely).
|
|
335
|
+
sub = p.add_subparsers(dest="cmd", required=True, metavar="{run,doctor,dynamic}")
|
|
336
|
+
|
|
337
|
+
r = sub.add_parser("run", help="full pipeline → briefing + tailored probes")
|
|
338
|
+
r.add_argument("target")
|
|
339
|
+
r.add_argument("--scan", action="store_true", help="also execute available static scanners")
|
|
340
|
+
r.add_argument("--out", help="output dir (default: ./websec-out)")
|
|
341
|
+
r.set_defaults(func=cmd_run)
|
|
342
|
+
|
|
343
|
+
# recon/proof/calibrate are hidden from the main --help (argparse.SUPPRESS): recon is a
|
|
344
|
+
# subset of `run`, and proof/calibrate are for developing the tool itself. They still work
|
|
345
|
+
# if invoked explicitly — the user-facing surface is just `run` (+ the advanced `dynamic`).
|
|
346
|
+
rc = sub.add_parser("recon")
|
|
347
|
+
rc.add_argument("target")
|
|
348
|
+
rc.add_argument("--out", help="output dir (default: ./websec-out)")
|
|
349
|
+
rc.set_defaults(func=cmd_recon)
|
|
350
|
+
|
|
351
|
+
d = sub.add_parser("doctor", help="show which scanners are installed")
|
|
352
|
+
d.add_argument("target", nargs="?", help="optional repo to scope scanner relevance")
|
|
353
|
+
d.set_defaults(func=cmd_doctor)
|
|
354
|
+
|
|
355
|
+
pf = sub.add_parser("proof")
|
|
356
|
+
pf.add_argument("--corpus", help="corpus JSON (default: bundled)")
|
|
357
|
+
pf.add_argument("--workdir", help="where to clone corpus apps (default: ~/.cache/websec-corpus)")
|
|
358
|
+
pf.set_defaults(func=cmd_proof)
|
|
359
|
+
|
|
360
|
+
cal = sub.add_parser("calibrate")
|
|
361
|
+
cal.add_argument("--corpus", help="corpus JSON with `truth` blocks (default: bundled)")
|
|
362
|
+
cal.add_argument("--workdir", help="where corpus apps are cloned (default: ~/.cache/websec-corpus)")
|
|
363
|
+
cal.add_argument("--out", help="where to write calibration.json (default: bundled, next to the package)")
|
|
364
|
+
cal.add_argument("--ingest", help="fold a hand-labeled findings JSON ({attack_class,confidence,is_real}) into your LOCAL overlay")
|
|
365
|
+
cal.set_defaults(func=cmd_calibrate)
|
|
366
|
+
|
|
367
|
+
dyn = sub.add_parser("dynamic", help="dynamic probes vs a LIVE target (read-only): cross-tenant BOLA (--config) or unauth reachability (--unauth)")
|
|
368
|
+
dyn.add_argument("--config", help="dynamic config JSON (target + role creds) for authenticated cross-tenant BOLA")
|
|
369
|
+
dyn.add_argument("--unauth", action="store_true", help="STRICT read-only: GET each data-read endpoint with NO auth (needs --target)")
|
|
370
|
+
dyn.add_argument("--probe-writes", action="store_true", help="also test write-verb auth enforcement (LOCALHOST-only, non-destructive)")
|
|
371
|
+
dyn.add_argument("--target", help="target base URL (for --unauth)")
|
|
372
|
+
dyn.add_argument("--facts", help="FACTS.json from a prior run (default: ./websec-out/FACTS.json)")
|
|
373
|
+
dyn.add_argument("--out", help="output dir (default: ./websec-out)")
|
|
374
|
+
dyn.set_defaults(func=cmd_dynamic)
|
|
375
|
+
return p
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
_COMMANDS = {"run", "recon", "doctor", "proof", "dynamic", "calibrate"}
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def main(argv=None) -> int:
|
|
382
|
+
argv = list(argv if argv is not None else sys.argv[1:])
|
|
383
|
+
parser = build_parser()
|
|
384
|
+
if not argv: # bare `websec` → show help, don't error
|
|
385
|
+
parser.print_help()
|
|
386
|
+
return 0
|
|
387
|
+
# bare `websec <path>` (no subcommand) ⇒ treat as `websec run <path>` — point-and-go
|
|
388
|
+
if argv[0] not in _COMMANDS and not argv[0].startswith("-"):
|
|
389
|
+
argv = ["run"] + argv
|
|
390
|
+
args = parser.parse_args(argv)
|
|
391
|
+
return args.func(args)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
if __name__ == "__main__":
|
|
395
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Security constitution — the app's invariants as checkable Given/When/Then.
|
|
2
|
+
|
|
3
|
+
Spec-kit's `constitution` idea applied to security: instead of (only) a list of
|
|
4
|
+
findings, emit the rules the app MUST uphold, derived deterministically from the
|
|
5
|
+
recon, each phrased as a verifiable acceptance scenario. The dynamic probes verify
|
|
6
|
+
them; a matching dynamically-confirmed ledger finding flips an invariant to VIOLATED.
|
|
7
|
+
This makes the output a *checkable spec*, not just prose.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def build(facts: dict, ledger: dict | None = None) -> list:
|
|
14
|
+
routes = facts.get("routes", {})
|
|
15
|
+
tgt = routes.get("targeting", {})
|
|
16
|
+
authz = facts.get("authz", {})
|
|
17
|
+
integ = facts.get("integrations", {})
|
|
18
|
+
tenant = facts.get("tenant", {})
|
|
19
|
+
|
|
20
|
+
# endpoints with a dynamically-confirmed access-control finding → VIOLATED
|
|
21
|
+
violated = {f["location"] for f in (ledger or {}).get("findings", [])
|
|
22
|
+
if f.get("category") == "access-control"
|
|
23
|
+
and any(e.get("layer") == "dynamic" for e in f.get("evidence", []))}
|
|
24
|
+
|
|
25
|
+
inv = []
|
|
26
|
+
|
|
27
|
+
def add(principle, statement, source, status="VERIFY"):
|
|
28
|
+
inv.append({"principle": principle, "statement": statement, "source": source, "status": status})
|
|
29
|
+
|
|
30
|
+
# P1 — Authentication: every non-public endpoint must reject unauthenticated access
|
|
31
|
+
n = 0
|
|
32
|
+
for eg in authz.get("endpoint_guards", []):
|
|
33
|
+
if eg.get("public_hint") or eg.get("guarded") or not eg.get("analyzed"):
|
|
34
|
+
continue
|
|
35
|
+
n += 1
|
|
36
|
+
if n > 40:
|
|
37
|
+
continue
|
|
38
|
+
status = "VIOLATED" if eg.get("path") in violated else "VERIFY"
|
|
39
|
+
add("Authentication", f"Given no auth token, When `{eg['method']} {eg['path']}`, Then 401/403 "
|
|
40
|
+
f"(no body, no mutation)", eg.get("code_path", "recon"), status)
|
|
41
|
+
if n > 40:
|
|
42
|
+
add("Authentication", f"_…and {n - 40} more endpoints with no visible guard — see findings-ledger.json_", "recon")
|
|
43
|
+
|
|
44
|
+
# P2 — Tenant isolation
|
|
45
|
+
for t in tenant.get("candidates", [])[:1]:
|
|
46
|
+
add("Tenant isolation", f"Given role A's token, When reading another tenant's resource via "
|
|
47
|
+
f"`{{{t['key']}}}`, Then 403/404 (no cross-tenant data)", "recon")
|
|
48
|
+
|
|
49
|
+
# P3 — SSRF defense
|
|
50
|
+
for s in tgt.get("ssrf_candidates", [])[:8]:
|
|
51
|
+
add("SSRF defense", f"Given a url/host param = 169.254.169.254 / RFC1918 / file://, "
|
|
52
|
+
f"When `{s}`, Then the fetch is blocked", "recon")
|
|
53
|
+
|
|
54
|
+
# P4 — Webhook integrity
|
|
55
|
+
for w in integ.get("webhook_endpoints", [])[:8]:
|
|
56
|
+
add("Webhook integrity", f"Given a forged or missing signature, When `{w}`, Then 401 "
|
|
57
|
+
f"(and replays inside the window are rejected)", "recon")
|
|
58
|
+
|
|
59
|
+
# P5 — Secret hygiene (always)
|
|
60
|
+
add("Secret hygiene", "Given the repo + git history, Then no live credential is present and no secret "
|
|
61
|
+
"reaches the client bundle", "recon")
|
|
62
|
+
|
|
63
|
+
return inv
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def render(inv: list) -> str:
|
|
67
|
+
by_principle: dict = {}
|
|
68
|
+
for i in inv:
|
|
69
|
+
by_principle.setdefault(i["principle"], []).append(i)
|
|
70
|
+
mark = {"VIOLATED": "🔴 VIOLATED", "VERIFY": "⬜ verify", "HOLDS": "✅ holds"}
|
|
71
|
+
out = ["# Security constitution\n",
|
|
72
|
+
"> Invariants this app must uphold, derived from recon. The dynamic probes verify them; "
|
|
73
|
+
"a dynamically-confirmed finding flips one to 🔴 VIOLATED. Treat ⬜ as a hypothesis to confirm.\n"]
|
|
74
|
+
viol = sum(1 for i in inv if i["status"] == "VIOLATED")
|
|
75
|
+
out.append(f"**{len(inv)} invariants · {viol} VIOLATED · {sum(1 for i in inv if i['status']=='VERIFY')} to verify**\n")
|
|
76
|
+
for p, items in by_principle.items():
|
|
77
|
+
out.append(f"## {p}")
|
|
78
|
+
for i in items:
|
|
79
|
+
out.append(f"- {mark.get(i['status'], i['status'])} — {i['statement']} · _{i['source']}_")
|
|
80
|
+
out.append("")
|
|
81
|
+
return "\n".join(out)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"name": "VAmPI",
|
|
4
|
+
"repo": "https://github.com/erev0s/VAmPI",
|
|
5
|
+
"language": "python",
|
|
6
|
+
"vulns": "OWASP API Top 10 — BOLA, mass-assignment, JWT weaknesses, excessive data exposure",
|
|
7
|
+
"expect": {
|
|
8
|
+
"frameworks": ["flask"],
|
|
9
|
+
"min_endpoints": 10,
|
|
10
|
+
"auth_scheme_contains": "jwt",
|
|
11
|
+
"idor_present": true
|
|
12
|
+
},
|
|
13
|
+
"truth": [
|
|
14
|
+
{"class": "missing-auth", "location_contains": "*", "note": "VAmPI is a broken-object/function-level-authorization demo: its API endpoints are intentionally missing or mis-applying auth"},
|
|
15
|
+
{"class": "sqli", "location_contains": "*", "note": "raw SQL string interpolation in the vulnerable code path"}
|
|
16
|
+
]
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"name": "NodeGoat",
|
|
20
|
+
"repo": "https://github.com/OWASP/NodeGoat",
|
|
21
|
+
"language": "node",
|
|
22
|
+
"vulns": "OWASP Top 10 — injection, broken auth, IDOR (allocations), access control",
|
|
23
|
+
"expect": {
|
|
24
|
+
"frameworks": ["express"],
|
|
25
|
+
"min_endpoints": 8,
|
|
26
|
+
"auth_scheme_contains": "session",
|
|
27
|
+
"idor_present": true
|
|
28
|
+
},
|
|
29
|
+
"truth": [
|
|
30
|
+
{"class": "missing-auth", "location_contains": "*", "note": "broken access control across allocation/admin routes"},
|
|
31
|
+
{"class": "command-injection", "location_contains": "*", "note": "SSJS injection via eval in the contributions handler"},
|
|
32
|
+
{"class": "ssti", "location_contains": "*", "note": "server-side template injection demo"}
|
|
33
|
+
]
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"name": "DVGA",
|
|
37
|
+
"repo": "https://github.com/dolevf/Damn-Vulnerable-GraphQL-Application",
|
|
38
|
+
"language": "python",
|
|
39
|
+
"vulns": "GraphQL — introspection, injection, DoS (batching/aliasing), info disclosure",
|
|
40
|
+
"expect": {
|
|
41
|
+
"frameworks": ["flask"],
|
|
42
|
+
"graphql_present": true
|
|
43
|
+
},
|
|
44
|
+
"truth": [
|
|
45
|
+
{"class": "graphql", "location_contains": "*", "note": "introspection + playground enabled, no depth/complexity limits"},
|
|
46
|
+
{"class": "command-injection", "location_contains": "*", "note": "OS command injection via the system-diagnostics resolver"}
|
|
47
|
+
]
|
|
48
|
+
}
|
|
49
|
+
]
|