osv-scan 0.1.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.
@@ -0,0 +1,15 @@
1
+ """dep-scanner — zero-dependency CVE scanner for dependency manifests."""
2
+ from __future__ import annotations
3
+
4
+ from ._models import PackageFinding, ScanResult, Vulnerability
5
+ from ._scanner import scan, scan_text
6
+
7
+ __version__ = "0.1.0"
8
+ __all__ = [
9
+ "scan",
10
+ "scan_text",
11
+ "ScanResult",
12
+ "PackageFinding",
13
+ "Vulnerability",
14
+ "__version__",
15
+ ]
dep_scanner/_cli.py ADDED
@@ -0,0 +1,177 @@
1
+ """CLI entry point for dep-scanner: dep-scan"""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import os
7
+ import sys
8
+
9
+ from ._parsers import detect_manifest
10
+ from ._scanner import scan
11
+
12
+ # ANSI colours (disabled when not a TTY)
13
+ _TTY = sys.stdout.isatty()
14
+ _C = {
15
+ "CRITICAL": "\033[91m\033[1m",
16
+ "HIGH": "\033[93m\033[1m",
17
+ "MEDIUM": "\033[94m\033[1m",
18
+ "LOW": "\033[37m",
19
+ "GREEN": "\033[92m",
20
+ "RESET": "\033[0m",
21
+ "BOLD": "\033[1m",
22
+ "DIM": "\033[2m",
23
+ }
24
+
25
+
26
+ def _c(text: str, key: str) -> str:
27
+ if not _TTY:
28
+ return text
29
+ return f"{_C.get(key, '')}{text}{_C['RESET']}"
30
+
31
+
32
+ def _print_result(result, use_json: bool, quiet: bool) -> None:
33
+ if use_json:
34
+ out = {
35
+ "source": result.source,
36
+ "ecosystem": result.ecosystem,
37
+ "packages_scanned": result.packages_scanned,
38
+ "highest_severity": result.highest_severity,
39
+ "elapsed_ms": result.elapsed_ms,
40
+ "findings": [
41
+ {
42
+ "package": f.package,
43
+ "version": f.version,
44
+ "severity": f.highest_severity,
45
+ "fix": f.fix_versions[0] if f.fix_versions else None,
46
+ "vulnerabilities": [
47
+ {
48
+ "id": v.cve or v.id,
49
+ "summary": v.summary,
50
+ "severity": v.severity,
51
+ "fixed_versions": v.fixed_versions,
52
+ }
53
+ for v in f.vulnerabilities
54
+ ],
55
+ }
56
+ for f in result.findings
57
+ ],
58
+ }
59
+ print(json.dumps(out, indent=2))
60
+ return
61
+
62
+ if not quiet:
63
+ print(
64
+ f"\n{_c('dep-scan', 'BOLD')} {result.source} "
65
+ f"{_c(f'({result.ecosystem})', 'DIM')} "
66
+ f"· {result.packages_scanned} packages\n"
67
+ )
68
+
69
+ if not result.has_findings:
70
+ if not quiet:
71
+ print(f" {_c('✓ No vulnerabilities found', 'GREEN')}\n")
72
+ return
73
+
74
+ for finding in result.findings:
75
+ sev = finding.highest_severity or "MEDIUM"
76
+ print(f" {_c(sev.ljust(9), sev)} {_c(finding.package, 'BOLD')} {finding.version}")
77
+
78
+ for vuln in finding.vulnerabilities:
79
+ vid = vuln.cve or vuln.id
80
+ print(f" {_c(vid, 'DIM')}")
81
+ if vuln.summary:
82
+ summary = vuln.summary[:90] + ("…" if len(vuln.summary) > 90 else "")
83
+ print(f" {summary}")
84
+
85
+ if finding.fix_versions:
86
+ print(f" {_c('Fix: upgrade to ' + finding.fix_versions[0], 'BOLD')}")
87
+ print()
88
+
89
+ # Summary line
90
+ parts = []
91
+ for sev in ("CRITICAL", "HIGH", "MEDIUM", "LOW"):
92
+ count = len([f for f in result.findings if f.highest_severity == sev])
93
+ if count:
94
+ parts.append(_c(f"{count} {sev}", sev))
95
+
96
+ total = sum(len(f.vulnerabilities) for f in result.findings)
97
+ print(
98
+ f" {' · '.join(parts)}"
99
+ f" {_c(f'({total} vuln{"s" if total != 1 else ""} · {result.elapsed_ms}ms)', 'DIM')}\n"
100
+ )
101
+
102
+
103
+ _FAIL_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3, "none": 99}
104
+ _SEV_RANK = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3}
105
+
106
+ _AUTO_DETECT = [
107
+ "requirements.txt", "package-lock.json", "package.json",
108
+ "go.mod", "Cargo.toml", "pom.xml",
109
+ ]
110
+
111
+
112
+ def main() -> None:
113
+ parser = argparse.ArgumentParser(
114
+ prog="dep-scan",
115
+ description=(
116
+ "Scan dependency manifests for known CVEs using OSV.dev.\n"
117
+ "Supports: requirements.txt, package.json, package-lock.json, "
118
+ "go.mod, Cargo.toml, pom.xml"
119
+ ),
120
+ formatter_class=argparse.RawDescriptionHelpFormatter,
121
+ )
122
+ parser.add_argument(
123
+ "paths",
124
+ nargs="*",
125
+ metavar="FILE",
126
+ help="Manifest files to scan (auto-detected if omitted)",
127
+ )
128
+ parser.add_argument(
129
+ "--json", action="store_true",
130
+ help="Output as JSON",
131
+ )
132
+ parser.add_argument(
133
+ "--fail-on",
134
+ default="critical",
135
+ choices=list(_FAIL_ORDER),
136
+ metavar="SEVERITY",
137
+ help="Exit 1 if findings at this level or above (default: critical)",
138
+ )
139
+ parser.add_argument(
140
+ "--quiet", "-q",
141
+ action="store_true",
142
+ help="Suppress informational output, print only findings",
143
+ )
144
+ args = parser.parse_args()
145
+
146
+ paths = args.paths or [p for p in _AUTO_DETECT if os.path.exists(p)]
147
+ if not paths:
148
+ print("No manifest files found. Pass a file or run from a project directory.", file=sys.stderr)
149
+ sys.exit(0)
150
+
151
+ threshold = _FAIL_ORDER[args.fail_on]
152
+ exit_code = 0
153
+
154
+ for path in paths:
155
+ if not os.path.exists(path):
156
+ print(f"File not found: {path}", file=sys.stderr)
157
+ continue
158
+ if detect_manifest(path) is None:
159
+ print(f"Unrecognised manifest: {path}", file=sys.stderr)
160
+ continue
161
+ try:
162
+ result = scan(path)
163
+ except Exception as exc:
164
+ print(f"Error scanning {path}: {exc}", file=sys.stderr)
165
+ continue
166
+
167
+ _print_result(result, use_json=args.json, quiet=args.quiet)
168
+
169
+ if result.highest_severity:
170
+ if _SEV_RANK.get(result.highest_severity, 99) <= threshold:
171
+ exit_code = 1
172
+
173
+ sys.exit(exit_code)
174
+
175
+
176
+ if __name__ == "__main__":
177
+ main()
dep_scanner/_models.py ADDED
@@ -0,0 +1,112 @@
1
+ """Data models for dep-scanner findings."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass, field
5
+ from typing import Any, Dict, List, Optional
6
+
7
+
8
+ @dataclass
9
+ class Vulnerability:
10
+ id: str
11
+ cve: str
12
+ aliases: List[str]
13
+ summary: str
14
+ details: str
15
+ severity: str # CRITICAL / HIGH / MEDIUM / LOW
16
+ fixed_versions: List[str]
17
+ published: str
18
+
19
+ @classmethod
20
+ def _from_dict(cls, d: Dict[str, Any]) -> "Vulnerability":
21
+ return cls(
22
+ id=d.get("id", ""),
23
+ cve=d.get("cve", ""),
24
+ aliases=d.get("aliases", []),
25
+ summary=d.get("summary", ""),
26
+ details=d.get("details", ""),
27
+ severity=d.get("severity", "MEDIUM"),
28
+ fixed_versions=d.get("fixed_versions", []),
29
+ published=d.get("published", ""),
30
+ )
31
+
32
+
33
+ @dataclass
34
+ class PackageFinding:
35
+ package: str
36
+ version: str
37
+ ecosystem: str
38
+ vulnerabilities: List[Vulnerability] = field(default_factory=list)
39
+
40
+ @property
41
+ def highest_severity(self) -> Optional[str]:
42
+ for sev in ("CRITICAL", "HIGH", "MEDIUM", "LOW"):
43
+ if any(v.severity == sev for v in self.vulnerabilities):
44
+ return sev
45
+ return None
46
+
47
+ @property
48
+ def fix_versions(self) -> List[str]:
49
+ """Unique fix versions across all vulnerabilities, sorted."""
50
+ seen: set = set()
51
+ result = []
52
+ for v in self.vulnerabilities:
53
+ for fv in v.fixed_versions:
54
+ if fv not in seen:
55
+ seen.add(fv)
56
+ result.append(fv)
57
+ return sorted(result)
58
+
59
+
60
+ @dataclass
61
+ class ScanResult:
62
+ source: str
63
+ ecosystem: str
64
+ packages_scanned: int
65
+ findings: List[PackageFinding]
66
+ elapsed_ms: int
67
+
68
+ @property
69
+ def has_findings(self) -> bool:
70
+ return bool(self.findings)
71
+
72
+ @property
73
+ def critical(self) -> List[PackageFinding]:
74
+ return [f for f in self.findings if f.highest_severity == "CRITICAL"]
75
+
76
+ @property
77
+ def high(self) -> List[PackageFinding]:
78
+ return [f for f in self.findings if f.highest_severity == "HIGH"]
79
+
80
+ @property
81
+ def medium(self) -> List[PackageFinding]:
82
+ return [f for f in self.findings if f.highest_severity == "MEDIUM"]
83
+
84
+ @property
85
+ def low(self) -> List[PackageFinding]:
86
+ return [f for f in self.findings if f.highest_severity == "LOW"]
87
+
88
+ @property
89
+ def highest_severity(self) -> Optional[str]:
90
+ for sev in ("CRITICAL", "HIGH", "MEDIUM", "LOW"):
91
+ if any(f.highest_severity == sev for f in self.findings):
92
+ return sev
93
+ return None
94
+
95
+ def to_dict(self) -> Dict[str, Any]:
96
+ result: Dict[str, Any] = {}
97
+ for sev in ("CRITICAL", "HIGH", "MEDIUM", "LOW"):
98
+ items = [f for f in self.findings if f.highest_severity == sev]
99
+ if items:
100
+ result[sev] = [
101
+ {
102
+ "package": f.package,
103
+ "version": f.version,
104
+ "fix": f.fix_versions[0] if f.fix_versions else None,
105
+ "vulnerabilities": [
106
+ {"id": v.cve or v.id, "summary": v.summary, "severity": v.severity}
107
+ for v in f.vulnerabilities
108
+ ],
109
+ }
110
+ for f in items
111
+ ]
112
+ return result
dep_scanner/_osv.py ADDED
@@ -0,0 +1,151 @@
1
+ """OSV.dev API client for dep-scanner.
2
+
3
+ Uses the batch query endpoint to check multiple packages in one request.
4
+ No API key required — OSV.dev is free and open.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from typing import Any, Dict, List
10
+ from urllib.error import HTTPError, URLError
11
+ from urllib.request import Request, urlopen
12
+
13
+ OSV_BATCH_URL = "https://api.osv.dev/v1/querybatch"
14
+ BATCH_SIZE = 100 # OSV hard limit is 1000; we use 100 for safety
15
+ TIMEOUT = 30 # seconds
16
+
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # Severity helpers
20
+ # ---------------------------------------------------------------------------
21
+
22
+ _SEV_MAP = {
23
+ "critical": "CRITICAL",
24
+ "high": "HIGH",
25
+ "moderate": "MEDIUM",
26
+ "medium": "MEDIUM",
27
+ "low": "LOW",
28
+ }
29
+
30
+
31
+ def _parse_severity(vuln: Dict[str, Any]) -> str:
32
+ # 1. database_specific.severity (GitHub Advisory style — most reliable)
33
+ db = vuln.get("database_specific") or {}
34
+ raw = str(db.get("severity", "")).lower()
35
+ if raw in _SEV_MAP:
36
+ return _SEV_MAP[raw]
37
+
38
+ # 2. database_specific.cvss_score (numeric)
39
+ score = db.get("cvss_score")
40
+ if score is not None:
41
+ return _score_to_sev(float(score))
42
+
43
+ # 3. severity array (CVSS vector or numeric string)
44
+ for entry in vuln.get("severity") or []:
45
+ raw_score = entry.get("score", "")
46
+ try:
47
+ return _score_to_sev(float(raw_score))
48
+ except (ValueError, TypeError):
49
+ pass
50
+
51
+ return "MEDIUM" # conservative default
52
+
53
+
54
+ def _score_to_sev(score: float) -> str:
55
+ if score >= 9.0:
56
+ return "CRITICAL"
57
+ if score >= 7.0:
58
+ return "HIGH"
59
+ if score >= 4.0:
60
+ return "MEDIUM"
61
+ return "LOW"
62
+
63
+
64
+ def _extract_fixed_versions(vuln: Dict[str, Any]) -> List[str]:
65
+ fixed: set = set()
66
+ for affected in vuln.get("affected") or []:
67
+ for rng in affected.get("ranges") or []:
68
+ if rng.get("type") == "ECOSYSTEM":
69
+ for event in rng.get("events") or []:
70
+ if "fixed" in event:
71
+ fixed.add(event["fixed"])
72
+ return sorted(fixed)
73
+
74
+
75
+ def _get_cve(vuln: Dict[str, Any]) -> str:
76
+ for alias in vuln.get("aliases") or []:
77
+ if alias.startswith("CVE-"):
78
+ return alias
79
+ return ""
80
+
81
+
82
+ def _parse_vuln(raw: Dict[str, Any]) -> Dict[str, Any]:
83
+ return {
84
+ "id": raw.get("id", ""),
85
+ "cve": _get_cve(raw),
86
+ "aliases": raw.get("aliases") or [],
87
+ "summary": raw.get("summary", ""),
88
+ "details": (raw.get("details") or "")[:500],
89
+ "severity": _parse_severity(raw),
90
+ "fixed_versions": _extract_fixed_versions(raw),
91
+ "published": (raw.get("published") or "")[:10],
92
+ }
93
+
94
+
95
+ # ---------------------------------------------------------------------------
96
+ # Batch query
97
+ # ---------------------------------------------------------------------------
98
+
99
+ def query_batch(packages: List[Dict[str, str]]) -> List[List[Dict[str, Any]]]:
100
+ """
101
+ Query OSV.dev for vulnerabilities in a list of packages.
102
+
103
+ Parameters
104
+ ----------
105
+ packages:
106
+ [{"name": "requests", "version": "2.27.0", "ecosystem": "PyPI"}, ...]
107
+
108
+ Returns
109
+ -------
110
+ Parallel list of vulnerability lists — one entry per input package.
111
+ Empty list means no known vulnerabilities for that package.
112
+ """
113
+ if not packages:
114
+ return []
115
+
116
+ all_results: List[List[Dict[str, Any]]] = []
117
+
118
+ for i in range(0, len(packages), BATCH_SIZE):
119
+ batch = packages[i : i + BATCH_SIZE]
120
+ payload = {
121
+ "queries": [
122
+ {
123
+ "version": p["version"],
124
+ "package": {"name": p["name"], "ecosystem": p["ecosystem"]},
125
+ }
126
+ for p in batch
127
+ ]
128
+ }
129
+ req = Request(
130
+ OSV_BATCH_URL,
131
+ data=json.dumps(payload).encode(),
132
+ method="POST",
133
+ headers={
134
+ "Content-Type": "application/json",
135
+ "Accept": "application/json",
136
+ "User-Agent": "dep-scanner/0.1.0 (https://github.com/SpiderCob/dep-scanner)",
137
+ },
138
+ )
139
+ try:
140
+ with urlopen(req, timeout=TIMEOUT) as resp:
141
+ data = json.loads(resp.read().decode())
142
+ except HTTPError as exc:
143
+ raise RuntimeError(f"OSV.dev API returned HTTP {exc.code}") from exc
144
+ except URLError as exc:
145
+ raise RuntimeError(f"OSV.dev API unreachable: {exc.reason}") from exc
146
+
147
+ for result in data.get("results") or []:
148
+ raw_vulns = result.get("vulns") or []
149
+ all_results.append([_parse_vuln(v) for v in raw_vulns])
150
+
151
+ return all_results
@@ -0,0 +1,237 @@
1
+ """Manifest file parsers for dep-scanner.
2
+
3
+ Each parser returns List[Tuple[name, version, ecosystem]].
4
+ Only pinned / resolved versions are returned — unpinned ranges
5
+ cannot be looked up in OSV and are silently skipped.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import re
12
+ import xml.etree.ElementTree as ET
13
+ from typing import Dict, List, Optional, Tuple
14
+
15
+ # Ecosystem labels used by OSV.dev
16
+ ECOSYSTEM_MAP: Dict[str, str] = {
17
+ "requirements": "PyPI",
18
+ "package_json": "npm",
19
+ "package_lock": "npm",
20
+ "go_mod": "Go",
21
+ "cargo_toml": "crates.io",
22
+ "pom_xml": "Maven",
23
+ }
24
+
25
+ Package = Tuple[str, str, str] # (name, version, ecosystem)
26
+
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Manifest type detection
30
+ # ---------------------------------------------------------------------------
31
+
32
+ def detect_manifest(path: str) -> Optional[str]:
33
+ name = os.path.basename(path).lower()
34
+ if name == "package-lock.json":
35
+ return "package_lock"
36
+ if name == "package.json":
37
+ return "package_json"
38
+ if name == "go.mod":
39
+ return "go_mod"
40
+ if name in ("cargo.toml",):
41
+ return "cargo_toml"
42
+ if name == "pom.xml":
43
+ return "pom_xml"
44
+ # requirements.txt, requirements-dev.txt, requirements/*.txt …
45
+ if "requirements" in name and name.endswith(".txt"):
46
+ return "requirements"
47
+ return None
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Individual parsers
52
+ # ---------------------------------------------------------------------------
53
+
54
+ def parse_requirements(content: str) -> List[Package]:
55
+ """Parse requirements.txt — only pinned (==) versions."""
56
+ packages: List[Package] = []
57
+ for line in content.splitlines():
58
+ line = line.strip()
59
+ if not line or line.startswith(("#", "-")):
60
+ continue
61
+ line = line.split("#")[0].split(";")[0].strip()
62
+ m = re.match(r"^([A-Za-z0-9_.-]+)\s*==\s*([^\s,]+)", line)
63
+ if m:
64
+ name = m.group(1).lower().replace("_", "-")
65
+ packages.append((name, m.group(2).strip(), "PyPI"))
66
+ return packages
67
+
68
+
69
+ def parse_package_json(content: str) -> List[Package]:
70
+ """Parse package.json dependencies + devDependencies."""
71
+ try:
72
+ data = json.loads(content)
73
+ except Exception:
74
+ return []
75
+
76
+ packages: List[Package] = []
77
+ for section in ("dependencies", "devDependencies", "peerDependencies"):
78
+ for name, spec in (data.get(section) or {}).items():
79
+ if not isinstance(spec, str):
80
+ continue
81
+ if spec in ("*", "latest", "next", "beta") or re.match(r"^(file:|git[+@]|http)", spec):
82
+ continue
83
+ # Strip leading range operators and take first part
84
+ clean = re.sub(r"^[\^~>=<v]+", "", spec.split("||")[0].strip())
85
+ clean = re.split(r"\s+", clean)[0]
86
+ if re.match(r"^\d+\.\d+", clean):
87
+ packages.append((name, clean, "npm"))
88
+ return packages
89
+
90
+
91
+ def parse_package_lock(content: str) -> List[Package]:
92
+ """Parse package-lock.json (v1, v2, v3)."""
93
+ try:
94
+ data = json.loads(content)
95
+ except Exception:
96
+ return []
97
+
98
+ packages: List[Package] = []
99
+
100
+ if "packages" in data:
101
+ # v2 / v3
102
+ for path, info in data["packages"].items():
103
+ if not path or not isinstance(info, dict) or info.get("link"):
104
+ continue
105
+ version = info.get("version", "")
106
+ if version and re.match(r"^\d+\.\d+", version):
107
+ name = re.sub(r"^.*node_modules/", "", path)
108
+ packages.append((name, version, "npm"))
109
+ elif "dependencies" in data:
110
+ # v1
111
+ def _walk(deps: dict) -> None:
112
+ for name, info in deps.items():
113
+ if not isinstance(info, dict):
114
+ continue
115
+ ver = info.get("version", "")
116
+ if ver and re.match(r"^\d+\.\d+", ver):
117
+ packages.append((name, ver, "npm"))
118
+ if "dependencies" in info:
119
+ _walk(info["dependencies"])
120
+ _walk(data["dependencies"])
121
+
122
+ return packages
123
+
124
+
125
+ def parse_go_mod(content: str) -> List[Package]:
126
+ """Parse go.mod require blocks."""
127
+ packages: List[Package] = []
128
+ in_block = False
129
+
130
+ for line in content.splitlines():
131
+ line = line.strip()
132
+ if re.match(r"^require\s*\(", line):
133
+ in_block = True
134
+ continue
135
+ if in_block and line == ")":
136
+ in_block = False
137
+ continue
138
+ # single-line: require github.com/foo/bar v1.2.3
139
+ m = re.match(r"^require\s+(\S+)\s+(v[\d.][^\s]*)", line)
140
+ if m:
141
+ packages.append((m.group(1), m.group(2), "Go"))
142
+ continue
143
+ if in_block and not line.startswith("//"):
144
+ m = re.match(r"^(\S+)\s+(v[\d.][^\s]*)", line)
145
+ if m:
146
+ packages.append((m.group(1), m.group(2), "Go"))
147
+
148
+ return packages
149
+
150
+
151
+ def parse_cargo_toml(content: str) -> List[Package]:
152
+ """Parse Cargo.toml [dependencies] / [dev-dependencies] sections."""
153
+ packages: List[Package] = []
154
+ in_deps = False
155
+
156
+ for line in content.splitlines():
157
+ stripped = line.strip()
158
+ if stripped in ("[dependencies]", "[dev-dependencies]", "[build-dependencies]"):
159
+ in_deps = True
160
+ continue
161
+ if in_deps and stripped.startswith("["):
162
+ in_deps = False
163
+ continue
164
+ if not in_deps or not stripped or stripped.startswith("#"):
165
+ continue
166
+
167
+ # name = "1.0.0" or name = "^1.0.0"
168
+ m = re.match(r'^([a-zA-Z0-9_-]+)\s*=\s*"([^"]+)"', stripped)
169
+ if m:
170
+ ver = re.sub(r"^[\^~>=<]+", "", m.group(2))
171
+ if re.match(r"^\d+", ver):
172
+ packages.append((m.group(1), ver, "crates.io"))
173
+ continue
174
+
175
+ # name = { version = "1.0.0", ... }
176
+ m = re.match(r'^([a-zA-Z0-9_-]+)\s*=\s*\{[^}]*version\s*=\s*"([^"]+)"', stripped)
177
+ if m:
178
+ ver = re.sub(r"^[\^~>=<]+", "", m.group(2))
179
+ if re.match(r"^\d+", ver):
180
+ packages.append((m.group(1), ver, "crates.io"))
181
+
182
+ return packages
183
+
184
+
185
+ def parse_pom_xml(content: str) -> List[Package]:
186
+ """Parse Maven pom.xml <dependency> blocks."""
187
+ packages: List[Package] = []
188
+ try:
189
+ root = ET.fromstring(content)
190
+ except Exception:
191
+ return []
192
+
193
+ ns = ""
194
+ if root.tag.startswith("{"):
195
+ ns = root.tag.split("}")[0] + "}"
196
+
197
+ for dep in root.iter(f"{ns}dependency"):
198
+ group = (dep.findtext(f"{ns}groupId") or "").strip()
199
+ artifact = (dep.findtext(f"{ns}artifactId") or "").strip()
200
+ version = (dep.findtext(f"{ns}version") or "").strip()
201
+ if not (group and artifact and version) or version.startswith("$"):
202
+ continue
203
+ packages.append((f"{group}:{artifact}", version, "Maven"))
204
+
205
+ return packages
206
+
207
+
208
+ # ---------------------------------------------------------------------------
209
+ # Public entry point
210
+ # ---------------------------------------------------------------------------
211
+
212
+ PARSERS = {
213
+ "requirements": parse_requirements,
214
+ "package_json": parse_package_json,
215
+ "package_lock": parse_package_lock,
216
+ "go_mod": parse_go_mod,
217
+ "cargo_toml": parse_cargo_toml,
218
+ "pom_xml": parse_pom_xml,
219
+ }
220
+
221
+
222
+ def parse_manifest(path: str) -> Tuple[List[Package], str]:
223
+ """
224
+ Parse a dependency manifest and return (packages, ecosystem).
225
+
226
+ Raises ValueError for unrecognised files.
227
+ """
228
+ manifest_type = detect_manifest(path)
229
+ if manifest_type is None:
230
+ raise ValueError(
231
+ f"Unrecognised manifest: {path}\n"
232
+ "Supported: requirements.txt, package.json, package-lock.json, "
233
+ "go.mod, Cargo.toml, pom.xml"
234
+ )
235
+ with open(path, encoding="utf-8", errors="replace") as fh:
236
+ content = fh.read()
237
+ return PARSERS[manifest_type](content), ECOSYSTEM_MAP[manifest_type]
@@ -0,0 +1,128 @@
1
+ """Core scanner logic for dep-scanner."""
2
+ from __future__ import annotations
3
+
4
+ import time
5
+ from typing import Dict, List
6
+
7
+ from ._models import PackageFinding, ScanResult, Vulnerability
8
+ from ._osv import query_batch
9
+ from ._parsers import ECOSYSTEM_MAP, PARSERS, detect_manifest, parse_manifest
10
+
11
+ _SEV_ORDER = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3, None: 4}
12
+
13
+
14
+ def _build_result(
15
+ raw_packages: list,
16
+ ecosystem: str,
17
+ source: str,
18
+ start: float,
19
+ ) -> ScanResult:
20
+ # Deduplicate by (name, version, ecosystem)
21
+ seen: set = set()
22
+ unique: List[Dict[str, str]] = []
23
+ for name, version, eco in raw_packages:
24
+ key = (name, version, eco)
25
+ if key not in seen:
26
+ seen.add(key)
27
+ unique.append({"name": name, "version": version, "ecosystem": eco})
28
+
29
+ if not unique:
30
+ return ScanResult(
31
+ source=source,
32
+ ecosystem=ecosystem,
33
+ packages_scanned=0,
34
+ findings=[],
35
+ elapsed_ms=int((time.time() - start) * 1000),
36
+ )
37
+
38
+ osv_results = query_batch(unique)
39
+
40
+ findings: List[PackageFinding] = []
41
+ for pkg, vulns in zip(unique, osv_results):
42
+ if not vulns:
43
+ continue
44
+ findings.append(
45
+ PackageFinding(
46
+ package=pkg["name"],
47
+ version=pkg["version"],
48
+ ecosystem=pkg["ecosystem"],
49
+ vulnerabilities=[Vulnerability._from_dict(v) for v in vulns],
50
+ )
51
+ )
52
+
53
+ findings.sort(key=lambda f: _SEV_ORDER.get(f.highest_severity, 4))
54
+
55
+ return ScanResult(
56
+ source=source,
57
+ ecosystem=ecosystem,
58
+ packages_scanned=len(unique),
59
+ findings=findings,
60
+ elapsed_ms=int((time.time() - start) * 1000),
61
+ )
62
+
63
+
64
+ def scan(path: str) -> ScanResult:
65
+ """
66
+ Scan a dependency manifest file for known CVEs via OSV.dev.
67
+
68
+ Supported files: requirements.txt, package.json, package-lock.json,
69
+ go.mod, Cargo.toml, pom.xml
70
+
71
+ Parameters
72
+ ----------
73
+ path:
74
+ Path to the manifest file.
75
+
76
+ Returns
77
+ -------
78
+ ScanResult
79
+ Findings grouped by severity with fix versions.
80
+
81
+ Examples
82
+ --------
83
+ >>> import dep_scanner
84
+ >>> result = dep_scanner.scan("requirements.txt")
85
+ >>> result.highest_severity
86
+ 'CRITICAL'
87
+ >>> result.critical[0].package
88
+ 'requests'
89
+ >>> result.critical[0].fix_versions
90
+ ['2.31.0']
91
+ """
92
+ start = time.time()
93
+ packages, ecosystem = parse_manifest(path)
94
+ return _build_result(packages, ecosystem, path, start)
95
+
96
+
97
+ def scan_text(content: str, ecosystem: str, source: str = "<text>") -> ScanResult:
98
+ """
99
+ Scan dependency file content directly (no file needed).
100
+
101
+ Parameters
102
+ ----------
103
+ content:
104
+ Raw manifest content as a string.
105
+ ecosystem:
106
+ One of: "PyPI", "npm", "Go", "crates.io", "Maven"
107
+ source:
108
+ Label shown in results (default: "<text>").
109
+
110
+ Examples
111
+ --------
112
+ >>> result = dep_scanner.scan_text(
113
+ ... "requests==2.27.0\\ndjango==3.2.1",
114
+ ... ecosystem="PyPI",
115
+ ... )
116
+ """
117
+ start = time.time()
118
+
119
+ eco_to_type = {v: k for k, v in ECOSYSTEM_MAP.items()}
120
+ manifest_type = eco_to_type.get(ecosystem)
121
+ if not manifest_type:
122
+ raise ValueError(
123
+ f"Unknown ecosystem: {ecosystem!r}. "
124
+ f"Valid options: {sorted(ECOSYSTEM_MAP.values())}"
125
+ )
126
+
127
+ packages = PARSERS[manifest_type](content)
128
+ return _build_result(packages, ecosystem, source, start)
@@ -0,0 +1,186 @@
1
+ Metadata-Version: 2.4
2
+ Name: osv-scan
3
+ Version: 0.1.0
4
+ Summary: Zero-dependency CVE scanner for dependency manifests — wraps the free OSV.dev API
5
+ Project-URL: Homepage, https://github.com/SpiderCob/dep-scanner
6
+ Project-URL: Repository, https://github.com/SpiderCob/dep-scanner
7
+ Project-URL: Issues, https://github.com/SpiderCob/dep-scanner/issues
8
+ Author-email: SpiderCob <dev@spidercob.com>
9
+ License: Apache-2.0
10
+ Keywords: cve,dependencies,osv,security,vulnerability
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: Apache Software License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Security
20
+ Classifier: Topic :: Software Development :: Libraries
21
+ Requires-Python: >=3.9
22
+ Description-Content-Type: text/markdown
23
+
24
+ # dep-scanner
25
+
26
+ Zero-dependency Python library that scans your dependency manifests for known CVEs using the free [OSV.dev](https://osv.dev) API.
27
+
28
+ No API key. No signup. No rate limits for normal use.
29
+
30
+ ```
31
+ pip install osv-scan
32
+ ```
33
+
34
+ ---
35
+
36
+ ## Why
37
+
38
+ - `safety` went paid in 2023
39
+ - `osv-scanner` is a Go binary — not embeddable in Python
40
+ - `pip-audit` is great but Python-only and pulls in several dependencies
41
+
42
+ `dep-scanner` fills the gap: a **pure stdlib** Python library that scans six ecosystems and works anywhere Python runs.
43
+
44
+ ---
45
+
46
+ ## Supported manifest files
47
+
48
+ | File | Ecosystem |
49
+ |---|---|
50
+ | `requirements.txt` | PyPI |
51
+ | `package.json` | npm |
52
+ | `package-lock.json` | npm |
53
+ | `go.mod` | Go |
54
+ | `Cargo.toml` | crates.io |
55
+ | `pom.xml` | Maven |
56
+
57
+ ---
58
+
59
+ ## CLI
60
+
61
+ ```bash
62
+ # auto-detect manifest in current directory
63
+ dep-scan
64
+
65
+ # scan a specific file
66
+ dep-scan requirements.txt
67
+
68
+ # multiple files
69
+ dep-scan requirements.txt package-lock.json
70
+
71
+ # JSON output (great for CI)
72
+ dep-scan --json requirements.txt
73
+
74
+ # fail CI only on HIGH or above (default: critical)
75
+ dep-scan --fail-on high requirements.txt
76
+
77
+ # quiet mode — only print findings
78
+ dep-scan -q requirements.txt
79
+ ```
80
+
81
+ ### Example output
82
+
83
+ ```
84
+ dep-scan requirements.txt (PyPI) · 42 packages
85
+
86
+ CRITICAL django 3.2.1
87
+ CVE-2023-36053
88
+ Potential ReDoS via cached 'urlsplit' results
89
+ Fix: upgrade to 3.2.20
90
+
91
+ HIGH requests 2.27.0
92
+ CVE-2023-32681
93
+ Unintended leak of proxy-authorization header
94
+ Fix: upgrade to 2.31.0
95
+
96
+ 1 CRITICAL · 1 HIGH (2 vulns · 840ms)
97
+ ```
98
+
99
+ ---
100
+
101
+ ## Python API
102
+
103
+ ```python
104
+ import dep_scanner
105
+
106
+ # scan a file on disk
107
+ result = dep_scanner.scan("requirements.txt")
108
+
109
+ print(result.highest_severity) # 'CRITICAL'
110
+ print(result.packages_scanned) # 42
111
+ print(len(result.findings)) # 3
112
+
113
+ for finding in result.critical:
114
+ print(finding.package, finding.version)
115
+ print("Fix:", finding.fix_versions[0] if finding.fix_versions else "none")
116
+
117
+ # scan content directly (no file needed)
118
+ result = dep_scanner.scan_text(
119
+ "requests==2.27.0\ndjango==3.2.1",
120
+ ecosystem="PyPI",
121
+ )
122
+
123
+ # convert to dict
124
+ data = result.to_dict()
125
+ # {
126
+ # "CRITICAL": [{"package": "django", "version": "3.2.1", ...}],
127
+ # "HIGH": [{"package": "requests", ...}],
128
+ # }
129
+ ```
130
+
131
+ ### ScanResult properties
132
+
133
+ | Property | Type | Description |
134
+ |---|---|---|
135
+ | `source` | `str` | File path or label |
136
+ | `ecosystem` | `str` | e.g. `"PyPI"` |
137
+ | `packages_scanned` | `int` | Total unique packages checked |
138
+ | `findings` | `list[PackageFinding]` | Packages with vulnerabilities |
139
+ | `elapsed_ms` | `int` | Wall time in milliseconds |
140
+ | `highest_severity` | `str \| None` | `"CRITICAL"`, `"HIGH"`, `"MEDIUM"`, `"LOW"` |
141
+ | `critical` | `list[PackageFinding]` | Only CRITICAL findings |
142
+ | `high` | `list[PackageFinding]` | Only HIGH findings |
143
+ | `has_findings` | `bool` | True if any vulnerabilities found |
144
+
145
+ ---
146
+
147
+ ## GitHub Actions
148
+
149
+ ```yaml
150
+ - name: Scan dependencies
151
+ run: |
152
+ pip install dep-scanner
153
+ dep-scan --fail-on high requirements.txt
154
+ ```
155
+
156
+ ---
157
+
158
+ ## pre-commit hook
159
+
160
+ ```yaml
161
+ # .pre-commit-config.yaml
162
+ repos:
163
+ - repo: https://github.com/SpiderCob/dep-scanner
164
+ rev: v0.1.0
165
+ hooks:
166
+ - id: dep-scan
167
+ ```
168
+
169
+ ---
170
+
171
+ ## How it works
172
+
173
+ 1. Parses your manifest file to extract `(name, version, ecosystem)` tuples
174
+ 2. Sends a single batch POST to `https://api.osv.dev/v1/querybatch`
175
+ 3. Parses severity from `database_specific.severity` → `cvss_score` → CVSS vector
176
+ 4. Returns structured findings sorted by severity
177
+
178
+ Packages with unpinned version ranges (`^1.0`, `>=2.0`) are skipped — OSV requires exact versions.
179
+
180
+ ---
181
+
182
+ ## License
183
+
184
+ Apache 2.0 — free to use in commercial projects.
185
+
186
+ Built by [SpiderCob](https://spidercob.com).
@@ -0,0 +1,10 @@
1
+ dep_scanner/__init__.py,sha256=VQ-zOvRopjnIefIrGt6aVbHcsAwJ2K8fJl5lCuOHvk8,359
2
+ dep_scanner/_cli.py,sha256=cJOh5nSD8Hs9lg0wEmflJeomA8NOcCKEsUbfzicNUTE,5483
3
+ dep_scanner/_models.py,sha256=wm0Ymqb1DLWi22Sm0KdgysW1D70z5_9Mq3gcA6bRwo4,3422
4
+ dep_scanner/_osv.py,sha256=0Rj03xQMHqnuYqe47G1SNlM611r-M1_H98ssvOks9BE,4717
5
+ dep_scanner/_parsers.py,sha256=vIM6FbfPAP1PbURnElaz6AfgDUgKqzasxmazl82STSc,7982
6
+ dep_scanner/_scanner.py,sha256=qjmwDpMHXq_aBRNaEBE_JPjaocDtnq77QXnd9q3qM8Y,3493
7
+ osv_scan-0.1.0.dist-info/METADATA,sha256=_698OYmPlZyNI3DHNiAWljM16zOeqxify2Z2zp_UG4g,4760
8
+ osv_scan-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
9
+ osv_scan-0.1.0.dist-info/entry_points.txt,sha256=MDeNvQCfST0ZAyNLIzqSBTp0wCnCCmj0gWQrVC-gXqY,51
10
+ osv_scan-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ dep-scan = dep_scanner._cli:main