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.
- owasp_guard/__init__.py +3 -0
- owasp_guard/__main__.py +5 -0
- owasp_guard/cli.py +507 -0
- owasp_guard/config.py +65 -0
- owasp_guard/errors.py +22 -0
- owasp_guard/models.py +46 -0
- owasp_guard/prompts.py +168 -0
- owasp_guard/reporting.py +225 -0
- owasp_guard/scanner.py +939 -0
- owasp_guard_cli-0.2.0.dist-info/METADATA +150 -0
- owasp_guard_cli-0.2.0.dist-info/RECORD +14 -0
- owasp_guard_cli-0.2.0.dist-info/WHEEL +5 -0
- owasp_guard_cli-0.2.0.dist-info/entry_points.txt +3 -0
- owasp_guard_cli-0.2.0.dist-info/top_level.txt +1 -0
owasp_guard/__init__.py
ADDED
owasp_guard/__main__.py
ADDED
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)
|