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.
- dep_scanner/__init__.py +15 -0
- dep_scanner/_cli.py +177 -0
- dep_scanner/_models.py +112 -0
- dep_scanner/_osv.py +151 -0
- dep_scanner/_parsers.py +237 -0
- dep_scanner/_scanner.py +128 -0
- osv_scan-0.1.0.dist-info/METADATA +186 -0
- osv_scan-0.1.0.dist-info/RECORD +10 -0
- osv_scan-0.1.0.dist-info/WHEEL +4 -0
- osv_scan-0.1.0.dist-info/entry_points.txt +2 -0
dep_scanner/__init__.py
ADDED
|
@@ -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
|
dep_scanner/_parsers.py
ADDED
|
@@ -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]
|
dep_scanner/_scanner.py
ADDED
|
@@ -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,,
|