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.
- vulnfind/__init__.py +3 -0
- vulnfind/__main__.py +6 -0
- vulnfind/ai.py +124 -0
- vulnfind/automation.py +250 -0
- vulnfind/bench/__init__.py +86 -0
- vulnfind/bench/cases/Dockerfile +9 -0
- vulnfind/bench/cases/ci.yml +14 -0
- vulnfind/bench/cases/flask_app.py +42 -0
- vulnfind/bench/cases/handler.go +16 -0
- vulnfind/bench/cases/index.php +12 -0
- vulnfind/bench/cases/server.js +35 -0
- vulnfind/bench/cases/settings.py +9 -0
- vulnfind/cli.py +1338 -0
- vulnfind/cloud.py +471 -0
- vulnfind/customrules.py +87 -0
- vulnfind/cvss.py +96 -0
- vulnfind/depgraph.py +344 -0
- vulnfind/depintel.py +319 -0
- vulnfind/engine.py +292 -0
- vulnfind/enrich.py +152 -0
- vulnfind/fixer.py +166 -0
- vulnfind/gitutil.py +121 -0
- vulnfind/history.py +184 -0
- vulnfind/image.py +524 -0
- vulnfind/manifests.py +428 -0
- vulnfind/models.py +172 -0
- vulnfind/osv.py +277 -0
- vulnfind/plain.py +83 -0
- vulnfind/progress.py +157 -0
- vulnfind/reach.py +181 -0
- vulnfind/remote.py +78 -0
- vulnfind/reporters/__init__.py +26 -0
- vulnfind/reporters/formats.py +208 -0
- vulnfind/reporters/html.py +133 -0
- vulnfind/reporters/terminal.py +326 -0
- vulnfind/scanners/__init__.py +125 -0
- vulnfind/scanners/code.py +413 -0
- vulnfind/scanners/deps.py +144 -0
- vulnfind/scanners/files.py +126 -0
- vulnfind/scanners/history.py +120 -0
- vulnfind/scanners/iac.py +316 -0
- vulnfind/scanners/routes.py +277 -0
- vulnfind/scanners/secrets.py +247 -0
- vulnfind/scanners/taint_js.py +280 -0
- vulnfind/scanners/taint_lines.py +268 -0
- vulnfind/scanners/taint_python.py +768 -0
- vulnfind/selfupdate.py +59 -0
- vulnfind/triage.py +112 -0
- vulnfind/walker.py +299 -0
- vulnfind/web/__init__.py +61 -0
- vulnfind/web/active.py +131 -0
- vulnfind/web/api.py +130 -0
- vulnfind/web/browser.py +63 -0
- vulnfind/web/checks.py +314 -0
- vulnfind/web/client.py +180 -0
- vulnfind/web/crawl.py +150 -0
- vulnfind/web/dns.py +84 -0
- vulnfind/web/libs.py +208 -0
- vulnfind/web/login.py +109 -0
- vulnfind/web/paths.py +233 -0
- vulnfind/web/scanner.py +229 -0
- vulnfind/web/software.py +246 -0
- vulnfind/web/subdomains.py +149 -0
- vulnfind/web/tls.py +118 -0
- vulnfind-3.0.0.dist-info/METADATA +182 -0
- vulnfind-3.0.0.dist-info/RECORD +69 -0
- vulnfind-3.0.0.dist-info/WHEEL +5 -0
- vulnfind-3.0.0.dist-info/entry_points.txt +2 -0
- vulnfind-3.0.0.dist-info/top_level.txt +1 -0
vulnfind/__init__.py
ADDED
vulnfind/__main__.py
ADDED
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
|