feat: vendorizar as 10 skills do pilar de seguranca em skills/
Copia local (nao fork, nao submodule) de safedeps, sca-audit, secrets-scan, code-review-security, api-security-review, web-security-review, cve-triage, patch-prioritization, dependency-scanning e hermaguard -- commit exato de origem e licenca de cada uma em skills/NOTICE.md. Elimina o passo de clone externo pro pilar de seguranca do wf-security-review: git clone deste repo ja traz tudo funcional. Corrige tambem o comando de instalacao pro Claude Code nos dois READMEs e no docs/SKILLS.md: Claude Code exige um diretorio por skill (skills/<nome>/SKILL.md), nao aceita os .md soltos que o comando antigo copiava -- confirmado testando a instalacao real.
This commit is contained in:
1 parent
ee44fa0f7a
commit
c17192c9fe
130 files changed
+10533
-33
No files matched your search
@@ -0,0 +1,33 @@
|
||||
# Hermaguard Benchmark
|
||||
|
||||
Score-only. It does **not** run HermaGuard (that's a skill, not a binary). Your
|
||||
harness produces a `--json` report per bug; this scores it against ground truth.
|
||||
|
||||
## Layout
|
||||
|
||||
- `bugs/<id>/` — one case per planted bug: `code/app.py` (fixture, **no answer-key
|
||||
comments**), `pre.diff` (the change that introduced it), `ground-truth.json`
|
||||
(the findings a correct review should surface).
|
||||
- `results/<id>.json` — a harness's review output for that bug (the shape the
|
||||
scorer reads). The committed ones are a **reference run**, see below.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
python3 benchmark.py --bugs bugs --results results --output results/report.html
|
||||
```
|
||||
|
||||
## Reference run (committed in `results/`)
|
||||
|
||||
A single `claude-opus-4-8` harness reviewed the 5 fixtures. **Not blind:** the
|
||||
corpus was authored and reviewed by the same agent, so recall here is an upper
|
||||
bound. The corpus exists for blind runs by any harness, seed bugs authored by
|
||||
someone other than the reviewer for an independent number.
|
||||
|
||||
| | Recall | Precision | F1 |
|
||||
|---|---|---|---|
|
||||
| 5 bugs | 100% | 90% | 93.3% |
|
||||
|
||||
The one sub-100% precision (bug 001) is a real out-of-ground-truth finding (an
|
||||
unclosed DB connection), not a hallucination. Each result file carries a
|
||||
`provenance` block (model, mode, blind flag).
|
||||
@@ -0,0 +1,402 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Hermaguard Benchmark — Score HermaGuard against known bug dataset.
|
||||
|
||||
The benchmark does NOT run HermaGuard itself (it's a skill, not a binary).
|
||||
Instead, it scores JSON output that any harness produces.
|
||||
|
||||
Usage:
|
||||
# 1. Run HermaGuard via your harness, capture JSON output per bug
|
||||
# (e.g., Claude Code: claude -p "/hermaguard --json" > results/bug-001.json)
|
||||
# (e.g., Codex CLI: codex exec --prompt "/hermaguard --json" > results/bug-001.json)
|
||||
# (e.g., Hermes Agent: run skill, save JSON output)
|
||||
|
||||
# 2. Score the results against ground truth
|
||||
benchmark.py --score --results results/ --bugs bugs/ --output report.html
|
||||
|
||||
# 3. Score a single bug
|
||||
benchmark.py --score --result results/bug-001.json --bug bugs/001-sql-injection
|
||||
|
||||
The neutral path: any harness emits the --json report, then benchmark.py --score
|
||||
against ground truth. No assumption about which CLI or framework runs HermaGuard.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
VERSION = "1.0.0"
|
||||
|
||||
|
||||
def discover_bugs(bugs_dir: str) -> list:
|
||||
"""Find all bug directories containing ground-truth.json."""
|
||||
bugs = []
|
||||
for entry in sorted(Path(bugs_dir).iterdir()):
|
||||
if entry.is_dir() and (entry / "ground-truth.json").exists():
|
||||
bugs.append(str(entry))
|
||||
return bugs
|
||||
|
||||
|
||||
def load_bug(bug_dir: str) -> dict:
|
||||
"""Load a bug's ground truth and metadata."""
|
||||
gt_path = os.path.join(bug_dir, "ground-truth.json")
|
||||
readme_path = os.path.join(bug_dir, "README.md")
|
||||
|
||||
with open(gt_path) as f:
|
||||
gt = json.load(f)
|
||||
|
||||
readme = ""
|
||||
if os.path.exists(readme_path):
|
||||
with open(readme_path) as f:
|
||||
readme = f.read()
|
||||
|
||||
return {
|
||||
"id": os.path.basename(bug_dir),
|
||||
"title": gt.get("title", os.path.basename(bug_dir)),
|
||||
"description": gt.get("description", ""),
|
||||
"severity": gt.get("severity", "UNKNOWN"),
|
||||
"category": gt.get("category", "unknown"),
|
||||
"expected_findings": gt.get("findings", []),
|
||||
"pre_diff": os.path.join(bug_dir, "pre.diff"),
|
||||
"post_diff": os.path.join(bug_dir, "post.diff"),
|
||||
"repo_path": os.path.join(bug_dir, "repo"),
|
||||
"readme": readme,
|
||||
}
|
||||
|
||||
|
||||
def _file_matches(exp_file: str, found_file: str) -> bool:
|
||||
"""Deterministic file match on basename. Empty either side = pass-through."""
|
||||
if not exp_file or not found_file:
|
||||
return True
|
||||
return os.path.basename(exp_file) == os.path.basename(found_file)
|
||||
|
||||
|
||||
def _class_matches(exp: dict, fnd: dict) -> bool:
|
||||
"""Deterministic class match: both sides carry `classes` and they intersect."""
|
||||
exp_classes = exp.get("classes") or []
|
||||
fnd_classes = fnd.get("classes") or []
|
||||
if not exp_classes or not fnd_classes:
|
||||
return False # not class-matchable — caller falls back to keywords
|
||||
return bool(set(exp_classes) & set(fnd_classes))
|
||||
|
||||
|
||||
def score_run(bug: dict, run_info: dict) -> dict:
|
||||
"""Score a run against ground truth.
|
||||
|
||||
Matching is two-tier and deterministic:
|
||||
Tier 1 (preferred): class-based — ground truth entries carry `classes`
|
||||
(CWE/HG ids) and findings carry `classes`; a match requires a non-empty
|
||||
class intersection AND a basename file match. No thresholds, no fuzzy
|
||||
scores. Identical inputs always produce identical results.
|
||||
Tier 2 (legacy fallback): keyword overlap for old results that predate
|
||||
the classes field. Threshold fixed at 0.3, severity boost 1.3 — kept ONLY
|
||||
for backward compatibility; new ground truth must carry classes.
|
||||
"""
|
||||
expected = bug.get("expected_findings", [])
|
||||
found = run_info.get("findings", [])
|
||||
|
||||
matched_expected = set()
|
||||
matched_found = set()
|
||||
matches = []
|
||||
match_modes = {"class": 0, "keyword": 0}
|
||||
|
||||
for i, exp in enumerate(expected):
|
||||
# ---- Tier 1: deterministic class match ----
|
||||
if exp.get("classes"):
|
||||
for j, fnd in enumerate(found):
|
||||
if j in matched_found:
|
||||
continue
|
||||
if _class_matches(exp, fnd) and _file_matches(exp.get("file", ""), fnd.get("file", "")):
|
||||
matched_expected.add(i)
|
||||
matched_found.add(j)
|
||||
match_modes["class"] += 1
|
||||
matches.append({
|
||||
"expected_id": f"EXP-{i+1}",
|
||||
"found_id": fnd.get("id", f"HG-{j+1}"),
|
||||
"mode": "class",
|
||||
"matched_classes": sorted(set(exp["classes"]) & set(fnd.get("classes") or [])),
|
||||
"expected_severity": exp.get("severity", "?"),
|
||||
"found_severity": fnd.get("severity", "?"),
|
||||
"expected_file": exp.get("file", "?"),
|
||||
"found_file": fnd.get("file", "?"),
|
||||
})
|
||||
break
|
||||
continue # class-carrying entries never fall through to keywords
|
||||
|
||||
# ---- Tier 2: legacy keyword fallback ----
|
||||
exp_text = f"{exp.get('file','')} {exp.get('trigger','')} {exp.get('description','')}".lower()
|
||||
best_score = 0
|
||||
best_j = -1
|
||||
|
||||
for j, fnd in enumerate(found):
|
||||
if j in matched_found:
|
||||
continue
|
||||
fnd_text = f"{fnd.get('file','')} {fnd.get('trigger_condition','')} {fnd.get('consequence','')}".lower()
|
||||
|
||||
# Simple keyword overlap score
|
||||
exp_words = set(exp_text.split())
|
||||
fnd_words = set(fnd_text.split())
|
||||
if not exp_words or not fnd_words:
|
||||
continue
|
||||
overlap = len(exp_words & fnd_words) / min(len(exp_words), len(fnd_words))
|
||||
|
||||
# Boost: same severity
|
||||
if exp.get("severity") == fnd.get("severity"):
|
||||
overlap *= 1.3
|
||||
|
||||
if overlap > best_score:
|
||||
best_score = overlap
|
||||
best_j = j
|
||||
|
||||
if best_score > 0.3 and best_j >= 0: # threshold for a "match"
|
||||
matched_expected.add(i)
|
||||
matched_found.add(best_j)
|
||||
matched_fnd = found[best_j]
|
||||
match_modes["keyword"] += 1
|
||||
matches.append({
|
||||
"expected_id": f"EXP-{i+1}",
|
||||
"found_id": matched_fnd.get("id", f"HG-{best_j+1}"),
|
||||
"mode": "keyword",
|
||||
"score": round(best_score, 2),
|
||||
"expected_severity": exp.get("severity", "?"),
|
||||
"found_severity": matched_fnd.get("severity", "?"),
|
||||
"expected_file": exp.get("file", "?"),
|
||||
"found_file": matched_fnd.get("file", "?"),
|
||||
})
|
||||
|
||||
total_expected = len(expected)
|
||||
total_found = len(found)
|
||||
true_positives = len(matched_expected)
|
||||
false_negatives = total_expected - true_positives
|
||||
false_positives = total_found - len(matched_found)
|
||||
|
||||
precision = round(true_positives / total_found * 100, 1) if total_found > 0 else 0
|
||||
recall = round(true_positives / total_expected * 100, 1) if total_expected > 0 else 0
|
||||
f1 = round(2 * precision * recall / (precision + recall), 1) if (precision + recall) > 0 else 0
|
||||
|
||||
# Severity accuracy
|
||||
severity_matches = 0
|
||||
for m in matches:
|
||||
if m["expected_severity"] == m["found_severity"]:
|
||||
severity_matches += 1
|
||||
severity_accuracy = round(severity_matches / len(matches) * 100, 1) if matches else 0
|
||||
|
||||
return {
|
||||
"bug_id": bug["id"],
|
||||
"bug_title": bug["title"],
|
||||
"bug_severity": bug["severity"],
|
||||
"bug_category": bug["category"],
|
||||
"expected_findings": total_expected,
|
||||
"found_findings": total_found,
|
||||
"true_positives": true_positives,
|
||||
"false_negatives": false_negatives,
|
||||
"false_positives": false_positives,
|
||||
"precision_pct": precision,
|
||||
"recall_pct": recall,
|
||||
"f1_score": f1,
|
||||
"severity_accuracy_pct": severity_accuracy,
|
||||
"matches": matches,
|
||||
"match_modes": match_modes,
|
||||
# A result file without an explicit run_ok flag is treated as a
|
||||
# completed run (backward compat) UNLESS it carries an error key —
|
||||
# a crashed run must never be scored as OK.
|
||||
"run_ok": bool(run_info.get("run_ok", not run_info.get("error"))),
|
||||
"run_error": run_info.get("error"),
|
||||
}
|
||||
|
||||
|
||||
def generate_report(scores: list, output_path: str):
|
||||
"""Generate HTML benchmark report."""
|
||||
total_bugs = len(scores)
|
||||
ok_runs = [s for s in scores if s["run_ok"]]
|
||||
avg_precision = round(sum(s["precision_pct"] for s in ok_runs) / len(ok_runs), 1) if ok_runs else 0
|
||||
avg_recall = round(sum(s["recall_pct"] for s in ok_runs) / len(ok_runs), 1) if ok_runs else 0
|
||||
avg_f1 = round(sum(s["f1_score"] for s in ok_runs) / len(ok_runs), 1) if ok_runs else 0
|
||||
avg_severity = round(sum(s["severity_accuracy_pct"] for s in ok_runs) / len(ok_runs), 1) if ok_runs else 0
|
||||
|
||||
total_fp = sum(s["false_positives"] for s in ok_runs)
|
||||
total_fn = sum(s["false_negatives"] for s in ok_runs)
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>HermaGuard Benchmark — {datetime.now().strftime('%d/%m/%Y')}</title>
|
||||
<style>
|
||||
:root {{ color-scheme: dark; }}
|
||||
* {{ margin:0; padding:0; box-sizing:border-box; }}
|
||||
body {{ background:#11100f; color:#f5f5f4; font-family:system-ui,sans-serif; line-height:1.6; padding:2rem; }}
|
||||
h1 {{ color:#fbbf24; font-size:1.5rem; margin-bottom:0.5rem; }}
|
||||
.subtitle {{ color:#a8a29e; font-size:0.9rem; margin-bottom:2rem; }}
|
||||
.summary {{ display:flex; gap:1.5rem; margin:1.5rem 0; flex-wrap:wrap; }}
|
||||
.stat {{ background:#1c1a18; border:1px solid #34302c; border-radius:6px; padding:1rem 1.25rem; }}
|
||||
.stat-value {{ font-size:1.5rem; font-weight:700; color:#fbbf24; }}
|
||||
.stat-label {{ font-size:0.8rem; color:#a8a29e; }}
|
||||
.good {{ color:#22c55e; }} .warn {{ color:#fbbf24; }} .bad {{ color:#ef4444; }}
|
||||
table {{ width:100%; border-collapse:collapse; margin:1.5rem 0; }}
|
||||
th, td {{ padding:0.6rem 0.8rem; text-align:left; border-bottom:1px solid #34302c; }}
|
||||
th {{ color:#a8a29e; font-size:0.85rem; font-weight:600; }}
|
||||
td {{ font-size:0.9rem; }}
|
||||
.bug-title {{ max-width:300px; }}
|
||||
.match {{ color:#22c55e; }} .miss {{ color:#ef4444; }}
|
||||
.error-row {{ background:#3b1111; }}
|
||||
h2 {{ color:#fbbf24; margin:2rem 0 1rem; padding-bottom:0.5rem; border-bottom:1px solid #34302c; }}
|
||||
.footer {{ margin-top:3rem; padding-top:1rem; border-top:1px solid #34302c; color:#78716c; font-size:0.8rem; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>HermaGuard Benchmark</h1>
|
||||
<div class="subtitle">{datetime.now().strftime('%d/%m/%Y %H:%M')} · {total_bugs} bugs · v{VERSION}</div>
|
||||
|
||||
<div class="summary">
|
||||
<div class="stat"><div class="stat-value">{avg_f1}<span style="font-size:0.8rem">%</span></div><div class="stat-label">Avg F1 Score</div></div>
|
||||
<div class="stat"><div class="stat-value">{avg_recall}<span style="font-size:0.8rem">%</span></div><div class="stat-label">Avg Recall</div></div>
|
||||
<div class="stat"><div class="stat-value">{avg_precision}<span style="font-size:0.8rem">%</span></div><div class="stat-label">Avg Precision</div></div>
|
||||
<div class="stat"><div class="stat-value">{avg_severity}<span style="font-size:0.8rem">%</span></div><div class="stat-label">Severity Accuracy</div></div>
|
||||
<div class="stat"><div class="stat-value">{total_fp}</div><div class="stat-label">False Positives</div></div>
|
||||
<div class="stat"><div class="stat-value">{total_fn}</div><div class="stat-label">False Negatives</div></div>
|
||||
</div>
|
||||
|
||||
<h2>Results by Bug</h2>
|
||||
<table>
|
||||
<tr><th>Bug</th><th>Severity</th><th>Expected</th><th>Found</th><th>TP</th><th>FP</th><th>FN</th><th>Precision</th><th>Recall</th><th>F1</th><th>Sev Acc</th></tr>
|
||||
"""
|
||||
|
||||
for s in scores:
|
||||
if not s["run_ok"]:
|
||||
html += f"""<tr class="error-row"><td class="bug-title">{s['bug_title'][:60]}</td>
|
||||
<td>-</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td><td colspan="4">ERROR: {s.get('run_error','Unknown')[:100]}</td></tr>\n"""
|
||||
continue
|
||||
|
||||
pcls = "good" if s["precision_pct"] >= 70 else ("warn" if s["precision_pct"] >= 40 else "bad")
|
||||
rcls = "good" if s["recall_pct"] >= 70 else ("warn" if s["recall_pct"] >= 40 else "bad")
|
||||
fcls = "good" if s["f1_score"] >= 70 else ("warn" if s["f1_score"] >= 40 else "bad")
|
||||
|
||||
html += f"""<tr>
|
||||
<td class="bug-title">{s['bug_title'][:60]}</td>
|
||||
<td>{s['bug_severity']}</td>
|
||||
<td>{s['expected_findings']}</td>
|
||||
<td>{s['found_findings']}</td>
|
||||
<td class="match">{s['true_positives']}</td>
|
||||
<td class="miss">{s['false_positives']}</td>
|
||||
<td class="miss">{s['false_negatives']}</td>
|
||||
<td class="{pcls}">{s['precision_pct']}%</td>
|
||||
<td class="{rcls}">{s['recall_pct']}%</td>
|
||||
<td class="{fcls}">{s['f1_score']}%</td>
|
||||
<td>{s['severity_accuracy_pct']}%</td>
|
||||
</tr>\n"""
|
||||
|
||||
html += "</table>\n"
|
||||
|
||||
# Detailed matches for each bug
|
||||
html += "<h2>Match Details</h2>\n"
|
||||
for s in scores:
|
||||
if not s["run_ok"]:
|
||||
continue
|
||||
html += f"<h3>{s['bug_title'][:80]} ({s['bug_severity']})</h3>\n"
|
||||
if s["matches"]:
|
||||
html += "<table><tr><th>Expected</th><th>Found</th><th>Sev Match</th><th>Score</th></tr>\n"
|
||||
for m in s["matches"]:
|
||||
sev_match = "✓" if m["expected_severity"] == m["found_severity"] else "✗"
|
||||
html += f"""<tr><td>{m['expected_file']} ({m['expected_severity']})</td>
|
||||
<td>{m['found_file'][:50]} ({m['found_severity']})</td><td>{sev_match}</td><td>{m['score']}</td></tr>\n"""
|
||||
html += "</table>\n"
|
||||
else:
|
||||
html += "<p class='miss'>No matches — all findings missed or all expected bugs not found.</p>\n"
|
||||
|
||||
if s.get("matches"):
|
||||
# Unmatched expected (FN)
|
||||
matched_exp_ids = {m["expected_id"] for m in s["matches"]}
|
||||
unmatched_exp = s.get("expected_findings_count", s["expected_findings"]) - len(matched_exp_ids)
|
||||
if unmatched_exp > 0:
|
||||
html += f"<p class='miss'>{unmatched_exp} expected bug{'s' if unmatched_exp > 1 else ''} not found (false negative).</p>\n"
|
||||
|
||||
html += f"""<div class="footer">
|
||||
HermaGuard Benchmark v{VERSION} · Generated {datetime.now().isoformat()}
|
||||
</div>
|
||||
</body></html>"""
|
||||
|
||||
with open(output_path, "w") as f:
|
||||
f.write(html)
|
||||
|
||||
print(f"Report written to {output_path}")
|
||||
return output_path
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="HermaGuard Benchmark — score JSON output against ground truth")
|
||||
parser.add_argument("--bugs", default="bugs/", help="Path to bugs directory")
|
||||
parser.add_argument("--bug", help="Score a single bug by path")
|
||||
parser.add_argument("--results", default="results/", help="Directory containing per-bug JSON result files")
|
||||
parser.add_argument("--result", help="Single result JSON file (use with --bug)")
|
||||
parser.add_argument("--output", default="report.html", help="HTML report output path")
|
||||
parser.add_argument("--version", action="version", version=f"%(prog)s {VERSION}")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.bug and args.result:
|
||||
# Single bug mode
|
||||
bug = load_bug(args.bug)
|
||||
with open(args.result) as f:
|
||||
run_info = json.load(f)
|
||||
s = score_run(bug, run_info)
|
||||
status = "✓" if s["run_ok"] else "✗"
|
||||
print(f" {status} {bug['title'][:60]} — P:{s['precision_pct']}% R:{s['recall_pct']}% F1:{s['f1_score']}%")
|
||||
return
|
||||
|
||||
bugs = discover_bugs(args.bugs)
|
||||
if not bugs:
|
||||
print(f"No bugs found in {args.bugs}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Found {len(bugs)} bug(s)")
|
||||
|
||||
scores = []
|
||||
for bug_dir in bugs:
|
||||
bug = load_bug(bug_dir)
|
||||
result_path = os.path.join(args.results, f"{bug['id']}.json")
|
||||
|
||||
if os.path.exists(result_path):
|
||||
with open(result_path) as f:
|
||||
run_info = json.load(f)
|
||||
s = score_run(bug, run_info)
|
||||
scores.append(s)
|
||||
status = "✓" if s["run_ok"] else "✗"
|
||||
print(f" {status} {bug['title'][:60]} — P:{s['precision_pct']}% R:{s['recall_pct']}% F1:{s['f1_score']}%")
|
||||
else:
|
||||
scores.append({
|
||||
"bug_id": bug["id"], "bug_title": bug["title"],
|
||||
"bug_severity": bug["severity"], "bug_category": bug["category"],
|
||||
"run_ok": False, "run_error": f"No results file at {result_path}",
|
||||
"precision_pct": 0, "recall_pct": 0, "f1_score": 0,
|
||||
"expected_findings": len(bug["expected_findings"]), "found_findings": 0,
|
||||
"true_positives": 0, "false_positives": 0, "false_negatives": len(bug["expected_findings"]),
|
||||
"severity_accuracy_pct": 0, "matches": [],
|
||||
})
|
||||
|
||||
# Generate report. --output is taken literally (path doubling bug fixed);
|
||||
# create the parent dir so an explicit path like results/report.html works.
|
||||
report_path = args.output
|
||||
parent = os.path.dirname(report_path)
|
||||
if parent:
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
generate_report(scores, report_path)
|
||||
|
||||
ok = [s for s in scores if s["run_ok"]]
|
||||
if ok:
|
||||
avg_f1 = round(sum(s["f1_score"] for s in ok) / len(ok), 1)
|
||||
print(f"\nOverall: {len(ok)}/{len(scores)} runs OK, avg F1: {avg_f1}%")
|
||||
else:
|
||||
print(f"\nAll runs lack result files. Run HermaGuard via your harness first, then score.")
|
||||
print(f"Example: run '/hermaguard --json' under any harness, save output to results/<bug-id>.json")
|
||||
|
||||
print(f"Report: {report_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,8 @@
|
||||
import sqlite3
|
||||
|
||||
def get_user_by_name(username: str):
|
||||
conn = sqlite3.connect("users.db")
|
||||
cur = conn.cursor()
|
||||
query = f"SELECT * FROM users WHERE name = '{username}'"
|
||||
cur.execute(query)
|
||||
return cur.fetchone()
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"title": "SQL injection in get_user_by_name",
|
||||
"description": "Username interpolated into SQL via f-string; attacker-controlled input reaches the query unsanitised.",
|
||||
"severity": "CRITICAL",
|
||||
"category": "security",
|
||||
"findings": [
|
||||
{"file": "app.py", "trigger": "f-string SQL query username interpolation", "description": "SQL injection via unparameterised query", "severity": "CRITICAL", "classes": ["CWE-89"]}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
--- a/app.py
|
||||
+++ b/app.py
|
||||
@@ -2,6 +2,6 @@ import sqlite3
|
||||
def get_user_by_name(username: str):
|
||||
conn = sqlite3.connect("users.db")
|
||||
cur = conn.cursor()
|
||||
- cur.execute("SELECT * FROM users WHERE name = ?", (username,))
|
||||
+ query = f"SELECT * FROM users WHERE name = '{username}'"
|
||||
+ cur.execute(query)
|
||||
return cur.fetchone()
|
||||
@@ -0,0 +1,5 @@
|
||||
import yaml
|
||||
|
||||
def load_config(path: str) -> dict:
|
||||
with open(path) as f:
|
||||
return yaml.load(f)
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"title": "Unsafe YAML deserialisation",
|
||||
"description": "yaml.load() without SafeLoader allows arbitrary object construction — RCE via crafted YAML.",
|
||||
"severity": "CRITICAL",
|
||||
"category": "security",
|
||||
"findings": [
|
||||
{"file": "app.py", "trigger": "yaml.load without SafeLoader", "description": "Arbitrary code execution via unsafe deserialisation", "severity": "CRITICAL", "classes": ["CWE-502"]}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
--- a/app.py
|
||||
+++ b/app.py
|
||||
@@ -2,4 +2,4 @@ import yaml
|
||||
def load_config(path: str) -> dict:
|
||||
with open(path) as f:
|
||||
- return yaml.safe_load(f)
|
||||
+ return yaml.load(f)
|
||||
@@ -0,0 +1,4 @@
|
||||
def get_user_email(cur, user_id: int) -> str:
|
||||
cur.execute("SELECT email FROM users WHERE id = ?", (user_id,))
|
||||
row = cur.fetchone()
|
||||
return row[0]
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"title": "Missing null check on fetchone result",
|
||||
"description": "fetchone() returns None when no row matches; indexing row[0] then raises TypeError, crashing the caller.",
|
||||
"severity": "MEDIUM",
|
||||
"category": "logic",
|
||||
"findings": [
|
||||
{"file": "app.py", "trigger": "cursor fetchone row indexed without null check", "description": "TypeError crash when user_id not found", "severity": "MEDIUM", "classes": ["CWE-476", "CWE-754"]}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
--- a/app.py
|
||||
+++ b/app.py
|
||||
@@ -2,4 +2,3 @@ def get_user_email(cur, user_id: int) -> str:
|
||||
cur.execute("SELECT email FROM users WHERE id = ?", (user_id,))
|
||||
row = cur.fetchone()
|
||||
- if row is None:
|
||||
- return None
|
||||
return row[0]
|
||||
@@ -0,0 +1,4 @@
|
||||
import subprocess
|
||||
|
||||
def ping_host(host: str) -> bytes:
|
||||
return subprocess.check_output(f"ping -c 1 {host}", shell=True)
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"title": "Shell command injection via ping utility",
|
||||
"description": "User-supplied host is concatenated into a shell command; shell metacharacters execute arbitrary commands.",
|
||||
"severity": "CRITICAL",
|
||||
"category": "security",
|
||||
"findings": [
|
||||
{"file": "app.py", "trigger": "user host concatenated into shell command", "description": "OS command injection via shell=True with unescaped input", "severity": "CRITICAL", "classes": ["CWE-78"]}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
--- a/app.py
|
||||
+++ b/app.py
|
||||
@@ -2,2 +2,2 @@ import subprocess
|
||||
def ping_host(host: str) -> bytes:
|
||||
- return subprocess.check_output(["ping", "-c", "1", host])
|
||||
+ return subprocess.check_output(f"ping -c 1 {host}", shell=True)
|
||||
@@ -0,0 +1,3 @@
|
||||
def read_doc(name: str) -> str:
|
||||
with open("/var/docs/" + name) as f:
|
||||
return f.read()
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"title": "Path traversal in file download",
|
||||
"description": "User-supplied filename is joined without validation; ../ sequences escape the intended directory.",
|
||||
"severity": "HIGH",
|
||||
"category": "security",
|
||||
"findings": [
|
||||
{"file": "app.py", "trigger": "filename joined into path without traversal check", "description": "Arbitrary file read via ../ traversal", "severity": "HIGH", "classes": ["CWE-22"]}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
--- a/app.py
|
||||
+++ b/app.py
|
||||
@@ -1,3 +1,3 @@
|
||||
def read_doc(name: str) -> str:
|
||||
- safe = os.path.basename(name)
|
||||
- with open(os.path.join("/var/docs", safe)) as f:
|
||||
+ with open("/var/docs/" + name) as f:
|
||||
return f.read()
|
||||
@@ -0,0 +1,11 @@
|
||||
# Deliberately broken sample: a live-looking API key committed to source.
|
||||
# NOTE: the value is an obviously-fake placeholder (marker, not a real key
|
||||
# format) so secret scanners do not flag the corpus; the CWE-798 pattern
|
||||
# (credential hardcoded in source) is what the benchmark tests for.
|
||||
PAYMENT_KEY = "EXAMPLE-KEY-DO-NOT-USE-0123456789"
|
||||
|
||||
def charge(card_token: str, amount: int):
|
||||
import requests
|
||||
return requests.post("https://api.example-payments.invalid/v1/charges",
|
||||
headers={"Authorization": f"Bearer {PAYMENT_KEY}"},
|
||||
data={"card": card_token, "amount": amount})
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"title": "Hard-coded API key committed to source",
|
||||
"description": "Live API key embedded in source; anyone with repo access can use the account.",
|
||||
"severity": "CRITICAL",
|
||||
"category": "security",
|
||||
"findings": [
|
||||
{
|
||||
"file": "app.py",
|
||||
"trigger": "live API key assigned to module constant",
|
||||
"description": "Credential theft and unauthorized charges from public repo",
|
||||
"severity": "CRITICAL",
|
||||
"classes": [
|
||||
"CWE-798"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
--- a/app.py
|
||||
+++ b/app.py
|
||||
@@ -0,0 +1 @@
|
||||
+ (new file — the bug is introduced by this change)
|
||||
@@ -0,0 +1,5 @@
|
||||
def delete_user(session_user, target_id: int):
|
||||
# authn done upstream, but no role check
|
||||
db.execute("DELETE FROM users WHERE id = ?", (target_id,))
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Admin endpoint without authorization check",
|
||||
"description": "delete_user performs no permission check; any authenticated user can delete anyone.",
|
||||
"severity": "CRITICAL",
|
||||
"category": "security",
|
||||
"findings": [
|
||||
{
|
||||
"file": "app.py",
|
||||
"trigger": "admin action without role/permission check",
|
||||
"description": "Privilege escalation \u2014 any user deletes any account",
|
||||
"severity": "CRITICAL",
|
||||
"classes": [
|
||||
"CWE-862",
|
||||
"CWE-269"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
--- a/app.py
|
||||
+++ b/app.py
|
||||
def delete_user(session_user, target_id: int):
|
||||
- if session_user.get("role") != "admin":
|
||||
- raise PermissionError("admin only")
|
||||
# authn done upstream, but no role check
|
||||
@@ -0,0 +1,3 @@
|
||||
def order_total(price_cents: int, quantity: int) -> int:
|
||||
# no upper bound on quantity
|
||||
return price_cents * quantity
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Unvalidated quantity causes integer overflow",
|
||||
"description": "quantity from request body multiplies price without bounds; extreme values overflow.",
|
||||
"severity": "MEDIUM",
|
||||
"category": "logic",
|
||||
"findings": [
|
||||
{
|
||||
"file": "app.py",
|
||||
"trigger": "quantity multiplied without upper-bound check",
|
||||
"description": "Overflow/wraparound corrupts totals on extreme input",
|
||||
"severity": "MEDIUM",
|
||||
"classes": [
|
||||
"CWE-190",
|
||||
"CWE-20"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
--- a/app.py
|
||||
+++ b/app.py
|
||||
def order_total(price_cents: int, quantity: int) -> int:
|
||||
- if quantity > 10_000:
|
||||
- raise ValueError("quantity too large")
|
||||
# no upper bound on quantity
|
||||
@@ -0,0 +1,5 @@
|
||||
import requests
|
||||
|
||||
def send_webhook(url: str, payload: dict):
|
||||
# user-supplied URL fetched without validation
|
||||
requests.post(url, json=payload, timeout=5)
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "SSRF via user-controlled webhook URL",
|
||||
"description": "Webhook URL comes from the request and is fetched server-side without allowlist.",
|
||||
"severity": "HIGH",
|
||||
"category": "security",
|
||||
"findings": [
|
||||
{
|
||||
"file": "app.py",
|
||||
"trigger": "user-supplied URL fetched server-side without allowlist",
|
||||
"description": "SSRF \u2014 internal network probing and metadata endpoint access",
|
||||
"severity": "HIGH",
|
||||
"classes": [
|
||||
"CWE-918",
|
||||
"CWE-20"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
--- a/app.py
|
||||
+++ b/app.py
|
||||
import requests
|
||||
|
||||
def send_webhook(url: str, payload: dict):
|
||||
- if not url.startswith("https://hooks.example.com/"):
|
||||
- raise ValueError("webhook host not allowed")
|
||||
# user-supplied URL fetched without validation
|
||||
@@ -0,0 +1,5 @@
|
||||
function renderResults(results, searchTerm) {
|
||||
const box = document.getElementById("results");
|
||||
box.innerHTML = `<h2>Results for ${searchTerm}</h2>` +
|
||||
results.map(r => `<li>${r.name}</li>`).join("");
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"title": "Reflected XSS via innerHTML assignment",
|
||||
"description": "Search term inserted into the DOM via innerHTML without escaping.",
|
||||
"severity": "HIGH",
|
||||
"category": "security",
|
||||
"findings": [
|
||||
{
|
||||
"file": "app.js",
|
||||
"trigger": "user input interpolated into innerHTML",
|
||||
"description": "Script injection in victim browsers via crafted search term",
|
||||
"severity": "HIGH",
|
||||
"classes": [
|
||||
"CWE-79"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
--- a/app.js
|
||||
+++ b/app.js
|
||||
@@ -0,0 +1 @@
|
||||
+ (new file — the bug is introduced by this change)
|
||||
@@ -0,0 +1,6 @@
|
||||
def withdraw(conn, user_id: int, amount: int):
|
||||
row = conn.execute("SELECT balance FROM accounts WHERE id = ?", (user_id,)).fetchone()
|
||||
if row[0] < amount:
|
||||
raise ValueError("insufficient funds")
|
||||
conn.execute("UPDATE accounts SET balance = balance - ? WHERE id = ?", (amount, user_id))
|
||||
conn.commit()
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"title": "TOCTOU race on balance check",
|
||||
"description": "Check-then-deduct without lock or atomic update; concurrent requests double-spend.",
|
||||
"severity": "HIGH",
|
||||
"category": "concurrency",
|
||||
"findings": [
|
||||
{
|
||||
"file": "app.py",
|
||||
"trigger": "balance check and deduct are separate non-atomic statements",
|
||||
"description": "Double-spend under concurrent requests",
|
||||
"severity": "HIGH",
|
||||
"classes": [
|
||||
"CWE-362"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
--- a/app.py
|
||||
+++ b/app.py
|
||||
def withdraw(conn, user_id: int, amount: int):
|
||||
row = conn.execute("SELECT balance FROM accounts WHERE id = ?", (user_id,)).fetchone()
|
||||
- # single atomic guarded update
|
||||
- cur = conn.execute("UPDATE accounts SET balance = balance - ? WHERE id = ? AND balance >= ?", (amount, user_id, amount))
|
||||
- if cur.rowcount == 0:
|
||||
- raise ValueError("insufficient funds")
|
||||
- conn.commit()
|
||||
- return
|
||||
if row[0] < amount:
|
||||
@@ -0,0 +1,5 @@
|
||||
async function loadProfile(userId) {
|
||||
const res = await fetch(`/api/users/${userId}`);
|
||||
const data = await res.json();
|
||||
return data;
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Unhandled promise rejection",
|
||||
"description": "fetch promise has no catch; network failure leaves UI in loading state forever.",
|
||||
"severity": "MEDIUM",
|
||||
"category": "async",
|
||||
"findings": [
|
||||
{
|
||||
"file": "app.js",
|
||||
"trigger": "await fetch chain with no try/catch or .catch",
|
||||
"description": "Unhandled rejection \u2014 loading state never resolves on failure",
|
||||
"severity": "MEDIUM",
|
||||
"classes": [
|
||||
"HG-ASYNC",
|
||||
"CWE-754"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
--- a/app.js
|
||||
+++ b/app.js
|
||||
async function loadProfile(userId) {
|
||||
- try {
|
||||
const res = await fetch(`/api/users/${userId}`);
|
||||
const data = await res.json();
|
||||
return data;
|
||||
- } catch (e) {
|
||||
- showError(e);
|
||||
- }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
def process_payment(order):
|
||||
try:
|
||||
gateway.charge(order)
|
||||
except Exception:
|
||||
pass
|
||||
return {"status": "paid"}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Swallowed exception in payment processing",
|
||||
"description": "Bare except: pass hides every failure; function returns fake success.",
|
||||
"severity": "HIGH",
|
||||
"category": "error-handling",
|
||||
"findings": [
|
||||
{
|
||||
"file": "app.py",
|
||||
"trigger": "bare except pass, returns success regardless",
|
||||
"description": "Fake success masks failed charges \u2014 silent data/money loss",
|
||||
"severity": "HIGH",
|
||||
"classes": [
|
||||
"HG-DEGENERATE",
|
||||
"CWE-754"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
--- a/app.py
|
||||
+++ b/app.py
|
||||
def process_payment(order):
|
||||
try:
|
||||
gateway.charge(order)
|
||||
except Exception:
|
||||
- log.exception("charge failed")
|
||||
- return {"status": "failed"}
|
||||
+ pass
|
||||
return {"status": "paid"}
|
||||
@@ -0,0 +1,4 @@
|
||||
def transfer(conn, from_id, to_id, amount):
|
||||
conn.execute("UPDATE accounts SET balance = balance - ? WHERE id = ?", (amount, from_id))
|
||||
conn.execute("UPDATE accounts SET balance = balance + ? WHERE id = ?", (amount, to_id))
|
||||
conn.commit()
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Non-atomic multi-step update",
|
||||
"description": "Two-table update without transaction; crash between statements corrupts state.",
|
||||
"severity": "HIGH",
|
||||
"category": "data-integrity",
|
||||
"findings": [
|
||||
{
|
||||
"file": "app.py",
|
||||
"trigger": "two dependent updates without a wrapping transaction",
|
||||
"description": "Crash mid-sequence loses money \u2014 debit without credit",
|
||||
"severity": "HIGH",
|
||||
"classes": [
|
||||
"HG-PARTIAL-WRITE",
|
||||
"CWE-362"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
--- a/app.py
|
||||
+++ b/app.py
|
||||
def transfer(conn, from_id, to_id, amount):
|
||||
- with conn:
|
||||
conn.execute("UPDATE accounts SET balance = balance - ? WHERE id = ?", (amount, from_id))
|
||||
conn.execute("UPDATE accounts SET balance = balance + ? WHERE id = ?", (amount, to_id))
|
||||
- conn.commit()
|
||||
@@ -0,0 +1,4 @@
|
||||
def read_config(path):
|
||||
f = open(path)
|
||||
data = json.load(f)
|
||||
return data
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Unclosed file handle on error path",
|
||||
"description": "open() without context manager; exception path leaks the descriptor.",
|
||||
"severity": "MEDIUM",
|
||||
"category": "resources",
|
||||
"findings": [
|
||||
{
|
||||
"file": "app.py",
|
||||
"trigger": "open without with/close on all paths",
|
||||
"description": "FD leak under repeated failures exhausts process limit",
|
||||
"severity": "MEDIUM",
|
||||
"classes": [
|
||||
"CWE-404",
|
||||
"CWE-754"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
--- a/app.py
|
||||
+++ b/app.py
|
||||
def read_config(path):
|
||||
- with open(path) as f:
|
||||
- return json.load(f)
|
||||
+ f = open(path)
|
||||
data = json.load(f)
|
||||
@@ -0,0 +1,5 @@
|
||||
def handler(event):
|
||||
try:
|
||||
return do_work(event)
|
||||
except Exception as e:
|
||||
return {"statusCode": 500, "body": str(e)}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"title": "Stack trace returned to client",
|
||||
"description": "Bare handler returns exception text in the HTTP response body.",
|
||||
"severity": "MEDIUM",
|
||||
"category": "security",
|
||||
"findings": [
|
||||
{
|
||||
"file": "app.py",
|
||||
"trigger": "exception text returned in response body",
|
||||
"description": "Internal paths, queries, and versions leaked to clients",
|
||||
"severity": "MEDIUM",
|
||||
"classes": [
|
||||
"CWE-200"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
--- a/app.py
|
||||
+++ b/app.py
|
||||
@@ -0,0 +1 @@
|
||||
+ (new file — the bug is introduced by this change)
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
def parse(stream):
|
||||
records = []
|
||||
while True:
|
||||
line = stream.readline()
|
||||
if not line:
|
||||
break
|
||||
records.append(json.loads(line))
|
||||
return records
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"title": "Unbounded loop over user input",
|
||||
"description": "parse runs until end-of-stream with no cap on iterations or size.",
|
||||
"severity": "MEDIUM",
|
||||
"category": "resources",
|
||||
"findings": [
|
||||
{
|
||||
"file": "app.py",
|
||||
"trigger": "read loop without record-count or byte cap",
|
||||
"description": "Memory exhaustion DoS from large uploads",
|
||||
"severity": "MEDIUM",
|
||||
"classes": [
|
||||
"CWE-400"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
--- a/app.py
|
||||
+++ b/app.py
|
||||
def parse(stream):
|
||||
records = []
|
||||
+ MAX = 10_000
|
||||
while True:
|
||||
line = stream.readline()
|
||||
if not line:
|
||||
break
|
||||
+ if len(records) >= MAX:
|
||||
+ raise ValueError("too many records")
|
||||
records.append(json.loads(line))
|
||||
@@ -0,0 +1,6 @@
|
||||
import hashlib
|
||||
|
||||
def verify(shared_secret: str, provided: str) -> bool:
|
||||
a = hashlib.sha256(shared_secret.encode()).hexdigest()
|
||||
b = hashlib.sha256(provided.encode()).hexdigest()
|
||||
return a == b
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Password comparison not constant-time",
|
||||
"description": "Plain == on password-derived token allows timing side channel.",
|
||||
"severity": "MEDIUM",
|
||||
"category": "security",
|
||||
"findings": [
|
||||
{
|
||||
"file": "app.py",
|
||||
"trigger": "secret compared with plain string equality",
|
||||
"description": "Timing side channel leaks secret one byte at a time",
|
||||
"severity": "MEDIUM",
|
||||
"classes": [
|
||||
"CWE-287",
|
||||
"CWE-200"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
--- a/app.py
|
||||
+++ b/app.py
|
||||
import hashlib
|
||||
+import hmac
|
||||
|
||||
def verify(shared_secret: str, provided: str) -> bool:
|
||||
a = hashlib.sha256(shared_secret.encode()).hexdigest()
|
||||
b = hashlib.sha256(provided.encode()).hexdigest()
|
||||
- return hmac.compare_digest(a, b)
|
||||
+ return a == b
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import pickle
|
||||
|
||||
def load_state(body: bytes):
|
||||
return pickle.loads(body)
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Pickle load of user-supplied bytes",
|
||||
"description": "pickle.loads on request body executes arbitrary code during deserialisation.",
|
||||
"severity": "CRITICAL",
|
||||
"category": "security",
|
||||
"findings": [
|
||||
{
|
||||
"file": "app.py",
|
||||
"trigger": "pickle.loads on untrusted request bytes",
|
||||
"description": "RCE \u2014 pickle deserialisation executes attacker code",
|
||||
"severity": "CRITICAL",
|
||||
"classes": [
|
||||
"CWE-502",
|
||||
"CWE-94"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
--- a/app.py
|
||||
+++ b/app.py
|
||||
@@ -0,0 +1 @@
|
||||
+ (new file — the bug is introduced by this change)
|
||||
@@ -0,0 +1,4 @@
|
||||
def reset_password(request, user_id: int):
|
||||
new_pw = request.json["password"]
|
||||
db.execute("UPDATE users SET pw = ? WHERE id = ?", (hash(new_pw), user_id))
|
||||
db.commit()
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "State-changing route without authentication",
|
||||
"description": "POST handler has no session check; anonymous requests reset any account.",
|
||||
"severity": "CRITICAL",
|
||||
"category": "security",
|
||||
"findings": [
|
||||
{
|
||||
"file": "app.py",
|
||||
"trigger": "state-changing handler without session/auth check",
|
||||
"description": "Account takeover by anonymous callers",
|
||||
"severity": "CRITICAL",
|
||||
"classes": [
|
||||
"CWE-306",
|
||||
"CWE-287"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
--- a/app.py
|
||||
+++ b/app.py
|
||||
def reset_password(request, user_id: int):
|
||||
- if not request.session.get("user"):
|
||||
- raise PermissionError("login required")
|
||||
new_pw = request.json["password"]
|
||||
@@ -0,0 +1,3 @@
|
||||
function canEdit(order) {
|
||||
return order.status == 1; // "1" also passes
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"title": "Loose equality on status check",
|
||||
"description": "JS == with string/number mix accepts wrong status values.",
|
||||
"severity": "LOW",
|
||||
"category": "logic",
|
||||
"findings": [
|
||||
{
|
||||
"file": "app.js",
|
||||
"trigger": "loose == between string and number",
|
||||
"description": "Wrong-type status grants edit rights unexpectedly",
|
||||
"severity": "LOW",
|
||||
"classes": [
|
||||
"CWE-20"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
--- a/app.js
|
||||
+++ b/app.js
|
||||
function canEdit(order) {
|
||||
- return order.status === 1;
|
||||
+ return order.status == 1; // "1" also passes
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
async function refresh() {
|
||||
state.loading = true;
|
||||
try {
|
||||
state.data = await api.get("/data");
|
||||
} catch (e) {
|
||||
state.error = e.message;
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Missing loading\u2192error UI transition",
|
||||
"description": "Error path updates message but never clears the loading flag.",
|
||||
"severity": "MEDIUM",
|
||||
"category": "logic",
|
||||
"findings": [
|
||||
{
|
||||
"file": "app.js",
|
||||
"trigger": "catch path never resets loading flag",
|
||||
"description": "Spinner stuck forever after a failed refresh",
|
||||
"severity": "MEDIUM",
|
||||
"classes": [
|
||||
"HG-STATE",
|
||||
"HG-ASYNC"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
--- a/app.js
|
||||
+++ b/app.js
|
||||
} catch (e) {
|
||||
state.error = e.message;
|
||||
+ state.loading = false;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
def change_email(request):
|
||||
if request.method in ("GET", "POST"):
|
||||
u = db.get_user(request.args["user"])
|
||||
u.email = request.args["email"]
|
||||
u.save()
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"title": "State change on GET without CSRF token",
|
||||
"description": "Password change honors GET with no token; img tags can trigger it.",
|
||||
"severity": "HIGH",
|
||||
"category": "security",
|
||||
"findings": [
|
||||
{
|
||||
"file": "app.py",
|
||||
"trigger": "state change accepted on GET without CSRF token",
|
||||
"description": "CSRF \u2014 attacker sites change victim emails via img/link",
|
||||
"severity": "HIGH",
|
||||
"classes": [
|
||||
"CWE-352"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
--- a/app.py
|
||||
+++ b/app.py
|
||||
def change_email(request):
|
||||
- if request.method == "POST" and request.form.get("csrf_token") == session_csrf():
|
||||
+ if request.method in ("GET", "POST"):
|
||||
u = db.get_user(request.args["user"])
|
||||
@@ -0,0 +1,2 @@
|
||||
def apply_filter(value, expr: str):
|
||||
return eval(f"{value} {expr}")
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"title": "eval on template expression",
|
||||
"description": "Template renderer evals user-supplied filter expressions.",
|
||||
"severity": "CRITICAL",
|
||||
"category": "security",
|
||||
"findings": [
|
||||
{
|
||||
"file": "app.py",
|
||||
"trigger": "eval on user-controlled expression string",
|
||||
"description": "Arbitrary code execution via crafted filter",
|
||||
"severity": "CRITICAL",
|
||||
"classes": [
|
||||
"CWE-94"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
--- a/app.py
|
||||
+++ b/app.py
|
||||
@@ -0,0 +1 @@
|
||||
+ (new file — the bug is introduced by this change)
|
||||
@@ -0,0 +1,6 @@
|
||||
import os
|
||||
|
||||
def save_upload(file_storage):
|
||||
dest = os.path.join("/srv/uploads", file_storage.filename)
|
||||
file_storage.save(dest)
|
||||
return dest
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Unrestricted upload accepts any type",
|
||||
"description": "Upload saves whatever the client sends under the original extension.",
|
||||
"severity": "HIGH",
|
||||
"category": "security",
|
||||
"findings": [
|
||||
{
|
||||
"file": "app.py",
|
||||
"trigger": "upload saved without extension/content-type validation",
|
||||
"description": "Webshell upload via .php/.jsp under web root",
|
||||
"severity": "HIGH",
|
||||
"classes": [
|
||||
"CWE-434",
|
||||
"CWE-22"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
--- a/app.py
|
||||
+++ b/app.py
|
||||
import os
|
||||
|
||||
def save_upload(file_storage):
|
||||
- ext = os.path.splitext(file_storage.filename)[1].lower()
|
||||
- if ext not in (".png", ".jpg", ".pdf"):
|
||||
- raise ValueError("file type not allowed")
|
||||
dest = os.path.join("/srv/uploads", file_storage.filename)
|
||||
Reference in new issue
Block a user