owasp-guard-cli 0.2.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.
@@ -0,0 +1,3 @@
1
+ __all__ = ['__version__']
2
+ __version__ = '0.2.0'
3
+
@@ -0,0 +1,5 @@
1
+ from .cli import main
2
+
3
+ if __name__ == '__main__':
4
+ main()
5
+
owasp_guard/cli.py ADDED
@@ -0,0 +1,507 @@
1
+ import argparse
2
+ import sys
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from .config import CONFIG_FILE, ensure_api_key, save_api_key
7
+ from .errors import (
8
+ ApiError,
9
+ ConfigError,
10
+ InputError,
11
+ OwaspGuardError,
12
+ ReportError,
13
+ ScanError,
14
+ )
15
+ from .reporting import write_reports
16
+ from .scanner import run_scan
17
+ from rich.console import Console
18
+ from rich.panel import Panel
19
+ from rich.progress import (
20
+ BarColumn,
21
+ Progress,
22
+ SpinnerColumn,
23
+ TaskProgressColumn,
24
+ TextColumn,
25
+ TimeElapsedColumn,
26
+ )
27
+ from rich.table import Table
28
+ from rich.text import Text
29
+
30
+
31
+ DEFAULT_MODEL = "llama-3.1-8b-instant"
32
+ TOOL_NAME = "owasp-guard"
33
+ console = Console(safe_box=True)
34
+
35
+
36
+ class ScanUI:
37
+ def __init__(
38
+ self, target_path: Path, output_dir: Path, model: str, max_files: int | None
39
+ ) -> None:
40
+ self.target_path = target_path
41
+ self.output_dir = output_dir
42
+ self.model = model
43
+ self.max_files = max_files
44
+ self._progress: Progress | None = None
45
+ self._task_id: int | None = None
46
+
47
+ def render_intro(self) -> None:
48
+ title = Text(" OWASP Guard ", style="bold black on cyan")
49
+ subtitle = Text("Secure Code Inspection Pipeline", style="bold white")
50
+ console.print(
51
+ Panel.fit(Text.assemble(title, "\n", subtitle), border_style="cyan")
52
+ )
53
+
54
+ meta = Table.grid(padding=(0, 2))
55
+ meta.add_column(style="bold cyan", justify="right")
56
+ meta.add_column(style="white")
57
+ meta.add_row("Target", str(self.target_path))
58
+ meta.add_row("Output", str(self.output_dir))
59
+ meta.add_row("Model", self.model)
60
+ meta.add_row("Scope", str(self.max_files) if self.max_files else "all files")
61
+ console.print(meta)
62
+ console.print()
63
+
64
+ def start_analyzer(self, total_chunks: int) -> None:
65
+ if total_chunks <= 0:
66
+ return
67
+ self._progress = Progress(
68
+ SpinnerColumn(spinner_name="line", style="cyan"),
69
+ TextColumn("[bold cyan]{task.description}"),
70
+ BarColumn(bar_width=32, complete_style="green", finished_style="green"),
71
+ TaskProgressColumn(),
72
+ TimeElapsedColumn(),
73
+ transient=False,
74
+ console=console,
75
+ )
76
+ self._progress.start()
77
+ self._task_id = self._progress.add_task("Analyzing chunks", total=total_chunks)
78
+
79
+ def update_analyzer(self, index: int, total: int, file_name: str) -> None:
80
+ if self._progress is None or self._task_id is None:
81
+ self.start_analyzer(total)
82
+ if self._progress is not None and self._task_id is not None:
83
+ self._progress.update(
84
+ self._task_id, completed=index, description=f"Analyzing {file_name}"
85
+ )
86
+
87
+ def finish_analyzer(self) -> None:
88
+ if self._progress is not None:
89
+ self._progress.stop()
90
+ self._progress = None
91
+ self._task_id = None
92
+
93
+ def progress_callback(self, stage: str, message: str, data: dict[str, Any]) -> None:
94
+ if stage == "collect_start":
95
+ console.print(f"[bold cyan]INFO[/] {message}...")
96
+ return
97
+ if stage == "collect_done":
98
+ console.print(
99
+ f"[bold green]DONE[/] Files collected: [bold]{data.get('total_files', 0)}[/]"
100
+ )
101
+ return
102
+ if stage == "chunk_file":
103
+ index = int(data.get("index", 0))
104
+ total = int(data.get("total", 0))
105
+ chunks = int(data.get("chunks", 0))
106
+ file_name = Path(str(data.get("file", ""))).name
107
+ console.print(
108
+ f"[bold blue]STEP[/] Chunked [bold]{file_name}[/] "
109
+ f"({index}/{total}) -> {chunks} chunk(s)"
110
+ )
111
+ return
112
+ if stage == "chunk_done":
113
+ console.print(
114
+ f"[bold green]DONE[/] Total chunks prepared: [bold]{data.get('total_chunks', 0)}[/]"
115
+ )
116
+ return
117
+ if stage == "analyze_chunk":
118
+ index = int(data.get("index", 0))
119
+ total = int(data.get("total", 0))
120
+ file_name = Path(str(data.get("file", ""))).name
121
+ self.update_analyzer(index, total, file_name)
122
+ return
123
+ if stage == "analyze_done":
124
+ self.finish_analyzer()
125
+ console.print(
126
+ f"[bold green]DONE[/] Initial findings detected: [bold]{data.get('findings', 0)}[/]"
127
+ )
128
+ return
129
+ if stage == "verify_start":
130
+ console.print(
131
+ f"[bold cyan]INFO[/] {message} ([bold]{data.get('total', 0)}[/] finding(s))..."
132
+ )
133
+ return
134
+ if stage == "verify_done":
135
+ console.print(
136
+ f"[bold green]DONE[/] Verification kept [bold]{data.get('kept', 0)}[/]/[bold]{data.get('total', 0)}[/]"
137
+ )
138
+ return
139
+ if stage == "dedup_done":
140
+ console.print(
141
+ f"[bold green]DONE[/] Deduplicated findings: [bold]{data.get('after', 0)}[/] "
142
+ f"(from {data.get('before', 0)})"
143
+ )
144
+ return
145
+
146
+ def render_outcome(self, json_path: Path, md_path: Path, findings: int) -> None:
147
+ status_color = "red" if findings > 0 else "green"
148
+ status_text = "Issues found" if findings > 0 else "No issues found"
149
+ summary = Table.grid(padding=(0, 1))
150
+ summary.add_column(style="bold cyan", justify="right")
151
+ summary.add_column(style="white")
152
+ summary.add_row("Status", f"[bold {status_color}]{status_text}[/]")
153
+ summary.add_row("Findings", str(findings))
154
+ summary.add_row("JSON", str(json_path.resolve()))
155
+ summary.add_row("Markdown", str(md_path.resolve()))
156
+ console.print()
157
+ console.print(
158
+ Panel(summary, title="[bold]Scan Summary[/bold]", border_style=status_color)
159
+ )
160
+
161
+
162
+ def _build_parser() -> argparse.ArgumentParser:
163
+ prog_name = Path(sys.argv[0]).name or TOOL_NAME
164
+ parser = argparse.ArgumentParser(
165
+ prog=prog_name,
166
+ description="OWASP Guard CLI - Prompt-engineered secure code inspector",
167
+ epilog=(
168
+ "Examples:\n"
169
+ f" {prog_name} init\n"
170
+ f" {prog_name} init --api-key gsk_xxx\n"
171
+ f" {prog_name} scan ./test --output-dir outputs\n"
172
+ f" {prog_name} scan ./juice-shop --max-files 10 --model llama-3.1-8b-instant\n"
173
+ f" {prog_name} help\n"
174
+ f" {prog_name} help scan"
175
+ ),
176
+ formatter_class=argparse.RawTextHelpFormatter,
177
+ )
178
+ sub = parser.add_subparsers(
179
+ dest="command",
180
+ required=True,
181
+ title="Actions",
182
+ description="Use one action at a time.",
183
+ )
184
+
185
+ init_cmd = sub.add_parser(
186
+ "init",
187
+ help="Initialize local configuration",
188
+ description=(
189
+ "Store a Groq API key in local user config.\n"
190
+ "Default location: ~/.owasp_guard/config.json"
191
+ ),
192
+ formatter_class=argparse.RawTextHelpFormatter,
193
+ )
194
+ init_cmd.add_argument(
195
+ "--api-key",
196
+ help="Groq API key. If omitted, input is requested securely.",
197
+ )
198
+
199
+ scan_cmd = sub.add_parser(
200
+ "scan",
201
+ help="Scan repository or file",
202
+ description=(
203
+ "Scan source code for OWASP Top 10 findings and write:\n"
204
+ " report.json (structured)\n"
205
+ " report.md (human readable)"
206
+ ),
207
+ formatter_class=argparse.RawTextHelpFormatter,
208
+ )
209
+ scan_cmd.add_argument("path", help="Target path: directory or single source file")
210
+ scan_cmd.add_argument(
211
+ "--output-dir",
212
+ default=".",
213
+ help="Output directory for report.json and report.md (default: current directory)",
214
+ )
215
+ scan_cmd.add_argument(
216
+ "--model",
217
+ default=DEFAULT_MODEL,
218
+ help=f"Groq model name (default: {DEFAULT_MODEL})",
219
+ )
220
+ scan_cmd.add_argument(
221
+ "--max-files",
222
+ type=int,
223
+ default=None,
224
+ help="Optional file limit for fixed-scope evaluation (example: 10)",
225
+ )
226
+
227
+ help_cmd = sub.add_parser(
228
+ "help",
229
+ help="Show detailed help pages",
230
+ description="Show detailed help and usage playbooks for the tool.",
231
+ formatter_class=argparse.RawTextHelpFormatter,
232
+ )
233
+ help_cmd.add_argument(
234
+ "topic",
235
+ nargs="?",
236
+ choices=["init", "scan", "reports", "errors"],
237
+ help="Optional topic-specific help page",
238
+ )
239
+ return parser
240
+
241
+
242
+ def _show_top_level_help(prog_name: str) -> int:
243
+ header = Text.assemble(
244
+ (" OWASP Guard ", "bold black on cyan"),
245
+ "\n",
246
+ ("Professional OWASP Top 10 Code Security Scanner", "bold white"),
247
+ )
248
+ console.print(Panel.fit(header, border_style="cyan"))
249
+
250
+ actions = Table(
251
+ title="Actions", title_style="bold cyan", show_lines=False, box=None
252
+ )
253
+ actions.add_column("Command", style="bold")
254
+ actions.add_column("Description", style="white")
255
+ actions.add_row("init", "Store API key in local secure config")
256
+ actions.add_row("scan", "Scan repository or file and generate reports")
257
+ actions.add_row("help", "Show detailed usage help by topic")
258
+ console.print(actions)
259
+
260
+ examples = Table(
261
+ title="Examples", title_style="bold cyan", show_header=False, box=None
262
+ )
263
+ examples.add_column(style="green")
264
+ examples.add_row(f"{prog_name} init")
265
+ examples.add_row(f"{prog_name} scan ./project --output-dir outputs")
266
+ examples.add_row(f"{prog_name} scan ./project --max-files 10")
267
+ examples.add_row(f"{prog_name} help scan")
268
+ console.print(examples)
269
+
270
+ options = Table(
271
+ title="Global Options", title_style="bold cyan", show_header=False, box=None
272
+ )
273
+ options.add_column(style="bold")
274
+ options.add_column(style="white")
275
+ options.add_row("-h, --help, --h", "Show this help screen")
276
+ options.add_row("", "")
277
+ options.add_row(
278
+ "Exit Codes",
279
+ "0: success/no findings, 1: findings found, 2: invalid usage, 3: runtime error",
280
+ )
281
+ console.print(options)
282
+ return 0
283
+
284
+
285
+ def _show_help(topic: str | None) -> int:
286
+ if topic == "init":
287
+ console.print(
288
+ Panel.fit(
289
+ "Init command\n\n"
290
+ "Purpose:\n"
291
+ " Save a Groq API key once for future scans.\n\n"
292
+ "Usage:\n"
293
+ " owasp-guard init\n"
294
+ " owasp-guard init --api-key gsk_xxx\n\n"
295
+ "Storage:\n"
296
+ " ~/.owasp_guard/config.json\n",
297
+ title="[bold]Help: init[/bold]",
298
+ border_style="cyan",
299
+ )
300
+ )
301
+ return 0
302
+ if topic == "scan":
303
+ console.print(
304
+ Panel.fit(
305
+ "Scan command\n\n"
306
+ "Purpose:\n"
307
+ " Analyze a repository or single file and generate OWASP reports.\n\n"
308
+ "Usage:\n"
309
+ " owasp-guard scan ./project --output-dir outputs\n"
310
+ " owasp-guard scan ./project --max-files 10\n"
311
+ " owasp-guard scan ./file.py --model llama-3.1-8b-instant\n\n"
312
+ "Outputs:\n"
313
+ " report.json (structured)\n"
314
+ " report.md (executive + detailed)\n",
315
+ title="[bold]Help: scan[/bold]",
316
+ border_style="cyan",
317
+ )
318
+ )
319
+ return 0
320
+ if topic == "reports":
321
+ console.print(
322
+ Panel.fit(
323
+ "Reports\n\n"
324
+ "report.json includes:\n"
325
+ " metadata, severity summary, OWASP distribution, per-file distribution, findings list.\n\n"
326
+ "report.md includes:\n"
327
+ " executive summary, risk snapshot, findings index, detailed findings,\n"
328
+ " remediation roadmap, and methodology.",
329
+ title="[bold]Help: reports[/bold]",
330
+ border_style="cyan",
331
+ )
332
+ )
333
+ return 0
334
+ if topic == "errors":
335
+ console.print(
336
+ Panel.fit(
337
+ "Error Handling\n\n"
338
+ "Configuration errors:\n"
339
+ " Missing/invalid API key, unreadable config file.\n\n"
340
+ "Input errors:\n"
341
+ " Path not found, unsupported extension, no source files, invalid max-files.\n\n"
342
+ "API errors:\n"
343
+ " Invalid key, model unavailable, request failure/timeout.\n\n"
344
+ "Report errors:\n"
345
+ " Output directory not writable, report serialization/write failure.\n",
346
+ title="[bold]Help: errors[/bold]",
347
+ border_style="cyan",
348
+ )
349
+ )
350
+ return 0
351
+
352
+ console.print(
353
+ Panel.fit(
354
+ "OWASP Guard Help\n\n"
355
+ "Quick start:\n"
356
+ " 1) owasp-guard init\n"
357
+ " 2) owasp-guard scan ./project --output-dir outputs\n\n"
358
+ "Topic help:\n"
359
+ " owasp-guard help init\n"
360
+ " owasp-guard help scan\n"
361
+ " owasp-guard help reports\n"
362
+ " owasp-guard help errors\n\n"
363
+ "If installed with alias entrypoint:\n"
364
+ " owasp help\n"
365
+ " owasp help scan",
366
+ title="[bold]Help[/bold]",
367
+ border_style="cyan",
368
+ )
369
+ )
370
+ return 0
371
+
372
+
373
+ def _handle_init(args: argparse.Namespace) -> int:
374
+ if args.api_key:
375
+ path = save_api_key(args.api_key)
376
+ else:
377
+ key = ensure_api_key(interactive=True)
378
+ path = save_api_key(key)
379
+ console.print(
380
+ Panel.fit(
381
+ f"[green]API key saved[/green]\n[bold]Path:[/bold] {path}",
382
+ title="[bold]Initialization Complete[/bold]",
383
+ border_style="green",
384
+ )
385
+ )
386
+ return 0
387
+
388
+
389
+ def _handle_scan(args: argparse.Namespace) -> int:
390
+ api_key = ensure_api_key(interactive=True)
391
+ if args.max_files is not None and args.max_files <= 0:
392
+ raise InputError("--max-files must be greater than 0.")
393
+ output_dir = Path(args.output_dir).resolve()
394
+ if output_dir.exists() and not output_dir.is_dir():
395
+ raise InputError(
396
+ f"--output-dir must be a directory path, got file: {output_dir}"
397
+ )
398
+ ui = ScanUI(
399
+ target_path=Path(args.path).resolve(),
400
+ output_dir=output_dir,
401
+ model=args.model,
402
+ max_files=args.max_files,
403
+ )
404
+ ui.render_intro()
405
+ report = run_scan(
406
+ target_path=args.path,
407
+ model_name=args.model,
408
+ api_key=api_key,
409
+ max_files=args.max_files,
410
+ progress_callback=ui.progress_callback,
411
+ )
412
+ ui.finish_analyzer()
413
+ json_path, md_path = write_reports(report, output_dir=args.output_dir)
414
+ ui.render_outcome(
415
+ json_path=json_path, md_path=md_path, findings=report.total_findings
416
+ )
417
+ return 1 if report.total_findings > 0 else 0
418
+
419
+
420
+ def _render_error_panel(kind: str, message: str, hint: str | None = None) -> None:
421
+ body = f"[bold]{kind}[/bold]\n\n{message}"
422
+ if hint:
423
+ body += f"\n\n[bold]Hint:[/bold] {hint}"
424
+ console.print(
425
+ Panel.fit(
426
+ body, title="[bold red]Execution Failed[/bold red]", border_style="red"
427
+ )
428
+ )
429
+
430
+
431
+ def main() -> None:
432
+ raw_args = sys.argv[1:]
433
+ prog_name = Path(sys.argv[0]).name or TOOL_NAME
434
+ if not raw_args or raw_args[0] in {"-h", "--help", "--h"}:
435
+ sys.exit(_show_top_level_help(prog_name))
436
+ if (
437
+ len(raw_args) >= 2
438
+ and raw_args[1] in {"-h", "--help", "--h"}
439
+ and raw_args[0] in {"init", "scan", "help"}
440
+ ):
441
+ topic = (
442
+ raw_args[0]
443
+ if raw_args[0] in {"init", "scan"}
444
+ else (raw_args[2] if len(raw_args) >= 3 else None)
445
+ )
446
+ sys.exit(_show_help(topic))
447
+
448
+ parser = _build_parser()
449
+ args = parser.parse_args()
450
+ try:
451
+ if args.command == "init":
452
+ code = _handle_init(args)
453
+ elif args.command == "scan":
454
+ code = _handle_scan(args)
455
+ elif args.command == "help":
456
+ code = _show_help(args.topic)
457
+ else:
458
+ parser.print_help()
459
+ code = 2
460
+ except KeyboardInterrupt:
461
+ console.print()
462
+ console.print("[yellow]Interrupted.[/]")
463
+ code = 130
464
+ except ConfigError as exc:
465
+ _render_error_panel(
466
+ "Configuration Error",
467
+ str(exc),
468
+ hint=f"Run [bold]{TOOL_NAME} init[/] to configure credentials.",
469
+ )
470
+ code = 3
471
+ except InputError as exc:
472
+ _render_error_panel(
473
+ "Input Error",
474
+ str(exc),
475
+ hint=f"Run [bold]{TOOL_NAME} help scan[/] for valid usage.",
476
+ )
477
+ code = 3
478
+ except ApiError as exc:
479
+ _render_error_panel(
480
+ "API Error",
481
+ str(exc),
482
+ hint="Check API key, internet access, and selected model.",
483
+ )
484
+ code = 3
485
+ except ReportError as exc:
486
+ _render_error_panel(
487
+ "Report Error",
488
+ str(exc),
489
+ hint="Check output directory permissions and available disk space.",
490
+ )
491
+ code = 3
492
+ except ScanError as exc:
493
+ _render_error_panel("Scan Error", str(exc))
494
+ code = 3
495
+ except OwaspGuardError as exc:
496
+ _render_error_panel("Application Error", str(exc))
497
+ code = 3
498
+ except Exception as exc:
499
+ _render_error_panel(
500
+ "Unexpected Error",
501
+ str(exc),
502
+ hint="Run again with a smaller scope and verify dependencies.",
503
+ )
504
+ if not CONFIG_FILE.exists():
505
+ console.print(f"Run first-time setup: [bold]{TOOL_NAME} init[/]")
506
+ code = 3
507
+ sys.exit(code)
owasp_guard/config.py ADDED
@@ -0,0 +1,65 @@
1
+ import json
2
+ import os
3
+ from getpass import getpass
4
+ from pathlib import Path
5
+
6
+ from .errors import ConfigError
7
+
8
+
9
+ CONFIG_DIR = Path.home() / ".owasp_guard"
10
+ CONFIG_FILE = CONFIG_DIR / "config.json"
11
+
12
+
13
+ def _load_config() -> dict:
14
+ if not CONFIG_FILE.exists():
15
+ return {}
16
+ try:
17
+ raw = CONFIG_FILE.read_text(encoding="utf-8")
18
+ loaded = json.loads(raw)
19
+ except json.JSONDecodeError as exc:
20
+ raise ConfigError(f"Invalid config file format: {CONFIG_FILE} ({exc})") from exc
21
+ except OSError as exc:
22
+ raise ConfigError(f"Unable to read config file: {CONFIG_FILE} ({exc})") from exc
23
+ if not isinstance(loaded, dict):
24
+ raise ConfigError(f"Invalid config file structure: {CONFIG_FILE}")
25
+ return loaded
26
+
27
+
28
+ def save_api_key(api_key: str) -> Path:
29
+ sanitized = api_key.strip()
30
+ if not sanitized:
31
+ raise ConfigError("API key cannot be empty.")
32
+ if not sanitized.startswith("gsk_"):
33
+ raise ConfigError("API key must start with 'gsk_'.")
34
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
35
+ payload = _load_config()
36
+ payload["groq_api_key"] = sanitized
37
+ try:
38
+ CONFIG_FILE.write_text(json.dumps(payload, indent=2), encoding="utf-8")
39
+ except OSError as exc:
40
+ raise ConfigError(f"Unable to write config file: {CONFIG_FILE} ({exc})") from exc
41
+ return CONFIG_FILE
42
+
43
+
44
+ def get_api_key() -> str | None:
45
+ from_env = os.getenv("GROQ_API_KEY")
46
+ if from_env:
47
+ return from_env.strip()
48
+ payload = _load_config()
49
+ key = payload.get("groq_api_key")
50
+ if isinstance(key, str) and key.strip():
51
+ return key.strip()
52
+ return None
53
+
54
+
55
+ def ensure_api_key(interactive: bool = True) -> str:
56
+ key = get_api_key()
57
+ if key:
58
+ return key
59
+ if not interactive:
60
+ raise ConfigError("Groq API key not set. Run: owasp-guard init")
61
+ entered = getpass("Enter your Groq API key (input hidden): ").strip()
62
+ if not entered:
63
+ raise ConfigError("Empty API key. Run again with a valid key.")
64
+ save_api_key(entered)
65
+ return entered
owasp_guard/errors.py ADDED
@@ -0,0 +1,22 @@
1
+ class OwaspGuardError(Exception):
2
+ """Base exception for all tool errors."""
3
+
4
+
5
+ class ConfigError(OwaspGuardError):
6
+ """Configuration or credential setup issues."""
7
+
8
+
9
+ class InputError(OwaspGuardError):
10
+ """Invalid or unsupported user input."""
11
+
12
+
13
+ class ScanError(OwaspGuardError):
14
+ """General scan pipeline failures."""
15
+
16
+
17
+ class ApiError(ScanError):
18
+ """Remote LLM API request failures."""
19
+
20
+
21
+ class ReportError(OwaspGuardError):
22
+ """Report serialization or write failures."""
owasp_guard/models.py ADDED
@@ -0,0 +1,46 @@
1
+ from pydantic import BaseModel, Field
2
+
3
+
4
+ class CodeChunk(BaseModel):
5
+ file: str
6
+ line_start: int = Field(ge=1)
7
+ line_end: int = Field(ge=1)
8
+ context: str
9
+ code: str
10
+
11
+
12
+ class Finding(BaseModel):
13
+ file: str
14
+ line_start: int = Field(ge=1)
15
+ line_end: int = Field(ge=1)
16
+ owasp_category: str
17
+ title: str
18
+ risk_summary: str
19
+ fix_recommendation: str
20
+ evidence: str
21
+ confidence: float = Field(ge=0.0, le=1.0)
22
+
23
+
24
+ class FindingsResponse(BaseModel):
25
+ findings: list[Finding] = Field(default_factory=list)
26
+
27
+
28
+ class VerificationDecision(BaseModel):
29
+ index: int = Field(ge=0)
30
+ keep: bool
31
+ normalized_owasp_category: str
32
+ normalized_title: str
33
+ adjusted_confidence: float = Field(ge=0.0, le=1.0)
34
+ reason: str
35
+
36
+
37
+ class VerificationResponse(BaseModel):
38
+ decisions: list[VerificationDecision] = Field(default_factory=list)
39
+
40
+
41
+ class ScanReport(BaseModel):
42
+ scanned_path: str
43
+ total_files: int
44
+ total_chunks: int
45
+ total_findings: int
46
+ findings: list[Finding] = Field(default_factory=list)