pisces-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.
- pisces/__init__.py +9 -0
- pisces/__main__.py +4 -0
- pisces/ast_visitor.py +347 -0
- pisces/cli.py +245 -0
- pisces/config_cmd.py +234 -0
- pisces/config_manager.py +208 -0
- pisces/context_manager.py +333 -0
- pisces/explain_cmd.py +608 -0
- pisces/fix_cmd.py +479 -0
- pisces/history_cmd.py +313 -0
- pisces/init_cmd.py +193 -0
- pisces/judge.py +131 -0
- pisces/llm_client.py +263 -0
- pisces/models.py +413 -0
- pisces/patch_engine.py +266 -0
- pisces/scan_cmd.py +230 -0
- pisces/scanner.py +677 -0
- pisces/tui.py +539 -0
- pisces/watcher.py +244 -0
- pisces_cli-0.1.0.dist-info/METADATA +448 -0
- pisces_cli-0.1.0.dist-info/RECORD +24 -0
- pisces_cli-0.1.0.dist-info/WHEEL +4 -0
- pisces_cli-0.1.0.dist-info/entry_points.txt +2 -0
- pisces_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
pisces/explain_cmd.py
ADDED
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pisces.explain_cmd
|
|
3
|
+
~~~~~~~~~~~~~~~~~~
|
|
4
|
+
Implements `pisces explain <file:line>` — AI-powered vulnerability analysis.
|
|
5
|
+
|
|
6
|
+
What it does (in order):
|
|
7
|
+
1. Parse the "file:line" reference from the user
|
|
8
|
+
2. Load the Finding from .pisces/cache/findings.json
|
|
9
|
+
3. Read ±20 lines of surrounding source code for context
|
|
10
|
+
4. Load the file's AST context (imports, functions, calls)
|
|
11
|
+
5. Build a structured prompt with all of that context
|
|
12
|
+
6. Call the configured LLM provider
|
|
13
|
+
7. Parse and render the explanation as a rich, formatted panel
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from rich import box
|
|
22
|
+
from rich.console import Group
|
|
23
|
+
from rich.markdown import Markdown
|
|
24
|
+
from rich.panel import Panel
|
|
25
|
+
from rich.syntax import Syntax
|
|
26
|
+
from rich.table import Table
|
|
27
|
+
from rich.text import Text
|
|
28
|
+
|
|
29
|
+
from pisces.config_manager import load_config
|
|
30
|
+
from pisces.context_manager import ContextManager
|
|
31
|
+
from pisces.llm_client import call_llm
|
|
32
|
+
from pisces.models import FileContext, Finding, LLMRequest
|
|
33
|
+
from pisces.scan_cmd import load_finding_by_ref
|
|
34
|
+
from pisces.tui import (
|
|
35
|
+
console,
|
|
36
|
+
print_error,
|
|
37
|
+
print_finding_detail,
|
|
38
|
+
print_section,
|
|
39
|
+
print_warning,
|
|
40
|
+
spinner,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# Number of lines of source context to send to the LLM (above + below the finding)
|
|
44
|
+
_CONTEXT_LINES = 20
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ─────────────────────────────────────────────────────────────
|
|
48
|
+
# Entry point
|
|
49
|
+
# ─────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def run_explain(
|
|
53
|
+
ref: str,
|
|
54
|
+
project_root: Path,
|
|
55
|
+
) -> int:
|
|
56
|
+
"""
|
|
57
|
+
Entry point for `pisces explain <ref>`.
|
|
58
|
+
ref format: "path/to/file.py:42"
|
|
59
|
+
|
|
60
|
+
Returns 0 on success, 1 on error.
|
|
61
|
+
"""
|
|
62
|
+
print_section("pisces explain")
|
|
63
|
+
|
|
64
|
+
# ── Parse reference ──────────────────────────────────────
|
|
65
|
+
file_path, line_no = _parse_ref(ref, project_root)
|
|
66
|
+
if file_path is None:
|
|
67
|
+
print_error(
|
|
68
|
+
f"Invalid reference: '{ref}'\n Expected format: pisces explain path/to/file.py:42"
|
|
69
|
+
)
|
|
70
|
+
return 1
|
|
71
|
+
|
|
72
|
+
rel_path = str(file_path.relative_to(project_root))
|
|
73
|
+
lookup = f"{rel_path}:{line_no}"
|
|
74
|
+
|
|
75
|
+
# ── Load finding from cache ───────────────────────────────
|
|
76
|
+
finding = load_finding_by_ref(project_root, lookup)
|
|
77
|
+
if finding is None:
|
|
78
|
+
print_warning(
|
|
79
|
+
f"No cached finding at '{lookup}'.\n"
|
|
80
|
+
" Run 'pisces scan .' first to populate the findings cache."
|
|
81
|
+
)
|
|
82
|
+
return 1
|
|
83
|
+
|
|
84
|
+
# ── Load surrounding source lines ─────────────────────────
|
|
85
|
+
source_snippet = _get_source_snippet(file_path, line_no, context=_CONTEXT_LINES)
|
|
86
|
+
if not source_snippet:
|
|
87
|
+
print_warning(f"Could not read source from {rel_path}")
|
|
88
|
+
# Continue anyway — LLM can still help with what we know
|
|
89
|
+
|
|
90
|
+
# ── Load AST file context ─────────────────────────────────
|
|
91
|
+
file_ctx = _get_file_context(project_root, rel_path)
|
|
92
|
+
|
|
93
|
+
# ── Load config ───────────────────────────────────────────
|
|
94
|
+
try:
|
|
95
|
+
cfg = load_config(project_root)
|
|
96
|
+
except RuntimeError as e:
|
|
97
|
+
print_error(str(e))
|
|
98
|
+
return 1
|
|
99
|
+
|
|
100
|
+
provider = cfg.llm.provider
|
|
101
|
+
model = cfg.llm.model
|
|
102
|
+
|
|
103
|
+
console.print(f" [dim]Using[/] [magenta]{provider}[/] / [cyan]{model}[/]")
|
|
104
|
+
console.print()
|
|
105
|
+
|
|
106
|
+
# ── Build and send LLM request ────────────────────────────
|
|
107
|
+
request = _build_explain_request(finding, source_snippet, file_ctx, rel_path)
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
with spinner(f"Asking {provider} to analyse the vulnerability..."):
|
|
111
|
+
response = call_llm(request, provider=provider, model=model)
|
|
112
|
+
except OSError as e:
|
|
113
|
+
print_error(str(e), hint=f"Set the API key env var for '{provider}'.")
|
|
114
|
+
return 1
|
|
115
|
+
except RuntimeError as e:
|
|
116
|
+
print_error(str(e))
|
|
117
|
+
return 1
|
|
118
|
+
|
|
119
|
+
# ── Parse LLM response ────────────────────────────────────
|
|
120
|
+
try:
|
|
121
|
+
explanation = _parse_llm_response(response.content)
|
|
122
|
+
except ValueError as e:
|
|
123
|
+
print_error(f"Could not parse the LLM response: {e}")
|
|
124
|
+
console.print(response.content)
|
|
125
|
+
return 1
|
|
126
|
+
|
|
127
|
+
# ── Display structured explanation ────────────────────────
|
|
128
|
+
_display_explanation(
|
|
129
|
+
finding=finding,
|
|
130
|
+
explanation=explanation,
|
|
131
|
+
response=response,
|
|
132
|
+
rel_path=rel_path,
|
|
133
|
+
line_no=line_no,
|
|
134
|
+
source_snippet=source_snippet,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# ── Token usage ───────────────────────────────────────────
|
|
138
|
+
console.print(
|
|
139
|
+
f"\n [dim]Tokens used:[/] "
|
|
140
|
+
f"[cyan]{response.input_tokens}[/] in / "
|
|
141
|
+
f"[cyan]{response.output_tokens}[/] out"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# ── Suggested next steps ──────────────────────────────────
|
|
145
|
+
console.print(
|
|
146
|
+
Panel(
|
|
147
|
+
"[bold]Preview the proposed patch:[/]\n"
|
|
148
|
+
f"[cyan]pisces fix {rel_path}:{line_no} --dry-run[/]\n\n"
|
|
149
|
+
"[bold]Start the remediation workflow:[/]\n"
|
|
150
|
+
f"[green]pisces fix {rel_path}:{line_no}[/]",
|
|
151
|
+
title="[bold magenta]NEXT STEP[/]",
|
|
152
|
+
border_style="magenta",
|
|
153
|
+
padding=(1, 2),
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
return 0
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _display_explanation(
|
|
161
|
+
finding: Finding,
|
|
162
|
+
explanation: dict[str, str],
|
|
163
|
+
response: object,
|
|
164
|
+
rel_path: str,
|
|
165
|
+
line_no: int,
|
|
166
|
+
source_snippet: str,
|
|
167
|
+
) -> None:
|
|
168
|
+
"""Render a structured, readable security analysis in the terminal."""
|
|
169
|
+
|
|
170
|
+
severity = str(finding.severity.value).upper()
|
|
171
|
+
|
|
172
|
+
severity_styles = {
|
|
173
|
+
"CRITICAL": ("bold white on red", "red"),
|
|
174
|
+
"HIGH": ("bold red", "red"),
|
|
175
|
+
"MEDIUM": ("bold yellow", "yellow"),
|
|
176
|
+
"LOW": ("bold green", "green"),
|
|
177
|
+
"INFO": ("bold cyan", "cyan"),
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
severity_style, border_style = severity_styles.get(severity, ("bold white", "cyan"))
|
|
181
|
+
|
|
182
|
+
console.print()
|
|
183
|
+
|
|
184
|
+
# ── Header ────────────────────────────────────────────────
|
|
185
|
+
console.print(
|
|
186
|
+
Panel(
|
|
187
|
+
Text.from_markup(
|
|
188
|
+
f"[bold]SECURITY ANALYSIS[/]\n"
|
|
189
|
+
f"[dim]{rel_path}:{line_no}[/]\n\n"
|
|
190
|
+
f"[{severity_style}] {severity} [/]"
|
|
191
|
+
f" [bold]{finding.rule}[/]\n"
|
|
192
|
+
f"[dim]{finding.description}[/]"
|
|
193
|
+
),
|
|
194
|
+
border_style=border_style,
|
|
195
|
+
box=box.DOUBLE,
|
|
196
|
+
padding=(1, 2),
|
|
197
|
+
title="[bold magenta]PISCES[/]",
|
|
198
|
+
subtitle="[dim]AI-assisted vulnerability analysis[/]",
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
# ── Summary ───────────────────────────────────────────────
|
|
203
|
+
summary = explanation.get("Summary", "")
|
|
204
|
+
if summary:
|
|
205
|
+
console.print(
|
|
206
|
+
Panel(
|
|
207
|
+
Markdown(summary),
|
|
208
|
+
title="[bold cyan]01 · Executive Summary[/]",
|
|
209
|
+
border_style="cyan",
|
|
210
|
+
padding=(1, 2),
|
|
211
|
+
)
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
# ── Finding metadata ──────────────────────────────────────
|
|
215
|
+
metadata = Table(
|
|
216
|
+
show_header=False,
|
|
217
|
+
box=box.SIMPLE,
|
|
218
|
+
expand=True,
|
|
219
|
+
padding=(0, 1),
|
|
220
|
+
)
|
|
221
|
+
metadata.add_column("Property", style="dim", width=18)
|
|
222
|
+
metadata.add_column("Value", style="bold")
|
|
223
|
+
|
|
224
|
+
metadata.add_row("Severity", f"[{severity_style}]{severity}[/]")
|
|
225
|
+
metadata.add_row("Rule", finding.rule)
|
|
226
|
+
metadata.add_row("Location", f"{rel_path}:{line_no}")
|
|
227
|
+
|
|
228
|
+
if finding.impact:
|
|
229
|
+
metadata.add_row("Potential impact", finding.impact)
|
|
230
|
+
|
|
231
|
+
console.print(
|
|
232
|
+
Panel(
|
|
233
|
+
metadata,
|
|
234
|
+
title="[bold]Finding Details[/]",
|
|
235
|
+
border_style="dim",
|
|
236
|
+
padding=(0, 1),
|
|
237
|
+
)
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
# ── Explanation ───────────────────────────────────────────
|
|
241
|
+
if explanation.get("Vulnerability Explained"):
|
|
242
|
+
console.print(
|
|
243
|
+
Panel(
|
|
244
|
+
Markdown(explanation["Vulnerability Explained"]),
|
|
245
|
+
title="[bold cyan]02 · How the Vulnerability Works[/]",
|
|
246
|
+
border_style="cyan",
|
|
247
|
+
padding=(1, 2),
|
|
248
|
+
)
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
# ── Attack scenario ───────────────────────────────────────
|
|
252
|
+
if explanation.get("Attack Scenario"):
|
|
253
|
+
console.print(
|
|
254
|
+
Panel(
|
|
255
|
+
Markdown(explanation["Attack Scenario"]),
|
|
256
|
+
title="[bold yellow]03 · Attack Scenario[/]",
|
|
257
|
+
border_style="yellow",
|
|
258
|
+
padding=(1, 2),
|
|
259
|
+
)
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
# ── Root cause ────────────────────────────────────────────
|
|
263
|
+
if explanation.get("Root Cause"):
|
|
264
|
+
console.print(
|
|
265
|
+
Panel(
|
|
266
|
+
Markdown(explanation["Root Cause"]),
|
|
267
|
+
title="[bold red]04 · Root Cause[/]",
|
|
268
|
+
border_style="red",
|
|
269
|
+
padding=(1, 2),
|
|
270
|
+
)
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
# ── Recommended fix ───────────────────────────────────────
|
|
274
|
+
fix_text = explanation.get("Recommended Fix", "")
|
|
275
|
+
|
|
276
|
+
if fix_text:
|
|
277
|
+
code_match = re.search(
|
|
278
|
+
r"```(?:python|py)?\s*\n(.*?)```",
|
|
279
|
+
fix_text,
|
|
280
|
+
re.DOTALL | re.IGNORECASE,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
if code_match:
|
|
284
|
+
before_code = fix_text[: code_match.start()].strip()
|
|
285
|
+
code = code_match.group(1).strip()
|
|
286
|
+
after_code = fix_text[code_match.end() :].strip()
|
|
287
|
+
|
|
288
|
+
fix_content = []
|
|
289
|
+
|
|
290
|
+
if before_code:
|
|
291
|
+
fix_content.append(Markdown(before_code))
|
|
292
|
+
|
|
293
|
+
fix_content.append(
|
|
294
|
+
Syntax(
|
|
295
|
+
code,
|
|
296
|
+
"python",
|
|
297
|
+
theme="monokai",
|
|
298
|
+
line_numbers=True,
|
|
299
|
+
word_wrap=True,
|
|
300
|
+
padding=1,
|
|
301
|
+
)
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
if after_code:
|
|
305
|
+
fix_content.append(Markdown(after_code))
|
|
306
|
+
|
|
307
|
+
console.print(
|
|
308
|
+
Panel(
|
|
309
|
+
Group(*fix_content),
|
|
310
|
+
title="[bold green]05 · Recommended Fix[/]",
|
|
311
|
+
border_style="green",
|
|
312
|
+
padding=(1, 2),
|
|
313
|
+
)
|
|
314
|
+
)
|
|
315
|
+
else:
|
|
316
|
+
console.print(
|
|
317
|
+
Panel(
|
|
318
|
+
Markdown(fix_text),
|
|
319
|
+
title="[bold green]05 · Recommended Fix[/]",
|
|
320
|
+
border_style="green",
|
|
321
|
+
padding=(1, 2),
|
|
322
|
+
)
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
# ── References ────────────────────────────────────────────
|
|
326
|
+
if explanation.get("References"):
|
|
327
|
+
console.print(
|
|
328
|
+
Panel(
|
|
329
|
+
Markdown(explanation["References"]),
|
|
330
|
+
title="[bold blue]06 · References & Standards[/]",
|
|
331
|
+
border_style="blue",
|
|
332
|
+
padding=(1, 2),
|
|
333
|
+
)
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
console.print()
|
|
337
|
+
console.print(
|
|
338
|
+
"[dim]Analysis complete · Review the suggested patch before applying it.[/]",
|
|
339
|
+
justify="center",
|
|
340
|
+
)
|
|
341
|
+
console.print()
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
# ─────────────────────────────────────────────────────────────
|
|
345
|
+
# Reference parsing
|
|
346
|
+
# ─────────────────────────────────────────────────────────────
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _parse_ref(ref: str, project_root: Path) -> tuple[Path | None, int]:
|
|
350
|
+
"""
|
|
351
|
+
Parse a 'file.py:42' reference into (Path, line_number).
|
|
352
|
+
|
|
353
|
+
Handles both:
|
|
354
|
+
- relative: pisces/scanner.py:100
|
|
355
|
+
- absolute: /home/user/project/pisces/scanner.py:100
|
|
356
|
+
"""
|
|
357
|
+
# Split on last colon — handles Windows paths like C:\foo\bar.py:10
|
|
358
|
+
match = re.match(r"^(.+):(\d+)$", ref.strip())
|
|
359
|
+
if not match:
|
|
360
|
+
return None, 0
|
|
361
|
+
|
|
362
|
+
file_str = match.group(1)
|
|
363
|
+
line_no = int(match.group(2))
|
|
364
|
+
|
|
365
|
+
# Try as-is, then relative to project_root
|
|
366
|
+
candidates = [
|
|
367
|
+
Path(file_str),
|
|
368
|
+
project_root / file_str,
|
|
369
|
+
]
|
|
370
|
+
for candidate in candidates:
|
|
371
|
+
if candidate.exists() and candidate.suffix == ".py":
|
|
372
|
+
return candidate.resolve(), line_no
|
|
373
|
+
|
|
374
|
+
return None, 0
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
# ─────────────────────────────────────────────────────────────
|
|
378
|
+
# Source snippet extraction
|
|
379
|
+
# ─────────────────────────────────────────────────────────────
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _get_source_snippet(
|
|
383
|
+
file_path: Path,
|
|
384
|
+
line_no: int,
|
|
385
|
+
context: int = _CONTEXT_LINES,
|
|
386
|
+
) -> str:
|
|
387
|
+
"""
|
|
388
|
+
Return lines [line_no - context, line_no + context] from *file_path*,
|
|
389
|
+
with line numbers prepended as: ' 42 | source code here'
|
|
390
|
+
"""
|
|
391
|
+
try:
|
|
392
|
+
lines = file_path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
393
|
+
except OSError:
|
|
394
|
+
return ""
|
|
395
|
+
|
|
396
|
+
start = max(1, line_no - context)
|
|
397
|
+
end = min(len(lines), line_no + context)
|
|
398
|
+
|
|
399
|
+
result_lines = []
|
|
400
|
+
for i in range(start, end + 1):
|
|
401
|
+
marker = "→" if i == line_no else " "
|
|
402
|
+
result_lines.append(f"{marker} {i:4d} | {lines[i - 1]}")
|
|
403
|
+
|
|
404
|
+
return "\n".join(result_lines)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
# ─────────────────────────────────────────────────────────────
|
|
408
|
+
# File context loading
|
|
409
|
+
# ─────────────────────────────────────────────────────────────
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _get_file_context(project_root: Path, rel_path: str) -> FileContext | None:
|
|
413
|
+
"""Load the FileContext from .pisces/context.json, if available."""
|
|
414
|
+
try:
|
|
415
|
+
cm = ContextManager(project_root)
|
|
416
|
+
cm.load()
|
|
417
|
+
return cm.get_file_context(rel_path)
|
|
418
|
+
except (OSError, RuntimeError, TypeError, ValueError, KeyError):
|
|
419
|
+
return None
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
# ─────────────────────────────────────────────────────────────
|
|
423
|
+
# Prompt engineering
|
|
424
|
+
# ─────────────────────────────────────────────────────────────
|
|
425
|
+
|
|
426
|
+
_SYSTEM_PROMPT = """\
|
|
427
|
+
You are an expert Python security engineer performing a code security review.
|
|
428
|
+
You will be given:
|
|
429
|
+
1. A security finding detected by static analysis
|
|
430
|
+
2. The surrounding source code with line numbers
|
|
431
|
+
3. File-level context: imports, functions, classes, related calls
|
|
432
|
+
|
|
433
|
+
Your task: produce a structured, thorough security analysis.
|
|
434
|
+
|
|
435
|
+
Respond ONLY in the following format with these exact section headers:
|
|
436
|
+
|
|
437
|
+
## Summary
|
|
438
|
+
One sentence: what is wrong and why it matters.
|
|
439
|
+
|
|
440
|
+
## Vulnerability Explained
|
|
441
|
+
2-4 paragraphs explaining:
|
|
442
|
+
- How this vulnerability works technically
|
|
443
|
+
- What an attacker could do to exploit it
|
|
444
|
+
- Real-world impact (data breach, RCE, privilege escalation, etc.)
|
|
445
|
+
|
|
446
|
+
## Attack Scenario
|
|
447
|
+
A concrete, step-by-step scenario showing how an attacker would exploit this.
|
|
448
|
+
Be specific to the actual code shown.
|
|
449
|
+
|
|
450
|
+
## Root Cause
|
|
451
|
+
Identify the exact line(s) and the underlying programming mistake.
|
|
452
|
+
|
|
453
|
+
## Recommended Fix
|
|
454
|
+
Provide the corrected Python code as a fenced code block (```python ... ```).
|
|
455
|
+
The fix must:
|
|
456
|
+
- Be minimal — change only what is necessary
|
|
457
|
+
- Preserve the original logic and function signature
|
|
458
|
+
- Follow security best practices
|
|
459
|
+
- Include a brief comment explaining WHY each change was made
|
|
460
|
+
|
|
461
|
+
## References
|
|
462
|
+
2-3 relevant references: CWE number, OWASP category, or CVE if applicable.
|
|
463
|
+
"""
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _build_explain_request(
|
|
467
|
+
finding: Finding,
|
|
468
|
+
source_snippet: str,
|
|
469
|
+
file_ctx: FileContext | None,
|
|
470
|
+
rel_path: str,
|
|
471
|
+
) -> LLMRequest:
|
|
472
|
+
"""Construct the LLMRequest for the explain command."""
|
|
473
|
+
|
|
474
|
+
# Build file context summary (from AST analysis)
|
|
475
|
+
ctx_lines: list[str] = []
|
|
476
|
+
if file_ctx:
|
|
477
|
+
if file_ctx.imports:
|
|
478
|
+
mods = [i.module for i in file_ctx.imports[:15]]
|
|
479
|
+
ctx_lines.append(f"Imports: {', '.join(mods)}")
|
|
480
|
+
if file_ctx.functions:
|
|
481
|
+
fns = [f.name for f in file_ctx.functions[:10]]
|
|
482
|
+
ctx_lines.append(f"Functions defined: {', '.join(fns)}")
|
|
483
|
+
if file_ctx.classes:
|
|
484
|
+
cls = [c.name for c in file_ctx.classes[:5]]
|
|
485
|
+
ctx_lines.append(f"Classes defined: {', '.join(cls)}")
|
|
486
|
+
if file_ctx.security_constructs:
|
|
487
|
+
kinds = [s.kind for s in file_ctx.security_constructs[:8]]
|
|
488
|
+
ctx_lines.append(f"Other security-relevant calls: {', '.join(set(kinds))}")
|
|
489
|
+
|
|
490
|
+
file_ctx_str = (
|
|
491
|
+
"\n".join(ctx_lines)
|
|
492
|
+
if ctx_lines
|
|
493
|
+
else "Context not available (context.json not found or file not indexed)."
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
user_prompt = f"""\
|
|
497
|
+
## Security Finding
|
|
498
|
+
|
|
499
|
+
- **File:** `{rel_path}`
|
|
500
|
+
- **Line:** {finding.line}
|
|
501
|
+
- **Rule:** `{finding.rule}`
|
|
502
|
+
- **Severity:** {finding.severity.value}
|
|
503
|
+
- **Description:** {finding.description}
|
|
504
|
+
- **Impact:** {finding.impact}
|
|
505
|
+
- **Offending code:** `{finding.code}`
|
|
506
|
+
|
|
507
|
+
---
|
|
508
|
+
|
|
509
|
+
## Surrounding Source Code (±{_CONTEXT_LINES} lines, line {finding.line} marked with →)
|
|
510
|
+
|
|
511
|
+
```python
|
|
512
|
+
{source_snippet if source_snippet else "(source not available)"}
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
## File Context (AST Analysis)
|
|
516
|
+
|
|
517
|
+
{file_ctx_str}
|
|
518
|
+
|
|
519
|
+
---
|
|
520
|
+
|
|
521
|
+
Analyze the finding using the source code and context above.
|
|
522
|
+
Be accurate and specific to the actual code. If the finding is a false
|
|
523
|
+
positive, explain why rather than recommending an unnecessary change.
|
|
524
|
+
"""
|
|
525
|
+
|
|
526
|
+
return LLMRequest(
|
|
527
|
+
system_prompt=_SYSTEM_PROMPT,
|
|
528
|
+
user_prompt=user_prompt,
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
# ─────────────────────────────────────────────────────────────
|
|
533
|
+
# Response parsing
|
|
534
|
+
# ─────────────────────────────────────────────────────────────
|
|
535
|
+
|
|
536
|
+
_SECTION_PATTERN = re.compile(r"^##\s+(.+?)\s*$", re.MULTILINE)
|
|
537
|
+
|
|
538
|
+
_REQUIRED_SECTIONS = (
|
|
539
|
+
"Summary",
|
|
540
|
+
"Vulnerability Explained",
|
|
541
|
+
"Attack Scenario",
|
|
542
|
+
"Root Cause",
|
|
543
|
+
"Recommended Fix",
|
|
544
|
+
"References",
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _parse_llm_response(content: str) -> dict[str, str]:
|
|
549
|
+
"""Parse the structured Markdown response into named sections."""
|
|
550
|
+
matches = list(_SECTION_PATTERN.finditer(content))
|
|
551
|
+
sections: dict[str, str] = {}
|
|
552
|
+
|
|
553
|
+
for index, match in enumerate(matches):
|
|
554
|
+
heading = match.group(1).strip()
|
|
555
|
+
start = match.end()
|
|
556
|
+
end = matches[index + 1].start() if index + 1 < len(matches) else len(content)
|
|
557
|
+
|
|
558
|
+
sections[heading] = content[start:end].strip()
|
|
559
|
+
|
|
560
|
+
if not sections:
|
|
561
|
+
raise ValueError("The LLM response contains no Markdown section headings.")
|
|
562
|
+
|
|
563
|
+
return sections
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
# ─────────────────────────────────────────────────────────────
|
|
567
|
+
# Response display
|
|
568
|
+
# ─────────────────────────────────────────────────────────────
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _display_explanation(
|
|
572
|
+
finding: Finding,
|
|
573
|
+
explanation: dict[str, str],
|
|
574
|
+
response: object,
|
|
575
|
+
rel_path: str,
|
|
576
|
+
line_no: int,
|
|
577
|
+
source_snippet: str,
|
|
578
|
+
) -> None:
|
|
579
|
+
|
|
580
|
+
console.print()
|
|
581
|
+
finding_data = {
|
|
582
|
+
"rule": finding.rule,
|
|
583
|
+
"file": rel_path,
|
|
584
|
+
"line": line_no,
|
|
585
|
+
"severity": str(finding.severity.value).upper(),
|
|
586
|
+
"description": finding.description,
|
|
587
|
+
"impact": finding.impact or "Not specified",
|
|
588
|
+
"remediation": getattr(finding, "remediation", None) or "See the recommended fix below.",
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
print_finding_detail(finding_data, source_snippet=source_snippet)
|
|
592
|
+
|
|
593
|
+
for heading in _REQUIRED_SECTIONS:
|
|
594
|
+
body = explanation.get(heading)
|
|
595
|
+
|
|
596
|
+
if not body:
|
|
597
|
+
print_warning(f"LLM response omitted section: {heading}")
|
|
598
|
+
continue
|
|
599
|
+
|
|
600
|
+
console.print(
|
|
601
|
+
Panel(
|
|
602
|
+
Markdown(body),
|
|
603
|
+
title=f"[bold cyan]{heading}[/]",
|
|
604
|
+
border_style="cyan",
|
|
605
|
+
expand=True,
|
|
606
|
+
padding=(1, 2),
|
|
607
|
+
)
|
|
608
|
+
)
|