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,816 @@
1
+ # SPDX-FileCopyrightText: 2026 H2Lab Development Team
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Arm Cortex-M security-posture diagnostic service."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass
9
+ from typing import Protocol
10
+
11
+ import gdb
12
+
13
+ from ...target_memory import TargetMemory, TargetReadError, WritableTargetMemory
14
+ from ..base import Architecture, SystemRegisterSet, TargetDescription
15
+ from ..diagnostics import (
16
+ DiagnosticField,
17
+ DiagnosticFinding,
18
+ DiagnosticReport,
19
+ DiagnosticRuntimeAccess,
20
+ DiagnosticSection,
21
+ DiagnosticServiceName,
22
+ DiagnosticSeverity,
23
+ )
24
+ from .coresight import discover_rom_tables
25
+ from .cortex_m import CortexMArchitecture, CortexMFeature, CortexMTargetDescription, read_scb
26
+ from .mpu import MpuAccessPermission, MpuDump, MpuRegion, dump_mpu_regions
27
+ from .providers import DEFAULT_PROVIDER_REGISTRY
28
+ from .sau import dump_sau_regions, read_sau_status
29
+
30
+ _DHCSR_ADDRESS = 0xE000EDF0
31
+ _CCR_UNALIGN_TRP_BIT = 3
32
+ _CCR_DIV_0_TRP_BIT = 4
33
+ _CCR_BFHFNMIGN_BIT = 10
34
+ _SHCSR_MEMFAULTENA_BIT = 16
35
+ _SHCSR_BUSFAULTENA_BIT = 17
36
+ _SHCSR_USGFAULTENA_BIT = 18
37
+ _V81M_ARCHITECTURES = frozenset((CortexMArchitecture.ARMV8_1_M_MAINLINE,))
38
+ _PAC_KEY_P_REGISTERS = ("pac_key_p_0", "pac_key_p_1", "pac_key_p_2", "pac_key_p_3")
39
+ _VECTOR_HANDLERS: tuple[tuple[int, str, int | None], ...] = (
40
+ (2, "NMI", None),
41
+ (3, "HardFault", None),
42
+ (4, "MemManage", _SHCSR_MEMFAULTENA_BIT),
43
+ (5, "BusFault", _SHCSR_BUSFAULTENA_BIT),
44
+ (6, "UsageFault", _SHCSR_USGFAULTENA_BIT),
45
+ (11, "SVCall", None),
46
+ (14, "PendSV", None),
47
+ (15, "SysTick", None),
48
+ )
49
+ _STM32_RDP_LAYOUTS: tuple[tuple[tuple[str, ...], int, int], ...] = (
50
+ (("STM32F2", "STM32F4", "STM32F7"), 0x40023C14, 8),
51
+ (("STM32H7",), 0x52002020, 8),
52
+ (
53
+ (
54
+ "STM32F0",
55
+ "STM32F1",
56
+ "STM32F3",
57
+ "STM32C0",
58
+ "STM32G0",
59
+ "STM32G4",
60
+ "STM32L0",
61
+ "STM32L1",
62
+ "STM32L4",
63
+ "STM32L5",
64
+ "STM32U5",
65
+ "STM32U0",
66
+ "STM32U3",
67
+ "STM32WB",
68
+ "STM32WL",
69
+ ),
70
+ 0x40022020,
71
+ 0,
72
+ ),
73
+ )
74
+
75
+
76
+ class CortexMRegisterReader(Protocol):
77
+ """Read an exposed Cortex-M architectural register by one of several names."""
78
+
79
+ def read_first(self, names: tuple[str, ...]) -> int | None:
80
+ """Return the first exposed register value, or ``None`` when none are exposed."""
81
+ ...
82
+
83
+
84
+ class GdbCortexMRegisterReader:
85
+ """Read Cortex-M core registers from GDB's selected frame."""
86
+
87
+ def read_first(self, names: tuple[str, ...]) -> int | None:
88
+ """Return the first GDB register exposed under a candidate name."""
89
+ frame = gdb.selected_frame()
90
+ for name in names:
91
+ try:
92
+ return int(frame.read_register(name))
93
+ except (gdb.error, ValueError, TypeError):
94
+ continue
95
+ return None
96
+
97
+
98
+ @dataclass
99
+ class _FindingCollector:
100
+ """Collect portable findings while retaining the established severity vocabulary."""
101
+
102
+ findings: list[DiagnosticFinding]
103
+
104
+ def __init__(self) -> None:
105
+ """Create an empty finding collection."""
106
+ self.findings = []
107
+
108
+ def add(self, category: str, severity: DiagnosticSeverity, title: str, detail: str) -> None:
109
+ """Append one portable security finding."""
110
+ self.findings.append(DiagnosticFinding(category, severity, title, detail))
111
+
112
+
113
+ class CortexMSecurityAuditor:
114
+ """Collect the Arm-specific implementation of the portable security audit."""
115
+
116
+ architecture = Architecture.ARM
117
+ service = DiagnosticServiceName.SECURITY_AUDIT
118
+
119
+ def __init__(self, registers: CortexMRegisterReader | None = None) -> None:
120
+ """Create an auditor using GDB registers unless a test adapter is supplied."""
121
+ self._registers = registers if registers is not None else GdbCortexMRegisterReader()
122
+
123
+ def supports(self, target: TargetDescription) -> bool:
124
+ """Return whether the target is a known Cortex-M implementation."""
125
+ return isinstance(target, CortexMTargetDescription) and target.core is not None
126
+
127
+ def collect(
128
+ self,
129
+ reader: TargetMemory,
130
+ target: TargetDescription,
131
+ access: DiagnosticRuntimeAccess | None = None,
132
+ ) -> DiagnosticReport:
133
+ """Collect Cortex-M security controls and device-specific protections."""
134
+ if not isinstance(target, CortexMTargetDescription) or target.core is None:
135
+ raise ValueError("Cortex-M security auditing requires a known core description")
136
+
137
+ scb = read_scb(reader, target, cpuid_value=target.raw_cpuid)
138
+ discovery = discover_rom_tables(reader)
139
+ device_report = DEFAULT_PROVIDER_REGISTRY.inspect(reader, target, discovery)
140
+ collector = _FindingCollector()
141
+
142
+ registers = (
143
+ access.registers
144
+ if access is not None and access.registers is not None
145
+ else self._registers
146
+ )
147
+
148
+ self._audit_mpu(reader, target, collector, registers)
149
+ self._audit_cmsis_core(reader, target, scb, collector)
150
+ self._audit_trustzone(reader, target, collector)
151
+ self._audit_vtor(reader, target, scb, collector)
152
+ self._audit_stack_limits(target, collector, registers)
153
+ self._audit_pacbti(target, collector, registers)
154
+ if device_report.vendor == "STMicroelectronics" and device_report.product_line.is_available:
155
+ self._audit_stm32_rdp(reader, device_report.product_line.display(), collector)
156
+
157
+ return DiagnosticReport(
158
+ self.service,
159
+ target,
160
+ (
161
+ DiagnosticSection(
162
+ "Target",
163
+ (
164
+ DiagnosticField("vendor", device_report.vendor),
165
+ DiagnosticField(
166
+ "device_name",
167
+ (
168
+ device_report.product_line.display()
169
+ if device_report.product_line.is_available
170
+ else None
171
+ ),
172
+ ),
173
+ ),
174
+ ),
175
+ ),
176
+ tuple(collector.findings),
177
+ )
178
+
179
+ def _audit_mpu(
180
+ self,
181
+ reader: TargetMemory,
182
+ target: CortexMTargetDescription,
183
+ collector: _FindingCollector,
184
+ registers: CortexMRegisterReader,
185
+ ) -> None:
186
+ """Audit MPU configuration through the typed MPU inspection API."""
187
+ category = "MPU"
188
+ if not isinstance(reader, WritableTargetMemory):
189
+ collector.add(
190
+ category,
191
+ DiagnosticSeverity.INFO,
192
+ "MPU not accessible",
193
+ "The target-memory backend does not support MPU region selection.",
194
+ )
195
+ return
196
+ dump = dump_mpu_regions(reader, target)
197
+ if not dump.is_available:
198
+ collector.add(
199
+ category,
200
+ DiagnosticSeverity.INFO,
201
+ "MPU not accessible",
202
+ f"Cannot read MPU_TYPE: {dump.unavailable_reason}",
203
+ )
204
+ return
205
+ assert dump.region_count is not None
206
+ if dump.region_count == 0:
207
+ collector.add(
208
+ category,
209
+ DiagnosticSeverity.INFO,
210
+ "MPU not implemented",
211
+ "MPU_TYPE.DREGION is 0.",
212
+ )
213
+ return
214
+
215
+ control = dump.get_register("CTRL")
216
+ if control is None or not control.is_available:
217
+ collector.add(
218
+ category,
219
+ DiagnosticSeverity.INFO,
220
+ "MPU control unavailable",
221
+ (
222
+ control.unavailable_reason
223
+ if control is not None and control.unavailable_reason is not None
224
+ else "MPU_CTRL was not read."
225
+ ),
226
+ )
227
+ return
228
+ assert control.value is not None
229
+ enabled = bool(control.value & 0x1)
230
+ privdefena = bool(control.value & 0x4)
231
+ if not enabled:
232
+ collector.add(
233
+ category,
234
+ DiagnosticSeverity.ERROR,
235
+ "MPU is disabled",
236
+ f"MPU_CTRL.ENABLE=0 while {dump.region_count} region(s) are implemented; "
237
+ "no memory protection is active.",
238
+ )
239
+ else:
240
+ collector.add(
241
+ category,
242
+ DiagnosticSeverity.PASS,
243
+ "MPU is enabled",
244
+ f"{dump.region_count} region(s) implemented.",
245
+ )
246
+
247
+ regions = self._available_mpu_regions(dump, collector)
248
+ enabled_regions = [region for region in regions if region.enabled]
249
+ for region in enabled_regions:
250
+ if region.end_address is None or region.end_address <= region.start_address:
251
+ collector.add(
252
+ category,
253
+ DiagnosticSeverity.WARNING,
254
+ f"Region {region.index} has an invalid size",
255
+ f"base=0x{region.start_address:08X} " f"limit=0x{region.end_address or 0:08X}.",
256
+ )
257
+ if self._region_writable(region) and region.executable:
258
+ collector.add(
259
+ category,
260
+ DiagnosticSeverity.ERROR,
261
+ f"Region {region.index} violates W^X",
262
+ f"0x{region.start_address:08X}-0x{region.end_address or 0:08X} "
263
+ "is both writable and executable.",
264
+ )
265
+
266
+ sorted_regions = sorted(enabled_regions, key=lambda region: region.start_address)
267
+ for previous, current in zip(sorted_regions, sorted_regions[1:]):
268
+ if self._overlaps(previous, current):
269
+ collector.add(
270
+ category,
271
+ DiagnosticSeverity.ERROR,
272
+ f"Regions {previous.index} and {current.index} overlap",
273
+ f"0x{previous.start_address:08X}-0x{previous.end_address or 0:08X} "
274
+ f"overlaps 0x{current.start_address:08X}-0x{current.end_address or 0:08X}.",
275
+ )
276
+
277
+ if enabled:
278
+ self._audit_stack_mpu_coverage(enabled_regions, privdefena, collector, registers)
279
+ for region in enabled_regions:
280
+ end_address = region.end_address
281
+ if end_address is None:
282
+ continue
283
+ if (
284
+ region.start_address < 0x40000000
285
+ and end_address >= 0x20000000
286
+ and region.executable
287
+ ):
288
+ collector.add(
289
+ category,
290
+ DiagnosticSeverity.ERROR,
291
+ f"Region {region.index} makes RAM executable",
292
+ f"0x{region.start_address:08X}-0x{end_address:08X} overlaps SRAM and is not XN.",
293
+ )
294
+ if region.start_address < 0x20000000 and end_address >= 0:
295
+ if self._region_writable(region):
296
+ collector.add(
297
+ category,
298
+ DiagnosticSeverity.ERROR,
299
+ f"Region {region.index} makes Flash writable",
300
+ f"0x{region.start_address:08X}-0x{end_address:08X} "
301
+ "overlaps Flash/code and is writable.",
302
+ )
303
+ if not region.executable:
304
+ collector.add(
305
+ category,
306
+ DiagnosticSeverity.WARNING,
307
+ f"Region {region.index} makes Flash non-executable",
308
+ f"0x{region.start_address:08X}-0x{end_address:08X} "
309
+ "overlaps Flash/code and sets XN.",
310
+ )
311
+
312
+ def _available_mpu_regions(
313
+ self,
314
+ dump: MpuDump,
315
+ collector: _FindingCollector,
316
+ ) -> list[MpuRegion]:
317
+ """Return readable regions and retain individual read failures as findings."""
318
+ regions: list[MpuRegion] = []
319
+ for result in dump.regions:
320
+ if result.region is None:
321
+ collector.add(
322
+ "MPU",
323
+ DiagnosticSeverity.INFO,
324
+ f"Region {result.index} unreadable",
325
+ result.unavailable_reason or "unknown MPU region read error",
326
+ )
327
+ continue
328
+ regions.append(result.region)
329
+ return regions
330
+
331
+ def _audit_stack_mpu_coverage(
332
+ self,
333
+ regions: list[MpuRegion],
334
+ privdefena: bool,
335
+ collector: _FindingCollector,
336
+ registers: CortexMRegisterReader,
337
+ ) -> None:
338
+ """Check whether current stack pointers have non-executable MPU coverage."""
339
+ for name, register_names in (
340
+ ("MSP", ("msp", "msp_ns", "msp_s")),
341
+ ("PSP", ("psp", "psp_ns", "psp_s")),
342
+ ):
343
+ value = registers.read_first(register_names)
344
+ if value is None:
345
+ continue
346
+ covering = next(
347
+ (region for region in regions if self._region_covers_address(region, value)),
348
+ None,
349
+ )
350
+ if covering is None:
351
+ if privdefena:
352
+ collector.add(
353
+ "MPU",
354
+ DiagnosticSeverity.WARNING,
355
+ f"{name} falls back to the default background map",
356
+ f"0x{value:08X} is not covered by any explicit region; "
357
+ "MPU_CTRL.PRIVDEFENA=1 grants the default executable memory map.",
358
+ )
359
+ else:
360
+ collector.add(
361
+ "MPU",
362
+ DiagnosticSeverity.ERROR,
363
+ f"{name} is not covered by any MPU region",
364
+ f"0x{value:08X} has no matching region and PRIVDEFENA=0 (access denied).",
365
+ )
366
+ elif covering.executable:
367
+ collector.add(
368
+ "MPU",
369
+ DiagnosticSeverity.ERROR,
370
+ f"{name} stack region is executable",
371
+ f"Region {covering.index} covering 0x{value:08X} does not set XN.",
372
+ )
373
+ else:
374
+ collector.add(
375
+ "MPU",
376
+ DiagnosticSeverity.PASS,
377
+ f"{name} stack region is non-executable",
378
+ f"Region {covering.index} covers 0x{value:08X} with XN set.",
379
+ )
380
+
381
+ def _audit_cmsis_core(
382
+ self,
383
+ reader: TargetMemory,
384
+ target: CortexMTargetDescription,
385
+ scb: SystemRegisterSet,
386
+ collector: _FindingCollector,
387
+ ) -> None:
388
+ """Audit typed SCB controls and the debug-halt control register."""
389
+ category = "CMSIS Core Security"
390
+ if target.core is not None and CortexMFeature.CONFIGURABLE_FAULTS in target.core.features:
391
+ shcsr = self._scb_value(scb, "SHCSR")
392
+ if shcsr is None:
393
+ collector.add(
394
+ category,
395
+ DiagnosticSeverity.INFO,
396
+ "Configurable fault status unavailable",
397
+ "SHCSR could not be read.",
398
+ )
399
+ else:
400
+ missing = [
401
+ name
402
+ for name, bit in (
403
+ ("MemManage", _SHCSR_MEMFAULTENA_BIT),
404
+ ("BusFault", _SHCSR_BUSFAULTENA_BIT),
405
+ ("UsageFault", _SHCSR_USGFAULTENA_BIT),
406
+ )
407
+ if not (shcsr & (1 << bit))
408
+ ]
409
+ if missing:
410
+ collector.add(
411
+ category,
412
+ DiagnosticSeverity.WARNING,
413
+ "Configurable fault handlers disabled",
414
+ f"{', '.join(missing)} disabled in SHCSR; these faults will escalate "
415
+ "to HardFault.",
416
+ )
417
+ else:
418
+ collector.add(
419
+ category,
420
+ DiagnosticSeverity.PASS,
421
+ "Configurable fault handlers enabled",
422
+ "MemManage, BusFault, and UsageFault are all enabled in SHCSR.",
423
+ )
424
+ else:
425
+ collector.add(
426
+ category,
427
+ DiagnosticSeverity.INFO,
428
+ "No configurable fault handlers on this core",
429
+ f"{target.core_name} only implements HardFault.",
430
+ )
431
+
432
+ ccr = self._scb_value(scb, "CCR")
433
+ if ccr is not None:
434
+ if not (ccr & (1 << _CCR_DIV_0_TRP_BIT)):
435
+ collector.add(
436
+ category,
437
+ DiagnosticSeverity.WARNING,
438
+ "Division-by-zero trap disabled",
439
+ "CCR.DIV_0_TRP=0: integer division by zero silently returns 0 instead of "
440
+ "faulting.",
441
+ )
442
+ if not (ccr & (1 << _CCR_UNALIGN_TRP_BIT)):
443
+ collector.add(
444
+ category,
445
+ DiagnosticSeverity.INFO,
446
+ "Unaligned-access trap disabled",
447
+ "CCR.UNALIGN_TRP=0: unaligned accesses are silently allowed except for "
448
+ "LDM/STM/PUSH/POP.",
449
+ )
450
+ if ccr & (1 << _CCR_BFHFNMIGN_BIT):
451
+ collector.add(
452
+ category,
453
+ DiagnosticSeverity.WARNING,
454
+ "BusFault ignored at high priority",
455
+ "CCR.BFHFNMIGN=1: precise data-bus faults are ignored in HardFault/NMI "
456
+ "handlers and priority -1/-2 code.",
457
+ )
458
+
459
+ try:
460
+ dhcsr = reader.read_uint32(_DHCSR_ADDRESS)
461
+ except TargetReadError as error:
462
+ collector.add(category, DiagnosticSeverity.INFO, "DHCSR unavailable", str(error))
463
+ else:
464
+ if dhcsr & 0x1:
465
+ collector.add(
466
+ category,
467
+ DiagnosticSeverity.INFO,
468
+ "Debug access is currently enabled",
469
+ "DHCSR.C_DEBUGEN=1: ensure the debug port is disabled/locked in production.",
470
+ )
471
+ else:
472
+ collector.add(
473
+ category,
474
+ DiagnosticSeverity.PASS,
475
+ "Debug access is disabled",
476
+ "DHCSR.C_DEBUGEN=0.",
477
+ )
478
+
479
+ def _audit_trustzone(
480
+ self,
481
+ reader: TargetMemory,
482
+ target: CortexMTargetDescription,
483
+ collector: _FindingCollector,
484
+ ) -> None:
485
+ """Audit TrustZone configuration using the typed SAU inspection API."""
486
+ category = "TrustZone (SAU)"
487
+ if not isinstance(reader, WritableTargetMemory):
488
+ collector.add(
489
+ category,
490
+ DiagnosticSeverity.INFO,
491
+ "TrustZone-M not implemented",
492
+ "The target-memory backend does not support SAU region selection.",
493
+ )
494
+ return
495
+ status = read_sau_status(reader, target)
496
+ if not status.is_available:
497
+ collector.add(
498
+ category,
499
+ DiagnosticSeverity.INFO,
500
+ "TrustZone-M not implemented",
501
+ "SAU_TYPE is not accessible on this core.",
502
+ )
503
+ return
504
+ dump = dump_sau_regions(reader, target, status)
505
+ assert status.region_count is not None
506
+ assert status.enabled is not None
507
+ assert status.all_non_secure is not None
508
+ if not status.enabled:
509
+ if status.region_count == 0:
510
+ collector.add(
511
+ category,
512
+ DiagnosticSeverity.INFO,
513
+ "TrustZone-M not used",
514
+ "SAU implements 0 regions and is disabled; this is likely a non-secure-only "
515
+ "build.",
516
+ )
517
+ else:
518
+ collector.add(
519
+ category,
520
+ DiagnosticSeverity.WARNING,
521
+ "SAU implemented but disabled",
522
+ f"SAU_CTRL.ENABLE=0 with {status.region_count} region(s) available; "
523
+ "all memory is treated as Non-Secure and TrustZone is not enforced.",
524
+ )
525
+ return
526
+ if status.all_non_secure:
527
+ collector.add(
528
+ category,
529
+ DiagnosticSeverity.WARNING,
530
+ "SAU_CTRL.ALLNS is set",
531
+ "All memory defaults to Non-Secure outside matched regions; verify region coverage.",
532
+ )
533
+
534
+ regions = []
535
+ for result in dump.regions:
536
+ if result.region is None:
537
+ collector.add(
538
+ category,
539
+ DiagnosticSeverity.INFO,
540
+ f"SAU region {result.index} unreadable",
541
+ result.unavailable_reason or "unknown SAU region read error",
542
+ )
543
+ continue
544
+ regions.append(result.region)
545
+ enabled_regions = [region for region in regions if region.enabled]
546
+ if not enabled_regions:
547
+ collector.add(
548
+ category,
549
+ DiagnosticSeverity.WARNING,
550
+ "SAU is enabled without active regions",
551
+ f"SAU_CTRL.ENABLE=1 but none of the {status.region_count} region(s) are enabled.",
552
+ )
553
+ else:
554
+ collector.add(
555
+ category,
556
+ DiagnosticSeverity.PASS,
557
+ "TrustZone-M active",
558
+ f"{len(enabled_regions)} enabled Secure-attribution region(s) out of "
559
+ f"{status.region_count}.",
560
+ )
561
+ sorted_regions = sorted(enabled_regions, key=lambda region: region.start_address)
562
+ for previous, current in zip(sorted_regions, sorted_regions[1:]):
563
+ if (
564
+ previous.start_address <= current.end_address
565
+ and current.start_address <= previous.end_address
566
+ ):
567
+ collector.add(
568
+ category,
569
+ DiagnosticSeverity.WARNING,
570
+ f"SAU regions {previous.index} and {current.index} overlap",
571
+ f"0x{previous.start_address:08X}-0x{previous.end_address:08X} overlaps "
572
+ f"0x{current.start_address:08X}-0x{current.end_address:08X}.",
573
+ )
574
+
575
+ def _audit_vtor(
576
+ self,
577
+ reader: TargetMemory,
578
+ target: CortexMTargetDescription,
579
+ scb: SystemRegisterSet,
580
+ collector: _FindingCollector,
581
+ ) -> None:
582
+ """Audit the vector table and critical exception handler entries."""
583
+ category = "Fault Handlers (VTOR)"
584
+ vtor = self._scb_value(scb, "VTOR")
585
+ if vtor is None:
586
+ return
587
+ collector.add(category, DiagnosticSeverity.INFO, "Vector table base", f"VTOR=0x{vtor:08X}.")
588
+ shcsr = self._scb_value(scb, "SHCSR")
589
+ configurable_faults = (
590
+ target.core is not None and CortexMFeature.CONFIGURABLE_FAULTS in target.core.features
591
+ )
592
+ for index, name, enable_bit in _VECTOR_HANDLERS:
593
+ if enable_bit is not None and not configurable_faults:
594
+ continue
595
+ try:
596
+ handler = reader.read_uint32(vtor + 4 * index)
597
+ except TargetReadError as error:
598
+ collector.add(
599
+ category, DiagnosticSeverity.INFO, f"{name} handler unreadable", str(error)
600
+ )
601
+ continue
602
+ if handler in (0x00000000, 0xFFFFFFFF):
603
+ collector.add(
604
+ category,
605
+ DiagnosticSeverity.ERROR,
606
+ f"{name} handler is missing",
607
+ f"Vector[{index}]=0x{handler:08X}: the core will lock up on this exception.",
608
+ )
609
+ continue
610
+ if not handler & 0x1:
611
+ collector.add(
612
+ category,
613
+ DiagnosticSeverity.WARNING,
614
+ f"{name} handler is not Thumb-encoded",
615
+ f"Vector[{index}]=0x{handler:08X}: bit0 is clear, entry will UsageFault.",
616
+ )
617
+ else:
618
+ collector.add(
619
+ category,
620
+ DiagnosticSeverity.PASS,
621
+ f"{name} handler is present",
622
+ f"0x{handler:08X}.",
623
+ )
624
+ if enable_bit is not None and shcsr is not None and not (shcsr & (1 << enable_bit)):
625
+ collector.add(
626
+ category,
627
+ DiagnosticSeverity.WARNING,
628
+ f"{name} handler configured but not enabled",
629
+ f"SHCSR bit {enable_bit} is clear; faults will escalate to HardFault instead.",
630
+ )
631
+
632
+ def _audit_stack_limits(
633
+ self,
634
+ target: CortexMTargetDescription,
635
+ collector: _FindingCollector,
636
+ registers: CortexMRegisterReader,
637
+ ) -> None:
638
+ """Audit Armv8-M stack limits when the typed core description supports them."""
639
+ if target.core is None or target.core.architecture_version not in _V81M_ARCHITECTURES | {
640
+ CortexMArchitecture.ARMV8_M_BASELINE,
641
+ CortexMArchitecture.ARMV8_M_MAINLINE,
642
+ }:
643
+ return
644
+ category = "ARMv8-M Stack Limits"
645
+ for name, stack_names, limit_names in (
646
+ ("MSP", ("msp", "msp_ns", "msp_s"), ("msplim", "msplim_ns", "msplim_s")),
647
+ ("PSP", ("psp", "psp_ns", "psp_s"), ("psplim", "psplim_ns", "psplim_s")),
648
+ ):
649
+ limit = registers.read_first(limit_names)
650
+ if limit is None:
651
+ collector.add(
652
+ category,
653
+ DiagnosticSeverity.INFO,
654
+ f"{name}LIM unavailable",
655
+ "GDB does not expose this register for the current target description.",
656
+ )
657
+ continue
658
+ if limit == 0:
659
+ collector.add(
660
+ category,
661
+ DiagnosticSeverity.WARNING,
662
+ f"{name}LIM is not configured",
663
+ f"{name}LIM=0x00000000: stack-overflow hardware detection is disabled for "
664
+ f"{name}.",
665
+ )
666
+ else:
667
+ collector.add(
668
+ category,
669
+ DiagnosticSeverity.PASS,
670
+ f"{name}LIM is configured",
671
+ f"{name}LIM=0x{limit:08X}.",
672
+ )
673
+ stack_pointer = registers.read_first(stack_names)
674
+ if stack_pointer is not None and limit != 0 and stack_pointer < limit:
675
+ collector.add(
676
+ category,
677
+ DiagnosticSeverity.ERROR,
678
+ f"{name} is below its configured limit",
679
+ f"{name}=0x{stack_pointer:08X} < {name}LIM=0x{limit:08X}: "
680
+ "stack already overflowed.",
681
+ )
682
+
683
+ def _audit_pacbti(
684
+ self,
685
+ target: CortexMTargetDescription,
686
+ collector: _FindingCollector,
687
+ registers: CortexMRegisterReader,
688
+ ) -> None:
689
+ """Audit best-effort PAC key exposure for typed Armv8.1-M cores."""
690
+ if target.core is None or target.core.architecture_version not in _V81M_ARCHITECTURES:
691
+ return
692
+ category = "PACBTI (ARMv8.1-M)"
693
+ key_words = [registers.read_first((name,)) for name in _PAC_KEY_P_REGISTERS]
694
+ if any(word is None for word in key_words):
695
+ collector.add(
696
+ category,
697
+ DiagnosticSeverity.INFO,
698
+ "PAC/BTI status cannot be determined",
699
+ f"{target.core_name} may implement the optional Armv8.1-M PACBTI extension, but "
700
+ "GDB does not expose the PAC_KEY_P_* registers for this target; the extension "
701
+ "may not be implemented, or the security state/target description hides it.",
702
+ )
703
+ return
704
+ if all(word == 0 for word in key_words):
705
+ collector.add(
706
+ category,
707
+ DiagnosticSeverity.WARNING,
708
+ "PAC privileged key is all-zero",
709
+ "PAC_KEY_P_0..3 are all 0: return-address signing provides no real protection "
710
+ "until firmware provisions a random, non-predictable key.",
711
+ )
712
+ else:
713
+ collector.add(
714
+ category,
715
+ DiagnosticSeverity.PASS,
716
+ "PAC privileged key material is present",
717
+ "PAC_KEY_P_0..3 are populated; PAC is available for firmware compiled with "
718
+ "return-address signing (-mbranch-protection=pac-ret).",
719
+ )
720
+ collector.add(
721
+ category,
722
+ DiagnosticSeverity.INFO,
723
+ "BTI has no runtime enable bit",
724
+ "Branch Target Identification is always active for code compiled with BTI landing "
725
+ "pads on cores implementing the extension; verify the firmware was built with "
726
+ "-mbranch-protection=bti (or pac-ret+bti) to benefit from it.",
727
+ )
728
+
729
+ def _audit_stm32_rdp(
730
+ self,
731
+ reader: TargetMemory,
732
+ product_line: str,
733
+ collector: _FindingCollector,
734
+ ) -> None:
735
+ """Decode STM32 Readout Protection using the documented Arm vendor layouts."""
736
+ category = "STM32 Readout Protection (RDP)"
737
+ normalized = product_line.upper()
738
+ layout = next(
739
+ (
740
+ (address, shift)
741
+ for prefixes, address, shift in _STM32_RDP_LAYOUTS
742
+ if any(prefix in normalized for prefix in prefixes)
743
+ ),
744
+ None,
745
+ )
746
+ if layout is None:
747
+ collector.add(
748
+ category,
749
+ DiagnosticSeverity.INFO,
750
+ "RDP layout not documented",
751
+ f"No known Flash-option register layout for product line '{product_line}'.",
752
+ )
753
+ return
754
+ address, shift = layout
755
+ try:
756
+ raw = reader.read_uint32(address)
757
+ except TargetReadError as error:
758
+ collector.add(category, DiagnosticSeverity.INFO, "RDP register unreadable", str(error))
759
+ return
760
+ rdp_byte = (raw >> shift) & 0xFF
761
+ if rdp_byte == 0xAA:
762
+ collector.add(
763
+ category,
764
+ DiagnosticSeverity.WARNING,
765
+ "RDP level 0 (no protection)",
766
+ f"0x{address:08X}=0x{raw:08X}.",
767
+ )
768
+ elif rdp_byte == 0xCC:
769
+ collector.add(
770
+ category,
771
+ DiagnosticSeverity.PASS,
772
+ "RDP level 2 (fully protected, debug disabled)",
773
+ f"0x{address:08X}=0x{raw:08X}.",
774
+ )
775
+ else:
776
+ collector.add(
777
+ category,
778
+ DiagnosticSeverity.PASS,
779
+ "RDP level 1 (debug restricted)",
780
+ f"0x{address:08X}=0x{raw:08X}, RDP byte=0x{rdp_byte:02X}.",
781
+ )
782
+
783
+ @staticmethod
784
+ def _scb_value(scb: SystemRegisterSet, name: str) -> int | None:
785
+ """Return an available SCB register value by architectural name."""
786
+ register = scb.get(name)
787
+ return register.value if register is not None else None
788
+
789
+ @staticmethod
790
+ def _region_writable(region: MpuRegion) -> bool:
791
+ """Return whether an MPU access encoding permits writes."""
792
+ return region.access in {
793
+ MpuAccessPermission.PRIVILEGED_READ_WRITE,
794
+ MpuAccessPermission.PRIVILEGED_READ_WRITE_USER_READ,
795
+ MpuAccessPermission.READ_WRITE,
796
+ }
797
+
798
+ @staticmethod
799
+ def _region_covers_address(region: MpuRegion, address: int) -> bool:
800
+ """Return whether an MPU region enables access to an address."""
801
+ if region.end_address is None or not region.start_address <= address <= region.end_address:
802
+ return False
803
+ if region.subregion_disable_mask is None or region.size_bytes is None:
804
+ return True
805
+ subregion_size = region.size_bytes // 8
806
+ if subregion_size == 0:
807
+ return True
808
+ subregion = (address - region.start_address) // subregion_size
809
+ return not (region.subregion_disable_mask & (1 << subregion))
810
+
811
+ @staticmethod
812
+ def _overlaps(left: MpuRegion, right: MpuRegion) -> bool:
813
+ """Return whether two valid MPU regions cover a common address."""
814
+ if left.end_address is None or right.end_address is None:
815
+ return False
816
+ return left.start_address <= right.end_address and right.start_address <= left.end_address