detect-forge 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.
Files changed (65) hide show
  1. detect_forge/__init__.py +3 -0
  2. detect_forge/_stubs.py +31 -0
  3. detect_forge/_tactics.py +57 -0
  4. detect_forge/audit/__init__.py +27 -0
  5. detect_forge/audit/cli.py +229 -0
  6. detect_forge/audit/models.py +74 -0
  7. detect_forge/audit/orchestrator.py +208 -0
  8. detect_forge/audit/reporter.py +185 -0
  9. detect_forge/audit/scoring.py +90 -0
  10. detect_forge/audit/templates/report.html.j2 +79 -0
  11. detect_forge/backtest/__init__.py +103 -0
  12. detect_forge/backtest/cli.py +152 -0
  13. detect_forge/backtest/corpus.py +246 -0
  14. detect_forge/backtest/corpus_data/__init__.py +0 -0
  15. detect_forge/backtest/corpus_data/mordor_index.json +183 -0
  16. detect_forge/backtest/matchers/__init__.py +1 -0
  17. detect_forge/backtest/matchers/_base.py +63 -0
  18. detect_forge/backtest/matchers/_kql.py +176 -0
  19. detect_forge/backtest/matchers/elastic.py +215 -0
  20. detect_forge/backtest/matchers/sigma.py +668 -0
  21. detect_forge/backtest/models.py +106 -0
  22. detect_forge/backtest/orchestrator.py +291 -0
  23. detect_forge/backtest/reporter.py +183 -0
  24. detect_forge/backtest/templates/report.html.j2 +95 -0
  25. detect_forge/cache.py +57 -0
  26. detect_forge/cli.py +26 -0
  27. detect_forge/common.py +43 -0
  28. detect_forge/config.py +198 -0
  29. detect_forge/console.py +18 -0
  30. detect_forge/coverage/__init__.py +82 -0
  31. detect_forge/coverage/analyzer.py +230 -0
  32. detect_forge/coverage/cli.py +129 -0
  33. detect_forge/coverage/models.py +94 -0
  34. detect_forge/coverage/priority.py +114 -0
  35. detect_forge/coverage/priority_data/MANIFEST.json +8 -0
  36. detect_forge/coverage/priority_data/__init__.py +0 -0
  37. detect_forge/coverage/priority_data/ctid_top_techniques_2024.json +34 -0
  38. detect_forge/coverage/reporter.py +211 -0
  39. detect_forge/coverage/templates/report.html.j2 +90 -0
  40. detect_forge/cti/__init__.py +0 -0
  41. detect_forge/cti/cli.py +28 -0
  42. detect_forge/exit_codes.py +11 -0
  43. detect_forge/py.typed +0 -0
  44. detect_forge/settings.py +27 -0
  45. detect_forge/stale/__init__.py +98 -0
  46. detect_forge/stale/_dates.py +40 -0
  47. detect_forge/stale/_proposals.py +201 -0
  48. detect_forge/stale/_semantic.py +88 -0
  49. detect_forge/stale/attack_client.py +156 -0
  50. detect_forge/stale/cli.py +126 -0
  51. detect_forge/stale/elastic_parser.py +99 -0
  52. detect_forge/stale/embeddings.py +152 -0
  53. detect_forge/stale/models.py +155 -0
  54. detect_forge/stale/prompts/__init__.py +0 -0
  55. detect_forge/stale/prompts/diff_proposal.j2 +28 -0
  56. detect_forge/stale/reporter.py +164 -0
  57. detect_forge/stale/rule_parser.py +57 -0
  58. detect_forge/stale/scorer.py +378 -0
  59. detect_forge/stale/sigma_parser.py +79 -0
  60. detect_forge/stale/templates/report.html.j2 +130 -0
  61. detect_forge-0.1.0.dist-info/METADATA +521 -0
  62. detect_forge-0.1.0.dist-info/RECORD +65 -0
  63. detect_forge-0.1.0.dist-info/WHEEL +4 -0
  64. detect_forge-0.1.0.dist-info/entry_points.txt +2 -0
  65. detect_forge-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,3 @@
1
+ from importlib.metadata import version
2
+
3
+ __version__ = version("detect-forge")
detect_forge/_stubs.py ADDED
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ import click
4
+
5
+ from .console import err_console
6
+ from .exit_codes import RESERVED
7
+
8
+
9
+ def stub_command(name: str, message: str, *, help_text: str | None = None) -> click.Command:
10
+ """Return a click command that prints `message` to stderr and exits RESERVED.
11
+
12
+ Used by every subcommand that is registered for discoverability but has
13
+ not yet been implemented.
14
+
15
+ Args:
16
+ name: The command name as it appears on the CLI.
17
+ message: Multi-line stderr message printed before exit.
18
+ help_text: Optional one-line summary shown in the parent group's
19
+ `--help` listing. Defaults to the first line of ``message``.
20
+ """
21
+ summary = help_text if help_text is not None else message.split("\n", 1)[0]
22
+
23
+ @click.command(name=name, help=summary)
24
+ @click.argument("args", nargs=-1, type=click.UNPROCESSED)
25
+ @click.pass_context
26
+ def _stub(ctx: click.Context, args: tuple[str, ...]) -> None:
27
+ _ = args # accept and ignore any positional args so users hit the stub
28
+ err_console.print(message)
29
+ ctx.exit(RESERVED)
30
+
31
+ return _stub
@@ -0,0 +1,57 @@
1
+ """Canonical ATT&CK enterprise tactic shortname → (TA-ID, display name) mapping.
2
+
3
+ Source: MITRE ATT&CK Enterprise matrix. Stable across ATT&CK versions — last refreshed
4
+ 2026-05-29 against ATT&CK v15.
5
+
6
+ The shortname is what each ``AttackTechnique.tactic_ids`` actually contains (parsed
7
+ from STIX ``kill_chain_phases.phase_name``). The TA-ID and display name are used for
8
+ reports and the Navigator layer.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ # Ordered by ATT&CK Navigator matrix display order (left to right).
14
+ TACTIC_DISPLAY_ORDER: tuple[str, ...] = (
15
+ "reconnaissance",
16
+ "resource-development",
17
+ "initial-access",
18
+ "execution",
19
+ "persistence",
20
+ "privilege-escalation",
21
+ "defense-evasion",
22
+ "credential-access",
23
+ "discovery",
24
+ "lateral-movement",
25
+ "collection",
26
+ "command-and-control",
27
+ "exfiltration",
28
+ "impact",
29
+ )
30
+
31
+ _TACTIC_LOOKUP: dict[str, tuple[str, str]] = {
32
+ "reconnaissance": ("TA0043", "Reconnaissance"),
33
+ "resource-development": ("TA0042", "Resource Development"),
34
+ "initial-access": ("TA0001", "Initial Access"),
35
+ "execution": ("TA0002", "Execution"),
36
+ "persistence": ("TA0003", "Persistence"),
37
+ "privilege-escalation": ("TA0004", "Privilege Escalation"),
38
+ "defense-evasion": ("TA0005", "Defense Evasion"),
39
+ "credential-access": ("TA0006", "Credential Access"),
40
+ "discovery": ("TA0007", "Discovery"),
41
+ "lateral-movement": ("TA0008", "Lateral Movement"),
42
+ "collection": ("TA0009", "Collection"),
43
+ "command-and-control": ("TA0011", "Command and Control"),
44
+ "exfiltration": ("TA0010", "Exfiltration"),
45
+ "impact": ("TA0040", "Impact"),
46
+ }
47
+
48
+
49
+ def lookup_tactic(shortname: str) -> tuple[str, str]:
50
+ """Return ``(TA-ID, display_name)`` for an ATT&CK tactic shortname.
51
+
52
+ Unknown shortnames return ``(shortname, shortname.title().replace("-", " "))`` so
53
+ the report stays informative if MITRE adds a new tactic before we refresh.
54
+ """
55
+ if shortname in _TACTIC_LOOKUP:
56
+ return _TACTIC_LOOKUP[shortname]
57
+ return (shortname, shortname.replace("-", " ").title())
@@ -0,0 +1,27 @@
1
+ """The audit meta-subcommand.
2
+
3
+ Composes stale + coverage + backtest into a unified report.
4
+ Public entry point: ``scan_audit(rule_dir, **kwargs)``.
5
+
6
+ See ``docs/superpowers/specs/2026-06-08-audit-design.md`` for design.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from .models import (
12
+ AuditReport,
13
+ AuditSubResult,
14
+ AuditSummary,
15
+ SubcommandName,
16
+ SubResultStatus,
17
+ )
18
+ from .orchestrator import run_audit as scan_audit
19
+
20
+ __all__ = [
21
+ "AuditReport",
22
+ "AuditSubResult",
23
+ "AuditSummary",
24
+ "SubResultStatus",
25
+ "SubcommandName",
26
+ "scan_audit",
27
+ ]
@@ -0,0 +1,229 @@
1
+ """The audit subcommand — composes stale + coverage + backtest."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from pathlib import Path
7
+
8
+ import click
9
+ from click.core import ParameterSource
10
+ from rich.progress import Progress, SpinnerColumn, TextColumn
11
+
12
+ from ..config import (
13
+ find_config_file,
14
+ load_audit_config_or_defaults,
15
+ load_backtest_config_or_defaults,
16
+ load_coverage_config_or_defaults,
17
+ load_stale_config_or_defaults,
18
+ )
19
+ from ..console import err_console
20
+ from ..exit_codes import GATED, RESERVED
21
+ from ..settings import Settings
22
+
23
+ log = logging.getLogger(__name__)
24
+
25
+
26
+ def _abs_against(base: Path | None, value: str) -> Path:
27
+ """Resolve a possibly-relative config path against the config-file dir."""
28
+ p = Path(value)
29
+ if p.is_absolute() or base is None:
30
+ return p
31
+ return base / p
32
+
33
+
34
+ @click.command(name="audit")
35
+ @click.argument(
36
+ "rule_dir",
37
+ type=click.Path(exists=True, file_okay=False, path_type=Path),
38
+ )
39
+ @click.option(
40
+ "--format", "output_format",
41
+ type=click.Choice(["terminal", "json", "html"]),
42
+ default="terminal", show_default=True,
43
+ help="Output format. Navigator is NOT supported in v0.1 — run "
44
+ "coverage/backtest directly for those layers.",
45
+ )
46
+ @click.option(
47
+ "--output", "-o",
48
+ type=click.Path(path_type=Path), default=None,
49
+ help="Write output to file instead of stdout",
50
+ )
51
+ @click.option(
52
+ "--no-cache", is_flag=True, default=False,
53
+ help="Bypass STIX + Mordor caches across all subcommands",
54
+ )
55
+ @click.option(
56
+ "--domain",
57
+ type=click.Choice(["enterprise-attack", "ics-attack", "mobile-attack"]),
58
+ default=Settings().attack_domain, show_default=True,
59
+ help="ATT&CK domain",
60
+ )
61
+ @click.option(
62
+ "--no-gate", is_flag=True, default=False,
63
+ help="Don't exit 2 even if the audit gate would have fired",
64
+ )
65
+ @click.option(
66
+ "--skip",
67
+ multiple=True,
68
+ type=click.Choice(["stale", "coverage", "backtest"]),
69
+ help="Skip a subcommand entirely. Repeatable.",
70
+ )
71
+ @click.option(
72
+ "--priority-list",
73
+ type=click.Path(exists=True, path_type=Path), default=None,
74
+ help="Path to custom priority list JSON (shared by coverage + backtest)",
75
+ )
76
+ @click.option(
77
+ "--with-llm-proposals", is_flag=True, default=False,
78
+ help="Enable LLM diff proposals in stale (off by default — cost gate)",
79
+ )
80
+ @click.option(
81
+ "--platform",
82
+ type=click.Choice(["windows", "linux", "macos", "all"]),
83
+ default="all", show_default=True,
84
+ help="Backtest-only: limit Mordor datasets by platform",
85
+ )
86
+ @click.option(
87
+ "--mordor-source",
88
+ type=click.Path(exists=True, path_type=Path), default=None,
89
+ help="Backtest-only: local Security-Datasets checkout (overrides config)",
90
+ )
91
+ @click.option(
92
+ "--techniques", default=None,
93
+ help="Backtest-only: comma-separated technique IDs to restrict scan",
94
+ )
95
+ @click.option(
96
+ "--semantic-threshold",
97
+ type=float, default=0.65, show_default=True,
98
+ help="Stale: cosine similarity threshold for semantic drift",
99
+ )
100
+ @click.pass_context
101
+ def audit_cmd(
102
+ ctx: click.Context,
103
+ rule_dir: Path,
104
+ output_format: str,
105
+ output: Path | None,
106
+ no_cache: bool,
107
+ domain: str,
108
+ no_gate: bool,
109
+ skip: tuple[str, ...],
110
+ priority_list: Path | None,
111
+ with_llm_proposals: bool,
112
+ platform: str,
113
+ mordor_source: Path | None,
114
+ techniques: str | None,
115
+ semantic_threshold: float,
116
+ ) -> None:
117
+ """Run every check in one step — stale + coverage + backtest."""
118
+ from . import reporter, scan_audit
119
+
120
+ settings = Settings()
121
+ audit_cfg = load_audit_config_or_defaults()
122
+ stale_cfg = load_stale_config_or_defaults()
123
+ coverage_cfg = load_coverage_config_or_defaults()
124
+ backtest_cfg = load_backtest_config_or_defaults()
125
+ config_file = find_config_file()
126
+ config_dir = config_file.parent if config_file is not None else None
127
+ effective_no_cache = no_cache or settings.no_cache
128
+
129
+ # Compute enabled subcommands: config.subcommands MINUS CLI --skip.
130
+ from typing import cast as _cast # noqa: PLC0415
131
+
132
+ from ..audit.models import SubcommandName # noqa: PLC0415
133
+ config_enabled: set[SubcommandName] = set(audit_cfg.subcommands)
134
+ cli_skip: set[SubcommandName] = _cast(
135
+ "set[SubcommandName]", set(skip)
136
+ )
137
+ enabled: set[SubcommandName] = config_enabled - cli_skip
138
+ if not enabled:
139
+ err_console.print(
140
+ "[critical]No subcommands enabled "
141
+ "(everything was either disabled in config or --skipped).[/critical]"
142
+ )
143
+ ctx.exit(RESERVED)
144
+ return
145
+
146
+ # Gate strategy: config drives ('all' default, 'never' kills); --no-gate forces never.
147
+ effective_gate_strategy = audit_cfg.gate_strategy
148
+ if no_gate:
149
+ effective_gate_strategy = "never"
150
+
151
+ # LLM: opt-in via CLI flag or config; model + quota come from [stale].
152
+ effective_llm_model: str | None = None
153
+ if with_llm_proposals or audit_cfg.include_llm_proposals:
154
+ effective_llm_model = stale_cfg.llm_model
155
+
156
+ # Semantic threshold precedence: env > CLI-explicit > [stale] config > default.
157
+ effective_threshold = stale_cfg.semantic_threshold
158
+ if ctx.get_parameter_source("semantic_threshold") == ParameterSource.COMMANDLINE:
159
+ effective_threshold = semantic_threshold
160
+ if settings.semantic_threshold is not None:
161
+ effective_threshold = settings.semantic_threshold
162
+
163
+ # Priority list: CLI --priority-list wins; else [coverage] priority_list
164
+ # resolved against the config-file dir (shared by coverage + backtest).
165
+ effective_priority: Path | None = priority_list
166
+ if effective_priority is None and coverage_cfg.priority_list:
167
+ effective_priority = _abs_against(config_dir, coverage_cfg.priority_list)
168
+
169
+ # Platform / mordor-source: CLI overrides [backtest] config.
170
+ effective_platform = platform
171
+ if ctx.get_parameter_source("platform") != ParameterSource.COMMANDLINE:
172
+ effective_platform = backtest_cfg.platform
173
+ effective_mordor: Path | None = mordor_source
174
+ if effective_mordor is None and backtest_cfg.mordor_source:
175
+ effective_mordor = _abs_against(config_dir, backtest_cfg.mordor_source)
176
+
177
+ # Technique filter parse.
178
+ technique_filter: set[str] | None = None
179
+ if techniques:
180
+ technique_filter = {t.strip() for t in techniques.split(",") if t.strip()}
181
+
182
+ with Progress(
183
+ SpinnerColumn(),
184
+ TextColumn("{task.description}"),
185
+ console=err_console,
186
+ transient=True,
187
+ ) as progress:
188
+ prog_task = progress.add_task("Running audit...", total=None)
189
+ report = scan_audit(
190
+ rule_dir,
191
+ enabled=enabled,
192
+ gate_strategy=effective_gate_strategy,
193
+ domain=domain,
194
+ cache_dir=settings.cache_dir,
195
+ cache_ttl_hours=settings.cache_ttl_hours,
196
+ no_cache=effective_no_cache,
197
+ priority_list=effective_priority,
198
+ platform=effective_platform,
199
+ technique_filter=technique_filter,
200
+ mordor_source=effective_mordor,
201
+ semantic_threshold=effective_threshold,
202
+ llm_model=effective_llm_model,
203
+ max_proposals=stale_cfg.max_proposals,
204
+ coverage_gate_on_priority_gaps=coverage_cfg.gate_on_priority_gaps,
205
+ backtest_gate_on_priority_silence=backtest_cfg.gate_on_priority_silence,
206
+ backtest_gate_on_broken_rules=backtest_cfg.gate_on_broken_rules,
207
+ )
208
+ progress.remove_task(prog_task)
209
+
210
+ rendered = reporter.render(report, output_format=output_format)
211
+
212
+ if output:
213
+ output.write_text(rendered, encoding="utf-8")
214
+ err_console.print(f"[info]Report written to {output}[/info]")
215
+ else:
216
+ click.echo(rendered, nl=False, color=output_format == "terminal")
217
+
218
+ # Exit code per spec §4:
219
+ # 2 — audit gate fired (gate-fire wins over errored)
220
+ # 1 — at least one subcommand errored AND gate didn't fire
221
+ # 0 — clean
222
+ if report.summary.audit_would_gate and not no_gate:
223
+ ctx.exit(GATED)
224
+ if report.summary.subcommands_errored > 0:
225
+ ctx.exit(RESERVED)
226
+
227
+
228
+ def register(group: click.Group) -> None:
229
+ group.add_command(audit_cmd)
@@ -0,0 +1,74 @@
1
+ """Pydantic models for the audit subcommand.
2
+
3
+ AuditReport is the public surface — passed to reporters and rendered.
4
+ Per spec §7, AuditSubResult is a tagged union over the 3 subcommand
5
+ report types; exactly one of {stale_report, coverage_report,
6
+ backtest_report} is populated when status == "ran".
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from datetime import datetime
12
+ from typing import Literal
13
+
14
+ from pydantic import BaseModel, Field
15
+
16
+ from ..backtest.models import BacktestReport
17
+ from ..coverage.models import CoverageReport
18
+ from ..stale.models import StalenessReport
19
+
20
+ SubcommandName = Literal["stale", "coverage", "backtest"]
21
+ SubResultStatus = Literal["ran", "skipped", "errored"]
22
+
23
+
24
+ class AuditSubResult(BaseModel):
25
+ """Wraps a single subcommand's outcome.
26
+
27
+ When ``status == "ran"``: the matching ``*_report`` field is populated;
28
+ ``error`` is None; ``would_gate`` and ``score`` are meaningful.
29
+ When ``status == "skipped"`` or ``"errored"``: all ``*_report`` fields
30
+ are None; ``score`` is None; ``would_gate`` is False.
31
+ """
32
+
33
+ subcommand: SubcommandName
34
+ status: SubResultStatus
35
+ error: str | None = None
36
+ would_gate: bool = False
37
+ score: int | None = None
38
+
39
+ stale_report: StalenessReport | None = None
40
+ coverage_report: CoverageReport | None = None
41
+ backtest_report: BacktestReport | None = None
42
+
43
+
44
+ class AuditSummary(BaseModel):
45
+ """Top-of-report stats. ``audit_would_gate`` drives the CI exit code."""
46
+
47
+ rules_scanned: int
48
+ """How many rules were discovered in the rule_dir (after parse)."""
49
+
50
+ stale_health: int | None
51
+ """Per spec §6: 100 * (total_rules - critical) / total_rules. None if skipped/errored."""
52
+ coverage_completeness: int | None
53
+ """100 * full / total_techniques."""
54
+ backtest_verification_rate: int | None
55
+ """100 * rules_fires / (rules_parsed - rules_unsupported)."""
56
+
57
+ subcommands_ran: int
58
+ subcommands_skipped: int
59
+ subcommands_errored: int
60
+
61
+ audit_would_gate: bool
62
+ """True when every enabled subcommand's ``would_gate`` predicate is True."""
63
+
64
+ attack_domain: str
65
+ generated_at: datetime
66
+ elapsed_seconds: float
67
+
68
+
69
+ class AuditReport(BaseModel):
70
+ """Final audit report passed to renderers."""
71
+
72
+ summary: AuditSummary
73
+ sub_results: list[AuditSubResult] = Field(default_factory=list)
74
+ """Always 3 entries when produced by run_audit(), ordered stale → coverage → backtest."""
@@ -0,0 +1,208 @@
1
+ """Audit orchestrator: composes stale + coverage + backtest into AuditReport.
2
+
3
+ Each subcommand runs sequentially in-process. Failure isolation: a
4
+ crashing subcommand becomes an AuditSubResult with status='errored';
5
+ the other two still run.
6
+
7
+ Gate composition per spec §5: strict-AND — audit_would_gate is True
8
+ only when ALL enabled subcommands' would_gate predicates are True.
9
+ gate_strategy='never' overrides to always-False.
10
+
11
+ The scoring/gate-predicate functions are imported from .scoring; this
12
+ module is purely composition + error handling + summary building.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ import time
19
+ from datetime import UTC, datetime
20
+ from pathlib import Path
21
+ from typing import Literal
22
+
23
+ from ..backtest import scan_backtest
24
+ from ..coverage import scan_coverage
25
+ from ..stale import scan as scan_stale
26
+ from .models import AuditReport, AuditSubResult, AuditSummary, SubcommandName
27
+ from .scoring import (
28
+ backtest_verification_rate,
29
+ backtest_would_gate,
30
+ coverage_completeness,
31
+ coverage_would_gate,
32
+ stale_health,
33
+ stale_would_gate,
34
+ )
35
+
36
+ log = logging.getLogger(__name__)
37
+
38
+
39
+ def run_audit(
40
+ rule_dir: Path,
41
+ *,
42
+ enabled: set[SubcommandName] | None = None,
43
+ gate_strategy: Literal["all", "never"] = "all",
44
+ domain: str = "enterprise-attack",
45
+ cache_dir: Path | None = None,
46
+ cache_ttl_hours: int = 24,
47
+ no_cache: bool = False,
48
+ priority_list: Path | None = None,
49
+ platform: str = "all",
50
+ technique_filter: set[str] | None = None,
51
+ mordor_source: Path | None = None,
52
+ semantic_threshold: float = 0.65,
53
+ llm_model: str | None = None,
54
+ max_proposals: int = 5,
55
+ coverage_gate_on_priority_gaps: bool = True,
56
+ backtest_gate_on_priority_silence: bool = True,
57
+ backtest_gate_on_broken_rules: bool = True,
58
+ ) -> AuditReport:
59
+ """Compose all 3 subcommands into a single AuditReport.
60
+
61
+ Each subcommand call is wrapped in try/except. A crashing subcommand
62
+ becomes a sub_result with status='errored' and the rest continue.
63
+
64
+ Args:
65
+ rule_dir: Detection rule directory.
66
+ enabled: Set of subcommands to run. Default: all 3.
67
+ gate_strategy: 'all' for strict-AND composition; 'never' to disable.
68
+ domain, cache_dir, cache_ttl_hours, no_cache, priority_list:
69
+ forwarded to each subcommand.
70
+ platform, technique_filter, mordor_source: backtest-only kwargs.
71
+ semantic_threshold, llm_model, max_proposals: stale-only kwargs.
72
+ """
73
+ if enabled is None:
74
+ enabled = {"stale", "coverage", "backtest"}
75
+
76
+ started = time.monotonic()
77
+ sub_results: list[AuditSubResult] = []
78
+ rules_scanned: int = 0
79
+
80
+ # ---- Stale ----
81
+ if "stale" in enabled:
82
+ try:
83
+ stale_report = scan_stale(
84
+ rule_dir,
85
+ domain=domain,
86
+ cache_dir=cache_dir,
87
+ cache_ttl_hours=cache_ttl_hours,
88
+ no_cache=no_cache,
89
+ semantic_threshold=semantic_threshold,
90
+ llm_model=llm_model,
91
+ max_proposals=max_proposals,
92
+ )
93
+ rules_scanned = max(rules_scanned, stale_report.summary.total_rules)
94
+ sub_results.append(AuditSubResult(
95
+ subcommand="stale",
96
+ status="ran",
97
+ would_gate=stale_would_gate(stale_report),
98
+ score=stale_health(stale_report),
99
+ stale_report=stale_report,
100
+ ))
101
+ except Exception as exc: # noqa: BLE001 — failure isolation per spec §11
102
+ log.error("stale subcommand failed: %s", exc, exc_info=True)
103
+ sub_results.append(AuditSubResult(
104
+ subcommand="stale",
105
+ status="errored",
106
+ error=f"{type(exc).__name__}: {exc}",
107
+ ))
108
+ else:
109
+ sub_results.append(AuditSubResult(subcommand="stale", status="skipped"))
110
+
111
+ # ---- Coverage ----
112
+ if "coverage" in enabled:
113
+ try:
114
+ cov_report = scan_coverage(
115
+ rule_dir,
116
+ domain=domain,
117
+ cache_dir=cache_dir,
118
+ cache_ttl_hours=cache_ttl_hours,
119
+ no_cache=no_cache,
120
+ priority_list=priority_list,
121
+ )
122
+ rules_scanned = max(rules_scanned, cov_report.summary.rules_parsed)
123
+ sub_results.append(AuditSubResult(
124
+ subcommand="coverage",
125
+ status="ran",
126
+ would_gate=coverage_would_gate(
127
+ cov_report, gate_on_priority_gaps=coverage_gate_on_priority_gaps
128
+ ),
129
+ score=coverage_completeness(cov_report),
130
+ coverage_report=cov_report,
131
+ ))
132
+ except Exception as exc: # noqa: BLE001
133
+ log.error("coverage subcommand failed: %s", exc, exc_info=True)
134
+ sub_results.append(AuditSubResult(
135
+ subcommand="coverage",
136
+ status="errored",
137
+ error=f"{type(exc).__name__}: {exc}",
138
+ ))
139
+ else:
140
+ sub_results.append(AuditSubResult(subcommand="coverage", status="skipped"))
141
+
142
+ # ---- Backtest ----
143
+ if "backtest" in enabled:
144
+ try:
145
+ bt_report = scan_backtest(
146
+ rule_dir,
147
+ domain=domain,
148
+ cache_dir=cache_dir,
149
+ cache_ttl_hours=cache_ttl_hours,
150
+ no_cache=no_cache,
151
+ priority_list=priority_list,
152
+ platform=platform,
153
+ technique_filter=technique_filter,
154
+ mordor_source=mordor_source,
155
+ )
156
+ rules_scanned = max(rules_scanned, bt_report.summary.rules_parsed)
157
+ sub_results.append(AuditSubResult(
158
+ subcommand="backtest",
159
+ status="ran",
160
+ would_gate=backtest_would_gate(
161
+ bt_report,
162
+ gate_on_priority_silence=backtest_gate_on_priority_silence,
163
+ gate_on_broken_rules=backtest_gate_on_broken_rules,
164
+ ),
165
+ score=backtest_verification_rate(bt_report),
166
+ backtest_report=bt_report,
167
+ ))
168
+ except Exception as exc: # noqa: BLE001
169
+ log.error("backtest subcommand failed: %s", exc, exc_info=True)
170
+ sub_results.append(AuditSubResult(
171
+ subcommand="backtest",
172
+ status="errored",
173
+ error=f"{type(exc).__name__}: {exc}",
174
+ ))
175
+ else:
176
+ sub_results.append(AuditSubResult(subcommand="backtest", status="skipped"))
177
+
178
+ # ---- Compose summary ----
179
+ ran = [sr for sr in sub_results if sr.status == "ran"]
180
+ skipped = [sr for sr in sub_results if sr.status == "skipped"]
181
+ errored = [sr for sr in sub_results if sr.status == "errored"]
182
+
183
+ audit_would_gate = (
184
+ gate_strategy == "all"
185
+ and len(ran) > 0
186
+ and all(sr.would_gate for sr in ran)
187
+ and len(ran) == len(enabled) # All enabled subcommands actually ran
188
+ )
189
+
190
+ stale_sr = next((sr for sr in ran if sr.subcommand == "stale"), None)
191
+ cov_sr = next((sr for sr in ran if sr.subcommand == "coverage"), None)
192
+ bt_sr = next((sr for sr in ran if sr.subcommand == "backtest"), None)
193
+
194
+ summary = AuditSummary(
195
+ rules_scanned=rules_scanned,
196
+ stale_health=stale_sr.score if stale_sr else None,
197
+ coverage_completeness=cov_sr.score if cov_sr else None,
198
+ backtest_verification_rate=bt_sr.score if bt_sr else None,
199
+ subcommands_ran=len(ran),
200
+ subcommands_skipped=len(skipped),
201
+ subcommands_errored=len(errored),
202
+ audit_would_gate=audit_would_gate,
203
+ attack_domain=domain,
204
+ generated_at=datetime.now(UTC),
205
+ elapsed_seconds=time.monotonic() - started,
206
+ )
207
+
208
+ return AuditReport(summary=summary, sub_results=sub_results)