pyGdbToolkit 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.
@@ -0,0 +1,466 @@
1
+ # SPDX-FileCopyrightText: 2026 H2Lab Development Team
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """The architecture-neutral ``secscan`` GDB command entry point."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass
9
+ from datetime import datetime, timezone
10
+ import html
11
+ import json
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ import gdb
16
+ from rich import box
17
+ from rich.console import Console
18
+ from rich.panel import Panel
19
+ from rich.table import Table
20
+ from rich.text import Text
21
+
22
+ from .arch import (
23
+ DiagnosticReport,
24
+ DiagnosticServiceName,
25
+ DiagnosticSeverity,
26
+ )
27
+ from .diagnostic_runtime import gdb_diagnostic_access
28
+ from .session import SESSION, ToolkitSession
29
+ from .target_memory import TargetReadError
30
+
31
+ CONSOLE = Console(force_terminal=True)
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class SecscanFinding:
36
+ """One security-audit observation."""
37
+
38
+ category: str
39
+ severity: str
40
+ title: str
41
+ detail: str
42
+
43
+ def to_dict(self) -> dict[str, Any]:
44
+ """Convert this finding to a plain dictionary."""
45
+ return {
46
+ "category": self.category,
47
+ "severity": self.severity,
48
+ "title": self.title,
49
+ "detail": self.detail,
50
+ }
51
+
52
+ @classmethod
53
+ def from_dict(cls, data: dict[str, Any]) -> SecscanFinding:
54
+ """Rebuild a finding from its dictionary representation."""
55
+ return cls(
56
+ category=str(data["category"]),
57
+ severity=str(data["severity"]),
58
+ title=str(data["title"]),
59
+ detail=str(data["detail"]),
60
+ )
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class SecscanReport:
65
+ """A complete security-posture audit report rendered by this command."""
66
+
67
+ core: str
68
+ vendor: str | None
69
+ device_name: str | None
70
+ generated_at: str
71
+ findings: tuple[SecscanFinding, ...]
72
+
73
+ def counts(self) -> dict[str, int]:
74
+ """Return the number of findings per established output severity."""
75
+ counts = {"FAIL": 0, "WARN": 0, "INFO": 0, "PASS": 0}
76
+ for finding in self.findings:
77
+ counts[finding.severity] = counts.get(finding.severity, 0) + 1
78
+ return counts
79
+
80
+ def to_dict(self) -> dict[str, Any]:
81
+ """Convert the complete report to the stable JSON schema."""
82
+ return {
83
+ "core": self.core,
84
+ "vendor": self.vendor,
85
+ "device_name": self.device_name,
86
+ "generated_at": self.generated_at,
87
+ "summary": self.counts(),
88
+ "findings": [finding.to_dict() for finding in self.findings],
89
+ }
90
+
91
+ @classmethod
92
+ def from_dict(cls, data: dict[str, Any]) -> SecscanReport:
93
+ """Rebuild a report from its stable JSON schema."""
94
+ return cls(
95
+ core=str(data["core"]),
96
+ vendor=data.get("vendor"),
97
+ device_name=data.get("device_name"),
98
+ generated_at=str(data["generated_at"]),
99
+ findings=tuple(SecscanFinding.from_dict(finding) for finding in data["findings"]),
100
+ )
101
+
102
+
103
+ _SEVERITY_NAMES = {
104
+ DiagnosticSeverity.PASS: "PASS",
105
+ DiagnosticSeverity.INFO: "INFO",
106
+ DiagnosticSeverity.WARNING: "WARN",
107
+ DiagnosticSeverity.ERROR: "FAIL",
108
+ DiagnosticSeverity.CRITICAL: "FAIL",
109
+ }
110
+
111
+
112
+ def run_audit(session: ToolkitSession = SESSION) -> SecscanReport:
113
+ """Run the registered security-audit service against the selected target.
114
+
115
+ Parameters
116
+ ----------
117
+ session : ToolkitSession
118
+ Unified session providing target access and the portable diagnostic
119
+ runtime. Dependency injection keeps command tests independent of a
120
+ live GDB target.
121
+
122
+ Returns
123
+ -------
124
+ SecscanReport
125
+ The output-compatible report model.
126
+
127
+ Raises
128
+ ------
129
+ gdb.GdbError
130
+ If no registered architecture can perform the requested audit.
131
+ """
132
+ try:
133
+ result = session.diagnose(
134
+ DiagnosticServiceName.SECURITY_AUDIT,
135
+ gdb_diagnostic_access(),
136
+ )
137
+ except TargetReadError as error:
138
+ raise gdb.GdbError(str(error)) from error
139
+ if result.report is None:
140
+ if result.access_error:
141
+ raise gdb.GdbError(result.unavailable_reason or "target unavailable")
142
+ raise gdb.GdbError("secscan only supports ARM Cortex-M targets")
143
+ return _render_model(result.report)
144
+
145
+
146
+ def _render_model(report: DiagnosticReport) -> SecscanReport:
147
+ """Adapt a portable diagnostic report to the stable secscan file schema."""
148
+ return SecscanReport(
149
+ core=report.target.core_name,
150
+ vendor=_target_field(report, "vendor"),
151
+ device_name=_target_field(report, "device_name"),
152
+ generated_at=datetime.now(timezone.utc).isoformat(timespec="seconds"),
153
+ findings=tuple(
154
+ SecscanFinding(
155
+ finding.category,
156
+ _SEVERITY_NAMES.get(finding.severity, finding.severity.value.upper()),
157
+ finding.title,
158
+ finding.detail,
159
+ )
160
+ for finding in report.findings
161
+ ),
162
+ )
163
+
164
+
165
+ def _target_field(report: DiagnosticReport, name: str) -> str | None:
166
+ """Return a string target metadata field supplied by a diagnostic service."""
167
+ for section in report.sections:
168
+ for field in section.fields:
169
+ if field.name == name and isinstance(field.value, str):
170
+ return field.value
171
+ return None
172
+
173
+
174
+ _SEVERITY_STYLE = {
175
+ "FAIL": "bold red",
176
+ "WARN": "bold yellow",
177
+ "INFO": "cyan",
178
+ "PASS": "bold green",
179
+ }
180
+
181
+
182
+ def render_report(report: SecscanReport) -> None:
183
+ """Render a security-audit report through the shared Rich console."""
184
+ counts = report.counts()
185
+ summary = (
186
+ f"Core: {report.core} | Vendor: {report.vendor or 'Unknown'} | "
187
+ f"Device: {report.device_name or 'Unknown'}\n"
188
+ f"Generated: {report.generated_at}\n"
189
+ f"[bold red]FAIL: {counts['FAIL']}[/bold red] "
190
+ f"[bold yellow]WARN: {counts['WARN']}[/bold yellow] "
191
+ f"[cyan]INFO: {counts['INFO']}[/cyan] "
192
+ f"[bold green]PASS: {counts['PASS']}[/bold green]"
193
+ )
194
+ CONSOLE.print(Panel(summary, title="secscan audit summary", box=box.SIMPLE_HEAVY))
195
+
196
+ categories: list[str] = []
197
+ for finding in report.findings:
198
+ if finding.category not in categories:
199
+ categories.append(finding.category)
200
+ for category in categories:
201
+ table = Table(
202
+ title=category,
203
+ box=box.SIMPLE_HEAVY,
204
+ header_style="bold cyan",
205
+ show_header=True,
206
+ )
207
+ table.add_column("Severity", no_wrap=True)
208
+ table.add_column("Finding", style="bold")
209
+ table.add_column("Detail")
210
+ for finding in report.findings:
211
+ if finding.category == category:
212
+ table.add_row(
213
+ Text(finding.severity, style=_SEVERITY_STYLE.get(finding.severity, "")),
214
+ finding.title,
215
+ finding.detail,
216
+ )
217
+ CONSOLE.print(table)
218
+
219
+
220
+ def dump_report_to_json(report: SecscanReport, output_path: Path) -> int:
221
+ """Serialize an audit report to a JSON file and return its byte size."""
222
+ output_path.parent.mkdir(parents=True, exist_ok=True)
223
+ with output_path.open("w", encoding="utf-8") as json_file:
224
+ json.dump(report.to_dict(), json_file, indent=2)
225
+ return output_path.stat().st_size
226
+
227
+
228
+ _HTML_STYLE = """
229
+ body { font-family: -apple-system, Segoe UI, Roboto, sans-serif; margin: 2rem; color: #1a1a1a; }
230
+ h1 { margin-bottom: 0.25rem; }
231
+ .meta { color: #555; margin-bottom: 1.5rem; }
232
+ .summary { display: flex; gap: 1rem; margin-bottom: 2rem; }
233
+ .badge { padding: 0.5rem 1rem; border-radius: 6px; font-weight: 600; color: #fff; }
234
+ .badge.fail { background: #c0392b; }
235
+ .badge.warn { background: #b9770e; }
236
+ .badge.info { background: #2874a6; }
237
+ .badge.pass { background: #1e8449; }
238
+ section { margin-bottom: 2rem; }
239
+ table { border-collapse: collapse; width: 100%; }
240
+ th, td { border: 1px solid #ddd; padding: 0.5rem 0.75rem; text-align: left; vertical-align: top; }
241
+ th { background: #f4f4f4; }
242
+ td.sev { font-weight: 700; white-space: nowrap; }
243
+ tr.sev-fail td.sev { color: #c0392b; }
244
+ tr.sev-warn td.sev { color: #b9770e; }
245
+ tr.sev-info td.sev { color: #2874a6; }
246
+ tr.sev-pass td.sev { color: #1e8449; }
247
+ """
248
+
249
+ _HTML_SEVERITY_CLASS = {
250
+ "FAIL": "sev-fail",
251
+ "WARN": "sev-warn",
252
+ "INFO": "sev-info",
253
+ "PASS": "sev-pass",
254
+ }
255
+
256
+
257
+ def _html_finding_row(finding: SecscanFinding) -> str:
258
+ """Render one finding as an HTML table row, escaping all user-facing text."""
259
+ row_class = _HTML_SEVERITY_CLASS.get(finding.severity, "")
260
+ severity = html.escape(finding.severity)
261
+ title = html.escape(finding.title)
262
+ detail = html.escape(finding.detail)
263
+ return (
264
+ f'<tr class="{row_class}"><td class="sev">{severity}</td>'
265
+ f"<td>{title}</td><td>{detail}</td></tr>"
266
+ )
267
+
268
+
269
+ def generate_html_report(report: SecscanReport) -> str:
270
+ """Render a security-audit report as a standalone HTML document."""
271
+ counts = report.counts()
272
+ categories: list[str] = []
273
+ for finding in report.findings:
274
+ if finding.category not in categories:
275
+ categories.append(finding.category)
276
+ sections: list[str] = []
277
+ for category in categories:
278
+ rows = "\n".join(
279
+ _html_finding_row(finding)
280
+ for finding in report.findings
281
+ if finding.category == category
282
+ )
283
+ sections.append(
284
+ f"<section><h2>{html.escape(category)}</h2>"
285
+ "<table><thead><tr><th>Severity</th><th>Finding</th><th>Detail</th></tr></thead>"
286
+ f"<tbody>{rows}</tbody></table></section>"
287
+ )
288
+ head = (
289
+ '<!DOCTYPE html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n'
290
+ f"<title>secscan report - {html.escape(report.core)}</title>\n"
291
+ f"<style>{_HTML_STYLE}</style>\n</head>\n<body>\n"
292
+ )
293
+ meta = (
294
+ "<h1>secscan audit report</h1>\n"
295
+ f'<p class="meta">Core: {html.escape(report.core)} &middot; '
296
+ f"Vendor: {html.escape(report.vendor or 'Unknown')} &middot; "
297
+ f"Device: {html.escape(report.device_name or 'Unknown')} &middot; "
298
+ f"Generated: {html.escape(report.generated_at)}</p>\n"
299
+ )
300
+ summary = (
301
+ '<div class="summary">'
302
+ f'<span class="badge fail">FAIL {counts["FAIL"]}</span>'
303
+ f'<span class="badge warn">WARN {counts["WARN"]}</span>'
304
+ f'<span class="badge info">INFO {counts["INFO"]}</span>'
305
+ f'<span class="badge pass">PASS {counts["PASS"]}</span>'
306
+ "</div>\n"
307
+ )
308
+ return head + meta + summary + "\n".join(sections) + "\n</body>\n</html>\n"
309
+
310
+
311
+ def dump_report_to_html(report: SecscanReport, output_path: Path) -> int:
312
+ """Write a standalone HTML report and return its byte size."""
313
+ output_path.parent.mkdir(parents=True, exist_ok=True)
314
+ with output_path.open("w", encoding="utf-8") as html_file:
315
+ html_file.write(generate_html_report(report))
316
+ return output_path.stat().st_size
317
+
318
+
319
+ def load_report_from_json(input_path: Path) -> SecscanReport:
320
+ """Load a previously dumped audit report from a JSON file."""
321
+ with input_path.open("r", encoding="utf-8") as json_file:
322
+ data = json.load(json_file)
323
+ try:
324
+ return SecscanReport.from_dict(data)
325
+ except (KeyError, TypeError) as error:
326
+ raise ValueError(f"'{input_path}' is not a valid secscan report: {error}") from error
327
+
328
+
329
+ def render_help() -> None:
330
+ """Render the secscan command overview and help table."""
331
+ table = Table(
332
+ title="Cortex-M Security Posture Audit",
333
+ box=box.SIMPLE_HEAVY,
334
+ header_style="bold cyan",
335
+ show_header=True,
336
+ )
337
+ table.add_column("Command Syntax", style="bold yellow", no_wrap=True)
338
+ table.add_column("Description")
339
+ table.add_row(
340
+ "secscan audit [<output.json>]",
341
+ "Run the full security audit against the live target and optionally dump it to JSON",
342
+ )
343
+ table.add_row(
344
+ "secscan report <report.json> [--html <output.html>]",
345
+ "Render a clean report from a previously dumped audit file (console by default, "
346
+ "or a standalone HTML file with --html)",
347
+ )
348
+ table.add_row("secscan help", "Show this command reference")
349
+ CONSOLE.print(table)
350
+
351
+
352
+ class SecscanCmd(gdb.Command):
353
+ """Run a registered security audit and render its portable report."""
354
+
355
+ def __init__(self) -> None:
356
+ """Register the command with GDB."""
357
+ super().__init__("secscan", gdb.COMMAND_USER, gdb.COMPLETE_NONE, True)
358
+
359
+ def invoke(self, arg: str, from_tty: bool) -> None:
360
+ """Display the help overview."""
361
+ del arg
362
+ del from_tty
363
+ render_help()
364
+
365
+ def _invoke_audit(self, args: list[str]) -> None:
366
+ """Handle ``secscan audit [<output.json>]``."""
367
+ if len(args) > 1:
368
+ raise gdb.GdbError("Usage: secscan audit [<output.json>]")
369
+ report = run_audit()
370
+ render_report(report)
371
+ if args:
372
+ out_path = Path(args[0]).expanduser().resolve()
373
+ try:
374
+ dump_report_to_json(report, out_path)
375
+ except OSError as error:
376
+ raise gdb.GdbError(f"Failed to write dump to '{out_path}': {error}") from error
377
+ CONSOLE.print(f"[green]Report written to {out_path}[/green]")
378
+
379
+ def _invoke_report(self, args: list[str]) -> None:
380
+ """Handle ``secscan report <report.json> [--html <output.html>]``."""
381
+ usage = "Usage: secscan report <report.json> [--html <output.html>]"
382
+ html_arg: str | None = None
383
+ positional: list[str] = []
384
+ index = 0
385
+ while index < len(args):
386
+ token = args[index]
387
+ if token == "--html":
388
+ if index + 1 >= len(args):
389
+ raise gdb.GdbError(f"--html requires an output file path. {usage}")
390
+ html_arg = args[index + 1]
391
+ index += 2
392
+ continue
393
+ positional.append(token)
394
+ index += 1
395
+ if len(positional) != 1:
396
+ raise gdb.GdbError(usage)
397
+ input_path = Path(positional[0]).expanduser().resolve()
398
+ try:
399
+ report = load_report_from_json(input_path)
400
+ except (OSError, ValueError) as error:
401
+ raise gdb.GdbError(f"Failed to read report from '{input_path}': {error}") from error
402
+ if html_arg is not None:
403
+ html_path = Path(html_arg).expanduser().resolve()
404
+ try:
405
+ file_size = dump_report_to_html(report, html_path)
406
+ except OSError as error:
407
+ raise gdb.GdbError(
408
+ f"Failed to write HTML report to '{html_path}': {error}"
409
+ ) from error
410
+ CONSOLE.print(f"[green]HTML report written to {html_path} ({file_size} bytes)[/green]")
411
+ return
412
+ render_report(report)
413
+
414
+
415
+ class _SecscanSubcommand(gdb.Command):
416
+ """Base class for concrete ``secscan`` subcommands."""
417
+
418
+ def __init__(self, parent: SecscanCmd, name: str) -> None:
419
+ """Register one ``secscan <name>`` subcommand."""
420
+ self.parent = parent
421
+ self.name = name
422
+ super().__init__(f"secscan {name}", gdb.COMMAND_USER)
423
+
424
+ def _argv(self, arg: str) -> list[str]:
425
+ """Split command arguments using GDB's CLI lexer."""
426
+ return list(gdb.string_to_argv(arg)) if arg.strip() else []
427
+
428
+
429
+ class SecscanAuditCmd(_SecscanSubcommand):
430
+ """Run the full security audit against the live target."""
431
+
432
+ def __init__(self, parent: SecscanCmd) -> None:
433
+ """Register the ``secscan audit`` subcommand."""
434
+ super().__init__(parent, "audit")
435
+
436
+ def invoke(self, arg: str, from_tty: bool) -> None:
437
+ """Execute ``secscan audit [<output.json>]``."""
438
+ del from_tty
439
+ self.parent._invoke_audit(self._argv(arg))
440
+
441
+
442
+ class SecscanReportCmd(_SecscanSubcommand):
443
+ """Render a previously dumped audit report from a JSON file."""
444
+
445
+ def __init__(self, parent: SecscanCmd) -> None:
446
+ """Register the ``secscan report`` subcommand."""
447
+ super().__init__(parent, "report")
448
+
449
+ def invoke(self, arg: str, from_tty: bool) -> None:
450
+ """Execute ``secscan report <report.json>``."""
451
+ del from_tty
452
+ self.parent._invoke_report(self._argv(arg))
453
+
454
+
455
+ class SecscanHelpCmd(_SecscanSubcommand):
456
+ """Show the secscan command reference."""
457
+
458
+ def __init__(self, parent: SecscanCmd) -> None:
459
+ """Register the ``secscan help`` subcommand."""
460
+ super().__init__(parent, "help")
461
+
462
+ def invoke(self, arg: str, from_tty: bool) -> None:
463
+ """Execute ``secscan help``."""
464
+ del arg
465
+ del from_tty
466
+ render_help()