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.
@@ -0,0 +1,238 @@
1
+ """PHP Composer dependency analyzer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from pathlib import Path
8
+ from typing import Dict, List, Optional
9
+
10
+ from cve_sentinel.analyzers.base import (
11
+ AnalyzerRegistry,
12
+ BaseAnalyzer,
13
+ FileDetector,
14
+ Package,
15
+ )
16
+
17
+
18
+ class PhpAnalyzer(BaseAnalyzer):
19
+ """Analyzer for PHP Composer.
20
+
21
+ Supports:
22
+ - composer.json (Level 1: direct dependencies)
23
+ - composer.lock (Level 2: transitive dependencies)
24
+ """
25
+
26
+ @property
27
+ def ecosystem(self) -> str:
28
+ """Return the ecosystem name."""
29
+ return "packagist"
30
+
31
+ @property
32
+ def manifest_patterns(self) -> List[str]:
33
+ """Return glob patterns for manifest files."""
34
+ default_patterns = ["composer.json"]
35
+ custom = self._custom_patterns.get("manifests", [])
36
+ return default_patterns + custom
37
+
38
+ @property
39
+ def lock_patterns(self) -> List[str]:
40
+ """Return glob patterns for lock files."""
41
+ default_patterns = ["composer.lock"]
42
+ custom = self._custom_patterns.get("locks", [])
43
+ return default_patterns + custom
44
+
45
+ def __init__(
46
+ self,
47
+ analysis_level: int = 2,
48
+ custom_patterns: Optional[Dict[str, List[str]]] = None,
49
+ ) -> None:
50
+ """Initialize PHP analyzer.
51
+
52
+ Args:
53
+ analysis_level: Analysis depth (1=manifest only, 2=include lock files)
54
+ custom_patterns: Optional custom file patterns {"manifests": [...], "locks": [...]}
55
+ """
56
+ self.analysis_level = analysis_level
57
+ self._custom_patterns = custom_patterns or {}
58
+ self._file_detector = FileDetector()
59
+
60
+ def detect_files(self, path: Path) -> List[Path]:
61
+ """Detect PHP dependency files."""
62
+ patterns = self.manifest_patterns.copy()
63
+ if self.analysis_level >= 2:
64
+ patterns.extend(self.lock_patterns)
65
+ return self._file_detector.find_files(path, patterns)
66
+
67
+ def parse(self, file_path: Path) -> List[Package]:
68
+ """Parse a PHP dependency file."""
69
+ if file_path.name == "composer.json":
70
+ return self._parse_composer_json(file_path)
71
+ elif file_path.name == "composer.lock":
72
+ return self._parse_composer_lock(file_path)
73
+ return []
74
+
75
+ def _parse_composer_json(self, file_path: Path) -> List[Package]:
76
+ """Parse composer.json file."""
77
+ packages: List[Package] = []
78
+ content = file_path.read_text(encoding="utf-8")
79
+
80
+ try:
81
+ data = json.loads(content)
82
+ except json.JSONDecodeError:
83
+ return packages
84
+
85
+ # Parse require section
86
+ require = data.get("require", {})
87
+ for name, version_spec in require.items():
88
+ # Skip PHP version constraints and extensions
89
+ if name == "php" or name.startswith("ext-"):
90
+ continue
91
+
92
+ version = self._normalize_version(version_spec)
93
+ line_num = self._find_line_number(content, name, "require")
94
+
95
+ packages.append(
96
+ Package(
97
+ name=name,
98
+ version=version,
99
+ ecosystem=self.ecosystem,
100
+ source_file=file_path,
101
+ source_line=line_num,
102
+ is_direct=True,
103
+ )
104
+ )
105
+
106
+ # Parse require-dev section
107
+ require_dev = data.get("require-dev", {})
108
+ for name, version_spec in require_dev.items():
109
+ if name == "php" or name.startswith("ext-"):
110
+ continue
111
+
112
+ version = self._normalize_version(version_spec)
113
+ line_num = self._find_line_number(content, name, "require-dev")
114
+
115
+ packages.append(
116
+ Package(
117
+ name=name,
118
+ version=version,
119
+ ecosystem=self.ecosystem,
120
+ source_file=file_path,
121
+ source_line=line_num,
122
+ is_direct=True,
123
+ )
124
+ )
125
+
126
+ return packages
127
+
128
+ def _parse_composer_lock(self, file_path: Path) -> List[Package]:
129
+ """Parse composer.lock file."""
130
+ packages: List[Package] = []
131
+ content = file_path.read_text(encoding="utf-8")
132
+
133
+ try:
134
+ data = json.loads(content)
135
+ except json.JSONDecodeError:
136
+ return packages
137
+
138
+ # Parse packages section
139
+ pkg_list = data.get("packages", [])
140
+ for pkg in pkg_list:
141
+ name = pkg.get("name", "")
142
+ version = pkg.get("version", "")
143
+
144
+ if not name or not version:
145
+ continue
146
+
147
+ # Normalize version (remove 'v' prefix)
148
+ if version.startswith("v"):
149
+ version = version[1:]
150
+
151
+ packages.append(
152
+ Package(
153
+ name=name,
154
+ version=version,
155
+ ecosystem=self.ecosystem,
156
+ source_file=file_path,
157
+ source_line=None,
158
+ is_direct=False,
159
+ )
160
+ )
161
+
162
+ # Parse packages-dev section
163
+ pkg_dev_list = data.get("packages-dev", [])
164
+ for pkg in pkg_dev_list:
165
+ name = pkg.get("name", "")
166
+ version = pkg.get("version", "")
167
+
168
+ if not name or not version:
169
+ continue
170
+
171
+ if version.startswith("v"):
172
+ version = version[1:]
173
+
174
+ packages.append(
175
+ Package(
176
+ name=name,
177
+ version=version,
178
+ ecosystem=self.ecosystem,
179
+ source_file=file_path,
180
+ source_line=None,
181
+ is_direct=False,
182
+ )
183
+ )
184
+
185
+ return packages
186
+
187
+ def _normalize_version(self, version_spec: str) -> str:
188
+ """Normalize Composer version specifier."""
189
+ if not version_spec:
190
+ return "*"
191
+
192
+ # Handle dev versions
193
+ if version_spec.startswith("dev-"):
194
+ return version_spec
195
+
196
+ # Remove operators: ^, ~, >=, <=, >, <, =, ||
197
+ version = re.sub(r"^[\^~>=<|]+\s*", "", version_spec)
198
+
199
+ # Handle OR: take first version
200
+ if "|" in version:
201
+ version = version.split("|")[0].strip()
202
+ version = re.sub(r"^[\^~>=<]+\s*", "", version)
203
+
204
+ # Handle range with space: ">1.0 <2.0" -> take first
205
+ if " " in version:
206
+ version = version.split()[0].strip()
207
+ version = re.sub(r"^[\^~>=<]+\s*", "", version)
208
+
209
+ # Remove 'v' prefix
210
+ if version.startswith("v"):
211
+ version = version[1:]
212
+
213
+ # Handle wildcard: 1.* -> 1.0
214
+ version = re.sub(r"\.\*$", ".0", version)
215
+
216
+ return version.strip() or "*"
217
+
218
+ def _find_line_number(self, content: str, package_name: str, section: str) -> int | None:
219
+ """Find line number of a package in composer.json."""
220
+ lines = content.split("\n")
221
+ in_section = False
222
+ escaped_name = re.escape(package_name)
223
+
224
+ for i, line in enumerate(lines, start=1):
225
+ if f'"{section}"' in line:
226
+ in_section = True
227
+ elif in_section:
228
+ if re.match(r"^\s*\}", line):
229
+ in_section = False
230
+ elif re.search(rf'"{escaped_name}"', line):
231
+ return i
232
+
233
+ return None
234
+
235
+
236
+ def register() -> None:
237
+ """Register the PHP analyzer."""
238
+ AnalyzerRegistry.get_instance().register(PhpAnalyzer())
@@ -0,0 +1,435 @@
1
+ """Python dependency analyzer for pip, poetry, and pipenv."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Any, Dict, List, Optional, Tuple
10
+
11
+ if sys.version_info >= (3, 11):
12
+ import tomllib
13
+ else:
14
+ import tomli as tomllib
15
+
16
+ from cve_sentinel.analyzers.base import BaseAnalyzer, FileDetector, Package
17
+
18
+
19
+ class PythonAnalyzer(BaseAnalyzer):
20
+ """Analyzer for Python dependencies (pip, poetry, pipenv)."""
21
+
22
+ @property
23
+ def ecosystem(self) -> str:
24
+ return "pypi"
25
+
26
+ @property
27
+ def manifest_patterns(self) -> List[str]:
28
+ default_patterns = ["requirements.txt", "requirements*.txt", "pyproject.toml", "Pipfile"]
29
+ custom = self._custom_patterns.get("manifests", [])
30
+ return default_patterns + custom
31
+
32
+ @property
33
+ def lock_patterns(self) -> List[str]:
34
+ default_patterns = ["poetry.lock", "Pipfile.lock"]
35
+ custom = self._custom_patterns.get("locks", [])
36
+ return default_patterns + custom
37
+
38
+ def __init__(
39
+ self,
40
+ exclude_patterns: Optional[List[str]] = None,
41
+ custom_patterns: Optional[Dict[str, List[str]]] = None,
42
+ ) -> None:
43
+ """Initialize the analyzer.
44
+
45
+ Args:
46
+ exclude_patterns: Glob patterns to exclude from file detection
47
+ custom_patterns: Optional custom file patterns {"manifests": [...], "locks": [...]}
48
+ """
49
+ self._custom_patterns = custom_patterns or {}
50
+ self._file_detector = FileDetector(exclude_patterns=exclude_patterns)
51
+
52
+ def detect_files(self, path: Path) -> List[Path]:
53
+ """Detect Python dependency files in the given path."""
54
+ all_patterns = self.manifest_patterns + self.lock_patterns
55
+ return self._file_detector.find_files(path, all_patterns)
56
+
57
+ def parse(self, file_path: Path) -> List[Package]:
58
+ """Parse a Python dependency file and return packages."""
59
+ filename = file_path.name.lower()
60
+
61
+ if filename == "requirements.txt" or filename.startswith("requirements"):
62
+ if filename.endswith(".txt"):
63
+ return self._parse_requirements_txt(file_path)
64
+ elif filename == "pyproject.toml":
65
+ return self._parse_pyproject_toml(file_path)
66
+ elif filename == "pipfile":
67
+ return self._parse_pipfile(file_path)
68
+ elif filename == "poetry.lock":
69
+ return self._parse_poetry_lock(file_path)
70
+ elif filename == "pipfile.lock":
71
+ return self._parse_pipfile_lock(file_path)
72
+
73
+ return []
74
+
75
+ def _parse_requirements_txt(
76
+ self, file_path: Path, visited: Optional[set] = None
77
+ ) -> List[Package]:
78
+ """Parse requirements.txt file.
79
+
80
+ Supports:
81
+ - package==version
82
+ - package>=version
83
+ - package[extra]==version
84
+ - Comments (#)
85
+ - -r includes
86
+ """
87
+ if visited is None:
88
+ visited = set()
89
+
90
+ # Prevent infinite recursion
91
+ abs_path = file_path.resolve()
92
+ if abs_path in visited:
93
+ return []
94
+ visited.add(abs_path)
95
+
96
+ packages: List[Package] = []
97
+ content = file_path.read_text(encoding="utf-8", errors="ignore")
98
+
99
+ for line_num, line in enumerate(content.splitlines(), start=1):
100
+ line = line.strip()
101
+
102
+ # Skip empty lines and comments
103
+ if not line or line.startswith("#"):
104
+ continue
105
+
106
+ # Handle -r includes
107
+ if line.startswith("-r ") or line.startswith("--requirement "):
108
+ include_path = line.split(None, 1)[1].strip()
109
+ include_file = file_path.parent / include_path
110
+ if include_file.exists():
111
+ packages.extend(self._parse_requirements_txt(include_file, visited))
112
+ continue
113
+
114
+ # Skip other flags
115
+ if line.startswith("-"):
116
+ continue
117
+
118
+ # Parse package specification
119
+ parsed = self._parse_requirement_line(line)
120
+ if parsed:
121
+ name, version = parsed
122
+ packages.append(
123
+ Package(
124
+ name=name,
125
+ version=version,
126
+ ecosystem=self.ecosystem,
127
+ source_file=file_path,
128
+ source_line=line_num,
129
+ is_direct=True,
130
+ )
131
+ )
132
+
133
+ return packages
134
+
135
+ def _parse_requirement_line(self, line: str) -> Optional[Tuple[str, str]]:
136
+ """Parse a single requirement line.
137
+
138
+ Returns:
139
+ Tuple of (package_name, version) or None if unparseable
140
+ """
141
+ # Remove inline comments
142
+ if " #" in line:
143
+ line = line.split(" #")[0].strip()
144
+
145
+ # Remove environment markers
146
+ if ";" in line:
147
+ line = line.split(";")[0].strip()
148
+
149
+ # Handle extras: package[extra1,extra2]==version
150
+ extras_match = re.match(r"^([a-zA-Z0-9_.-]+)\[([^\]]+)\](.*)$", line)
151
+ if extras_match:
152
+ name = extras_match.group(1)
153
+ rest = extras_match.group(3).strip()
154
+ # Parse version from rest (e.g., "==2.28.0")
155
+ version_only_pattern = r"^(==|>=|<=|~=|!=|>|<)\s*([a-zA-Z0-9_.*+-]+)"
156
+ version_match = re.match(version_only_pattern, rest)
157
+ if version_match:
158
+ version = version_match.group(2)
159
+ return self._normalize_package_name(name), version
160
+ elif not rest:
161
+ return self._normalize_package_name(name), "*"
162
+ return None
163
+
164
+ # Match version specifiers
165
+ # Patterns: ==, >=, <=, ~=, !=, >, <
166
+ version_pattern = r"^([a-zA-Z0-9_.-]+)\s*(==|>=|<=|~=|!=|>|<)\s*([a-zA-Z0-9_.*+-]+)"
167
+ match = re.match(version_pattern, line)
168
+
169
+ if match:
170
+ pkg_name = match.group(1)
171
+ version = match.group(3)
172
+ return self._normalize_package_name(pkg_name), version
173
+
174
+ # Package without version
175
+ simple_match = re.match(r"^([a-zA-Z0-9_.-]+)\s*$", line)
176
+ if simple_match:
177
+ pkg_name = simple_match.group(1)
178
+ return self._normalize_package_name(pkg_name), "*"
179
+
180
+ return None
181
+
182
+ def _parse_pyproject_toml(self, file_path: Path) -> List[Package]:
183
+ """Parse pyproject.toml for dependencies.
184
+
185
+ Supports:
186
+ - [project.dependencies] (PEP 621)
187
+ - [project.optional-dependencies]
188
+ - [tool.poetry.dependencies]
189
+ - [tool.poetry.dev-dependencies]
190
+ """
191
+ packages: List[Package] = []
192
+ content = file_path.read_text(encoding="utf-8")
193
+
194
+ try:
195
+ data = tomllib.loads(content)
196
+ except Exception:
197
+ return []
198
+
199
+ # PEP 621 style: [project.dependencies]
200
+ project = data.get("project", {})
201
+ deps = project.get("dependencies", [])
202
+ for dep in deps:
203
+ parsed = self._parse_requirement_line(dep)
204
+ if parsed:
205
+ name, version = parsed
206
+ packages.append(
207
+ Package(
208
+ name=name,
209
+ version=version,
210
+ ecosystem=self.ecosystem,
211
+ source_file=file_path,
212
+ source_line=None,
213
+ is_direct=True,
214
+ )
215
+ )
216
+
217
+ # PEP 621 optional dependencies
218
+ optional_deps = project.get("optional-dependencies", {})
219
+ for _group, deps_list in optional_deps.items():
220
+ for dep in deps_list:
221
+ parsed = self._parse_requirement_line(dep)
222
+ if parsed:
223
+ name, version = parsed
224
+ packages.append(
225
+ Package(
226
+ name=name,
227
+ version=version,
228
+ ecosystem=self.ecosystem,
229
+ source_file=file_path,
230
+ source_line=None,
231
+ is_direct=True,
232
+ )
233
+ )
234
+
235
+ # Poetry style: [tool.poetry.dependencies]
236
+ tool = data.get("tool", {})
237
+ poetry = tool.get("poetry", {})
238
+ poetry_deps = poetry.get("dependencies", {})
239
+ for name, spec in poetry_deps.items():
240
+ if name.lower() == "python":
241
+ continue
242
+ version = self._extract_poetry_version(spec)
243
+ packages.append(
244
+ Package(
245
+ name=self._normalize_package_name(name),
246
+ version=version,
247
+ ecosystem=self.ecosystem,
248
+ source_file=file_path,
249
+ source_line=None,
250
+ is_direct=True,
251
+ )
252
+ )
253
+
254
+ # Poetry dev dependencies
255
+ dev_deps = poetry.get("dev-dependencies", {})
256
+ for name, spec in dev_deps.items():
257
+ version = self._extract_poetry_version(spec)
258
+ packages.append(
259
+ Package(
260
+ name=self._normalize_package_name(name),
261
+ version=version,
262
+ ecosystem=self.ecosystem,
263
+ source_file=file_path,
264
+ source_line=None,
265
+ is_direct=True,
266
+ )
267
+ )
268
+
269
+ # Poetry group dependencies (poetry 1.2+)
270
+ groups = poetry.get("group", {})
271
+ for _group_name, group_data in groups.items():
272
+ group_deps = group_data.get("dependencies", {})
273
+ for name, spec in group_deps.items():
274
+ version = self._extract_poetry_version(spec)
275
+ packages.append(
276
+ Package(
277
+ name=self._normalize_package_name(name),
278
+ version=version,
279
+ ecosystem=self.ecosystem,
280
+ source_file=file_path,
281
+ source_line=None,
282
+ is_direct=True,
283
+ )
284
+ )
285
+
286
+ return packages
287
+
288
+ def _extract_poetry_version(self, spec: Any) -> str:
289
+ """Extract version from Poetry dependency specification."""
290
+ if isinstance(spec, str):
291
+ # Simple version string: "^1.0.0" or ">=1.0,<2.0"
292
+ return spec.lstrip("^~")
293
+ elif isinstance(spec, dict):
294
+ # Complex specification: {version = "^1.0.0", optional = true}
295
+ version = spec.get("version", "*")
296
+ return version.lstrip("^~") if isinstance(version, str) else "*"
297
+ return "*"
298
+
299
+ def _parse_pipfile(self, file_path: Path) -> List[Package]:
300
+ """Parse Pipfile for dependencies."""
301
+ packages: List[Package] = []
302
+ content = file_path.read_text(encoding="utf-8")
303
+
304
+ try:
305
+ data = tomllib.loads(content)
306
+ except Exception:
307
+ return []
308
+
309
+ # Parse [packages] section
310
+ pkgs = data.get("packages", {})
311
+ for name, spec in pkgs.items():
312
+ version = self._extract_pipfile_version(spec)
313
+ packages.append(
314
+ Package(
315
+ name=self._normalize_package_name(name),
316
+ version=version,
317
+ ecosystem=self.ecosystem,
318
+ source_file=file_path,
319
+ source_line=None,
320
+ is_direct=True,
321
+ )
322
+ )
323
+
324
+ # Parse [dev-packages] section
325
+ dev_pkgs = data.get("dev-packages", {})
326
+ for name, spec in dev_pkgs.items():
327
+ version = self._extract_pipfile_version(spec)
328
+ packages.append(
329
+ Package(
330
+ name=self._normalize_package_name(name),
331
+ version=version,
332
+ ecosystem=self.ecosystem,
333
+ source_file=file_path,
334
+ source_line=None,
335
+ is_direct=True,
336
+ )
337
+ )
338
+
339
+ return packages
340
+
341
+ def _extract_pipfile_version(self, spec: Any) -> str:
342
+ """Extract version from Pipfile dependency specification."""
343
+ if isinstance(spec, str):
344
+ if spec == "*":
345
+ return "*"
346
+ # Remove operators
347
+ return re.sub(r"^[=<>~!]+", "", spec)
348
+ elif isinstance(spec, dict):
349
+ version = spec.get("version", "*")
350
+ if version == "*":
351
+ return "*"
352
+ return re.sub(r"^[=<>~!]+", "", version)
353
+ return "*"
354
+
355
+ def _parse_poetry_lock(self, file_path: Path) -> List[Package]:
356
+ """Parse poetry.lock for transitive dependencies."""
357
+ packages: List[Package] = []
358
+ content = file_path.read_text(encoding="utf-8")
359
+
360
+ try:
361
+ data = tomllib.loads(content)
362
+ except Exception:
363
+ return []
364
+
365
+ # Parse [[package]] entries
366
+ pkg_list = data.get("package", [])
367
+ for pkg in pkg_list:
368
+ name = pkg.get("name", "")
369
+ version = pkg.get("version", "*")
370
+ if name:
371
+ packages.append(
372
+ Package(
373
+ name=self._normalize_package_name(name),
374
+ version=version,
375
+ ecosystem=self.ecosystem,
376
+ source_file=file_path,
377
+ source_line=None,
378
+ is_direct=False, # Lock file = transitive dependencies
379
+ )
380
+ )
381
+
382
+ return packages
383
+
384
+ def _parse_pipfile_lock(self, file_path: Path) -> List[Package]:
385
+ """Parse Pipfile.lock for transitive dependencies."""
386
+ packages: List[Package] = []
387
+ content = file_path.read_text(encoding="utf-8")
388
+
389
+ try:
390
+ data = json.loads(content)
391
+ except json.JSONDecodeError:
392
+ return []
393
+
394
+ # Parse "default" section (production dependencies)
395
+ default = data.get("default", {})
396
+ for name, spec in default.items():
397
+ version = self._extract_pipfile_lock_version(spec)
398
+ packages.append(
399
+ Package(
400
+ name=self._normalize_package_name(name),
401
+ version=version,
402
+ ecosystem=self.ecosystem,
403
+ source_file=file_path,
404
+ source_line=None,
405
+ is_direct=False,
406
+ )
407
+ )
408
+
409
+ # Parse "develop" section (dev dependencies)
410
+ develop = data.get("develop", {})
411
+ for name, spec in develop.items():
412
+ version = self._extract_pipfile_lock_version(spec)
413
+ packages.append(
414
+ Package(
415
+ name=self._normalize_package_name(name),
416
+ version=version,
417
+ ecosystem=self.ecosystem,
418
+ source_file=file_path,
419
+ source_line=None,
420
+ is_direct=False,
421
+ )
422
+ )
423
+
424
+ return packages
425
+
426
+ def _extract_pipfile_lock_version(self, spec: Dict) -> str:
427
+ """Extract version from Pipfile.lock entry."""
428
+ version = spec.get("version", "*")
429
+ if version.startswith("=="):
430
+ return version[2:]
431
+ return version
432
+
433
+ def _normalize_package_name(self, name: str) -> str:
434
+ """Normalize Python package name (PEP 503)."""
435
+ return re.sub(r"[-_.]+", "-", name).lower()