osvcheck 1.0.0__tar.gz
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.
- osvcheck-1.0.0/PKG-INFO +103 -0
- osvcheck-1.0.0/README.md +81 -0
- osvcheck-1.0.0/pyproject.toml +40 -0
- osvcheck-1.0.0/src/osvcheck/__init__.py +203 -0
- osvcheck-1.0.0/src/osvcheck/cache.py +71 -0
- osvcheck-1.0.0/src/osvcheck/cli.py +69 -0
- osvcheck-1.0.0/src/osvcheck/dependencies.py +32 -0
- osvcheck-1.0.0/src/osvcheck/log.py +124 -0
- osvcheck-1.0.0/src/osvcheck/osv.py +74 -0
- osvcheck-1.0.0/src/osvcheck/package_detection.py +74 -0
- osvcheck-1.0.0/src/osvcheck/py.typed +0 -0
- osvcheck-1.0.0/src/osvcheck/scanner.py +162 -0
osvcheck-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: osvcheck
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Security vulnerability scanner for Python dependencies
|
|
5
|
+
Author: David Nugent
|
|
6
|
+
Author-email: David Nugent <davidn@uniquode.io>
|
|
7
|
+
Classifier: Development Status :: 4 - Beta
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: Topic :: Security
|
|
10
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Requires-Dist: rich>=13.0.0 ; extra == 'rich'
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Provides-Extra: rich
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# osvcheck
|
|
24
|
+
|
|
25
|
+
[](https://github.com/deeprave/osvcheck/actions/workflows/python-test.yml)
|
|
26
|
+
[](https://pypi.org/project/osvcheck/)
|
|
27
|
+
[](https://pypi.org/project/osvcheck/)
|
|
28
|
+
[](https://pypi.org/project/osvcheck/)
|
|
29
|
+
[](https://github.com/deeprave/osvcheck/security/code-scanning)
|
|
30
|
+
[](https://github.com/deeprave/osvcheck/graphs/commit-activity)
|
|
31
|
+
|
|
32
|
+
Lightweight vulnerability scanner for Python dependencies using the OSV database.
|
|
33
|
+
|
|
34
|
+
osvcheck scans your Python project's dependencies for known security vulnerabilities by querying the [OSV (Open Source Vulnerabilities)](https://osv.dev) database. It's designed for source-level checking during development and CI/CD pipelines.
|
|
35
|
+
|
|
36
|
+
**Key features:**
|
|
37
|
+
- Zero runtime dependencies (stdlib only)
|
|
38
|
+
- Auto-detects package manager (uv.lock, uv, or pip)
|
|
39
|
+
- Smart caching (12-48 hour TTL) minimizes API calls
|
|
40
|
+
- Distinguishes direct vs indirect vulnerabilities
|
|
41
|
+
- Optional rich integration for enhanced output (auto-detected if installed)
|
|
42
|
+
|
|
43
|
+
## Installation
|
|
44
|
+
|
|
45
|
+
Install via pip or uv, or add to your project's dev dependencies.
|
|
46
|
+
|
|
47
|
+
## Usage
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
# Scan current project
|
|
51
|
+
osvcheck
|
|
52
|
+
|
|
53
|
+
# Logging options
|
|
54
|
+
osvcheck -v # Verbose (debug) output
|
|
55
|
+
osvcheck -q # Quiet (warnings/errors only)
|
|
56
|
+
osvcheck --log-json # JSON format logs
|
|
57
|
+
osvcheck --log-file FILE # Write logs to file
|
|
58
|
+
|
|
59
|
+
# Color control
|
|
60
|
+
osvcheck --color # Force color output
|
|
61
|
+
osvcheck --no-color # Disable color output
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
**Exit codes:**
|
|
65
|
+
- `0` - No vulnerabilities found
|
|
66
|
+
- `1` - Indirect dependency vulnerabilities only
|
|
67
|
+
- `2` - Direct dependency vulnerabilities found
|
|
68
|
+
|
|
69
|
+
**As a Pre-commit hook:**
|
|
70
|
+
|
|
71
|
+
Add to `.pre-commit-config.yaml`:
|
|
72
|
+
```yaml
|
|
73
|
+
- repo: https://github.com/deeprave/osvcheck
|
|
74
|
+
rev: v1.0.0b1
|
|
75
|
+
hooks:
|
|
76
|
+
- id: osvcheck
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
**CI/CD integration:**
|
|
80
|
+
```bash
|
|
81
|
+
# Fail only on direct vulnerabilities
|
|
82
|
+
osvcheck || [ $? -eq 1 ]
|
|
83
|
+
|
|
84
|
+
# Fail on any vulnerabilities
|
|
85
|
+
osvcheck
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Features
|
|
89
|
+
|
|
90
|
+
- Scans Python dependencies for known security vulnerabilities
|
|
91
|
+
- Uses the OSV (Open Source Vulnerabilities) database
|
|
92
|
+
- Multi-environment support with auto-detection:
|
|
93
|
+
- Uses `uv.lock` if present and up-to-date (fastest)
|
|
94
|
+
- Falls back to `uv pip list` if uv is available
|
|
95
|
+
- Falls back to `pip list` if pip is available
|
|
96
|
+
- Smart caching with 12-48 hour randomized TTL
|
|
97
|
+
- Distinguishes between direct and indirect dependency vulnerabilities
|
|
98
|
+
- Zero runtime dependencies (Python stdlib only)
|
|
99
|
+
- Optional rich integration for enhanced output (auto-detected if already installed)
|
|
100
|
+
|
|
101
|
+
## License
|
|
102
|
+
|
|
103
|
+
MIT License - See LICENSE file for details.
|
osvcheck-1.0.0/README.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# osvcheck
|
|
2
|
+
|
|
3
|
+
[](https://github.com/deeprave/osvcheck/actions/workflows/python-test.yml)
|
|
4
|
+
[](https://pypi.org/project/osvcheck/)
|
|
5
|
+
[](https://pypi.org/project/osvcheck/)
|
|
6
|
+
[](https://pypi.org/project/osvcheck/)
|
|
7
|
+
[](https://github.com/deeprave/osvcheck/security/code-scanning)
|
|
8
|
+
[](https://github.com/deeprave/osvcheck/graphs/commit-activity)
|
|
9
|
+
|
|
10
|
+
Lightweight vulnerability scanner for Python dependencies using the OSV database.
|
|
11
|
+
|
|
12
|
+
osvcheck scans your Python project's dependencies for known security vulnerabilities by querying the [OSV (Open Source Vulnerabilities)](https://osv.dev) database. It's designed for source-level checking during development and CI/CD pipelines.
|
|
13
|
+
|
|
14
|
+
**Key features:**
|
|
15
|
+
- Zero runtime dependencies (stdlib only)
|
|
16
|
+
- Auto-detects package manager (uv.lock, uv, or pip)
|
|
17
|
+
- Smart caching (12-48 hour TTL) minimizes API calls
|
|
18
|
+
- Distinguishes direct vs indirect vulnerabilities
|
|
19
|
+
- Optional rich integration for enhanced output (auto-detected if installed)
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
Install via pip or uv, or add to your project's dev dependencies.
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
# Scan current project
|
|
29
|
+
osvcheck
|
|
30
|
+
|
|
31
|
+
# Logging options
|
|
32
|
+
osvcheck -v # Verbose (debug) output
|
|
33
|
+
osvcheck -q # Quiet (warnings/errors only)
|
|
34
|
+
osvcheck --log-json # JSON format logs
|
|
35
|
+
osvcheck --log-file FILE # Write logs to file
|
|
36
|
+
|
|
37
|
+
# Color control
|
|
38
|
+
osvcheck --color # Force color output
|
|
39
|
+
osvcheck --no-color # Disable color output
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
**Exit codes:**
|
|
43
|
+
- `0` - No vulnerabilities found
|
|
44
|
+
- `1` - Indirect dependency vulnerabilities only
|
|
45
|
+
- `2` - Direct dependency vulnerabilities found
|
|
46
|
+
|
|
47
|
+
**As a Pre-commit hook:**
|
|
48
|
+
|
|
49
|
+
Add to `.pre-commit-config.yaml`:
|
|
50
|
+
```yaml
|
|
51
|
+
- repo: https://github.com/deeprave/osvcheck
|
|
52
|
+
rev: v1.0.0b1
|
|
53
|
+
hooks:
|
|
54
|
+
- id: osvcheck
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
**CI/CD integration:**
|
|
58
|
+
```bash
|
|
59
|
+
# Fail only on direct vulnerabilities
|
|
60
|
+
osvcheck || [ $? -eq 1 ]
|
|
61
|
+
|
|
62
|
+
# Fail on any vulnerabilities
|
|
63
|
+
osvcheck
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Features
|
|
67
|
+
|
|
68
|
+
- Scans Python dependencies for known security vulnerabilities
|
|
69
|
+
- Uses the OSV (Open Source Vulnerabilities) database
|
|
70
|
+
- Multi-environment support with auto-detection:
|
|
71
|
+
- Uses `uv.lock` if present and up-to-date (fastest)
|
|
72
|
+
- Falls back to `uv pip list` if uv is available
|
|
73
|
+
- Falls back to `pip list` if pip is available
|
|
74
|
+
- Smart caching with 12-48 hour randomized TTL
|
|
75
|
+
- Distinguishes between direct and indirect dependency vulnerabilities
|
|
76
|
+
- Zero runtime dependencies (Python stdlib only)
|
|
77
|
+
- Optional rich integration for enhanced output (auto-detected if already installed)
|
|
78
|
+
|
|
79
|
+
## License
|
|
80
|
+
|
|
81
|
+
MIT License - See LICENSE file for details.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "osvcheck"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "Security vulnerability scanner for Python dependencies"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "David Nugent", email = "davidn@uniquode.io" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
dependencies = []
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 4 - Beta",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"Topic :: Security",
|
|
15
|
+
"Topic :: Software Development :: Quality Assurance",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.11",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
"Programming Language :: Python :: 3.14",
|
|
21
|
+
"License :: OSI Approved :: MIT License",
|
|
22
|
+
"Operating System :: OS Independent",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.optional-dependencies]
|
|
26
|
+
rich = ["rich>=13.0.0"]
|
|
27
|
+
|
|
28
|
+
[project.scripts]
|
|
29
|
+
osvcheck = "osvcheck:main"
|
|
30
|
+
|
|
31
|
+
[build-system]
|
|
32
|
+
requires = ["uv_build>=0.9.27,<0.11.0"]
|
|
33
|
+
build-backend = "uv_build"
|
|
34
|
+
|
|
35
|
+
[dependency-groups]
|
|
36
|
+
dev = [
|
|
37
|
+
"mypy>=1.19.1",
|
|
38
|
+
"pytest>=9.0.2",
|
|
39
|
+
"ruff>=0.15.1",
|
|
40
|
+
]
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""osvcheck - Lightweight vulnerability scanner for Python dependencies."""
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional, Tuple
|
|
7
|
+
|
|
8
|
+
from .cache import load_cache, save_cache
|
|
9
|
+
from .cli import get_log_level, parse_args
|
|
10
|
+
from .dependencies import get_direct_dependencies
|
|
11
|
+
from .log import setup_logging
|
|
12
|
+
from .osv import OSVClient
|
|
13
|
+
from .package_detection import is_pip_available, is_uv_available, is_uv_lock_current
|
|
14
|
+
from .scanner import (
|
|
15
|
+
PackageLister,
|
|
16
|
+
PackageScanner,
|
|
17
|
+
PipPackageLister,
|
|
18
|
+
UvLockPackageLister,
|
|
19
|
+
UvPackageLister,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
CACHE_FILE = Path(".osvcheck_cache")
|
|
23
|
+
logger = logging.getLogger("osvcheck")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def detect_package_lister() -> Tuple[PackageLister, Optional[str]]:
|
|
27
|
+
"""Detect and return appropriate package lister.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
Tuple of (PackageLister, warning_message)
|
|
31
|
+
"""
|
|
32
|
+
project_root = Path.cwd()
|
|
33
|
+
pyproject = project_root / "pyproject.toml"
|
|
34
|
+
uv_lock = project_root / "uv.lock"
|
|
35
|
+
warning = None
|
|
36
|
+
|
|
37
|
+
logger.debug("Detecting package manager...")
|
|
38
|
+
logger.debug(" pyproject.toml exists: %s", pyproject.exists())
|
|
39
|
+
logger.debug(" uv.lock exists: %s", uv_lock.exists())
|
|
40
|
+
|
|
41
|
+
# If no pyproject.toml, not a uv project - require pip
|
|
42
|
+
if not pyproject.exists():
|
|
43
|
+
logger.debug(" No pyproject.toml found")
|
|
44
|
+
if is_pip_available():
|
|
45
|
+
logger.debug(" Selected package lister: PipPackageLister")
|
|
46
|
+
return PipPackageLister(), None
|
|
47
|
+
raise RuntimeError(
|
|
48
|
+
"No pyproject.toml found and pip is not available.\n"
|
|
49
|
+
"osvcheck requires either:\n"
|
|
50
|
+
" - A pyproject.toml-based project with uv or pip\n"
|
|
51
|
+
" - pip installed for scanning arbitrary environments\n"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# pyproject.toml exists - check uv.lock
|
|
55
|
+
if uv_lock.exists():
|
|
56
|
+
if is_uv_lock_current(project_root):
|
|
57
|
+
logger.debug(" uv.lock is current")
|
|
58
|
+
logger.debug(" Selected package lister: UvLockPackageLister")
|
|
59
|
+
return UvLockPackageLister(uv_lock), None
|
|
60
|
+
else:
|
|
61
|
+
logger.debug(" uv.lock is stale")
|
|
62
|
+
warning = "uv.lock is out of date with pyproject.toml"
|
|
63
|
+
|
|
64
|
+
# Try uv
|
|
65
|
+
logger.debug(" uv available: %s", is_uv_available())
|
|
66
|
+
if is_uv_available():
|
|
67
|
+
logger.debug(" Selected package lister: UvPackageLister")
|
|
68
|
+
return UvPackageLister(), warning
|
|
69
|
+
|
|
70
|
+
# Try pip
|
|
71
|
+
logger.debug(" pip available: %s", is_pip_available())
|
|
72
|
+
if is_pip_available():
|
|
73
|
+
logger.debug(" Selected package lister: PipPackageLister")
|
|
74
|
+
return PipPackageLister(), warning
|
|
75
|
+
|
|
76
|
+
# Fail
|
|
77
|
+
raise RuntimeError(
|
|
78
|
+
"No package manager found.\n"
|
|
79
|
+
"osvcheck requires either:\n"
|
|
80
|
+
" - uv (recommended): https://github.com/astral-sh/uv\n"
|
|
81
|
+
" - pip (usually included with Python)\n\n"
|
|
82
|
+
"Ensure one is installed and available."
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def report_results(
|
|
87
|
+
direct_vulnerable: list[str], indirect_vulnerable: list[str]
|
|
88
|
+
) -> None:
|
|
89
|
+
"""Report vulnerability scan results."""
|
|
90
|
+
for pkg_name in direct_vulnerable:
|
|
91
|
+
logger.warning("⚠️ [DIRECT] %s: vulnerabilities found", pkg_name)
|
|
92
|
+
|
|
93
|
+
for pkg_name in indirect_vulnerable:
|
|
94
|
+
logger.warning(" ⚠️ [indirect] %s: vulnerabilities found", pkg_name)
|
|
95
|
+
|
|
96
|
+
v_direct = len(direct_vulnerable)
|
|
97
|
+
v_indirect = len(indirect_vulnerable)
|
|
98
|
+
|
|
99
|
+
if v_direct or v_indirect:
|
|
100
|
+
logger.info("")
|
|
101
|
+
logger.info("Vulnerabilities found!")
|
|
102
|
+
if v_direct:
|
|
103
|
+
logger.info(" - %d direct dependency vulnerabilities:", v_direct)
|
|
104
|
+
logger.info(" %s", ", ".join(direct_vulnerable))
|
|
105
|
+
if v_indirect:
|
|
106
|
+
logger.info(" - %d indirect dependency vulnerabilities:", v_indirect)
|
|
107
|
+
logger.info(" %s", ", ".join(indirect_vulnerable))
|
|
108
|
+
|
|
109
|
+
if not v_direct and not v_indirect:
|
|
110
|
+
logger.info("No vulnerabilities found.")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def determine_exit_code(
|
|
114
|
+
direct_vulnerable: list[str], indirect_vulnerable: list[str]
|
|
115
|
+
) -> int:
|
|
116
|
+
"""Determine exit code based on vulnerabilities found."""
|
|
117
|
+
if direct_vulnerable:
|
|
118
|
+
return 2
|
|
119
|
+
if indirect_vulnerable:
|
|
120
|
+
return 1
|
|
121
|
+
return 0
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def main() -> None:
|
|
125
|
+
"""Main entry point for osvcheck."""
|
|
126
|
+
# Parse CLI arguments
|
|
127
|
+
args = parse_args()
|
|
128
|
+
|
|
129
|
+
# Setup logging
|
|
130
|
+
log_level = get_log_level(args)
|
|
131
|
+
setup_logging(
|
|
132
|
+
level=log_level,
|
|
133
|
+
log_file=args.log_file,
|
|
134
|
+
use_json=args.log_json,
|
|
135
|
+
use_color=args.color,
|
|
136
|
+
use_rich=not args.log_json and args.color is not False,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
logger.debug("Starting osvcheck")
|
|
140
|
+
|
|
141
|
+
# Detect package manager
|
|
142
|
+
package_lister, warning = detect_package_lister()
|
|
143
|
+
|
|
144
|
+
if warning:
|
|
145
|
+
logger.warning(warning)
|
|
146
|
+
|
|
147
|
+
# Load dependencies if pyproject.toml exists
|
|
148
|
+
pyproject = Path("pyproject.toml")
|
|
149
|
+
direct_deps = get_direct_dependencies(pyproject) if pyproject.exists() else []
|
|
150
|
+
logger.debug("Loaded %d direct dependencies", len(direct_deps))
|
|
151
|
+
|
|
152
|
+
# Load cache
|
|
153
|
+
logger.debug("Loading cache from %s", CACHE_FILE)
|
|
154
|
+
cache = load_cache(CACHE_FILE)
|
|
155
|
+
logger.debug("Cache loaded with %d entries", len(cache))
|
|
156
|
+
|
|
157
|
+
# Setup scanner
|
|
158
|
+
osv_client = OSVClient()
|
|
159
|
+
scanner = PackageScanner(osv_client, package_lister)
|
|
160
|
+
|
|
161
|
+
# Get package count for display
|
|
162
|
+
all_packages = package_lister.list_packages()
|
|
163
|
+
if direct_deps:
|
|
164
|
+
logger.info(
|
|
165
|
+
"Checking %d packages (%d direct dependencies)...",
|
|
166
|
+
len(all_packages),
|
|
167
|
+
len(direct_deps),
|
|
168
|
+
)
|
|
169
|
+
else:
|
|
170
|
+
logger.info("Checking %d packages...", len(all_packages))
|
|
171
|
+
|
|
172
|
+
# Scan for vulnerabilities
|
|
173
|
+
direct_vulnerable, indirect_vulnerable = scanner.scan_packages(direct_deps, cache)
|
|
174
|
+
|
|
175
|
+
# Log statistics
|
|
176
|
+
logger.info(
|
|
177
|
+
"Queries: %d, %d packages",
|
|
178
|
+
scanner.stats.api_queries,
|
|
179
|
+
scanner.stats.cache_misses,
|
|
180
|
+
)
|
|
181
|
+
next_expiry = scanner.stats.get_next_expiry_str()
|
|
182
|
+
expiry_str = f", next expiry: {next_expiry}" if next_expiry else ""
|
|
183
|
+
logger.debug("Cache entries: %d%s", len(cache), expiry_str)
|
|
184
|
+
|
|
185
|
+
# Save cache and report
|
|
186
|
+
save_cache(cache, CACHE_FILE)
|
|
187
|
+
logger.debug("Cache saved to %s", CACHE_FILE)
|
|
188
|
+
|
|
189
|
+
report_results(direct_vulnerable, indirect_vulnerable)
|
|
190
|
+
|
|
191
|
+
# Exit with appropriate code
|
|
192
|
+
exit(determine_exit_code(direct_vulnerable, indirect_vulnerable))
|
|
193
|
+
|
|
194
|
+
# Save cache and report
|
|
195
|
+
save_cache(cache, CACHE_FILE)
|
|
196
|
+
report_results(direct_vulnerable, indirect_vulnerable)
|
|
197
|
+
|
|
198
|
+
# Exit with appropriate code
|
|
199
|
+
exit(determine_exit_code(direct_vulnerable, indirect_vulnerable))
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
if __name__ == "__main__":
|
|
203
|
+
main()
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Cache operations for vulnerability scan results."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import random
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Dict, Optional
|
|
8
|
+
|
|
9
|
+
CACHE_MIN_TTL = 12 * 3600 # 12 hours in seconds
|
|
10
|
+
CACHE_MAX_TTL = 48 * 3600 # 48 hours in seconds
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_cache_key(pkg_name: str, pkg_version: str) -> str:
|
|
14
|
+
"""Generate cache key for a package."""
|
|
15
|
+
return f"{pkg_name}:{pkg_version}"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def is_cache_entry_valid(entry: Dict[str, Any], current_time: float) -> bool:
|
|
19
|
+
"""Check if cache entry is still valid."""
|
|
20
|
+
return entry.get("expires_at", 0) > current_time
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def generate_cache_ttl() -> int:
|
|
24
|
+
"""Generate random TTL between min and max."""
|
|
25
|
+
return random.randint(CACHE_MIN_TTL, CACHE_MAX_TTL)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load_cache(cache_file: Path) -> Dict[str, Dict[str, Any]]:
|
|
29
|
+
"""Load cache from file, removing expired entries."""
|
|
30
|
+
if not cache_file.exists():
|
|
31
|
+
return {}
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
with open(cache_file, "r") as f:
|
|
35
|
+
cache = json.load(f)
|
|
36
|
+
|
|
37
|
+
# Remove expired entries
|
|
38
|
+
current_time = time.time()
|
|
39
|
+
return {k: v for k, v in cache.items() if is_cache_entry_valid(v, current_time)}
|
|
40
|
+
except Exception:
|
|
41
|
+
return {}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def save_cache(cache: Dict[str, Dict[str, Any]], cache_file: Path) -> None:
|
|
45
|
+
"""Save cache to file."""
|
|
46
|
+
try:
|
|
47
|
+
with open(cache_file, "w") as f:
|
|
48
|
+
json.dump(cache, f, indent=2)
|
|
49
|
+
except Exception:
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_cached_result(
|
|
54
|
+
cache: Dict[str, Dict[str, Any]], pkg_name: str, pkg_version: str
|
|
55
|
+
) -> Optional[str]:
|
|
56
|
+
"""Get cached vulnerability result for a package."""
|
|
57
|
+
cache_key = get_cache_key(pkg_name, pkg_version)
|
|
58
|
+
cached_entry = cache.get(cache_key)
|
|
59
|
+
return cached_entry.get("vuln_type") if cached_entry else None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def update_cache(
|
|
63
|
+
cache: Dict[str, Dict[str, Any]],
|
|
64
|
+
pkg_name: str,
|
|
65
|
+
pkg_version: str,
|
|
66
|
+
vuln_type: Optional[str],
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Update cache with vulnerability result."""
|
|
69
|
+
cache_key = get_cache_key(pkg_name, pkg_version)
|
|
70
|
+
ttl = generate_cache_ttl()
|
|
71
|
+
cache[cache_key] = {"vuln_type": vuln_type, "expires_at": time.time() + ttl}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Command-line interface."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import logging
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def parse_args() -> argparse.Namespace:
|
|
9
|
+
"""Parse command-line arguments."""
|
|
10
|
+
parser = argparse.ArgumentParser(
|
|
11
|
+
prog="osvcheck",
|
|
12
|
+
description="Lightweight vulnerability scanner for Python dependencies",
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
# Logging options
|
|
16
|
+
parser.add_argument(
|
|
17
|
+
"--log-file",
|
|
18
|
+
type=Path,
|
|
19
|
+
help="Write logs to file",
|
|
20
|
+
)
|
|
21
|
+
parser.add_argument(
|
|
22
|
+
"--log-json",
|
|
23
|
+
action="store_true",
|
|
24
|
+
help="Output logs as JSON",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Color options (mutually exclusive)
|
|
28
|
+
color_group = parser.add_mutually_exclusive_group()
|
|
29
|
+
color_group.add_argument(
|
|
30
|
+
"--color",
|
|
31
|
+
"--colour",
|
|
32
|
+
action="store_true",
|
|
33
|
+
dest="color",
|
|
34
|
+
help="Force color output",
|
|
35
|
+
)
|
|
36
|
+
color_group.add_argument(
|
|
37
|
+
"--no-color",
|
|
38
|
+
"--no-colour",
|
|
39
|
+
action="store_false",
|
|
40
|
+
dest="color",
|
|
41
|
+
help="Disable color output",
|
|
42
|
+
)
|
|
43
|
+
parser.set_defaults(color=None) # Auto-detect
|
|
44
|
+
|
|
45
|
+
# Verbosity options (mutually exclusive)
|
|
46
|
+
verbosity_group = parser.add_mutually_exclusive_group()
|
|
47
|
+
verbosity_group.add_argument(
|
|
48
|
+
"-v",
|
|
49
|
+
"--verbose",
|
|
50
|
+
action="store_true",
|
|
51
|
+
help="Enable debug logging",
|
|
52
|
+
)
|
|
53
|
+
verbosity_group.add_argument(
|
|
54
|
+
"-q",
|
|
55
|
+
"--quiet",
|
|
56
|
+
action="store_true",
|
|
57
|
+
help="Only show warnings and errors",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
return parser.parse_args()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def get_log_level(args: argparse.Namespace) -> int:
|
|
64
|
+
"""Determine log level from arguments."""
|
|
65
|
+
if args.verbose:
|
|
66
|
+
return logging.DEBUG
|
|
67
|
+
if args.quiet:
|
|
68
|
+
return logging.WARNING
|
|
69
|
+
return logging.INFO
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Dependency parsing from pyproject.toml."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import tomllib
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import List
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def parse_package_name(dep_spec: str) -> str:
|
|
10
|
+
"""Extract package name from dependency specifier.
|
|
11
|
+
|
|
12
|
+
Examples:
|
|
13
|
+
'requests>=2.0' -> 'requests'
|
|
14
|
+
'django[extra]~=4.0' -> 'django'
|
|
15
|
+
"""
|
|
16
|
+
return re.split(r"[<>=\[~!]", dep_spec)[0].strip().lower()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_direct_dependencies(pyproject_path: Path) -> List[str]:
|
|
20
|
+
"""Get list of direct dependency names from pyproject.toml."""
|
|
21
|
+
try:
|
|
22
|
+
with open(pyproject_path, "rb") as f:
|
|
23
|
+
data = tomllib.load(f)
|
|
24
|
+
deps = data.get("project", {}).get("dependencies", [])
|
|
25
|
+
return [parse_package_name(dep) for dep in deps]
|
|
26
|
+
except Exception:
|
|
27
|
+
return []
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def is_direct_dependency(pkg_name: str, direct_deps: List[str]) -> bool:
|
|
31
|
+
"""Check if package is a direct dependency."""
|
|
32
|
+
return pkg_name.lower() in direct_deps
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Logging configuration and setup."""
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import sys
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def is_rich_available() -> bool:
|
|
13
|
+
"""Check if rich module is available."""
|
|
14
|
+
return importlib.util.find_spec("rich") is not None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class JSONFormatter(logging.Formatter):
|
|
18
|
+
"""JSON log formatter."""
|
|
19
|
+
|
|
20
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
21
|
+
"""Format log record as JSON."""
|
|
22
|
+
log_data = {
|
|
23
|
+
"timestamp": datetime.fromtimestamp(record.created).isoformat(),
|
|
24
|
+
"level": record.levelname,
|
|
25
|
+
"message": record.getMessage(),
|
|
26
|
+
}
|
|
27
|
+
if record.exc_info:
|
|
28
|
+
log_data["exception"] = self.formatException(record.exc_info)
|
|
29
|
+
return json.dumps(log_data)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ColorFormatter(logging.Formatter):
|
|
33
|
+
"""Color log formatter for console output."""
|
|
34
|
+
|
|
35
|
+
COLORS = {
|
|
36
|
+
"DEBUG": "\033[36m", # Cyan
|
|
37
|
+
"INFO": "\033[32m", # Green
|
|
38
|
+
"WARNING": "\033[33m", # Yellow
|
|
39
|
+
"ERROR": "\033[31m", # Red
|
|
40
|
+
"CRITICAL": "\033[35m", # Magenta
|
|
41
|
+
}
|
|
42
|
+
RESET = "\033[0m"
|
|
43
|
+
|
|
44
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
45
|
+
"""Format log record with colors."""
|
|
46
|
+
color = self.COLORS.get(record.levelname, "")
|
|
47
|
+
record.levelname = f"{color}{record.levelname}{self.RESET}"
|
|
48
|
+
return super().format(record)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def setup_logging(
|
|
52
|
+
level: int = logging.INFO,
|
|
53
|
+
log_file: Optional[Path] = None,
|
|
54
|
+
use_json: bool = False,
|
|
55
|
+
use_color: Optional[bool] = None,
|
|
56
|
+
use_rich: bool = True,
|
|
57
|
+
) -> logging.Logger:
|
|
58
|
+
"""Setup and configure logging.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
level: Logging level (DEBUG, INFO, WARNING)
|
|
62
|
+
log_file: Optional file path for logging
|
|
63
|
+
use_json: Output logs as JSON
|
|
64
|
+
use_color: Enable color output (None = auto-detect)
|
|
65
|
+
use_rich: Use rich console if available
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
Configured logger instance
|
|
69
|
+
"""
|
|
70
|
+
logger = logging.getLogger("osvcheck")
|
|
71
|
+
logger.setLevel(level)
|
|
72
|
+
logger.handlers.clear()
|
|
73
|
+
|
|
74
|
+
# Auto-detect color support if not specified
|
|
75
|
+
if use_color is None:
|
|
76
|
+
use_color = sys.stderr.isatty()
|
|
77
|
+
|
|
78
|
+
# Console handler
|
|
79
|
+
console_handler: logging.Handler
|
|
80
|
+
if use_json:
|
|
81
|
+
console_handler = logging.StreamHandler(sys.stderr)
|
|
82
|
+
console_handler.setFormatter(JSONFormatter())
|
|
83
|
+
elif use_rich and use_color and is_rich_available():
|
|
84
|
+
from rich.logging import RichHandler # type: ignore[import-not-found]
|
|
85
|
+
|
|
86
|
+
console_handler = RichHandler(
|
|
87
|
+
rich_tracebacks=True,
|
|
88
|
+
markup=False,
|
|
89
|
+
show_time=False,
|
|
90
|
+
show_path=False,
|
|
91
|
+
)
|
|
92
|
+
else:
|
|
93
|
+
console_handler = logging.StreamHandler(sys.stderr)
|
|
94
|
+
if use_color:
|
|
95
|
+
console_handler.setFormatter(
|
|
96
|
+
ColorFormatter(
|
|
97
|
+
"%(asctime)s %(levelname)s %(message)s",
|
|
98
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
else:
|
|
102
|
+
console_handler.setFormatter(
|
|
103
|
+
logging.Formatter(
|
|
104
|
+
"%(asctime)s %(levelname)s %(message)s",
|
|
105
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
console_handler.setLevel(level)
|
|
110
|
+
logger.addHandler(console_handler)
|
|
111
|
+
|
|
112
|
+
# File handler
|
|
113
|
+
if log_file:
|
|
114
|
+
file_handler = logging.FileHandler(log_file)
|
|
115
|
+
if use_json:
|
|
116
|
+
file_handler.setFormatter(JSONFormatter())
|
|
117
|
+
else:
|
|
118
|
+
file_handler.setFormatter(
|
|
119
|
+
logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")
|
|
120
|
+
)
|
|
121
|
+
file_handler.setLevel(level)
|
|
122
|
+
logger.addHandler(file_handler)
|
|
123
|
+
|
|
124
|
+
return logger
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""OSV API client for vulnerability checking."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import urllib.request
|
|
5
|
+
from typing import Dict, List, Tuple
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class OSVClient:
|
|
9
|
+
"""Client for querying the OSV vulnerability database."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, api_url: str = "https://api.osv.dev/v1"):
|
|
12
|
+
self.api_url = api_url
|
|
13
|
+
|
|
14
|
+
def check_vulnerability(self, pkg_name: str, pkg_version: str) -> bool:
|
|
15
|
+
"""Check if package version has known vulnerabilities.
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
True if vulnerabilities found, False otherwise.
|
|
19
|
+
"""
|
|
20
|
+
query = {
|
|
21
|
+
"package": {"ecosystem": "PyPI", "name": pkg_name},
|
|
22
|
+
"version": pkg_version,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
req = urllib.request.Request(
|
|
26
|
+
f"{self.api_url}/query",
|
|
27
|
+
data=json.dumps(query).encode(),
|
|
28
|
+
headers={"Content-Type": "application/json"},
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
if not req.full_url.startswith("https://"):
|
|
33
|
+
raise ValueError("Only HTTPS URLs are allowed for security")
|
|
34
|
+
|
|
35
|
+
with urllib.request.urlopen(req) as response: # nosec B310
|
|
36
|
+
result = json.loads(response.read())
|
|
37
|
+
return bool(result.get("vulns", []))
|
|
38
|
+
except Exception:
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
def check_vulnerabilities_batch(
|
|
42
|
+
self, packages: List[Tuple[str, str]]
|
|
43
|
+
) -> Dict[Tuple[str, str], bool]:
|
|
44
|
+
"""Check multiple packages for vulnerabilities in a single request.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
packages: List of (pkg_name, pkg_version) tuples
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
Dict mapping (pkg_name, pkg_version) to vulnerability status
|
|
51
|
+
"""
|
|
52
|
+
queries = [
|
|
53
|
+
{"package": {"ecosystem": "PyPI", "name": name}, "version": version}
|
|
54
|
+
for name, version in packages
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
req = urllib.request.Request(
|
|
58
|
+
f"{self.api_url}/querybatch",
|
|
59
|
+
data=json.dumps({"queries": queries}).encode(),
|
|
60
|
+
headers={"Content-Type": "application/json"},
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
if not req.full_url.startswith("https://"):
|
|
65
|
+
raise ValueError("Only HTTPS URLs are allowed for security")
|
|
66
|
+
|
|
67
|
+
with urllib.request.urlopen(req) as response: # nosec B310
|
|
68
|
+
result = json.loads(response.read())
|
|
69
|
+
return {
|
|
70
|
+
packages[i]: bool(r.get("vulns", []))
|
|
71
|
+
for i, r in enumerate(result.get("results", []))
|
|
72
|
+
}
|
|
73
|
+
except Exception:
|
|
74
|
+
return {pkg: False for pkg in packages}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Package manager detection and package listing."""
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import json
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import tomllib
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Dict, List
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def is_uv_lock_current(project_root: Path) -> bool:
|
|
14
|
+
"""Check if uv.lock is up-to-date with pyproject.toml."""
|
|
15
|
+
uv_lock = project_root / "uv.lock"
|
|
16
|
+
pyproject = project_root / "pyproject.toml"
|
|
17
|
+
|
|
18
|
+
if not uv_lock.exists() or not pyproject.exists():
|
|
19
|
+
return False
|
|
20
|
+
|
|
21
|
+
return uv_lock.stat().st_mtime >= pyproject.stat().st_mtime
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def parse_uv_lock(uv_lock_path: Path) -> List[Dict[str, str]]:
|
|
25
|
+
"""Parse uv.lock and extract package list."""
|
|
26
|
+
try:
|
|
27
|
+
with open(uv_lock_path, "rb") as f:
|
|
28
|
+
data = tomllib.load(f)
|
|
29
|
+
|
|
30
|
+
packages = []
|
|
31
|
+
for pkg in data.get("package", []):
|
|
32
|
+
packages.append({"name": pkg["name"], "version": pkg["version"]})
|
|
33
|
+
|
|
34
|
+
return packages
|
|
35
|
+
except Exception:
|
|
36
|
+
return []
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def is_uv_available() -> bool:
|
|
40
|
+
"""Check if uv command is available."""
|
|
41
|
+
return shutil.which("uv") is not None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def is_pip_available() -> bool:
|
|
45
|
+
"""Check if pip module is available."""
|
|
46
|
+
return importlib.util.find_spec("pip") is not None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_packages_via_uv() -> List[Dict[str, str]]:
|
|
50
|
+
"""Get packages using uv pip list."""
|
|
51
|
+
try:
|
|
52
|
+
result = subprocess.run(
|
|
53
|
+
["uv", "pip", "list", "--format", "json"],
|
|
54
|
+
capture_output=True,
|
|
55
|
+
text=True,
|
|
56
|
+
check=True,
|
|
57
|
+
)
|
|
58
|
+
return json.loads(result.stdout)
|
|
59
|
+
except Exception:
|
|
60
|
+
return []
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def get_packages_via_pip() -> List[Dict[str, str]]:
|
|
64
|
+
"""Get packages using pip list."""
|
|
65
|
+
try:
|
|
66
|
+
result = subprocess.run(
|
|
67
|
+
[sys.executable, "-m", "pip", "list", "--format", "json"],
|
|
68
|
+
capture_output=True,
|
|
69
|
+
text=True,
|
|
70
|
+
check=True,
|
|
71
|
+
)
|
|
72
|
+
return json.loads(result.stdout)
|
|
73
|
+
except Exception:
|
|
74
|
+
return []
|
|
File without changes
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Package scanning logic."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Dict, List, Optional, Protocol, Tuple
|
|
7
|
+
|
|
8
|
+
from .cache import get_cached_result, update_cache
|
|
9
|
+
from .dependencies import is_direct_dependency
|
|
10
|
+
from .package_detection import get_packages_via_pip, get_packages_via_uv, parse_uv_lock
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger("osvcheck")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ScanStatistics:
|
|
16
|
+
"""Track scanning statistics."""
|
|
17
|
+
|
|
18
|
+
def __init__(self) -> None:
|
|
19
|
+
self.cache_hits = 0
|
|
20
|
+
self.cache_misses = 0
|
|
21
|
+
self.api_queries = 0
|
|
22
|
+
self.packages_scanned = 0
|
|
23
|
+
self.next_expiry: Optional[float] = None
|
|
24
|
+
|
|
25
|
+
def update_next_expiry(self, expires_at: float) -> None:
|
|
26
|
+
"""Update next expiry time."""
|
|
27
|
+
if self.next_expiry is None or expires_at < self.next_expiry:
|
|
28
|
+
self.next_expiry = expires_at
|
|
29
|
+
|
|
30
|
+
def get_next_expiry_str(self) -> Optional[str]:
|
|
31
|
+
"""Get formatted next expiry time."""
|
|
32
|
+
if self.next_expiry:
|
|
33
|
+
return datetime.fromtimestamp(self.next_expiry).strftime(
|
|
34
|
+
"%Y-%m-%d %H:%M:%S"
|
|
35
|
+
)
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class PackageLister(Protocol):
|
|
40
|
+
"""Protocol for listing installed packages."""
|
|
41
|
+
|
|
42
|
+
def list_packages(self) -> List[Dict[str, str]]:
|
|
43
|
+
"""Return list of installed packages with 'name' and 'version' keys."""
|
|
44
|
+
...
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class UvLockPackageLister:
|
|
48
|
+
"""Package lister using uv.lock file."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, uv_lock_path: Path):
|
|
51
|
+
self.uv_lock_path = uv_lock_path
|
|
52
|
+
|
|
53
|
+
def list_packages(self) -> List[Dict[str, str]]:
|
|
54
|
+
"""Get packages from uv.lock."""
|
|
55
|
+
return parse_uv_lock(self.uv_lock_path)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class UvPackageLister:
|
|
59
|
+
"""Package lister using uv pip list."""
|
|
60
|
+
|
|
61
|
+
def list_packages(self) -> List[Dict[str, str]]:
|
|
62
|
+
"""Get all installed packages using uv."""
|
|
63
|
+
return get_packages_via_uv()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class PipPackageLister:
|
|
67
|
+
"""Package lister using pip list."""
|
|
68
|
+
|
|
69
|
+
def list_packages(self) -> List[Dict[str, str]]:
|
|
70
|
+
"""Get all installed packages using pip."""
|
|
71
|
+
return get_packages_via_pip()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class PackageScanner:
|
|
75
|
+
"""Coordinates vulnerability scanning of packages."""
|
|
76
|
+
|
|
77
|
+
def __init__(self, osv_client: Any, package_lister: PackageLister):
|
|
78
|
+
self.osv_client = osv_client
|
|
79
|
+
self.package_lister = package_lister
|
|
80
|
+
self.stats = ScanStatistics()
|
|
81
|
+
|
|
82
|
+
def scan_packages(
|
|
83
|
+
self, direct_deps: List[str], cache: Dict[str, Dict[str, Any]]
|
|
84
|
+
) -> Tuple[List[str], List[str]]:
|
|
85
|
+
"""Scan packages for vulnerabilities.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
Tuple of (direct_vulnerable, indirect_vulnerable) package names.
|
|
89
|
+
"""
|
|
90
|
+
all_packages = self.package_lister.list_packages()
|
|
91
|
+
direct_vulnerable: List[str] = []
|
|
92
|
+
indirect_vulnerable: List[str] = []
|
|
93
|
+
|
|
94
|
+
# Calculate next expiry from cache
|
|
95
|
+
for entry in cache.values():
|
|
96
|
+
if "expires_at" in entry:
|
|
97
|
+
self.stats.update_next_expiry(entry["expires_at"])
|
|
98
|
+
|
|
99
|
+
# Separate cached and uncached packages
|
|
100
|
+
uncached_packages = []
|
|
101
|
+
for pkg in all_packages:
|
|
102
|
+
pkg_name = pkg["name"]
|
|
103
|
+
pkg_version = pkg["version"]
|
|
104
|
+
is_direct = is_direct_dependency(pkg_name, direct_deps)
|
|
105
|
+
|
|
106
|
+
self.stats.packages_scanned += 1
|
|
107
|
+
|
|
108
|
+
cached = get_cached_result(cache, pkg_name, pkg_version)
|
|
109
|
+
if cached:
|
|
110
|
+
self.stats.cache_hits += 1
|
|
111
|
+
dep_type = "direct" if is_direct else "indirect"
|
|
112
|
+
logger.debug(" %s:%s cache hit (%s)", pkg_name, pkg_version, dep_type)
|
|
113
|
+
if cached in ("direct", "indirect"):
|
|
114
|
+
(direct_vulnerable if is_direct else indirect_vulnerable).append(
|
|
115
|
+
pkg_name
|
|
116
|
+
)
|
|
117
|
+
else:
|
|
118
|
+
self.stats.cache_misses += 1
|
|
119
|
+
dep_type = "direct" if is_direct else "indirect"
|
|
120
|
+
logger.debug(" %s:%s cache miss (%s)", pkg_name, pkg_version, dep_type)
|
|
121
|
+
uncached_packages.append((pkg_name, pkg_version, is_direct))
|
|
122
|
+
|
|
123
|
+
# Batch query uncached packages
|
|
124
|
+
logger.info(
|
|
125
|
+
"Cache scan: %d packages processed, %d hits (skipped), %d misses",
|
|
126
|
+
len(all_packages),
|
|
127
|
+
self.stats.cache_hits,
|
|
128
|
+
self.stats.cache_misses,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
if uncached_packages:
|
|
132
|
+
batch_size = 256
|
|
133
|
+
for i in range(0, len(uncached_packages), batch_size):
|
|
134
|
+
batch = uncached_packages[i : i + batch_size]
|
|
135
|
+
pkg_tuples = [(name, ver) for name, ver, _ in batch]
|
|
136
|
+
|
|
137
|
+
logger.debug(
|
|
138
|
+
" Batch querying %d packages (batch %d/%d)",
|
|
139
|
+
len(pkg_tuples),
|
|
140
|
+
i // batch_size + 1,
|
|
141
|
+
(len(uncached_packages) + batch_size - 1) // batch_size,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
self.stats.api_queries += 1
|
|
145
|
+
results = self.osv_client.check_vulnerabilities_batch(pkg_tuples)
|
|
146
|
+
|
|
147
|
+
for (pkg_name, pkg_version, is_direct), has_vuln in zip(
|
|
148
|
+
batch, [results.get((n, v), False) for n, v in pkg_tuples]
|
|
149
|
+
):
|
|
150
|
+
vuln_status = "vulnerability" if has_vuln else "no vulnerability"
|
|
151
|
+
logger.debug(" %s:%s %s", pkg_name, pkg_version, vuln_status)
|
|
152
|
+
vuln_type = (
|
|
153
|
+
("direct" if is_direct else "indirect") if has_vuln else None
|
|
154
|
+
)
|
|
155
|
+
update_cache(cache, pkg_name, pkg_version, vuln_type)
|
|
156
|
+
|
|
157
|
+
if has_vuln:
|
|
158
|
+
(
|
|
159
|
+
direct_vulnerable if is_direct else indirect_vulnerable
|
|
160
|
+
).append(pkg_name)
|
|
161
|
+
|
|
162
|
+
return direct_vulnerable, indirect_vulnerable
|