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.
- detect_forge/__init__.py +3 -0
- detect_forge/_stubs.py +31 -0
- detect_forge/_tactics.py +57 -0
- detect_forge/audit/__init__.py +27 -0
- detect_forge/audit/cli.py +229 -0
- detect_forge/audit/models.py +74 -0
- detect_forge/audit/orchestrator.py +208 -0
- detect_forge/audit/reporter.py +185 -0
- detect_forge/audit/scoring.py +90 -0
- detect_forge/audit/templates/report.html.j2 +79 -0
- detect_forge/backtest/__init__.py +103 -0
- detect_forge/backtest/cli.py +152 -0
- detect_forge/backtest/corpus.py +246 -0
- detect_forge/backtest/corpus_data/__init__.py +0 -0
- detect_forge/backtest/corpus_data/mordor_index.json +183 -0
- detect_forge/backtest/matchers/__init__.py +1 -0
- detect_forge/backtest/matchers/_base.py +63 -0
- detect_forge/backtest/matchers/_kql.py +176 -0
- detect_forge/backtest/matchers/elastic.py +215 -0
- detect_forge/backtest/matchers/sigma.py +668 -0
- detect_forge/backtest/models.py +106 -0
- detect_forge/backtest/orchestrator.py +291 -0
- detect_forge/backtest/reporter.py +183 -0
- detect_forge/backtest/templates/report.html.j2 +95 -0
- detect_forge/cache.py +57 -0
- detect_forge/cli.py +26 -0
- detect_forge/common.py +43 -0
- detect_forge/config.py +198 -0
- detect_forge/console.py +18 -0
- detect_forge/coverage/__init__.py +82 -0
- detect_forge/coverage/analyzer.py +230 -0
- detect_forge/coverage/cli.py +129 -0
- detect_forge/coverage/models.py +94 -0
- detect_forge/coverage/priority.py +114 -0
- detect_forge/coverage/priority_data/MANIFEST.json +8 -0
- detect_forge/coverage/priority_data/__init__.py +0 -0
- detect_forge/coverage/priority_data/ctid_top_techniques_2024.json +34 -0
- detect_forge/coverage/reporter.py +211 -0
- detect_forge/coverage/templates/report.html.j2 +90 -0
- detect_forge/cti/__init__.py +0 -0
- detect_forge/cti/cli.py +28 -0
- detect_forge/exit_codes.py +11 -0
- detect_forge/py.typed +0 -0
- detect_forge/settings.py +27 -0
- detect_forge/stale/__init__.py +98 -0
- detect_forge/stale/_dates.py +40 -0
- detect_forge/stale/_proposals.py +201 -0
- detect_forge/stale/_semantic.py +88 -0
- detect_forge/stale/attack_client.py +156 -0
- detect_forge/stale/cli.py +126 -0
- detect_forge/stale/elastic_parser.py +99 -0
- detect_forge/stale/embeddings.py +152 -0
- detect_forge/stale/models.py +155 -0
- detect_forge/stale/prompts/__init__.py +0 -0
- detect_forge/stale/prompts/diff_proposal.j2 +28 -0
- detect_forge/stale/reporter.py +164 -0
- detect_forge/stale/rule_parser.py +57 -0
- detect_forge/stale/scorer.py +378 -0
- detect_forge/stale/sigma_parser.py +79 -0
- detect_forge/stale/templates/report.html.j2 +130 -0
- detect_forge-0.1.0.dist-info/METADATA +521 -0
- detect_forge-0.1.0.dist-info/RECORD +65 -0
- detect_forge-0.1.0.dist-info/WHEEL +4 -0
- detect_forge-0.1.0.dist-info/entry_points.txt +2 -0
- detect_forge-0.1.0.dist-info/licenses/LICENSE +21 -0
detect_forge/__init__.py
ADDED
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
|
detect_forge/_tactics.py
ADDED
|
@@ -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)
|