pipsutra 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pipsutra/__init__.py +20 -0
- pipsutra/_version.py +1 -0
- pipsutra/api.py +87 -0
- pipsutra/cli.py +179 -0
- pipsutra/discovery.py +281 -0
- pipsutra/models.py +98 -0
- pipsutra/risk.py +45 -0
- pipsutra/static_analysis.py +207 -0
- pipsutra/vulnerability.py +109 -0
- pipsutra-0.1.0.dist-info/METADATA +428 -0
- pipsutra-0.1.0.dist-info/RECORD +15 -0
- pipsutra-0.1.0.dist-info/WHEEL +4 -0
- pipsutra-0.1.0.dist-info/entry_points.txt +2 -0
- pipsutra-0.1.0.dist-info/licenses/LICENSE +196 -0
- pipsutra-0.1.0.dist-info/licenses/NOTICE +5 -0
pipsutra/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""PipSutra: explainable security analysis for Python dependencies."""
|
|
2
|
+
|
|
3
|
+
from pipsutra._version import __version__
|
|
4
|
+
from pipsutra.api import build_inventory_report, scan_project
|
|
5
|
+
from pipsutra.models import Dependency, DependencyEdge, Inventory, Package
|
|
6
|
+
|
|
7
|
+
__author__ = "Nikhil Mutreja"
|
|
8
|
+
__license__ = "Apache-2.0"
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"__version__",
|
|
12
|
+
"__author__",
|
|
13
|
+
"__license__",
|
|
14
|
+
"build_inventory_report",
|
|
15
|
+
"scan_project",
|
|
16
|
+
"Dependency",
|
|
17
|
+
"DependencyEdge",
|
|
18
|
+
"Inventory",
|
|
19
|
+
"Package",
|
|
20
|
+
]
|
pipsutra/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
pipsutra/api.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from pipsutra._version import __version__
|
|
7
|
+
from pipsutra.discovery import build_inventory
|
|
8
|
+
from pipsutra.models import Inventory, ScanReport
|
|
9
|
+
from pipsutra.risk import calculate_risk
|
|
10
|
+
from pipsutra.static_analysis import scan_python_source
|
|
11
|
+
from pipsutra.vulnerability import query_osv
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def build_inventory_report(
|
|
15
|
+
project: str | Path = ".", environment: str | Path | None = None
|
|
16
|
+
) -> Inventory:
|
|
17
|
+
selected_environment = Path(environment) if environment else Path(sys.prefix)
|
|
18
|
+
return build_inventory(project, selected_environment)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def scan_project(
|
|
22
|
+
project: str | Path = ".",
|
|
23
|
+
environment: str | Path | None = None,
|
|
24
|
+
*,
|
|
25
|
+
check_vulnerabilities: bool = True,
|
|
26
|
+
check_static: bool = True,
|
|
27
|
+
network_timeout: float = 15.0,
|
|
28
|
+
) -> ScanReport:
|
|
29
|
+
inventory = build_inventory_report(project, environment)
|
|
30
|
+
errors: list[str] = []
|
|
31
|
+
checks = {
|
|
32
|
+
"inventory": (
|
|
33
|
+
"completed" if inventory.discovery_status == "complete" else "partial"
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
osv_errors: list[str] = []
|
|
38
|
+
if check_vulnerabilities:
|
|
39
|
+
relevant_packages = [
|
|
40
|
+
package
|
|
41
|
+
for package in inventory.packages
|
|
42
|
+
if package.direct or package.dependency_depth is not None
|
|
43
|
+
]
|
|
44
|
+
vulnerabilities, osv_errors = query_osv(
|
|
45
|
+
relevant_packages, timeout=network_timeout
|
|
46
|
+
)
|
|
47
|
+
errors.extend(osv_errors)
|
|
48
|
+
checks["osv"] = "completed" if not osv_errors else "failed"
|
|
49
|
+
else:
|
|
50
|
+
vulnerabilities = []
|
|
51
|
+
checks["osv"] = "skipped"
|
|
52
|
+
|
|
53
|
+
if check_static:
|
|
54
|
+
static_findings, static_warnings = scan_python_source(inventory.project_path)
|
|
55
|
+
inventory.warnings.extend(static_warnings)
|
|
56
|
+
checks["static_analysis"] = "partial" if static_warnings else "completed"
|
|
57
|
+
else:
|
|
58
|
+
static_findings = []
|
|
59
|
+
checks["static_analysis"] = "skipped"
|
|
60
|
+
|
|
61
|
+
risk = calculate_risk(vulnerabilities, static_findings)
|
|
62
|
+
incomplete_checks = [
|
|
63
|
+
name for name, status in checks.items() if status != "completed"
|
|
64
|
+
]
|
|
65
|
+
if incomplete_checks:
|
|
66
|
+
reason = ", ".join(incomplete_checks)
|
|
67
|
+
risk = risk.model_copy(
|
|
68
|
+
update={
|
|
69
|
+
"level": "UNKNOWN",
|
|
70
|
+
"explanation": (
|
|
71
|
+
risk.explanation
|
|
72
|
+
+ f" Coverage is incomplete; check status: {reason}."
|
|
73
|
+
),
|
|
74
|
+
}
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
return ScanReport(
|
|
78
|
+
tool_version=__version__,
|
|
79
|
+
project_path=inventory.project_path,
|
|
80
|
+
environment_path=inventory.environment_path,
|
|
81
|
+
inventory=inventory,
|
|
82
|
+
vulnerabilities=vulnerabilities,
|
|
83
|
+
static_findings=static_findings,
|
|
84
|
+
risk=risk,
|
|
85
|
+
checks=checks,
|
|
86
|
+
errors=errors,
|
|
87
|
+
)
|
pipsutra/cli.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
|
|
10
|
+
from pipsutra._version import __version__
|
|
11
|
+
from pipsutra.api import build_inventory_report, scan_project
|
|
12
|
+
from pipsutra.models import ScanReport
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(
|
|
15
|
+
name="pipsutra",
|
|
16
|
+
help="Explainable security analysis for Python dependencies.",
|
|
17
|
+
no_args_is_help=True,
|
|
18
|
+
epilog="Developed by Nikhil Mutreja | Apache-2.0 License",
|
|
19
|
+
)
|
|
20
|
+
console = Console()
|
|
21
|
+
_SEVERITY_RANK = {"LOW": 1, "MEDIUM": 2, "HIGH": 3, "CRITICAL": 4}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _has_blocking_findings(report: ScanReport, threshold: str) -> bool:
|
|
25
|
+
if threshold == "none":
|
|
26
|
+
return False
|
|
27
|
+
minimum = _SEVERITY_RANK[threshold.upper()]
|
|
28
|
+
vulnerability_match = any(
|
|
29
|
+
_SEVERITY_RANK.get(finding.severity, 0) >= minimum
|
|
30
|
+
for finding in report.vulnerabilities
|
|
31
|
+
)
|
|
32
|
+
static_match = any(
|
|
33
|
+
_SEVERITY_RANK.get(finding.severity, 0) >= minimum
|
|
34
|
+
for finding in report.static_findings
|
|
35
|
+
)
|
|
36
|
+
return vulnerability_match or static_match
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@app.command()
|
|
40
|
+
def version() -> None:
|
|
41
|
+
"""Print the installed PipSutra version."""
|
|
42
|
+
console.print(f"PipSutra {__version__}\nDeveloped by Nikhil Mutreja")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _write_json(payload: str, output: Path | None) -> None:
|
|
46
|
+
if output is None:
|
|
47
|
+
console.print(payload)
|
|
48
|
+
return
|
|
49
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
output.write_text(payload + "\n", encoding="utf-8")
|
|
51
|
+
console.print(f"[green]JSON report written to {output}[/green]")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@app.command()
|
|
55
|
+
def inventory(
|
|
56
|
+
project: Path = typer.Option(
|
|
57
|
+
Path("."), "--project", "-p", exists=True, file_okay=False
|
|
58
|
+
),
|
|
59
|
+
environment: Path | None = typer.Option(
|
|
60
|
+
None, "--environment", "-e", exists=True, file_okay=False
|
|
61
|
+
),
|
|
62
|
+
output: Path | None = typer.Option(None, "--output", "-o"),
|
|
63
|
+
) -> None:
|
|
64
|
+
"""Discover declared and installed dependencies and export an inventory."""
|
|
65
|
+
try:
|
|
66
|
+
report = build_inventory_report(project, environment)
|
|
67
|
+
except (ValueError, OSError) as exc:
|
|
68
|
+
console.print(f"[red]Inventory failed:[/red] {exc}")
|
|
69
|
+
raise typer.Exit(code=2) from exc
|
|
70
|
+
|
|
71
|
+
table = Table(title="PipSutra dependency inventory")
|
|
72
|
+
for heading in ("Package", "Version", "Classification", "Depth"):
|
|
73
|
+
table.add_column(heading)
|
|
74
|
+
for package in report.packages:
|
|
75
|
+
if package.direct:
|
|
76
|
+
classification = "direct"
|
|
77
|
+
elif package.dependency_depth is not None:
|
|
78
|
+
classification = "transitive"
|
|
79
|
+
else:
|
|
80
|
+
classification = "unlinked"
|
|
81
|
+
depth = (
|
|
82
|
+
str(package.dependency_depth)
|
|
83
|
+
if package.dependency_depth is not None
|
|
84
|
+
else "—"
|
|
85
|
+
)
|
|
86
|
+
table.add_row(package.name, package.version, classification, depth)
|
|
87
|
+
console.print(table)
|
|
88
|
+
console.print(
|
|
89
|
+
f"Declared: {len(report.declared_dependencies)} | "
|
|
90
|
+
f"Installed: {len(report.packages)} | Edges: {len(report.edges)} | "
|
|
91
|
+
f"Missing direct: {len(report.missing_declared)}"
|
|
92
|
+
)
|
|
93
|
+
for warning in report.warnings:
|
|
94
|
+
console.print(f"[yellow]Warning:[/yellow] {warning}")
|
|
95
|
+
if output:
|
|
96
|
+
_write_json(report.model_dump_json(indent=2), output)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@app.command()
|
|
100
|
+
def scan(
|
|
101
|
+
project: Path = typer.Option(
|
|
102
|
+
Path("."), "--project", "-p", exists=True, file_okay=False
|
|
103
|
+
),
|
|
104
|
+
environment: Path | None = typer.Option(
|
|
105
|
+
None, "--environment", "-e", exists=True, file_okay=False
|
|
106
|
+
),
|
|
107
|
+
output: Path | None = typer.Option(None, "--output", "-o"),
|
|
108
|
+
no_osv: bool = typer.Option(
|
|
109
|
+
False, "--no-osv", help="Skip online OSV vulnerability checks."
|
|
110
|
+
),
|
|
111
|
+
no_static: bool = typer.Option(
|
|
112
|
+
False, "--no-static", help="Skip Python AST checks."
|
|
113
|
+
),
|
|
114
|
+
timeout: float = typer.Option(15.0, "--timeout", min=1.0, max=120.0),
|
|
115
|
+
fail_on: Literal["none", "low", "medium", "high", "critical"] = typer.Option(
|
|
116
|
+
"none",
|
|
117
|
+
"--fail-on",
|
|
118
|
+
help="Exit with status 1 when a finding meets this severity threshold.",
|
|
119
|
+
),
|
|
120
|
+
) -> None:
|
|
121
|
+
"""Run dependency inventory, OSV checks, and Python AST rules."""
|
|
122
|
+
try:
|
|
123
|
+
report = scan_project(
|
|
124
|
+
project,
|
|
125
|
+
environment,
|
|
126
|
+
check_vulnerabilities=not no_osv,
|
|
127
|
+
check_static=not no_static,
|
|
128
|
+
network_timeout=timeout,
|
|
129
|
+
)
|
|
130
|
+
except (ValueError, OSError) as exc:
|
|
131
|
+
console.print(f"[red]Scan failed:[/red] {exc}")
|
|
132
|
+
raise typer.Exit(code=2) from exc
|
|
133
|
+
|
|
134
|
+
console.print(f"[bold]PipSutra scan:[/bold] {report.project_path}")
|
|
135
|
+
console.print(f"Risk: [bold]{report.risk.level} ({report.risk.score}/100)[/bold]")
|
|
136
|
+
console.print(report.risk.explanation)
|
|
137
|
+
if report.vulnerabilities:
|
|
138
|
+
table = Table(title="Known vulnerabilities (OSV)")
|
|
139
|
+
for heading in ("Package", "Version", "ID", "Severity", "Summary"):
|
|
140
|
+
table.add_column(heading)
|
|
141
|
+
for finding in report.vulnerabilities:
|
|
142
|
+
table.add_row(
|
|
143
|
+
finding.package,
|
|
144
|
+
finding.version,
|
|
145
|
+
finding.vulnerability_id,
|
|
146
|
+
finding.severity,
|
|
147
|
+
finding.summary[:80],
|
|
148
|
+
)
|
|
149
|
+
console.print(table)
|
|
150
|
+
else:
|
|
151
|
+
osv_status = report.checks["osv"]
|
|
152
|
+
console.print(
|
|
153
|
+
"OSV findings: 0" if osv_status == "completed" else f"OSV: {osv_status}"
|
|
154
|
+
)
|
|
155
|
+
if report.static_findings:
|
|
156
|
+
table = Table(title="Static analysis findings")
|
|
157
|
+
for heading in ("Severity", "Rule", "Location", "Finding"):
|
|
158
|
+
table.add_column(heading)
|
|
159
|
+
for sf in report.static_findings:
|
|
160
|
+
table.add_row(
|
|
161
|
+
sf.severity,
|
|
162
|
+
sf.rule_id,
|
|
163
|
+
f"{sf.file}:{sf.line}",
|
|
164
|
+
sf.title,
|
|
165
|
+
)
|
|
166
|
+
console.print(table)
|
|
167
|
+
else:
|
|
168
|
+
console.print("Static findings: 0")
|
|
169
|
+
for error in report.errors:
|
|
170
|
+
console.print(f"[yellow]Warning:[/yellow] {error}")
|
|
171
|
+
if output:
|
|
172
|
+
_write_json(report.model_dump_json(indent=2), output)
|
|
173
|
+
if _has_blocking_findings(report, fail_on):
|
|
174
|
+
console.print(f"[red]Findings meet the --fail-on {fail_on} threshold.[/red]")
|
|
175
|
+
raise typer.Exit(code=1)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
if __name__ == "__main__":
|
|
179
|
+
app()
|
pipsutra/discovery.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import tomllib
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from importlib import metadata
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from packaging.requirements import InvalidRequirement, Requirement
|
|
10
|
+
from packaging.utils import canonicalize_name
|
|
11
|
+
|
|
12
|
+
from pipsutra.models import Dependency, DependencyEdge, Inventory, Package
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _parse_requirement(
|
|
16
|
+
raw: str, *, source: str, group: str = "main"
|
|
17
|
+
) -> Dependency | None:
|
|
18
|
+
value = raw.strip()
|
|
19
|
+
if not value or value.startswith("#"):
|
|
20
|
+
return None
|
|
21
|
+
if value.startswith(("-r ", "--requirement ", "-c ", "--constraint ")):
|
|
22
|
+
raise ValueError(
|
|
23
|
+
f"Nested requirements and constraints are not supported in v0.1.0: {value}"
|
|
24
|
+
)
|
|
25
|
+
if value.startswith(("-e ", "--editable ")):
|
|
26
|
+
raise ValueError(
|
|
27
|
+
f"Editable/VCS requirements are not supported in v0.1.0: {value}"
|
|
28
|
+
)
|
|
29
|
+
try:
|
|
30
|
+
requirement = Requirement(value)
|
|
31
|
+
except InvalidRequirement as exc:
|
|
32
|
+
raise ValueError(f"Invalid requirement {value!r}: {exc}") from exc
|
|
33
|
+
|
|
34
|
+
if requirement.url and requirement.url.startswith(("git+", "hg+", "svn+", "bzr+")):
|
|
35
|
+
raise ValueError(f"VCS requirements are not supported in v0.1.0: {value}")
|
|
36
|
+
|
|
37
|
+
active = requirement.marker is None or requirement.marker.evaluate()
|
|
38
|
+
|
|
39
|
+
return Dependency(
|
|
40
|
+
name=requirement.name,
|
|
41
|
+
normalized_name=canonicalize_name(requirement.name),
|
|
42
|
+
specifier=str(requirement.specifier),
|
|
43
|
+
source=source,
|
|
44
|
+
group=group,
|
|
45
|
+
marker=str(requirement.marker) if requirement.marker else None,
|
|
46
|
+
active=active,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def discover_requirements(path: Path) -> list[Dependency]:
|
|
51
|
+
dependencies: list[Dependency] = []
|
|
52
|
+
for line_number, line in enumerate(
|
|
53
|
+
path.read_text(encoding="utf-8").splitlines(), start=1
|
|
54
|
+
):
|
|
55
|
+
# Strip inline comments only when separated by whitespace.
|
|
56
|
+
content = line.split(" #", maxsplit=1)[0].strip()
|
|
57
|
+
if not content or content.startswith("#"):
|
|
58
|
+
continue
|
|
59
|
+
if content.startswith(
|
|
60
|
+
(
|
|
61
|
+
"--index-url",
|
|
62
|
+
"--extra-index-url",
|
|
63
|
+
"--trusted-host",
|
|
64
|
+
"--find-links",
|
|
65
|
+
"-f ",
|
|
66
|
+
"--no-index",
|
|
67
|
+
"--pre",
|
|
68
|
+
"--only-binary",
|
|
69
|
+
"--no-binary",
|
|
70
|
+
"--require-hashes",
|
|
71
|
+
"--prefer-binary",
|
|
72
|
+
"--no-deps",
|
|
73
|
+
)
|
|
74
|
+
):
|
|
75
|
+
continue
|
|
76
|
+
content = re.sub(r"\s+--hash(?:=|\s+)\S+", "", content).strip()
|
|
77
|
+
if not content:
|
|
78
|
+
continue
|
|
79
|
+
try:
|
|
80
|
+
dependency = _parse_requirement(
|
|
81
|
+
content, source=f"requirements.txt:{line_number}"
|
|
82
|
+
)
|
|
83
|
+
except ValueError as exc:
|
|
84
|
+
raise ValueError(f"{path}:{line_number}: {exc}") from exc
|
|
85
|
+
if dependency is not None:
|
|
86
|
+
dependencies.append(dependency)
|
|
87
|
+
return dependencies
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def discover_pyproject(path: Path) -> list[Dependency]:
|
|
91
|
+
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
92
|
+
project = data.get("project", {})
|
|
93
|
+
dependencies: list[Dependency] = []
|
|
94
|
+
|
|
95
|
+
for raw in project.get("dependencies", []):
|
|
96
|
+
dependency = _parse_requirement(
|
|
97
|
+
raw, source="pyproject.toml:[project].dependencies"
|
|
98
|
+
)
|
|
99
|
+
if dependency:
|
|
100
|
+
dependencies.append(dependency)
|
|
101
|
+
|
|
102
|
+
for group, values in project.get("optional-dependencies", {}).items():
|
|
103
|
+
for raw in values:
|
|
104
|
+
dependency = _parse_requirement(
|
|
105
|
+
raw,
|
|
106
|
+
source=f"pyproject.toml:[project].optional-dependencies.{group}",
|
|
107
|
+
group=group,
|
|
108
|
+
)
|
|
109
|
+
if dependency:
|
|
110
|
+
dependencies.append(dependency)
|
|
111
|
+
|
|
112
|
+
return dependencies
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def discover_declared(project_path: Path) -> tuple[list[Dependency], list[str]]:
|
|
116
|
+
dependencies: list[Dependency] = []
|
|
117
|
+
warnings: list[str] = []
|
|
118
|
+
|
|
119
|
+
requirements_file = project_path / "requirements.txt"
|
|
120
|
+
pyproject_file = project_path / "pyproject.toml"
|
|
121
|
+
|
|
122
|
+
if requirements_file.is_file():
|
|
123
|
+
dependencies.extend(discover_requirements(requirements_file))
|
|
124
|
+
if pyproject_file.is_file():
|
|
125
|
+
dependencies.extend(discover_pyproject(pyproject_file))
|
|
126
|
+
pyproject_data = tomllib.loads(pyproject_file.read_text(encoding="utf-8"))
|
|
127
|
+
dynamic_fields = pyproject_data.get("project", {}).get("dynamic", [])
|
|
128
|
+
if any(
|
|
129
|
+
field in {"dependencies", "optional-dependencies"}
|
|
130
|
+
for field in dynamic_fields
|
|
131
|
+
):
|
|
132
|
+
warnings.append(
|
|
133
|
+
"Dynamic dependency fields in pyproject.toml are not resolved "
|
|
134
|
+
"by PipSutra v0.1.0."
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
if not requirements_file.is_file() and not pyproject_file.is_file():
|
|
138
|
+
warnings.append(
|
|
139
|
+
"No supported manifest found (requirements.txt or pyproject.toml)."
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
# Keep separate source records but avoid exact duplicates.
|
|
143
|
+
unique: dict[tuple[str, str, str], Dependency] = {}
|
|
144
|
+
for dependency in dependencies:
|
|
145
|
+
key = (
|
|
146
|
+
dependency.normalized_name,
|
|
147
|
+
dependency.specifier,
|
|
148
|
+
dependency.group,
|
|
149
|
+
)
|
|
150
|
+
unique.setdefault(key, dependency)
|
|
151
|
+
|
|
152
|
+
return list(unique.values()), warnings
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _distribution_requirements(dist: metadata.Distribution) -> Iterable[Requirement]:
|
|
156
|
+
for raw in dist.requires or []:
|
|
157
|
+
try:
|
|
158
|
+
requirement = Requirement(raw)
|
|
159
|
+
except InvalidRequirement:
|
|
160
|
+
continue
|
|
161
|
+
if requirement.marker is None or requirement.marker.evaluate():
|
|
162
|
+
yield requirement
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _metadata_search_paths(environment_path: Path) -> list[str]:
|
|
166
|
+
"""Find site-packages directories for a venv or an explicit metadata path."""
|
|
167
|
+
candidates = [
|
|
168
|
+
*environment_path.glob("lib/python*/site-packages"),
|
|
169
|
+
environment_path / "Lib" / "site-packages",
|
|
170
|
+
environment_path,
|
|
171
|
+
]
|
|
172
|
+
return [str(path) for path in candidates if path.is_dir()]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def discover_installed(
|
|
176
|
+
environment_path: Path,
|
|
177
|
+
) -> tuple[list[Package], list[DependencyEdge]]:
|
|
178
|
+
# importlib.metadata reads distribution metadata from site-packages. It does
|
|
179
|
+
# not import or execute the distributions' Python modules.
|
|
180
|
+
search_paths = _metadata_search_paths(environment_path)
|
|
181
|
+
distributions = list(metadata.distributions(path=search_paths))
|
|
182
|
+
by_name: dict[str, metadata.Distribution] = {}
|
|
183
|
+
packages: list[Package] = []
|
|
184
|
+
|
|
185
|
+
for dist in distributions:
|
|
186
|
+
name = dist.metadata["Name"]
|
|
187
|
+
version = dist.version
|
|
188
|
+
if not name or not version:
|
|
189
|
+
continue
|
|
190
|
+
normalized = canonicalize_name(name)
|
|
191
|
+
by_name[normalized] = dist
|
|
192
|
+
packages.append(
|
|
193
|
+
Package(
|
|
194
|
+
name=name,
|
|
195
|
+
normalized_name=normalized,
|
|
196
|
+
version=version,
|
|
197
|
+
location=str(dist.locate_file("")),
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
edges: list[DependencyEdge] = []
|
|
202
|
+
for parent_name, dist in by_name.items():
|
|
203
|
+
for requirement in _distribution_requirements(dist):
|
|
204
|
+
child_name = canonicalize_name(requirement.name)
|
|
205
|
+
if child_name in by_name:
|
|
206
|
+
edges.append(
|
|
207
|
+
DependencyEdge(
|
|
208
|
+
parent=parent_name,
|
|
209
|
+
child=child_name,
|
|
210
|
+
requirement=str(requirement),
|
|
211
|
+
marker=str(requirement.marker) if requirement.marker else None,
|
|
212
|
+
)
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
return packages, edges
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def build_inventory(
|
|
219
|
+
project_path: str | Path, environment_path: str | Path
|
|
220
|
+
) -> Inventory:
|
|
221
|
+
project = Path(project_path).expanduser().resolve()
|
|
222
|
+
environment = Path(environment_path).expanduser().resolve()
|
|
223
|
+
|
|
224
|
+
if not project.exists() or not project.is_dir():
|
|
225
|
+
raise ValueError(f"Project directory does not exist: {project}")
|
|
226
|
+
if not environment.exists() or not environment.is_dir():
|
|
227
|
+
raise ValueError(f"Environment directory does not exist: {environment}")
|
|
228
|
+
|
|
229
|
+
declared, warnings = discover_declared(project)
|
|
230
|
+
packages, edges = discover_installed(environment)
|
|
231
|
+
|
|
232
|
+
direct_names = {
|
|
233
|
+
item.normalized_name
|
|
234
|
+
for item in declared
|
|
235
|
+
if item.group == "main" and item.active
|
|
236
|
+
}
|
|
237
|
+
installed_names = {item.normalized_name for item in packages}
|
|
238
|
+
missing = sorted(direct_names - installed_names)
|
|
239
|
+
|
|
240
|
+
# Compute transitive reachability from declared main dependencies.
|
|
241
|
+
adjacency: dict[str, set[str]] = {}
|
|
242
|
+
for edge in edges:
|
|
243
|
+
adjacency.setdefault(edge.parent, set()).add(edge.child)
|
|
244
|
+
|
|
245
|
+
depth: dict[str, int] = {}
|
|
246
|
+
queue = [(name, 0) for name in sorted(direct_names & installed_names)]
|
|
247
|
+
while queue:
|
|
248
|
+
node, current_depth = queue.pop(0)
|
|
249
|
+
if node in depth and depth[node] <= current_depth:
|
|
250
|
+
continue
|
|
251
|
+
depth[node] = current_depth
|
|
252
|
+
for child in sorted(adjacency.get(node, set())):
|
|
253
|
+
if child not in depth or current_depth + 1 < depth[child]:
|
|
254
|
+
queue.append((child, current_depth + 1))
|
|
255
|
+
|
|
256
|
+
updated_packages = [
|
|
257
|
+
package.model_copy(
|
|
258
|
+
update={
|
|
259
|
+
"direct": package.normalized_name in direct_names,
|
|
260
|
+
"dependency_depth": depth.get(package.normalized_name),
|
|
261
|
+
}
|
|
262
|
+
)
|
|
263
|
+
for package in packages
|
|
264
|
+
]
|
|
265
|
+
|
|
266
|
+
if missing:
|
|
267
|
+
warnings.append(
|
|
268
|
+
"Some declared main dependencies are not installed in the selected "
|
|
269
|
+
"environment: " + ", ".join(missing)
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
return Inventory(
|
|
273
|
+
project_path=str(project),
|
|
274
|
+
environment_path=str(environment),
|
|
275
|
+
declared_dependencies=declared,
|
|
276
|
+
packages=sorted(updated_packages, key=lambda item: item.normalized_name),
|
|
277
|
+
edges=sorted(edges, key=lambda item: (item.parent, item.child)),
|
|
278
|
+
missing_declared=missing,
|
|
279
|
+
warnings=warnings,
|
|
280
|
+
discovery_status="partial" if warnings or missing else "complete",
|
|
281
|
+
)
|
pipsutra/models.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import UTC, datetime
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Dependency(BaseModel):
|
|
10
|
+
model_config = ConfigDict(frozen=True)
|
|
11
|
+
name: str
|
|
12
|
+
normalized_name: str
|
|
13
|
+
specifier: str = ""
|
|
14
|
+
source: str
|
|
15
|
+
group: str = "main"
|
|
16
|
+
marker: str | None = None
|
|
17
|
+
active: bool = True
|
|
18
|
+
direct: bool = True
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Package(BaseModel):
|
|
22
|
+
model_config = ConfigDict(frozen=True)
|
|
23
|
+
name: str
|
|
24
|
+
normalized_name: str
|
|
25
|
+
version: str
|
|
26
|
+
location: str | None = None
|
|
27
|
+
direct: bool = False
|
|
28
|
+
dependency_depth: int | None = None
|
|
29
|
+
metadata_complete: bool = True
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class DependencyEdge(BaseModel):
|
|
33
|
+
model_config = ConfigDict(frozen=True)
|
|
34
|
+
parent: str
|
|
35
|
+
child: str
|
|
36
|
+
requirement: str | None = None
|
|
37
|
+
marker: str | None = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class VulnerabilityFinding(BaseModel):
|
|
41
|
+
model_config = ConfigDict(frozen=True)
|
|
42
|
+
package: str
|
|
43
|
+
version: str
|
|
44
|
+
vulnerability_id: str
|
|
45
|
+
summary: str = ""
|
|
46
|
+
details: str = ""
|
|
47
|
+
aliases: list[str] = Field(default_factory=list)
|
|
48
|
+
references: list[str] = Field(default_factory=list)
|
|
49
|
+
severity: str = "UNKNOWN"
|
|
50
|
+
source: str = "OSV"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class StaticFinding(BaseModel):
|
|
54
|
+
model_config = ConfigDict(frozen=True)
|
|
55
|
+
rule_id: str
|
|
56
|
+
title: str
|
|
57
|
+
severity: Literal["LOW", "MEDIUM", "HIGH"]
|
|
58
|
+
confidence: Literal["LOW", "MEDIUM", "HIGH"]
|
|
59
|
+
message: str
|
|
60
|
+
file: str
|
|
61
|
+
line: int
|
|
62
|
+
evidence: str
|
|
63
|
+
recommendation: str
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class RiskSummary(BaseModel):
|
|
67
|
+
model_config = ConfigDict(frozen=True)
|
|
68
|
+
score: int = Field(ge=0, le=100)
|
|
69
|
+
level: Literal["LOW", "MEDIUM", "HIGH", "CRITICAL", "UNKNOWN"]
|
|
70
|
+
explanation: str
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Inventory(BaseModel):
|
|
74
|
+
schema_version: Literal["1.0"] = "1.0"
|
|
75
|
+
generated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
|
76
|
+
project_path: str
|
|
77
|
+
environment_path: str
|
|
78
|
+
declared_dependencies: list[Dependency] = Field(default_factory=list)
|
|
79
|
+
packages: list[Package] = Field(default_factory=list)
|
|
80
|
+
edges: list[DependencyEdge] = Field(default_factory=list)
|
|
81
|
+
missing_declared: list[str] = Field(default_factory=list)
|
|
82
|
+
warnings: list[str] = Field(default_factory=list)
|
|
83
|
+
discovery_status: Literal["complete", "partial"] = "complete"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class ScanReport(BaseModel):
|
|
87
|
+
schema_version: Literal["1.0"] = "1.0"
|
|
88
|
+
tool: str = "PipSutra"
|
|
89
|
+
tool_version: str
|
|
90
|
+
generated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
|
91
|
+
project_path: str
|
|
92
|
+
environment_path: str
|
|
93
|
+
inventory: Inventory
|
|
94
|
+
vulnerabilities: list[VulnerabilityFinding] = Field(default_factory=list)
|
|
95
|
+
static_findings: list[StaticFinding] = Field(default_factory=list)
|
|
96
|
+
risk: RiskSummary
|
|
97
|
+
checks: dict[str, str] = Field(default_factory=dict)
|
|
98
|
+
errors: list[str] = Field(default_factory=list)
|