Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions benchmarks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,16 @@ python benchmarks/run_benchmarks.py juiceshop-writes # + --probe-writes (mutate
python benchmarks/run_benchmarks.py --all # + NodeGoat + crAPI + Juice Shop (heavy)
python benchmarks/run_benchmarks.py crapi # a single target
python benchmarks/run_benchmarks.py sast-injection # taint recall/FP, no Docker
python benchmarks/run_benchmarks.py go-sast # Go recall vs real govwa (clones a pinned commit)
python benchmarks/run_benchmarks.py --keep vampi-vulnerable # leave it running
python benchmarks/run_benchmarks.py juiceshop --report-dir /tmp/r # keep the raw JSON reports
```

Docker is required for every target **except** `sast-injection`, which instead
needs the `semgrep` binary (`pipx install semgrep` or `pip install semgrep`).
Docker is required for every target **except** `sast-injection` and `go-sast`,
which instead need the `semgrep` binary (`pipx install semgrep` or
`pip install semgrep`). `go-sast` also clones the real `govwa` app at a pinned
commit (needs `git`; `go`+`gopls` add the route/auth half) and is opt-in — named
explicitly or via `--all`, never in the default run.

`--report-dir DIR` keeps each target's raw JSON report instead of discarding
it. The scorecard says *how many* findings went unmatched but not *which*, and
Expand Down Expand Up @@ -54,7 +58,8 @@ For each target the scorecard reports two things — both matter:
| `crapi` | microservices | upstream compose (auto-cloned) | OWASP API Top 10; IDOR/BAC/auth depth |
| `juiceshop` | Angular/Express SPA | single image | **headline recall** — scored per-challenge vs the app's own `/api/Challenges` |
| `juiceshop-auth` | same, authenticated | single image | adds the login-gated challenges (the `~40%` number) |
| `sast-injection` | JS/TS fixtures | **none** (code-only) | **taint layer recall/FP** — deterministic SAST injection floor (#4) |
| `sast-injection` | JS/TS/Python/Java/Kotlin/Go fixtures | **none** (code-only) | **taint layer recall/FP** — deterministic SAST injection floor (#4) |
| `go-sast` | real govwa app (pinned clone) | **none** (code-only) | **Go recall** — SQLi/XSS/dep/missing-auth class recall across the whole Go SAST path (opt-in; needs `git`, and `go`/`gopls` for the auth half) |

VAmPI is a single container and runs in the default set. NodeGoat and crAPI are
heavier (compose, mongo, several GB for crAPI) — run them individually. They are
Expand Down
7 changes: 7 additions & 0 deletions benchmarks/fixtures/sast-injection/safe/SafeHandler.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package safe

import (
"database/sql"
"html/template"
"net/http"
"os"
"os/exec"
Expand Down Expand Up @@ -31,3 +32,9 @@ func Download(w http.ResponseWriter, r *http.Request) {
f, _ := os.Open(filepath.Join("/data", name))
_ = f
}

// Static markup cast to template.HTML is the intentional, safe use — a string
// literal, not user input, so it must NOT be flagged as XSS.
func Banner() template.HTML {
return template.HTML("<b>Welcome</b>")
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package vulnerable
import (
"database/sql"
"fmt"
"html/template"
"net/http"
"os"
"os/exec"
Expand Down Expand Up @@ -51,3 +52,10 @@ func Download(w http.ResponseWriter, r *http.Request) {
f, _ := os.Open("/data/" + name) // EXPECT path-traversal
_ = f
}

// --- XSS: casting user input to template.HTML bypasses auto-escaping ---
func Greet(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
data := map[string]interface{}{"name": template.HTML(name)} // EXPECT reflected-xss
_ = data
}
162 changes: 162 additions & 0 deletions benchmarks/go_sast.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""Go SAST benchmark — recall against a real vulnerable Go app (govwa).

Unlike ``sast_injection.py`` (a curated fixture tree with per-line ``// EXPECT``
markers), this scores a code-only scan of a *real* intentionally-vulnerable app —
`govwa <https://github.com/0c34/govwa>`_ (Go Vulnerable Web Application) — pinned
to a commit so the ground truth doesn't drift. It exercises the whole Go SAST
path together: the injection taint pack, the route mapper (middleware-wrapped
handlers), route/auth analysis, and OSV dependency scanning.

Ground truth is CLASS-LEVEL, not per-line: govwa is documented to contain SQLi,
XSS, IDOR, and it ships an old gin (CVEs) plus unauthenticated setup routes. We
assert the scan surfaces each class at least once — deliberately NOT pinned to
line numbers or govwa-internal symbols, so the engine stays app-agnostic and the
benchmark survives cosmetic upstream edits.

Usage:
python benchmarks/go_sast.py # clone (pinned) + scan + score
python benchmarks/go_sast.py findings.json # score an existing scan JSON

Requires (scan path): ``isitsecure`` + the ``semgrep`` binary, ``git``, and — for
the route/auth half — the ``go`` toolchain + ``gopls`` on PATH. Missing tools
SKIP (exit 0) rather than fail, so CI without them is not blocked.
"""

from __future__ import annotations

import json
import pathlib
import shutil
import subprocess
import sys
import tempfile

REPO_URL = "https://github.com/0c34/govwa"
# Pinned so ground truth can't drift (2025-02-20).
PIN = "4058f79f31eeb4a36d8f1e64bba1f0c899646e6f"
CLONE_DIR = pathlib.Path(__file__).parent / "_ext" / "govwa"

# Class-level ground truth: (label, predicate over a finding dict).
# Each MUST be satisfied by at least one finding. Kept to category + broad
# keywords so it checks capability, not exact wording.
EXPECTED = {
"sqli": lambda f: _is(f, "injection") and _kw(f, "sql"),
"xss": lambda f: _is(f, "injection") and _kw(f, "xss", "cross-site", "template.html"),
"vulnerable-dependency": lambda f: _cat(f) in ("dependency_vuln", "vulnerable_dependency")
or _kw(f, "vulnerable dependency"),
"missing-auth": lambda f: _cat(f) == "auth_weakness" and _kw(f, "auth"),
}


def _cat(f: dict) -> str:
c = f.get("category", "")
return c.get("value", "") if isinstance(c, dict) else str(c)


def _text(f: dict) -> str:
return f"{f.get('title', '')} {f.get('description', '')}".lower()


def _kw(f: dict, *words: str) -> bool:
t = _text(f)
return any(w in t for w in words)


def _is(f: dict, cat_substr: str) -> bool:
return cat_substr in _cat(f).lower()


def score(findings: list[dict]) -> dict:
by_class = {}
for label, pred in EXPECTED.items():
hits = [f for f in findings if pred(f)]
by_class[label] = {"found": len(hits), "detected": bool(hits)}
found = sum(1 for v in by_class.values() if v["detected"])
return {
"by_class": by_class,
"recall": {"found": found, "total": len(EXPECTED)},
"total_findings": len(findings),
}


def passed(result: dict) -> bool:
return result["recall"]["found"] == result["recall"]["total"]


def _have(*bins: str) -> bool:
return all(shutil.which(b) for b in bins)


def ensure_clone() -> bool:
"""Clone govwa at the pinned commit. Returns False if git/network is
unavailable (the benchmark then SKIPs rather than fails)."""
if (CLONE_DIR / "go.mod").exists():
return True
if not shutil.which("git"):
return False
CLONE_DIR.parent.mkdir(parents=True, exist_ok=True)
try:
subprocess.run(["git", "clone", REPO_URL, str(CLONE_DIR)], # noqa: S603,S607
check=True, timeout=180, capture_output=True)
subprocess.run(["git", "-C", str(CLONE_DIR), "checkout", PIN], # noqa: S603,S607
check=True, timeout=60, capture_output=True)
return True
except Exception as exc: # noqa: BLE001
print(f" clone failed ({exc}) — skipping")
shutil.rmtree(CLONE_DIR, ignore_errors=True)
return False


def run_scan() -> list[dict]:
out = tempfile.NamedTemporaryFile("r", suffix=".json", delete=False).name
cmd = ["isitsecure", "scan", "-r", f"file://{CLONE_DIR.resolve()}",
"--mode", "code-only", "--llm", "none", "--output", "json", "-f", out]
print(f"$ {' '.join(cmd)}")
try:
subprocess.run(cmd, check=True, timeout=600) # noqa: S603
data = json.loads(pathlib.Path(out).read_text())
return data.get("findings", []) if isinstance(data, dict) else data
finally:
pathlib.Path(out).unlink(missing_ok=True)


def print_report(r: dict) -> None:
rec = r["recall"]
pct = (100 * rec["found"] / rec["total"]) if rec["total"] else 0
print("=" * 60)
print("GO SAST BENCHMARK — govwa class-level recall")
print("=" * 60)
print(f"\nClass recall: {rec['found']}/{rec['total']} ({pct:.0f}%)")
print(f"Total findings: {r['total_findings']}")
print("\nBy class (detected? / count):")
for cls, v in r["by_class"].items():
mark = "x" if v["detected"] else " "
print(f" [{mark}] {cls:22} {v['found']}")


def main() -> int:
if len(sys.argv) == 2:
data = json.loads(pathlib.Path(sys.argv[1]).read_text())
findings = data.get("findings", data) if isinstance(data, dict) else data
else:
if not _have("isitsecure", "semgrep"):
print("SKIPPED — need `isitsecure` and `semgrep` on PATH.")
return 0
if not _have("go", "gopls"):
print("NOTE — `go`/`gopls` not on PATH; route/auth half is degraded "
"(injection + dependency recall still scored).")
if not ensure_clone():
print("SKIPPED — could not obtain govwa (git/network unavailable).")
return 0
findings = run_scan()
result = score(findings)
print_report(result)
ok = passed(result)
print(f"\n{'PASS' if ok else 'FAIL'} — class recall "
f"{result['recall']['found']}/{result['recall']['total']}")
return 0 if ok else 1


if __name__ == "__main__":
sys.exit(main())
64 changes: 53 additions & 11 deletions benchmarks/run_benchmarks.py
Original file line number Diff line number Diff line change
Expand Up @@ -495,25 +495,34 @@ def print_scorecard(results: list[dict]) -> None:
# code-only scan of a local fixture tree (see sast_injection.py). It's exposed
# here as a pseudo-target so it runs from the same entrypoint (and by default).
SAST_INJECTION = "sast-injection"
# Go SAST recall against the real govwa app. Opt-in only (named or --all): it
# clones an external repo and wants the go/gopls toolchain, so it must not run
# in the default no-arg set (which stays offline-friendly).
GO_SAST = "go-sast"
_PSEUDO = {SAST_INJECTION, GO_SAST}


def resolve_selection(targets: list[str], all_flag: bool) -> tuple[list[str], bool, list[str]]:
def resolve_selection(
targets: list[str], all_flag: bool
) -> tuple[list[str], bool, bool, list[str]]:
"""Plan a run from the CLI args (pure — no Docker, no side effects).

Returns (docker target names to run, whether to run sast-injection, unknown names).
The SAST pseudo-target runs when named, with --all, or in the default (no-arg) set.
Returns (docker names, run sast-injection?, run go-sast?, unknown names).
sast-injection runs when named, with --all, or in the default (no-arg) set.
go-sast runs only when named or with --all (never in the default set).
"""
valid = {t.name for t in TARGETS}
want_sast = SAST_INJECTION in targets or all_flag or not targets
docker_names = [n for n in targets if n != SAST_INJECTION]
want_go_sast = GO_SAST in targets or all_flag
docker_names = [n for n in targets if n not in _PSEUDO]
if docker_names:
unknown = [n for n in docker_names if n not in valid]
return [n for n in docker_names if n in valid], want_sast, unknown
return [n for n in docker_names if n in valid], want_sast, want_go_sast, unknown
if all_flag:
return [t.name for t in TARGETS], want_sast, []
if targets: # only sast-injection was requested
return [], want_sast, []
return ["vampi-vulnerable", "vampi-secure"], want_sast, []
return [t.name for t in TARGETS], want_sast, want_go_sast, []
if targets: # only pseudo-targets were requested
return [], want_sast, want_go_sast, []
return ["vampi-vulnerable", "vampi-secure"], want_sast, want_go_sast, []


def run_sast_injection() -> int:
Expand All @@ -539,6 +548,32 @@ def run_sast_injection() -> int:
return 0 if si.passed(r) else 1


def run_go_sast() -> int:
"""Run the Go SAST benchmark (govwa); 0 on full class recall, else 1.

Skips (returns 0) when its tools or the network are unavailable — like the
Docker targets skip without Docker.
"""
import go_sast as gs

print(f"\n=== {GO_SAST} ===\n code-only recall against the real govwa "
f"app (clones a pinned commit; needs semgrep, and go/gopls for auth)")
if not gs._have("isitsecure", "semgrep"):
print(" SKIPPED — need isitsecure + semgrep on PATH.")
return 0
if not gs.ensure_clone():
print(" SKIPPED — could not obtain govwa (git/network unavailable).")
return 0
try:
findings = gs.run_scan()
except Exception as e: # noqa: BLE001 - surface, don't crash the whole suite
print(f" Go SAST scan failed: {e}")
return 1
r = gs.score(findings)
gs.print_report(r)
return 0 if gs.passed(r) else 1


def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("targets", nargs="*", help="target names (default: vampi + sast-injection)")
Expand All @@ -554,9 +589,12 @@ def main() -> int:
REPORT_DIR = args.report_dir

by_name = {t.name: t for t in TARGETS}
docker_names, want_sast, unknown = resolve_selection(args.targets, args.all)
docker_names, want_sast, want_go_sast, unknown = resolve_selection(
args.targets, args.all
)
if unknown:
print(f"Unknown targets: {unknown}. Available: {list(by_name) + [SAST_INJECTION]}")
print(f"Unknown targets: {unknown}. "
f"Available: {list(by_name) + [SAST_INJECTION, GO_SAST]}")
return 2
selected = [by_name[n] for n in docker_names]

Expand All @@ -569,13 +607,17 @@ def main() -> int:
for r in results
)
sast_rc = run_sast_injection() if want_sast else 0
go_sast_rc = run_go_sast() if want_go_sast else 0
if regressions:
print(f"\n✗ {regressions} regression failure(s) — a finding the scanner "
f"reliably catches was dropped by the full scan. See ⚠ REGRESSION above.")
return 1
if sast_rc:
print("\n✗ SAST injection benchmark failed (recall < 100% or FP > 0).")
return 1
if go_sast_rc:
print("\n✗ Go SAST benchmark failed (class recall < 100%).")
return 1
return 0


Expand Down
3 changes: 2 additions & 1 deletion benchmarks/sast_injection.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@
# they don't collide (e.g. "dom" would match "domain"/"random"). First hit wins.
_TITLE_CUES = [
("dom-xss", ("into the dom", "innerhtml", "document.write", "dom xss")),
("reflected-xss", ("reflected", "html response")),
("reflected-xss", ("reflected", "html response", "cross-site scripting",
"auto-escaping")),
("ssti", ("template injection", "ssti", "server-side template")),
("ssrf", ("outbound request", "ssrf")),
("path-traversal", ("filesystem write", "path traversal", "request-derived")),
Expand Down
Loading
Loading