pr-analyzer-cli 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.
- pr_analyzer/__init__.py +0 -0
- pr_analyzer/analyzer/__init__.py +0 -0
- pr_analyzer/analyzer/ast_analyzer.py +467 -0
- pr_analyzer/analyzer/line_lexer.py +249 -0
- pr_analyzer/analyzer/scorer.py +132 -0
- pr_analyzer/cache.py +373 -0
- pr_analyzer/cli.py +171 -0
- pr_analyzer/config.py +50 -0
- pr_analyzer/diff.py +74 -0
- pr_analyzer/exceptions.py +41 -0
- pr_analyzer/github/__init__.py +0 -0
- pr_analyzer/github/client.py +398 -0
- pr_analyzer/github/markdown.py +199 -0
- pr_analyzer/github/ratelimit.py +277 -0
- pr_analyzer/llm/__init__.py +0 -0
- pr_analyzer/llm/chunking.py +149 -0
- pr_analyzer/llm/engine.py +336 -0
- pr_analyzer/llm/prompts.py +109 -0
- pr_analyzer/llm/review.py +141 -0
- pr_analyzer/logging_config.py +21 -0
- pr_analyzer/models.py +65 -0
- pr_analyzer/pipeline.py +273 -0
- pr_analyzer/project_config.py +266 -0
- pr_analyzer/reporting/__init__.py +0 -0
- pr_analyzer/reporting/rich_reporter.py +244 -0
- pr_analyzer/security/__init__.py +0 -0
- pr_analyzer/security/dep_auditor.py +179 -0
- pr_analyzer/security/scanner.py +272 -0
- pr_analyzer/security/semgrep_runner.py +190 -0
- pr_analyzer/security/tools.py +22 -0
- pr_analyzer/suppression.py +52 -0
- pr_analyzer_cli-0.1.0.dist-info/METADATA +598 -0
- pr_analyzer_cli-0.1.0.dist-info/RECORD +36 -0
- pr_analyzer_cli-0.1.0.dist-info/WHEEL +4 -0
- pr_analyzer_cli-0.1.0.dist-info/entry_points.txt +3 -0
- pr_analyzer_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from rich.console import Console
|
|
3
|
+
from rich.markup import escape
|
|
4
|
+
from rich.table import Table
|
|
5
|
+
from rich.panel import Panel
|
|
6
|
+
from rich.text import Text
|
|
7
|
+
from pr_analyzer.models import AnalysisResult, Suggestion
|
|
8
|
+
|
|
9
|
+
console = Console()
|
|
10
|
+
|
|
11
|
+
SEVERITY_ORDER = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3}
|
|
12
|
+
|
|
13
|
+
SEVERITY_COLOURS = {
|
|
14
|
+
"CRITICAL": "white on red",
|
|
15
|
+
"HIGH": "bold red",
|
|
16
|
+
"MEDIUM": "yellow",
|
|
17
|
+
"LOW": "blue",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
# PR titles, file names and LLM text are untrusted: they're escaped (or wrapped in Text) before
|
|
21
|
+
# Rich renders them, otherwise "[docs] Fix" loses its "[docs]" and "[/x]" crashes the report.
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RichReporter:
|
|
25
|
+
|
|
26
|
+
def print_rich_report(self, result: AnalysisResult) -> None:
|
|
27
|
+
self._print_pr_header(result)
|
|
28
|
+
self._print_score_panel(result)
|
|
29
|
+
self._print_warnings(result)
|
|
30
|
+
self._print_dimension_table(result)
|
|
31
|
+
sorted_suggestions = self._sort_suggestions(result.suggestions)
|
|
32
|
+
self._print_suggestion_table(sorted_suggestions)
|
|
33
|
+
self._print_detail_panels(sorted_suggestions)
|
|
34
|
+
self._print_final_line(result)
|
|
35
|
+
|
|
36
|
+
def print_json_report(self, result: AnalysisResult) -> None:
|
|
37
|
+
output = {
|
|
38
|
+
"pr": {
|
|
39
|
+
"number": result.pr.number,
|
|
40
|
+
"title": result.pr.title,
|
|
41
|
+
"author": result.pr.author,
|
|
42
|
+
"repo": result.pr.repo,
|
|
43
|
+
"base": result.pr.base_branch,
|
|
44
|
+
"head": result.pr.head_branch,
|
|
45
|
+
"head_sha": result.pr.head_sha,
|
|
46
|
+
},
|
|
47
|
+
"score": {
|
|
48
|
+
"overall": result.score.overall_score,
|
|
49
|
+
"grade": result.score.grade,
|
|
50
|
+
"passed": result.score.passed,
|
|
51
|
+
"summary": result.score.summary,
|
|
52
|
+
"delta": result.score.delta,
|
|
53
|
+
"dimensions": result.score.dimension_scores,
|
|
54
|
+
"critical": result.score.critical_count,
|
|
55
|
+
"high": result.score.high_count,
|
|
56
|
+
},
|
|
57
|
+
"suggestions": [
|
|
58
|
+
{
|
|
59
|
+
"filename": s.filename,
|
|
60
|
+
"line": s.line,
|
|
61
|
+
"severity": s.severity,
|
|
62
|
+
"category": s.category,
|
|
63
|
+
"title": s.title,
|
|
64
|
+
"description": s.description,
|
|
65
|
+
"source": s.source,
|
|
66
|
+
"confidence": s.confidence,
|
|
67
|
+
"rule": s.rule_id, # the id .pr-analyzer.toml can disable
|
|
68
|
+
}
|
|
69
|
+
for s in result.suggestions
|
|
70
|
+
],
|
|
71
|
+
"warnings": result.warnings,
|
|
72
|
+
"llm_provider": result.llm_provider_used,
|
|
73
|
+
"duration": result.duration,
|
|
74
|
+
}
|
|
75
|
+
print(json.dumps(output, indent=2))
|
|
76
|
+
|
|
77
|
+
def print_markdown_report(self, result: AnalysisResult) -> None:
|
|
78
|
+
lines = []
|
|
79
|
+
|
|
80
|
+
lines.append(f"# PR Analysis Report — #{result.pr.number} {_md(result.pr.title)}\n")
|
|
81
|
+
lines.append(f"**Author:** {_md(result.pr.author)} ")
|
|
82
|
+
lines.append(f"**Repo:** {_md(result.pr.repo)} ")
|
|
83
|
+
lines.append(f"**Branch:** `{result.pr.head_branch}` → `{result.pr.base_branch}`\n")
|
|
84
|
+
|
|
85
|
+
lines.append("## Score\n")
|
|
86
|
+
lines.append("| Overall | Grade | Status | Critical | High |")
|
|
87
|
+
lines.append("|---------|-------|--------|----------|------|")
|
|
88
|
+
status = "✅ PASSED" if result.score.passed else "❌ BLOCKED"
|
|
89
|
+
lines.append(
|
|
90
|
+
f"| {result.score.overall_score:.1f} "
|
|
91
|
+
f"| {result.score.grade} "
|
|
92
|
+
f"| {status} "
|
|
93
|
+
f"| {result.score.critical_count} "
|
|
94
|
+
f"| {result.score.high_count} |"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
if result.warnings:
|
|
98
|
+
lines.append("\n## Warnings\n")
|
|
99
|
+
lines.extend(f"- {_md(w)}" for w in result.warnings)
|
|
100
|
+
|
|
101
|
+
lines.append("\n## Dimension Scores\n")
|
|
102
|
+
lines.append("| Dimension | Score |")
|
|
103
|
+
lines.append("|-----------|-------|")
|
|
104
|
+
for dim, score in result.score.dimension_scores.items():
|
|
105
|
+
lines.append(f"| {dim} | {score:.1f} |")
|
|
106
|
+
|
|
107
|
+
if result.suggestions:
|
|
108
|
+
lines.append("\n## Suggestions\n")
|
|
109
|
+
lines.append("| Severity | File | Line | Category | Title |")
|
|
110
|
+
lines.append("|----------|------|------|----------|-------|")
|
|
111
|
+
for s in self._sort_suggestions(result.suggestions):
|
|
112
|
+
line = str(s.line) if s.line else "—"
|
|
113
|
+
lines.append(f"| {s.severity} | {_md(s.filename)} | {line} | {_md(s.category)} | {_md(s.title)} |")
|
|
114
|
+
|
|
115
|
+
lines.append("\n## Details\n")
|
|
116
|
+
for s in self._sort_suggestions(result.suggestions):
|
|
117
|
+
if s.severity in ("HIGH", "CRITICAL"):
|
|
118
|
+
where = f" line {s.line}" if s.line else ""
|
|
119
|
+
lines.append(f"### {s.severity} — {_md(s.title)}")
|
|
120
|
+
lines.append(f"**File:** `{s.filename}`{where} ")
|
|
121
|
+
lines.append(f"**Source:** {s.source} ")
|
|
122
|
+
lines.append(f"> {_md(s.description)}\n")
|
|
123
|
+
|
|
124
|
+
lines.append(f"\n---\n*Generated by pr-analyzer — duration {result.duration:.2f}s*")
|
|
125
|
+
print("\n".join(lines))
|
|
126
|
+
|
|
127
|
+
def _sort_suggestions(self, suggestions: list[Suggestion]) -> list[Suggestion]:
|
|
128
|
+
return sorted(suggestions, key=lambda s: SEVERITY_ORDER.get(s.severity, 99))
|
|
129
|
+
|
|
130
|
+
def _severity_colour(self, severity: str) -> str:
|
|
131
|
+
return SEVERITY_COLOURS.get(severity, "white")
|
|
132
|
+
|
|
133
|
+
def _print_pr_header(self, result: AnalysisResult) -> None:
|
|
134
|
+
pr = result.pr
|
|
135
|
+
content = (
|
|
136
|
+
f"[bold]Author:[/bold] {escape(pr.author)}\n"
|
|
137
|
+
f"[bold]Repo:[/bold] {escape(pr.repo)}\n"
|
|
138
|
+
f"[bold]Branch:[/bold] {escape(pr.head_branch)} → {escape(pr.base_branch)}\n"
|
|
139
|
+
f"[bold]Draft:[/bold] {'Yes' if pr.is_draft else 'No'}\n"
|
|
140
|
+
f"[bold]Files:[/bold] {len(pr.files)} "
|
|
141
|
+
f"[green]+{pr.total_additions}[/green] "
|
|
142
|
+
f"[red]-{pr.total_deletions}[/red]"
|
|
143
|
+
)
|
|
144
|
+
title = f"PR #{pr.number} — {escape(pr.title)}"
|
|
145
|
+
console.print(Panel(content, title=title, border_style="blue"))
|
|
146
|
+
|
|
147
|
+
def _print_score_panel(self, result: AnalysisResult) -> None:
|
|
148
|
+
score = result.score
|
|
149
|
+
status_colour = "green" if score.passed else "red"
|
|
150
|
+
status_text = "✓ PASSED" if score.passed else "✗ BLOCKED"
|
|
151
|
+
|
|
152
|
+
delta_text = ""
|
|
153
|
+
if score.delta is not None:
|
|
154
|
+
delta_colour = "green" if score.delta >= 0 else "red"
|
|
155
|
+
delta_text = f"\n[bold]Delta:[/bold] [{delta_colour}]{score.delta:+.1f}[/{delta_colour}]"
|
|
156
|
+
|
|
157
|
+
content = (
|
|
158
|
+
f"[bold]Score:[/bold] {score.overall_score:.1f} "
|
|
159
|
+
f"[bold]Grade:[/bold] {score.grade} "
|
|
160
|
+
f"[{status_colour}][bold]{status_text}[/bold][/{status_colour}]"
|
|
161
|
+
f"{delta_text}\n"
|
|
162
|
+
f"[italic]{escape(score.summary)}[/italic]"
|
|
163
|
+
)
|
|
164
|
+
console.print(Panel(content, title="Quality Score", border_style=status_colour))
|
|
165
|
+
|
|
166
|
+
def _print_warnings(self, result: AnalysisResult) -> None:
|
|
167
|
+
if not result.warnings:
|
|
168
|
+
return
|
|
169
|
+
content = "\n".join(f"• {escape(w)}" for w in result.warnings)
|
|
170
|
+
console.print(Panel(content, title="Warnings", border_style="yellow"))
|
|
171
|
+
|
|
172
|
+
def _print_dimension_table(self, result: AnalysisResult) -> None:
|
|
173
|
+
table = Table(title="Dimension Scores", show_lines=True)
|
|
174
|
+
table.add_column("Dimension", style="bold")
|
|
175
|
+
table.add_column("Score", justify="right")
|
|
176
|
+
table.add_column("Bar", width=20)
|
|
177
|
+
|
|
178
|
+
for dim, score in result.score.dimension_scores.items():
|
|
179
|
+
if score >= 80:
|
|
180
|
+
colour = "green"
|
|
181
|
+
elif score >= 60:
|
|
182
|
+
colour = "yellow"
|
|
183
|
+
else:
|
|
184
|
+
colour = "red"
|
|
185
|
+
|
|
186
|
+
filled = int(score / 5)
|
|
187
|
+
bar = "█" * filled + "░" * (20 - filled)
|
|
188
|
+
|
|
189
|
+
table.add_row(dim, f"[{colour}]{score:.1f}[/{colour}]", f"[{colour}]{bar}[/{colour}]")
|
|
190
|
+
|
|
191
|
+
console.print(table)
|
|
192
|
+
|
|
193
|
+
def _print_suggestion_table(self, suggestions: list[Suggestion]) -> None:
|
|
194
|
+
if not suggestions:
|
|
195
|
+
console.print("[green]No suggestions — clean code![/green]")
|
|
196
|
+
return
|
|
197
|
+
|
|
198
|
+
# no fixed widths: they cut "CRITICAL" down to "CRIT…" on 80-column CI logs
|
|
199
|
+
table = Table(title="Suggestions", show_lines=True)
|
|
200
|
+
table.add_column("Severity", no_wrap=True)
|
|
201
|
+
table.add_column("File", overflow="fold")
|
|
202
|
+
table.add_column("Category", no_wrap=True)
|
|
203
|
+
table.add_column("Title", overflow="fold")
|
|
204
|
+
|
|
205
|
+
for s in suggestions:
|
|
206
|
+
colour = self._severity_colour(s.severity)
|
|
207
|
+
file_line = f"{s.filename}:{s.line}" if s.line else s.filename
|
|
208
|
+
table.add_row(
|
|
209
|
+
Text(s.severity, style=colour),
|
|
210
|
+
Text(file_line),
|
|
211
|
+
Text(s.category),
|
|
212
|
+
Text(s.title),
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
console.print(table)
|
|
216
|
+
|
|
217
|
+
def _print_detail_panels(self, suggestions: list[Suggestion]) -> None:
|
|
218
|
+
for s in suggestions:
|
|
219
|
+
if s.severity not in ("HIGH", "CRITICAL"):
|
|
220
|
+
continue
|
|
221
|
+
content = (
|
|
222
|
+
f"[bold]Description:[/bold] {escape(s.description)}\n"
|
|
223
|
+
f"[bold]Source:[/bold] {escape(s.source)}\n"
|
|
224
|
+
f"[bold]Category:[/bold] {escape(s.category)}"
|
|
225
|
+
)
|
|
226
|
+
title = f"{s.severity} — {escape(s.filename)}" + (f" line {s.line}" if s.line else "")
|
|
227
|
+
console.print(Panel(content, title=title, border_style="red"))
|
|
228
|
+
|
|
229
|
+
def _print_final_line(self, result: AnalysisResult) -> None:
|
|
230
|
+
if result.score.passed:
|
|
231
|
+
status = "[bold green]✓ PASSED[/bold green]"
|
|
232
|
+
else:
|
|
233
|
+
status = "[bold red]✗ BLOCKED[/bold red]"
|
|
234
|
+
|
|
235
|
+
console.print(
|
|
236
|
+
f"\n{status} "
|
|
237
|
+
f"[dim]{len(result.suggestions)} suggestion(s) "
|
|
238
|
+
f"duration {result.duration:.2f}s[/dim]"
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _md(text: str) -> str:
|
|
243
|
+
# keep untrusted text from breaking Markdown tables or blockquotes
|
|
244
|
+
return str(text).replace("|", "\\|").replace("\r", " ").replace("\n", " ")
|
|
File without changes
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
import subprocess
|
|
5
|
+
import tempfile
|
|
6
|
+
|
|
7
|
+
import structlog
|
|
8
|
+
|
|
9
|
+
from pr_analyzer.config import settings
|
|
10
|
+
from pr_analyzer.diff import iter_added_lines
|
|
11
|
+
from pr_analyzer.models import FileChange, Suggestion
|
|
12
|
+
from pr_analyzer.security import tools
|
|
13
|
+
|
|
14
|
+
log = structlog.get_logger(__name__)
|
|
15
|
+
|
|
16
|
+
# the stage's id in .pr-analyzer.toml
|
|
17
|
+
DEPENDENCY_AUDIT_RULE = "dependency-audit"
|
|
18
|
+
|
|
19
|
+
# Only exact pins (`name==version`, optionally with extras or an environment marker) are audited.
|
|
20
|
+
# Anything else - ranges, URLs, VCS links, -r/-e/--index-url options - is skipped: handing it to
|
|
21
|
+
# pip would make the analyzer download and build packages chosen by the PR's author.
|
|
22
|
+
PINNED_REQUIREMENT = re.compile(
|
|
23
|
+
r"^\s*(?P<name>[A-Za-z0-9][A-Za-z0-9._-]*)\s*(?:\[[^\]]*\])?\s*"
|
|
24
|
+
r"==\s*(?P<version>[A-Za-z0-9][A-Za-z0-9.+!_-]*)\s*(?:;.*)?$"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def is_requirements_file(filename: str) -> bool:
|
|
29
|
+
# requirements.txt, requirements-dev.txt, dev-requirements.txt, requirements/base.txt, ...
|
|
30
|
+
parts = filename.replace("\\", "/").lower().split("/")
|
|
31
|
+
name = parts[-1]
|
|
32
|
+
in_requirements_dir = len(parts) > 1 and parts[-2] == "requirements"
|
|
33
|
+
return name.endswith(".txt") and ("requirements" in name or in_requirements_dir)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def canonical_name(name: str) -> str:
|
|
37
|
+
# PEP 503 normalisation, so "Jinja2" in the file matches "jinja2" in pip-audit's output
|
|
38
|
+
return re.sub(r"[-_.]+", "-", name).lower()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class DepAuditScanner:
|
|
42
|
+
|
|
43
|
+
def __init__(self, timeout: int | None = None):
|
|
44
|
+
self.timeout = timeout or settings.pip_audit_timeout_seconds
|
|
45
|
+
self.warnings: list[str] = []
|
|
46
|
+
|
|
47
|
+
def scan(self, files: list[FileChange]) -> list[Suggestion]:
|
|
48
|
+
self.warnings = []
|
|
49
|
+
suggestions = []
|
|
50
|
+
|
|
51
|
+
for file in files:
|
|
52
|
+
if file.status == "removed" or not file.patch or not is_requirements_file(file.filename):
|
|
53
|
+
continue
|
|
54
|
+
|
|
55
|
+
pins = self._extract_added_pins(file)
|
|
56
|
+
if not pins:
|
|
57
|
+
continue
|
|
58
|
+
|
|
59
|
+
executable = tools.find_tool("pip-audit")
|
|
60
|
+
if executable is None:
|
|
61
|
+
self._warn("Dependency audit skipped: pip-audit is not installed "
|
|
62
|
+
"(pip install 'pr-analyzer-cli[security]')")
|
|
63
|
+
break
|
|
64
|
+
|
|
65
|
+
log.info("running pip-audit", filename=file.filename, packages=len(pins))
|
|
66
|
+
requirements = [requirement for requirement, _ in pins.values()]
|
|
67
|
+
output = self._run_pip_audit(executable, requirements)
|
|
68
|
+
suggestions.extend(self._parse_audit_output(output, file.filename, pins))
|
|
69
|
+
|
|
70
|
+
log.info("dep audit complete", total_suggestions=len(suggestions))
|
|
71
|
+
return suggestions
|
|
72
|
+
|
|
73
|
+
def _extract_added_pins(self, file: FileChange) -> dict[str, tuple[str, int]]:
|
|
74
|
+
# canonical package name -> ("name==version", line number in the new file)
|
|
75
|
+
pins = {}
|
|
76
|
+
skipped = 0
|
|
77
|
+
for line_no, text in iter_added_lines(file.patch or ""):
|
|
78
|
+
requirement = re.sub(r"\s+#.*$", "", text).strip() # pip comments need a space before #
|
|
79
|
+
if not requirement or requirement.startswith("#"):
|
|
80
|
+
continue
|
|
81
|
+
match = PINNED_REQUIREMENT.match(requirement)
|
|
82
|
+
if match is None:
|
|
83
|
+
skipped += 1
|
|
84
|
+
continue
|
|
85
|
+
name, version = match.group("name"), match.group("version")
|
|
86
|
+
pins[canonical_name(name)] = (f"{name}=={version}", line_no)
|
|
87
|
+
|
|
88
|
+
if skipped:
|
|
89
|
+
log.info("unpinned_requirements_skipped", filename=file.filename, count=skipped)
|
|
90
|
+
return pins
|
|
91
|
+
|
|
92
|
+
def _run_pip_audit(self, executable: str, requirements: list[str]) -> dict:
|
|
93
|
+
with tempfile.TemporaryDirectory(prefix="pr-analyzer-pip-audit-") as workdir:
|
|
94
|
+
path = os.path.join(workdir, "requirements.txt")
|
|
95
|
+
with open(path, "w", encoding="utf-8") as handle:
|
|
96
|
+
handle.write("\n".join(requirements) + "\n")
|
|
97
|
+
|
|
98
|
+
# --no-deps --disable-pip: look the pinned versions up in the advisory database
|
|
99
|
+
# without installing, building or resolving anything
|
|
100
|
+
command = [
|
|
101
|
+
executable, "-r", path, "--no-deps", "--disable-pip",
|
|
102
|
+
"--format", "json", "--progress-spinner", "off",
|
|
103
|
+
]
|
|
104
|
+
try:
|
|
105
|
+
result = subprocess.run(
|
|
106
|
+
command,
|
|
107
|
+
capture_output=True,
|
|
108
|
+
text=True,
|
|
109
|
+
timeout=self.timeout,
|
|
110
|
+
env=tools.scrubbed_env(),
|
|
111
|
+
)
|
|
112
|
+
except subprocess.TimeoutExpired:
|
|
113
|
+
self._warn(f"Dependency audit skipped: pip-audit took longer than {self.timeout}s")
|
|
114
|
+
return {}
|
|
115
|
+
except OSError as e:
|
|
116
|
+
self._warn(f"Dependency audit skipped: could not run pip-audit ({e})")
|
|
117
|
+
return {}
|
|
118
|
+
|
|
119
|
+
# pip-audit exits 1 both when it finds vulnerabilities and when it fails, so trust the
|
|
120
|
+
# JSON report rather than the exit code
|
|
121
|
+
try:
|
|
122
|
+
output = json.loads(result.stdout) if result.stdout.strip() else None
|
|
123
|
+
except json.JSONDecodeError:
|
|
124
|
+
output = None
|
|
125
|
+
if not isinstance(output, dict):
|
|
126
|
+
self._warn(f"Dependency audit failed (exit {result.returncode}): "
|
|
127
|
+
f"{result.stderr.strip()[:200]}")
|
|
128
|
+
return {}
|
|
129
|
+
return output
|
|
130
|
+
|
|
131
|
+
def _parse_audit_output(
|
|
132
|
+
self, output: dict, filename: str, pins: dict[str, tuple[str, int]]
|
|
133
|
+
) -> list[Suggestion]:
|
|
134
|
+
# pip-audit output structure:
|
|
135
|
+
# output
|
|
136
|
+
# dependencies[]
|
|
137
|
+
# name → package name e.g. "flask"
|
|
138
|
+
# version → pinned version e.g. "0.12.2"
|
|
139
|
+
# vulns[]
|
|
140
|
+
# id → PYSEC/GHSA id
|
|
141
|
+
# aliases[] → other ids for the same advisory, e.g. a CVE
|
|
142
|
+
# description → what the vulnerability is
|
|
143
|
+
# fix_versions[] → versions that fix it
|
|
144
|
+
suggestions = []
|
|
145
|
+
seen = set() # pip-audit can list the same advisory more than once
|
|
146
|
+
for dep in output.get("dependencies") or []:
|
|
147
|
+
name = dep.get("name", "unknown")
|
|
148
|
+
version = dep.get("version", "")
|
|
149
|
+
_, line = pins.get(canonical_name(name), ("", None))
|
|
150
|
+
|
|
151
|
+
for vuln in dep.get("vulns") or []:
|
|
152
|
+
vuln_id = vuln.get("id", "unknown")
|
|
153
|
+
key = (canonical_name(name), version, vuln_id)
|
|
154
|
+
if key in seen:
|
|
155
|
+
continue
|
|
156
|
+
seen.add(key)
|
|
157
|
+
aliases = [a for a in vuln.get("aliases") or [] if a != vuln_id]
|
|
158
|
+
fix_versions = vuln.get("fix_versions") or []
|
|
159
|
+
alias_text = f" (also {', '.join(aliases[:3])})" if aliases else ""
|
|
160
|
+
fix_text = f" Fix: upgrade to {fix_versions[0]}." if fix_versions else ""
|
|
161
|
+
description = (vuln.get("description") or "").strip()[:300]
|
|
162
|
+
|
|
163
|
+
suggestions.append(Suggestion(
|
|
164
|
+
filename=filename,
|
|
165
|
+
severity="HIGH",
|
|
166
|
+
category="security",
|
|
167
|
+
# one suggestion per advisory: the id keeps several advisories for the same
|
|
168
|
+
# package from being collapsed into one
|
|
169
|
+
title=f"Vulnerable dependency: {name}=={version} ({vuln_id})",
|
|
170
|
+
description=f"{vuln_id}{alias_text}: {description}{fix_text}",
|
|
171
|
+
line=line,
|
|
172
|
+
source="security",
|
|
173
|
+
rule_id=DEPENDENCY_AUDIT_RULE,
|
|
174
|
+
))
|
|
175
|
+
return suggestions
|
|
176
|
+
|
|
177
|
+
def _warn(self, message: str) -> None:
|
|
178
|
+
log.warning("pip_audit", problem=message)
|
|
179
|
+
self.warnings.append(message)
|