vulnfind 3.0.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.
Files changed (69) hide show
  1. vulnfind/__init__.py +3 -0
  2. vulnfind/__main__.py +6 -0
  3. vulnfind/ai.py +124 -0
  4. vulnfind/automation.py +250 -0
  5. vulnfind/bench/__init__.py +86 -0
  6. vulnfind/bench/cases/Dockerfile +9 -0
  7. vulnfind/bench/cases/ci.yml +14 -0
  8. vulnfind/bench/cases/flask_app.py +42 -0
  9. vulnfind/bench/cases/handler.go +16 -0
  10. vulnfind/bench/cases/index.php +12 -0
  11. vulnfind/bench/cases/server.js +35 -0
  12. vulnfind/bench/cases/settings.py +9 -0
  13. vulnfind/cli.py +1338 -0
  14. vulnfind/cloud.py +471 -0
  15. vulnfind/customrules.py +87 -0
  16. vulnfind/cvss.py +96 -0
  17. vulnfind/depgraph.py +344 -0
  18. vulnfind/depintel.py +319 -0
  19. vulnfind/engine.py +292 -0
  20. vulnfind/enrich.py +152 -0
  21. vulnfind/fixer.py +166 -0
  22. vulnfind/gitutil.py +121 -0
  23. vulnfind/history.py +184 -0
  24. vulnfind/image.py +524 -0
  25. vulnfind/manifests.py +428 -0
  26. vulnfind/models.py +172 -0
  27. vulnfind/osv.py +277 -0
  28. vulnfind/plain.py +83 -0
  29. vulnfind/progress.py +157 -0
  30. vulnfind/reach.py +181 -0
  31. vulnfind/remote.py +78 -0
  32. vulnfind/reporters/__init__.py +26 -0
  33. vulnfind/reporters/formats.py +208 -0
  34. vulnfind/reporters/html.py +133 -0
  35. vulnfind/reporters/terminal.py +326 -0
  36. vulnfind/scanners/__init__.py +125 -0
  37. vulnfind/scanners/code.py +413 -0
  38. vulnfind/scanners/deps.py +144 -0
  39. vulnfind/scanners/files.py +126 -0
  40. vulnfind/scanners/history.py +120 -0
  41. vulnfind/scanners/iac.py +316 -0
  42. vulnfind/scanners/routes.py +277 -0
  43. vulnfind/scanners/secrets.py +247 -0
  44. vulnfind/scanners/taint_js.py +280 -0
  45. vulnfind/scanners/taint_lines.py +268 -0
  46. vulnfind/scanners/taint_python.py +768 -0
  47. vulnfind/selfupdate.py +59 -0
  48. vulnfind/triage.py +112 -0
  49. vulnfind/walker.py +299 -0
  50. vulnfind/web/__init__.py +61 -0
  51. vulnfind/web/active.py +131 -0
  52. vulnfind/web/api.py +130 -0
  53. vulnfind/web/browser.py +63 -0
  54. vulnfind/web/checks.py +314 -0
  55. vulnfind/web/client.py +180 -0
  56. vulnfind/web/crawl.py +150 -0
  57. vulnfind/web/dns.py +84 -0
  58. vulnfind/web/libs.py +208 -0
  59. vulnfind/web/login.py +109 -0
  60. vulnfind/web/paths.py +233 -0
  61. vulnfind/web/scanner.py +229 -0
  62. vulnfind/web/software.py +246 -0
  63. vulnfind/web/subdomains.py +149 -0
  64. vulnfind/web/tls.py +118 -0
  65. vulnfind-3.0.0.dist-info/METADATA +182 -0
  66. vulnfind-3.0.0.dist-info/RECORD +69 -0
  67. vulnfind-3.0.0.dist-info/WHEEL +5 -0
  68. vulnfind-3.0.0.dist-info/entry_points.txt +2 -0
  69. vulnfind-3.0.0.dist-info/top_level.txt +1 -0
vulnfind/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """vulnfind - find vulnerabilities in a project folder."""
2
+
3
+ __version__ = "3.0.0"
vulnfind/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__": # guard: worker processes re-import this module
6
+ sys.exit(main())
vulnfind/ai.py ADDED
@@ -0,0 +1,124 @@
1
+ """`vulnfind ai`: project-specific explanations and fix patches from Claude (optional).
2
+
3
+ Requires the official SDK (`pip install "vulnfind[ai]"`, i.e. `anthropic`) and credentials
4
+ (ANTHROPIC_API_KEY or an `ant auth login` profile). Sends each selected finding plus the
5
+ surrounding source lines to the Claude API - nothing is sent unless you run this command.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ from typing import Dict, Iterator, List, Optional, Tuple
13
+
14
+ from .models import Finding
15
+
16
+ MODEL = "claude-opus-5-5"
17
+ CONTEXT_LINES = 30
18
+
19
+ SYSTEM = """You are a senior application security engineer reviewing findings from an automated scanner (vulnfind) \
20
+ in the user's own codebase. For each finding you get the scanner's output and the relevant source code.
21
+
22
+ Answer in Markdown with exactly these sections:
23
+ ## Verdict
24
+ One line: "Real issue", "Likely real", "Probably a false positive" or "Can't tell from this context", then one sentence why.
25
+ ## What could happen
26
+ 2-4 sentences, concrete to this code: who could do what, and what the impact is.
27
+ ## Fix
28
+ The smallest correct change, as a unified diff against the file shown (use the real file path and line numbers). \
29
+ Keep the project's style; don't refactor unrelated code. If dependencies need upgrading, give the exact command instead.
30
+ ## Check
31
+ One or two ways to confirm the fix (a test case, a request to try, a command to run).
32
+
33
+ If the code shown isn't enough to decide, say what else you would need to see instead of guessing."""
34
+
35
+
36
+ class AIUnavailable(RuntimeError):
37
+ pass
38
+
39
+
40
+ def check_available() -> None:
41
+ try:
42
+ import anthropic # noqa: F401
43
+ except ImportError:
44
+ raise AIUnavailable("`vulnfind ai` needs the Anthropic SDK: pip install \"vulnfind[ai]\" (or pip install anthropic)")
45
+
46
+
47
+ def _client():
48
+ try:
49
+ import anthropic
50
+ except ImportError:
51
+ raise AIUnavailable("`vulnfind ai` needs the Anthropic SDK: pip install \"vulnfind[ai]\" (or pip install anthropic)")
52
+ return anthropic, anthropic.Anthropic()
53
+
54
+
55
+ def _context(root: str, rel: str, line: Optional[int]) -> Optional[str]:
56
+ path = os.path.join(root, rel)
57
+ if not line or not os.path.isfile(path):
58
+ return None
59
+ try:
60
+ with open(path, encoding="utf-8", errors="replace") as fh:
61
+ lines = fh.read().splitlines()
62
+ except OSError:
63
+ return None
64
+ lo, hi = max(1, line - CONTEXT_LINES), min(len(lines), line + CONTEXT_LINES)
65
+ return "\n".join(f"{n:>5}{'>' if n == line else ' '} {lines[n - 1]}" for n in range(lo, hi + 1))
66
+
67
+
68
+ def build_prompt(f: Finding, root: str) -> str:
69
+ finding = {k: v for k, v in f.to_dict().items() if k not in ("fingerprint",) and v not in (None, "", [], {})}
70
+ parts = [f"Scanner finding:\n```json\n{json.dumps(finding, indent=2, default=str)}\n```"]
71
+ seen = set()
72
+ files: List[Tuple[str, Optional[int]]] = [(f.file, f.line)]
73
+ for step in f.extra.get("flow") or []: # cross-file flows: "app/db.py:4: …"
74
+ head = step.split(": ", 1)[0]
75
+ if ".py:" in head or ".js:" in head or ".go:" in head or ".php:" in head:
76
+ path, _, ln = head.rpartition(":")
77
+ if ln.isdigit():
78
+ files.append((path, int(ln)))
79
+ for rel, line in files:
80
+ if (rel, line) in seen:
81
+ continue
82
+ seen.add((rel, line))
83
+ ctx = _context(root, rel, line)
84
+ if ctx:
85
+ parts.append(f"Source of {rel} around line {line} ('>' marks the line):\n```\n{ctx}\n```")
86
+ return "\n\n".join(parts)
87
+
88
+
89
+ def explain(findings: List[Finding], root: str, model: str = MODEL, effort: str = "high") -> Iterator[Tuple[Finding, str]]:
90
+ anthropic, client = _client()
91
+ for f in findings:
92
+ try:
93
+ # Security-analysis prompts can trip safety classifiers on benign code; the server-side
94
+ # "default" fallback re-runs a declined request on Anthropic's recommended fallback model.
95
+ with client.beta.messages.stream(
96
+ model=model,
97
+ max_tokens=16000,
98
+ betas=["server-side-fallback-2026-07-01"],
99
+ fallbacks="default",
100
+ thinking={"type": "adaptive"},
101
+ output_config={"effort": effort},
102
+ system=SYSTEM,
103
+ messages=[{"role": "user", "content": build_prompt(f, root)}],
104
+ ) as stream:
105
+ message = stream.get_final_message()
106
+ except anthropic.AuthenticationError:
107
+ raise AIUnavailable("no valid Anthropic credentials: set ANTHROPIC_API_KEY or run `ant auth login`")
108
+ except anthropic.PermissionDeniedError as e:
109
+ raise AIUnavailable(f"API key lacks permission: {e.message}")
110
+ except anthropic.RateLimitError:
111
+ yield f, "_Rate limited by the Claude API - try again shortly or with fewer findings (--top)._"
112
+ continue
113
+ except anthropic.APIStatusError as e:
114
+ yield f, f"_Claude API error {e.status_code}: {e.message}_"
115
+ continue
116
+ except anthropic.APIConnectionError:
117
+ raise AIUnavailable("could not reach the Claude API (network error)")
118
+ if message.stop_reason == "refusal":
119
+ yield f, "_The model declined to analyse this finding._"
120
+ continue
121
+ text = "".join(b.text for b in message.content if b.type == "text").strip()
122
+ if message.stop_reason == "max_tokens":
123
+ text += "\n\n_(answer truncated at the output limit)_"
124
+ yield f, text or "_(empty response)_"
vulnfind/automation.py ADDED
@@ -0,0 +1,250 @@
1
+ """Automation helpers: config files, CI templates, webhooks, report comparison, watch mode."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import time
8
+ import urllib.request
9
+ from typing import Any, Callable, Dict, List, Optional, Tuple
10
+
11
+ from . import __version__
12
+ from .models import ScanResult, Severity
13
+
14
+ # ---------------------------------------------------------------- config file
15
+
16
+ CONFIG_NAMES = (".vulnfind.json", "vulnfind.json")
17
+ CONFIG_KEYS = {
18
+ "scanners": str, "skip": str, "fail_on": str, "min_severity": str, "exclude": list, "output": list,
19
+ "offline": bool, "no_cache": bool, "no_dev": bool, "no_test_downgrade": bool, "baseline": str, "history": bool,
20
+ "history_depth": int, "no_deep": bool, "no_enrich": bool, "jobs": int, "notify": list, "notify_always": bool,
21
+ "include_vendor": bool, "no_gitignore": bool, "limit": int, "format": str, "rules": list,
22
+ "no_health": bool, "all_deps": bool,
23
+ }
24
+
25
+ DEFAULT_CONFIG = {
26
+ "scanners": "deps,secrets,code,config,files",
27
+ "fail_on": "high",
28
+ "min_severity": "low",
29
+ "exclude": [],
30
+ "history": False,
31
+ "no_dev": False,
32
+ "output": [],
33
+ "notify": [],
34
+ }
35
+
36
+
37
+ def find_config(root: str, explicit: Optional[str] = None) -> Optional[str]:
38
+ if explicit:
39
+ return explicit
40
+ for name in CONFIG_NAMES:
41
+ p = os.path.join(root, name)
42
+ if os.path.isfile(p):
43
+ return p
44
+ return None
45
+
46
+
47
+ def load_config(path: str) -> Dict[str, Any]:
48
+ with open(path, encoding="utf-8") as fh:
49
+ data = json.load(fh)
50
+ if not isinstance(data, dict):
51
+ raise ValueError(f"{path}: expected a JSON object")
52
+ out: Dict[str, Any] = {}
53
+ for key, value in data.items():
54
+ k = key.replace("-", "_")
55
+ if k.startswith("$") or k == "comment":
56
+ continue
57
+ if k not in CONFIG_KEYS:
58
+ raise ValueError(f"{path}: unknown option {key!r} (valid: {', '.join(sorted(CONFIG_KEYS))})")
59
+ want = CONFIG_KEYS[k]
60
+ if want is list and isinstance(value, str):
61
+ value = [value]
62
+ if want is str and isinstance(value, list):
63
+ value = ",".join(value)
64
+ if not isinstance(value, want):
65
+ raise ValueError(f"{path}: option {key!r} should be {want.__name__}")
66
+ out[k] = value
67
+ return out
68
+
69
+
70
+ # ---------------------------------------------------------------- CI templates
71
+
72
+ def github_workflow(install: str) -> str:
73
+ return f"""# Generated by vulnfind {__version__} - security scan on every push, PR and nightly.
74
+ name: vulnfind
75
+ on:
76
+ push:
77
+ branches: [main, master]
78
+ pull_request:
79
+ schedule:
80
+ - cron: "17 4 * * *" # nightly: catches new CVEs in unchanged dependencies
81
+ workflow_dispatch:
82
+
83
+ permissions:
84
+ contents: read
85
+ security-events: write # upload SARIF to the Security tab
86
+
87
+ jobs:
88
+ scan:
89
+ runs-on: ubuntu-latest
90
+ steps:
91
+ - uses: actions/checkout@v4
92
+ with:
93
+ fetch-depth: 0 # full history for --history and --diff
94
+ - uses: actions/setup-python@v5
95
+ with:
96
+ python-version: "3.12"
97
+ - name: Install vulnfind
98
+ run: pip install "{install}"
99
+ - name: Scan (PR - only changed files)
100
+ if: github.event_name == 'pull_request'
101
+ run: vulnfind --diff "origin/${{{{ github.base_ref }}}}" --format github --fail-on high -o vulnfind.sarif -o vulnfind.md
102
+ - name: Scan (full + git history)
103
+ if: github.event_name != 'pull_request'
104
+ run: vulnfind --history --fail-on high --no-progress -o vulnfind.sarif -o vulnfind.md -o vulnfind.html
105
+ - name: Job summary
106
+ if: always()
107
+ run: cat vulnfind.md >> "$GITHUB_STEP_SUMMARY" || true
108
+ - uses: github/codeql-action/upload-sarif@v3
109
+ if: always()
110
+ with:
111
+ sarif_file: vulnfind.sarif
112
+ - uses: actions/upload-artifact@v4
113
+ if: always()
114
+ with:
115
+ name: vulnfind-report
116
+ path: vulnfind.*
117
+ """
118
+
119
+
120
+ def gitlab_job(install: str) -> str:
121
+ return f"""# Generated by vulnfind {__version__}. Add to .gitlab-ci.yml (or `include:` this file).
122
+ vulnfind:
123
+ stage: test
124
+ image: python:3.12-slim
125
+ variables:
126
+ GIT_DEPTH: "0"
127
+ before_script:
128
+ - apt-get update -qq && apt-get install -y -qq git >/dev/null
129
+ - pip install "{install}"
130
+ script:
131
+ - |
132
+ if [ -n "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" ]; then
133
+ git fetch -q origin "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME"
134
+ vulnfind --diff "origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" --fail-on high -o vulnfind-junit.xml -o vulnfind.html
135
+ else
136
+ vulnfind --history --fail-on high -o vulnfind-junit.xml -o vulnfind.html -o vulnfind.json
137
+ fi
138
+ artifacts:
139
+ when: always
140
+ paths: [vulnfind.html, vulnfind.json]
141
+ reports:
142
+ junit: vulnfind-junit.xml
143
+ rules:
144
+ - if: $CI_PIPELINE_SOURCE == "merge_request_event"
145
+ - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
146
+ - if: $CI_PIPELINE_SOURCE == "schedule"
147
+ """
148
+
149
+
150
+ def precommit_config() -> str:
151
+ return """# Add to .pre-commit-config.yaml (https://pre-commit.com)
152
+ repos:
153
+ - repo: local
154
+ hooks:
155
+ - id: vulnfind
156
+ name: vulnfind security scan
157
+ entry: vulnfind --staged --scanners secrets,code,config --fail-on high --no-progress
158
+ language: system
159
+ pass_filenames: false
160
+ always_run: true
161
+ """
162
+
163
+
164
+ # ---------------------------------------------------------------- webhooks
165
+
166
+ def _summary_text(result: ScanResult, limit: int = 8, markdown: bool = True) -> str:
167
+ c = result.counts()
168
+ name = os.path.basename(result.root.rstrip("/")) or result.root
169
+ b = "*" if markdown else ""
170
+ head = (f"{b}vulnfind{b}: {b}{name}{b} - grade {b}{result.grade}{b} ({result.score}/100) · "
171
+ f"{c['CRITICAL']} critical, {c['HIGH']} high, {c['MEDIUM']} medium, {c['LOW']} low")
172
+ lines = [head]
173
+ for f in result.findings[:limit]:
174
+ what = f"{f.rule_id} {f.snippet}" if f.category == "dependency" else f.title
175
+ flag = " (exploited in the wild)" if f.extra.get("kev") else ""
176
+ lines.append(f"• [{f.severity.label}] {what}{flag} - {f.location}")
177
+ if len(result.findings) > limit:
178
+ lines.append(f"… and {len(result.findings) - limit} more")
179
+ return "\n".join(lines)
180
+
181
+
182
+ def notify(url: str, result: ScanResult, timeout: float = 15) -> Tuple[bool, str]:
183
+ host = url.split("/")[2] if "://" in url else url
184
+ if "hooks.slack.com" in host:
185
+ payload: Dict[str, Any] = {"text": _summary_text(result)}
186
+ elif "discord" in host:
187
+ payload = {"content": _summary_text(result, markdown=True).replace("*", "**")[:1900]}
188
+ elif "office.com" in host or "logic.azure.com" in host:
189
+ payload = {"text": _summary_text(result).replace("\n", "<br>")}
190
+ else:
191
+ payload = {
192
+ "tool": "vulnfind", "version": __version__, "root": result.root, "grade": result.grade, "score": result.score,
193
+ "counts": result.counts(), "total": len(result.findings), "text": _summary_text(result, markdown=False),
194
+ "findings": [f.to_dict() for f in result.findings[:50]],
195
+ }
196
+ req = urllib.request.Request(url, data=json.dumps(payload).encode(), method="POST",
197
+ headers={"Content-Type": "application/json", "User-Agent": f"vulnfind/{__version__}"})
198
+ try:
199
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
200
+ return True, f"HTTP {resp.status}"
201
+ except Exception as e:
202
+ return False, f"{e.__class__.__name__}: {e}"
203
+
204
+
205
+ # ---------------------------------------------------------------- compare two JSON reports
206
+
207
+ def load_report(path: str) -> Dict[str, Any]:
208
+ with open(path, encoding="utf-8") as fh:
209
+ data = json.load(fh)
210
+ if isinstance(data, list): # multi-path report: merge
211
+ merged = {"findings": []}
212
+ for r in data:
213
+ merged["findings"] += r.get("findings", [])
214
+ return merged
215
+ return data
216
+
217
+
218
+ def compare(old: Dict[str, Any], new: Dict[str, Any]) -> Dict[str, List[dict]]:
219
+ old_f = {f["fingerprint"]: f for f in old.get("findings", [])}
220
+ new_f = {f["fingerprint"]: f for f in new.get("findings", [])}
221
+ order = lambda f: (-Severity.parse(f["severity"]), f["file"], f.get("line") or 0)
222
+ return {
223
+ "new": sorted((f for k, f in new_f.items() if k not in old_f), key=order),
224
+ "fixed": sorted((f for k, f in old_f.items() if k not in new_f), key=order),
225
+ "unchanged": sorted((f for k, f in new_f.items() if k in old_f), key=order),
226
+ }
227
+
228
+
229
+ # ---------------------------------------------------------------- watch mode
230
+
231
+ def snapshot(root: str, files) -> Dict[str, Tuple[float, int]]:
232
+ out = {}
233
+ for f in files:
234
+ try:
235
+ st = os.stat(f.path)
236
+ except OSError:
237
+ continue
238
+ out[f.rel] = (st.st_mtime, st.st_size)
239
+ return out
240
+
241
+
242
+ def watch(root: str, list_files: Callable[[], list], on_change: Callable[[set], None], interval: float = 1.5) -> None:
243
+ prev = snapshot(root, list_files())
244
+ while True:
245
+ time.sleep(interval)
246
+ cur = snapshot(root, list_files())
247
+ changed = {k for k in cur if prev.get(k) != cur[k]} | {k for k in prev if k not in cur}
248
+ if changed:
249
+ on_change(changed)
250
+ prev = cur
@@ -0,0 +1,86 @@
1
+ """`vulnfind bench`: precision / recall of the scanner on a labelled corpus.
2
+
3
+ Labels live in comments of the case files:
4
+ `expect: RULE-ID` this line must produce a finding with that rule
5
+ `expect-above: RULE-ID` the previous non-comment line must (for formats without trailing comments)
6
+ `safe` this line must produce no finding at all (false-positive check)
7
+ Unlabelled lines are not scored.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import re
14
+ import shutil
15
+ import tempfile
16
+ from dataclasses import dataclass, field
17
+ from typing import Dict, List, Set, Tuple
18
+
19
+ CASES = os.path.join(os.path.dirname(os.path.abspath(__file__)), "cases")
20
+ PLACEMENT = {"ci.yml": ".github/workflows/ci.yml"}
21
+ LABEL = re.compile(r"(?:#|//)\s*(expect|expect-above):\s*([A-Z0-9][A-Z0-9-]+)|(?:#|//)\s*safe\b")
22
+
23
+
24
+ @dataclass
25
+ class Score:
26
+ tp: List[Tuple[str, int, str]] = field(default_factory=list)
27
+ fn: List[Tuple[str, int, str]] = field(default_factory=list)
28
+ fp: List[Tuple[str, int, str]] = field(default_factory=list)
29
+ safe_lines: int = 0
30
+
31
+ @property
32
+ def recall(self) -> float:
33
+ return len(self.tp) / max(1, len(self.tp) + len(self.fn))
34
+
35
+ @property
36
+ def precision(self) -> float:
37
+ return len(self.tp) / max(1, len(self.tp) + len(self.fp))
38
+
39
+
40
+ def labels(path: str, rel: str) -> Tuple[Dict[int, Set[str]], Set[int]]:
41
+ expected: Dict[int, Set[str]] = {}
42
+ safe: Set[int] = set()
43
+ with open(path, encoding="utf-8") as fh:
44
+ lines = fh.read().splitlines()
45
+ last_code = 0
46
+ for i, line in enumerate(lines, 1):
47
+ for m in LABEL.finditer(line):
48
+ if m.group(1) == "expect":
49
+ expected.setdefault(i, set()).add(m.group(2))
50
+ elif m.group(1) == "expect-above":
51
+ expected.setdefault(last_code, set()).add(m.group(2))
52
+ else:
53
+ safe.add(i)
54
+ if line.strip() and not line.lstrip().startswith(("#", "//")):
55
+ last_code = i
56
+ return expected, safe
57
+
58
+
59
+ def run() -> Score:
60
+ from ..engine import ScanOptions, scan
61
+ score = Score()
62
+ with tempfile.TemporaryDirectory(prefix="vulnfind-bench-") as root:
63
+ layout: Dict[str, str] = {}
64
+ for name in sorted(os.listdir(CASES)):
65
+ rel = PLACEMENT.get(name, name)
66
+ dest = os.path.join(root, rel)
67
+ os.makedirs(os.path.dirname(dest), exist_ok=True)
68
+ shutil.copy(os.path.join(CASES, name), dest)
69
+ layout[rel] = dest
70
+ result = scan(root, ScanOptions(offline=True, scanners=["secrets", "code", "config"], use_gitignore=False,
71
+ downgrade_tests=False))
72
+ by_line: Dict[Tuple[str, int], Set[str]] = {}
73
+ for f in result.findings:
74
+ if f.line:
75
+ by_line.setdefault((f.file, f.line), set()).add(f.rule_id)
76
+ for rel, path in layout.items():
77
+ expected, safe = labels(path, rel)
78
+ score.safe_lines += len(safe)
79
+ for line, rules in expected.items():
80
+ got = by_line.get((rel, line), set())
81
+ for r in rules:
82
+ (score.tp if r in got else score.fn).append((rel, line, r))
83
+ for line in safe:
84
+ for r in by_line.get((rel, line), set()):
85
+ score.fp.append((rel, line, r))
86
+ return score
@@ -0,0 +1,9 @@
1
+ # Labelled benchmark case.
2
+ FROM python:3.8
3
+ # expect-above: RUNTIME-EOL
4
+ ENV API_TOKEN=abc123secret
5
+ # expect-above: DOCKER-SECRET-ENV
6
+ RUN curl -fsSL https://get.example.com/setup | bash
7
+ # expect-above: SH-CURL-PIPE
8
+ COPY . /app
9
+ CMD ["python", "app.py"]
@@ -0,0 +1,14 @@
1
+ # Labelled benchmark case (lives at .github/workflows/ci.yml in the corpus).
2
+ on:
3
+ pull_request_target:
4
+ jobs:
5
+ t:
6
+ runs-on: ubuntu-latest
7
+ steps:
8
+ - uses: actions/checkout@v4
9
+ with:
10
+ ref: ${{ github.event.pull_request.head.sha }} # expect: GHA-PWN-REQUEST
11
+ - uses: some/action@v1 # expect: GHA-UNPINNED-ACTION
12
+ - uses: pinned/action@0123456789abcdef0123456789abcdef01234567 # safe
13
+ - run: echo "${{ github.event.issue.title }}" # expect: GHA-SCRIPT-INJECTION
14
+ - run: echo "${{ github.sha }}" # safe
@@ -0,0 +1,42 @@
1
+ # Labelled benchmark case: `expect: RULE` = must be flagged on this line, `safe` = must not be.
2
+ import os, pickle, subprocess, yaml, requests
3
+ from flask import Flask, request, redirect, render_template_string
4
+
5
+ app = Flask(__name__)
6
+
7
+
8
+ def build(name):
9
+ return "SELECT * FROM users WHERE name = '%s'" % name
10
+
11
+
12
+ @app.route("/a")
13
+ def a(db):
14
+ q = build(request.args.get("name"))
15
+ db.cursor().execute(q) # expect: PY-TAINT-SQLI
16
+ db.cursor().execute("SELECT * FROM t WHERE id = %s", (request.args.get("id"),)) # safe
17
+ n = int(request.args.get("n"))
18
+ os.system("echo %d" % n) # safe
19
+
20
+
21
+ @app.route("/b/<fname>")
22
+ def b(fname):
23
+ return open(os.path.join("/srv", fname)).read() # expect: PY-TAINT-PATH
24
+
25
+
26
+ @app.route("/c")
27
+ def c():
28
+ subprocess.run("ping " + request.args["h"], shell=True) # expect: PY-TAINT-CMDI
29
+ data = pickle.loads(request.data) # expect: PY-TAINT-DESER
30
+ cfg = yaml.load(request.form["c"]) # expect: PY-TAINT-DESER
31
+ safe_cfg = yaml.safe_load(request.form["c"]) # safe
32
+ requests.get(request.form["url"], timeout=3) # expect: PY-TAINT-SSRF
33
+ render_template_string("Hi " + request.args["n"]) # expect: PY-TAINT-SSTI
34
+ x = "constant"
35
+ eval(x) # safe
36
+ return redirect(request.args.get("next")) # expect: PY-TAINT-REDIRECT
37
+
38
+
39
+ def helpers():
40
+ hashlib.md5(b"x") # expect: PY-WEAK-HASH
41
+ requests.get("https://example.com", verify=False, timeout=3) # expect: PY-TLS-VERIFY-OFF
42
+ msg = "never call eval(x) here" # safe
@@ -0,0 +1,16 @@
1
+ // Labelled benchmark case.
2
+ package main
3
+
4
+ import ("database/sql"; "fmt"; "net/http"; "os"; "os/exec"; "strconv"; "crypto/tls")
5
+
6
+ func h(w http.ResponseWriter, r *http.Request, db *sql.DB) {
7
+ name := r.URL.Query().Get("name")
8
+ db.Query(fmt.Sprintf("SELECT * FROM u WHERE name = '%s'", name)) // expect: GO-TAINT-SQLI
9
+ db.Query("SELECT * FROM u WHERE name = $1", name) // safe
10
+ id, _ := strconv.Atoi(r.FormValue("id"))
11
+ db.Query(fmt.Sprintf("SELECT * FROM t WHERE id = %d", id)) // safe
12
+ exec.Command("sh", "-c", "ping "+r.FormValue("host")).Run() // expect: GO-TAINT-CMDI
13
+ os.ReadFile("/srv/" + r.FormValue("f")) // expect: GO-TAINT-PATH
14
+ http.Redirect(w, r, r.URL.Query().Get("next"), 302) // expect: GO-TAINT-REDIRECT
15
+ _ = &tls.Config{InsecureSkipVerify: true} // expect: GO-TLS-INSECURE
16
+ }
@@ -0,0 +1,12 @@
1
+ <?php
2
+ // Labelled benchmark case.
3
+ $id = $_GET['id'];
4
+ mysqli_query($c, "SELECT * FROM u WHERE id = $id"); // expect: PHP-TAINT-SQLI
5
+ $n = intval($_GET['n']);
6
+ mysqli_query($c, "SELECT * FROM t WHERE n = " . $n); // safe
7
+ include($_GET['page'] . ".php"); // expect: PHP-TAINT-LFI
8
+ echo htmlspecialchars($_GET['q'], ENT_QUOTES); // safe
9
+ echo 'Hello ' . $_GET['q']; // expect: PHP-TAINT-XSS
10
+ system("ping " . $_POST['host']); // expect: PHP-TAINT-CMDI
11
+ $data = unserialize($_COOKIE['prefs']); // expect: PHP-TAINT-DESER
12
+ $stmt = $pdo->prepare('SELECT * FROM u WHERE id = ?'); // safe
@@ -0,0 +1,35 @@
1
+ // Labelled benchmark case.
2
+ const express = require('express');
3
+ const { exec } = require('child_process');
4
+ const app = express();
5
+
6
+ app.get('/a', async (req, res) => {
7
+ const { id, name: userName } = req.query;
8
+ const sql = `SELECT * FROM users WHERE id = ${id}`;
9
+ await db.query(sql); // expect: JS-TAINT-SQLI
10
+ res.sendFile(path.join('/srv', userName)); // expect: JS-TAINT-PATH
11
+ res.send(`<h1>Hello ${userName}</h1>`); // expect: JS-TAINT-XSS
12
+ });
13
+
14
+ app.post('/b', (req, res) => {
15
+ const n = parseInt(req.body.n);
16
+ db.query(`SELECT * FROM t WHERE id = ${n}`); // safe
17
+ exec('ping -c1 ' + req.body.host); // expect: JS-TAINT-CMDI
18
+ const m = /^Bearer (.+)$/.exec(req.headers.authorization); // safe
19
+ fetch('/api/local'); // safe
20
+ axios.get(req.body.url); // expect: JS-TAINT-SSRF
21
+ res.redirect(new URL('/', req.url)); // safe
22
+ res.redirect(req.query.next); // expect: JS-TAINT-REDIRECT
23
+ });
24
+
25
+ function other() {
26
+ const id = 5;
27
+ db.query(`SELECT ${id}`); // safe
28
+ el.innerHTML = userHtml; // expect: JS-XSS-INNERHTML
29
+ const token = jwt.sign({ u: 1 }, 'hunter2-secret'); // expect: JS-HARDCODED-JWT-SECRET
30
+ }
31
+
32
+ function tooltip(point) {
33
+ el.innerHTML = `
34
+ <b>${point.label}</b>`; // expect-above: JS-XSS-INNERHTML
35
+ }
@@ -0,0 +1,9 @@
1
+ # Labelled benchmark case (fake credentials).
2
+ AWS_KEY = "AKIAQ3EGRQFAKE7DEMO1" # expect: SECRET-AWS-ACCESS-KEY
3
+ GITHUB = "ghp_aB3dE6gH9jK2mN5pQ8sT1vW4yZ7bC0eF3hJ6" # expect: SECRET-GITHUB-TOKEN
4
+ DB = "postgres://admin:Sup3rS3cretPw@db.internal:5432/prod" # expect: SECRET-DB-URL
5
+ DOCS_DB = "postgres://user:hunter2pw@host:5432/app" # safe
6
+ db_password = "Winter2026!" # expect: SECRET-GENERIC
7
+ api_key = "your-api-key-here" # safe
8
+ EXAMPLE = "AKIAIOSFODNN7EXAMPLE" # safe
9
+ password = os.environ["DB_PASSWORD"] # safe