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 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)