cve-sentinel 0.1.2__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.
- cve_sentinel/__init__.py +4 -0
- cve_sentinel/__main__.py +18 -0
- cve_sentinel/analyzers/__init__.py +19 -0
- cve_sentinel/analyzers/base.py +274 -0
- cve_sentinel/analyzers/go.py +186 -0
- cve_sentinel/analyzers/maven.py +291 -0
- cve_sentinel/analyzers/npm.py +586 -0
- cve_sentinel/analyzers/php.py +238 -0
- cve_sentinel/analyzers/python.py +435 -0
- cve_sentinel/analyzers/ruby.py +182 -0
- cve_sentinel/analyzers/rust.py +199 -0
- cve_sentinel/cli.py +517 -0
- cve_sentinel/config.py +347 -0
- cve_sentinel/fetchers/__init__.py +22 -0
- cve_sentinel/fetchers/nvd.py +544 -0
- cve_sentinel/fetchers/osv.py +719 -0
- cve_sentinel/matcher.py +496 -0
- cve_sentinel/reporter.py +549 -0
- cve_sentinel/scanner.py +513 -0
- cve_sentinel/scanners/__init__.py +13 -0
- cve_sentinel/scanners/import_scanner.py +1121 -0
- cve_sentinel/utils/__init__.py +5 -0
- cve_sentinel/utils/cache.py +61 -0
- cve_sentinel-0.1.2.dist-info/METADATA +454 -0
- cve_sentinel-0.1.2.dist-info/RECORD +28 -0
- cve_sentinel-0.1.2.dist-info/WHEEL +4 -0
- cve_sentinel-0.1.2.dist-info/entry_points.txt +2 -0
- cve_sentinel-0.1.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Ruby dependency analyzer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Dict, List, Optional
|
|
8
|
+
|
|
9
|
+
from cve_sentinel.analyzers.base import (
|
|
10
|
+
AnalyzerRegistry,
|
|
11
|
+
BaseAnalyzer,
|
|
12
|
+
FileDetector,
|
|
13
|
+
Package,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RubyAnalyzer(BaseAnalyzer):
|
|
18
|
+
"""Analyzer for Ruby Bundler.
|
|
19
|
+
|
|
20
|
+
Supports:
|
|
21
|
+
- Gemfile (Level 1: direct dependencies)
|
|
22
|
+
- Gemfile.lock (Level 2: transitive dependencies)
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def ecosystem(self) -> str:
|
|
27
|
+
"""Return the ecosystem name."""
|
|
28
|
+
return "rubygems"
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def manifest_patterns(self) -> List[str]:
|
|
32
|
+
"""Return glob patterns for manifest files."""
|
|
33
|
+
default_patterns = ["Gemfile"]
|
|
34
|
+
custom = self._custom_patterns.get("manifests", [])
|
|
35
|
+
return default_patterns + custom
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def lock_patterns(self) -> List[str]:
|
|
39
|
+
"""Return glob patterns for lock files."""
|
|
40
|
+
default_patterns = ["Gemfile.lock"]
|
|
41
|
+
custom = self._custom_patterns.get("locks", [])
|
|
42
|
+
return default_patterns + custom
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
analysis_level: int = 2,
|
|
47
|
+
custom_patterns: Optional[Dict[str, List[str]]] = None,
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Initialize Ruby analyzer.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
analysis_level: Analysis depth (1=manifest only, 2=include lock files)
|
|
53
|
+
custom_patterns: Optional custom file patterns {"manifests": [...], "locks": [...]}
|
|
54
|
+
"""
|
|
55
|
+
self.analysis_level = analysis_level
|
|
56
|
+
self._custom_patterns = custom_patterns or {}
|
|
57
|
+
self._file_detector = FileDetector()
|
|
58
|
+
|
|
59
|
+
def detect_files(self, path: Path) -> List[Path]:
|
|
60
|
+
"""Detect Ruby dependency files."""
|
|
61
|
+
patterns = self.manifest_patterns.copy()
|
|
62
|
+
if self.analysis_level >= 2:
|
|
63
|
+
patterns.extend(self.lock_patterns)
|
|
64
|
+
return self._file_detector.find_files(path, patterns)
|
|
65
|
+
|
|
66
|
+
def parse(self, file_path: Path) -> List[Package]:
|
|
67
|
+
"""Parse a Ruby dependency file."""
|
|
68
|
+
if file_path.name == "Gemfile":
|
|
69
|
+
return self._parse_gemfile(file_path)
|
|
70
|
+
elif file_path.name == "Gemfile.lock":
|
|
71
|
+
return self._parse_gemfile_lock(file_path)
|
|
72
|
+
return []
|
|
73
|
+
|
|
74
|
+
def _parse_gemfile(self, file_path: Path) -> List[Package]:
|
|
75
|
+
"""Parse Gemfile."""
|
|
76
|
+
packages: List[Package] = []
|
|
77
|
+
content = file_path.read_text(encoding="utf-8")
|
|
78
|
+
lines = content.split("\n")
|
|
79
|
+
|
|
80
|
+
for line_num, line in enumerate(lines, start=1):
|
|
81
|
+
stripped = line.strip()
|
|
82
|
+
|
|
83
|
+
# Skip comments and empty lines
|
|
84
|
+
if not stripped or stripped.startswith("#"):
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
# Parse gem declarations
|
|
88
|
+
# gem 'name', 'version'
|
|
89
|
+
# gem 'name', '~> version'
|
|
90
|
+
# gem 'name', '>= version', '< version'
|
|
91
|
+
# gem 'name', version: 'x.y.z'
|
|
92
|
+
match = re.match(r"gem\s+['\"]([^'\"]+)['\"](?:\s*,\s*(.+))?", stripped)
|
|
93
|
+
if match:
|
|
94
|
+
name = match.group(1)
|
|
95
|
+
version_part = match.group(2)
|
|
96
|
+
|
|
97
|
+
version = self._extract_version(version_part) if version_part else "*"
|
|
98
|
+
|
|
99
|
+
packages.append(
|
|
100
|
+
Package(
|
|
101
|
+
name=name,
|
|
102
|
+
version=version,
|
|
103
|
+
ecosystem=self.ecosystem,
|
|
104
|
+
source_file=file_path,
|
|
105
|
+
source_line=line_num,
|
|
106
|
+
is_direct=True,
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
return packages
|
|
111
|
+
|
|
112
|
+
def _extract_version(self, version_str: str) -> str:
|
|
113
|
+
"""Extract version from Gemfile version specification."""
|
|
114
|
+
if not version_str:
|
|
115
|
+
return "*"
|
|
116
|
+
|
|
117
|
+
# Handle version: 'x.y.z' syntax
|
|
118
|
+
version_match = re.search(r"version:\s*['\"]([^'\"]+)['\"]", version_str)
|
|
119
|
+
if version_match:
|
|
120
|
+
return self._normalize_version(version_match.group(1))
|
|
121
|
+
|
|
122
|
+
# Handle string version: '~> 1.2.3' or '>= 1.0'
|
|
123
|
+
string_match = re.search(r"['\"]([^'\"]+)['\"]", version_str)
|
|
124
|
+
if string_match:
|
|
125
|
+
return self._normalize_version(string_match.group(1))
|
|
126
|
+
|
|
127
|
+
return "*"
|
|
128
|
+
|
|
129
|
+
def _normalize_version(self, version: str) -> str:
|
|
130
|
+
"""Normalize Ruby version specifier."""
|
|
131
|
+
# Remove operators: ~>, >=, <=, >, <, =
|
|
132
|
+
version = re.sub(r"^[~>=<]+\s*", "", version.strip())
|
|
133
|
+
return version
|
|
134
|
+
|
|
135
|
+
def _parse_gemfile_lock(self, file_path: Path) -> List[Package]:
|
|
136
|
+
"""Parse Gemfile.lock."""
|
|
137
|
+
packages: List[Package] = []
|
|
138
|
+
content = file_path.read_text(encoding="utf-8")
|
|
139
|
+
lines = content.split("\n")
|
|
140
|
+
|
|
141
|
+
in_specs = False
|
|
142
|
+
seen: set = set()
|
|
143
|
+
|
|
144
|
+
for line in lines:
|
|
145
|
+
# Check for GEM section specs
|
|
146
|
+
if line.strip() == "specs:":
|
|
147
|
+
in_specs = True
|
|
148
|
+
continue
|
|
149
|
+
|
|
150
|
+
# End of specs section (new section or blank line after indented content)
|
|
151
|
+
if in_specs and line and not line.startswith(" "):
|
|
152
|
+
in_specs = False
|
|
153
|
+
continue
|
|
154
|
+
|
|
155
|
+
if in_specs:
|
|
156
|
+
# Parse gem entries: " gem_name (version)"
|
|
157
|
+
# Indentation of 4 spaces indicates a direct spec
|
|
158
|
+
match = re.match(r"^ (\S+)\s+\(([^)]+)\)$", line)
|
|
159
|
+
if match:
|
|
160
|
+
name = match.group(1)
|
|
161
|
+
version = match.group(2)
|
|
162
|
+
|
|
163
|
+
pkg_key = (name, version)
|
|
164
|
+
if pkg_key not in seen:
|
|
165
|
+
seen.add(pkg_key)
|
|
166
|
+
packages.append(
|
|
167
|
+
Package(
|
|
168
|
+
name=name,
|
|
169
|
+
version=version,
|
|
170
|
+
ecosystem=self.ecosystem,
|
|
171
|
+
source_file=file_path,
|
|
172
|
+
source_line=None,
|
|
173
|
+
is_direct=False,
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
return packages
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def register() -> None:
|
|
181
|
+
"""Register the Ruby analyzer."""
|
|
182
|
+
AnalyzerRegistry.get_instance().register(RubyAnalyzer())
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Rust dependency analyzer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
if sys.version_info >= (3, 11):
|
|
11
|
+
import tomllib
|
|
12
|
+
else:
|
|
13
|
+
import tomli as tomllib
|
|
14
|
+
|
|
15
|
+
from cve_sentinel.analyzers.base import (
|
|
16
|
+
AnalyzerRegistry,
|
|
17
|
+
BaseAnalyzer,
|
|
18
|
+
FileDetector,
|
|
19
|
+
Package,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RustAnalyzer(BaseAnalyzer):
|
|
24
|
+
"""Analyzer for Rust Cargo.
|
|
25
|
+
|
|
26
|
+
Supports:
|
|
27
|
+
- Cargo.toml (Level 1: direct dependencies)
|
|
28
|
+
- Cargo.lock (Level 2: transitive dependencies)
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def ecosystem(self) -> str:
|
|
33
|
+
"""Return the ecosystem name."""
|
|
34
|
+
return "crates.io"
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def manifest_patterns(self) -> List[str]:
|
|
38
|
+
"""Return glob patterns for manifest files."""
|
|
39
|
+
default_patterns = ["Cargo.toml"]
|
|
40
|
+
custom = self._custom_patterns.get("manifests", [])
|
|
41
|
+
return default_patterns + custom
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def lock_patterns(self) -> List[str]:
|
|
45
|
+
"""Return glob patterns for lock files."""
|
|
46
|
+
default_patterns = ["Cargo.lock"]
|
|
47
|
+
custom = self._custom_patterns.get("locks", [])
|
|
48
|
+
return default_patterns + custom
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
analysis_level: int = 2,
|
|
53
|
+
custom_patterns: Optional[Dict[str, List[str]]] = None,
|
|
54
|
+
) -> None:
|
|
55
|
+
"""Initialize Rust analyzer.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
analysis_level: Analysis depth (1=manifest only, 2=include lock files)
|
|
59
|
+
custom_patterns: Optional custom file patterns {"manifests": [...], "locks": [...]}
|
|
60
|
+
"""
|
|
61
|
+
self.analysis_level = analysis_level
|
|
62
|
+
self._custom_patterns = custom_patterns or {}
|
|
63
|
+
self._file_detector = FileDetector()
|
|
64
|
+
|
|
65
|
+
def detect_files(self, path: Path) -> List[Path]:
|
|
66
|
+
"""Detect Rust dependency files."""
|
|
67
|
+
patterns = self.manifest_patterns.copy()
|
|
68
|
+
if self.analysis_level >= 2:
|
|
69
|
+
patterns.extend(self.lock_patterns)
|
|
70
|
+
return self._file_detector.find_files(path, patterns)
|
|
71
|
+
|
|
72
|
+
def parse(self, file_path: Path) -> List[Package]:
|
|
73
|
+
"""Parse a Rust dependency file."""
|
|
74
|
+
if file_path.name == "Cargo.toml":
|
|
75
|
+
return self._parse_cargo_toml(file_path)
|
|
76
|
+
elif file_path.name == "Cargo.lock":
|
|
77
|
+
return self._parse_cargo_lock(file_path)
|
|
78
|
+
return []
|
|
79
|
+
|
|
80
|
+
def _parse_cargo_toml(self, file_path: Path) -> List[Package]:
|
|
81
|
+
"""Parse Cargo.toml file."""
|
|
82
|
+
packages: List[Package] = []
|
|
83
|
+
content = file_path.read_text(encoding="utf-8")
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
data = tomllib.loads(content)
|
|
87
|
+
except Exception:
|
|
88
|
+
return packages
|
|
89
|
+
|
|
90
|
+
# Get package name to skip self-references
|
|
91
|
+
pkg_name = data.get("package", {}).get("name", "")
|
|
92
|
+
|
|
93
|
+
# Parse dependencies
|
|
94
|
+
deps = data.get("dependencies", {})
|
|
95
|
+
packages.extend(self._parse_deps_section(deps, file_path, pkg_name, True))
|
|
96
|
+
|
|
97
|
+
# Parse dev-dependencies
|
|
98
|
+
dev_deps = data.get("dev-dependencies", {})
|
|
99
|
+
packages.extend(self._parse_deps_section(dev_deps, file_path, pkg_name, True))
|
|
100
|
+
|
|
101
|
+
# Parse build-dependencies
|
|
102
|
+
build_deps = data.get("build-dependencies", {})
|
|
103
|
+
packages.extend(self._parse_deps_section(build_deps, file_path, pkg_name, True))
|
|
104
|
+
|
|
105
|
+
return packages
|
|
106
|
+
|
|
107
|
+
def _parse_deps_section(
|
|
108
|
+
self,
|
|
109
|
+
deps: Dict[str, Any],
|
|
110
|
+
file_path: Path,
|
|
111
|
+
skip_name: str,
|
|
112
|
+
is_direct: bool,
|
|
113
|
+
) -> List[Package]:
|
|
114
|
+
"""Parse a dependencies section."""
|
|
115
|
+
packages: List[Package] = []
|
|
116
|
+
|
|
117
|
+
for name, spec in deps.items():
|
|
118
|
+
if name == skip_name:
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
version = self._extract_version(spec)
|
|
122
|
+
if not version:
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
packages.append(
|
|
126
|
+
Package(
|
|
127
|
+
name=name,
|
|
128
|
+
version=version,
|
|
129
|
+
ecosystem=self.ecosystem,
|
|
130
|
+
source_file=file_path,
|
|
131
|
+
source_line=None,
|
|
132
|
+
is_direct=is_direct,
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
return packages
|
|
137
|
+
|
|
138
|
+
def _extract_version(self, spec: Any) -> Optional[str]:
|
|
139
|
+
"""Extract version from dependency specification."""
|
|
140
|
+
if isinstance(spec, str):
|
|
141
|
+
return self._normalize_version(spec)
|
|
142
|
+
elif isinstance(spec, dict):
|
|
143
|
+
version = spec.get("version")
|
|
144
|
+
if version:
|
|
145
|
+
return self._normalize_version(version)
|
|
146
|
+
# Git or path dependencies don't have versions
|
|
147
|
+
if spec.get("git") or spec.get("path"):
|
|
148
|
+
return None
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
def _normalize_version(self, version: str) -> str:
|
|
152
|
+
"""Normalize Cargo version specifier."""
|
|
153
|
+
# Remove operators: ^, ~, >=, <=, >, <, =
|
|
154
|
+
version = re.sub(r"^[\^~>=<]+\s*", "", version.strip())
|
|
155
|
+
# Handle wildcard: 1.* -> 1.0
|
|
156
|
+
version = re.sub(r"\.\*$", ".0", version)
|
|
157
|
+
return version
|
|
158
|
+
|
|
159
|
+
def _parse_cargo_lock(self, file_path: Path) -> List[Package]:
|
|
160
|
+
"""Parse Cargo.lock file."""
|
|
161
|
+
packages: List[Package] = []
|
|
162
|
+
content = file_path.read_text(encoding="utf-8")
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
data = tomllib.loads(content)
|
|
166
|
+
except Exception:
|
|
167
|
+
return packages
|
|
168
|
+
|
|
169
|
+
# Parse [[package]] entries
|
|
170
|
+
pkg_list = data.get("package", [])
|
|
171
|
+
seen: set = set()
|
|
172
|
+
|
|
173
|
+
for pkg in pkg_list:
|
|
174
|
+
name = pkg.get("name", "")
|
|
175
|
+
version = pkg.get("version", "")
|
|
176
|
+
|
|
177
|
+
if not name or not version:
|
|
178
|
+
continue
|
|
179
|
+
|
|
180
|
+
pkg_key = (name, version)
|
|
181
|
+
if pkg_key not in seen:
|
|
182
|
+
seen.add(pkg_key)
|
|
183
|
+
packages.append(
|
|
184
|
+
Package(
|
|
185
|
+
name=name,
|
|
186
|
+
version=version,
|
|
187
|
+
ecosystem=self.ecosystem,
|
|
188
|
+
source_file=file_path,
|
|
189
|
+
source_line=None,
|
|
190
|
+
is_direct=False,
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
return packages
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def register() -> None:
|
|
198
|
+
"""Register the Rust analyzer."""
|
|
199
|
+
AnalyzerRegistry.get_instance().register(RustAnalyzer())
|