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.
@@ -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
+ [![Python Tests](https://img.shields.io/github/actions/workflow/status/deeprave/osvcheck/python-test.yml?branch=main&label=tests)](https://github.com/deeprave/osvcheck/actions/workflows/python-test.yml)
26
+ [![PyPI version](https://img.shields.io/pypi/v/osvcheck)](https://pypi.org/project/osvcheck/)
27
+ [![Python versions](https://img.shields.io/pypi/pyversions/osvcheck)](https://pypi.org/project/osvcheck/)
28
+ [![Downloads](https://img.shields.io/pypi/dm/osvcheck)](https://pypi.org/project/osvcheck/)
29
+ [![Security](https://img.shields.io/github/actions/workflow/status/deeprave/osvcheck/codeql.yml?branch=main&label=security&logo=github)](https://github.com/deeprave/osvcheck/security/code-scanning)
30
+ [![Maintenance](https://img.shields.io/badge/maintained-yes-green.svg)](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.
@@ -0,0 +1,81 @@
1
+ # osvcheck
2
+
3
+ [![Python Tests](https://img.shields.io/github/actions/workflow/status/deeprave/osvcheck/python-test.yml?branch=main&label=tests)](https://github.com/deeprave/osvcheck/actions/workflows/python-test.yml)
4
+ [![PyPI version](https://img.shields.io/pypi/v/osvcheck)](https://pypi.org/project/osvcheck/)
5
+ [![Python versions](https://img.shields.io/pypi/pyversions/osvcheck)](https://pypi.org/project/osvcheck/)
6
+ [![Downloads](https://img.shields.io/pypi/dm/osvcheck)](https://pypi.org/project/osvcheck/)
7
+ [![Security](https://img.shields.io/github/actions/workflow/status/deeprave/osvcheck/codeql.yml?branch=main&label=security&logo=github)](https://github.com/deeprave/osvcheck/security/code-scanning)
8
+ [![Maintenance](https://img.shields.io/badge/maintained-yes-green.svg)](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