redgap 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 (53) hide show
  1. redgap/__init__.py +9 -0
  2. redgap/_resources.py +44 -0
  3. redgap/allowlist.py +147 -0
  4. redgap/catalog.py +63 -0
  5. redgap/cli.py +134 -0
  6. redgap/detection/__init__.py +5 -0
  7. redgap/detection/coverage.py +133 -0
  8. redgap/detection/engine.py +276 -0
  9. redgap/detection/sigma_ast.py +238 -0
  10. redgap/engine_facade.py +75 -0
  11. redgap/fixtures/replay/T1057/events.jsonl +3 -0
  12. redgap/fixtures/replay/T1057/provenance.json +16 -0
  13. redgap/fixtures/replay/T1057/raw/exec.log +3 -0
  14. redgap/fixtures/replay/T1070.006/events.jsonl +5 -0
  15. redgap/fixtures/replay/T1070.006/provenance.json +18 -0
  16. redgap/fixtures/replay/T1070.006/raw/exec.log +5 -0
  17. redgap/fixtures/replay/T1087.001/events.jsonl +3 -0
  18. redgap/fixtures/replay/T1087.001/provenance.json +16 -0
  19. redgap/fixtures/replay/T1087.001/raw/exec.log +3 -0
  20. redgap/fixtures/replay/T1136.001/events.jsonl +5 -0
  21. redgap/fixtures/replay/T1136.001/provenance.json +18 -0
  22. redgap/fixtures/replay/T1136.001/raw/exec.log +5 -0
  23. redgap/fixtures/replay/T1548.001/events.jsonl +9 -0
  24. redgap/fixtures/replay/T1548.001/provenance.json +19 -0
  25. redgap/fixtures/replay/T1548.001/raw/exec.log +9 -0
  26. redgap/lab/Dockerfile +30 -0
  27. redgap/lab/collector/redgap_exec.c +82 -0
  28. redgap/lab/compose.yaml +24 -0
  29. redgap/lab.py +118 -0
  30. redgap/models.py +149 -0
  31. redgap/pipeline.py +68 -0
  32. redgap/planner.py +179 -0
  33. redgap/report/__init__.py +10 -0
  34. redgap/report/build.py +87 -0
  35. redgap/report/markdown.py +84 -0
  36. redgap/report/navigator.py +92 -0
  37. redgap/rules/proc_creation_lnx_setgid_setuid.yml +32 -0
  38. redgap/rules/redgap/account_discovery_etc_passwd.yml +36 -0
  39. redgap/rules/redgap/create_account_useradd.yml +32 -0
  40. redgap/rules/roundtrip/timestomp_touch.yml +32 -0
  41. redgap/target.py +119 -0
  42. redgap/techniques/__init__.py +11 -0
  43. redgap/techniques/base.py +21 -0
  44. redgap/techniques/registry.py +277 -0
  45. redgap/telemetry/__init__.py +1 -0
  46. redgap/telemetry/schema.py +104 -0
  47. redgap/telemetry/snoopy.py +73 -0
  48. redgap-0.1.0.dist-info/METADATA +153 -0
  49. redgap-0.1.0.dist-info/RECORD +53 -0
  50. redgap-0.1.0.dist-info/WHEEL +4 -0
  51. redgap-0.1.0.dist-info/entry_points.txt +2 -0
  52. redgap-0.1.0.dist-info/licenses/LICENSE +21 -0
  53. redgap-0.1.0.dist-info/licenses/NOTICE +46 -0
redgap/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """RedGap — automated MITRE ATT&CK-mapped offense<->detection coverage harness.
2
+
3
+ The deterministic core (models, allowlist, telemetry schema, detection engine,
4
+ coverage) has no third-party dependencies beyond pySigma at the parsing edge and
5
+ never depends on a language model. The optional LLM planner lives behind the
6
+ ``redgap[llm]`` extra and is disabled by default.
7
+ """
8
+
9
+ __version__ = "0.1.0"
redgap/_resources.py ADDED
@@ -0,0 +1,44 @@
1
+ """Locate RedGap's committed data (Sigma rules, replay fixtures, lab build context)
2
+ in BOTH an editable checkout and an installed wheel.
3
+
4
+ In a source checkout the data lives at the repository root (``rules/``, ``fixtures/``,
5
+ ``lab/``). The wheel force-includes those same trees *inside* the package
6
+ (``redgap/rules`` …), so an installed ``redgap`` resolves them through
7
+ ``importlib.resources`` instead of guessing a path relative to the repo root. This is
8
+ what lets ``pip install .`` / ``pipx install redgap`` run ``redgap run`` from any
9
+ directory — not only an editable install run from the source tree.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from importlib.resources import files
15
+ from pathlib import Path
16
+
17
+
18
+ def _data_root(name: str) -> Path:
19
+ # Installed wheel: the data tree is force-included inside the package. This assumes a
20
+ # filesystem-based install (pip/pipx unpack to site-packages — the supported targets),
21
+ # not zipimport/zipapp; str() on the Traversable is a real path there.
22
+ try:
23
+ packaged = files("redgap") / name
24
+ if packaged.is_dir():
25
+ return Path(str(packaged))
26
+ except (ModuleNotFoundError, FileNotFoundError, NotADirectoryError):
27
+ pass
28
+ # Editable install / source checkout: the data lives at the repository root.
29
+ return Path(__file__).resolve().parents[2] / name
30
+
31
+
32
+ def rules_dir() -> Path:
33
+ """Directory holding the shipped Sigma rules."""
34
+ return _data_root("rules")
35
+
36
+
37
+ def fixtures_dir() -> Path:
38
+ """Directory holding the committed replay telemetry fixtures."""
39
+ return _data_root("fixtures") / "replay"
40
+
41
+
42
+ def lab_dir() -> Path:
43
+ """Docker build context for the disposable lab (LIVE mode only)."""
44
+ return _data_root("lab")
redgap/allowlist.py ADDED
@@ -0,0 +1,147 @@
1
+ """Target allowlist — the gate that keeps RedGap pointed at its own lab.
2
+
3
+ RedGap must never be usable against a system the operator does not own. That is
4
+ enforced structurally here, not by convention, on two surfaces:
5
+
6
+ * **Container launches (the LIVE path).** Every ``docker run`` goes through
7
+ :func:`assert_lab_only`, which refuses to start a container unless networking is
8
+ disabled (``--network none``) or pinned to the private lab subnet. ``lab.py`` routes
9
+ each launch through it, so an edit that quietly networks the lab fails at runtime.
10
+ * **Target strings.** :func:`resolve_and_check` accepts only loopback, the lab subnet,
11
+ or the fixed lab hostnames. There is deliberately **no** function to add a host and
12
+ nothing here reads the environment or CLI to widen the set; hostnames are matched
13
+ literally (no DNS), so a name that resolves to loopback cannot slip through. This is
14
+ the guard for any network-reachable target surface (LIVE-remote is on the roadmap).
15
+
16
+ ``tests/test_allowlist.py`` asserts both gates and that neither can be widened.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import ipaddress
22
+ from collections.abc import Sequence
23
+
24
+ #: Loopback ranges (the only IP targets that make sense for a local lab).
25
+ _LOOPBACK_NETS = (
26
+ ipaddress.ip_network("127.0.0.0/8"),
27
+ ipaddress.ip_network("::1/128"),
28
+ )
29
+
30
+ #: The fixed private bridge the disposable Docker lab is pinned to. Not routable.
31
+ _LAB_SUBNET = ipaddress.ip_network("172.28.0.0/24")
32
+
33
+ #: The only literal hostnames accepted (the lab's compose service names + loopback).
34
+ _LAB_HOSTNAMES = frozenset({"localhost", "lab", "redgap-lab"})
35
+
36
+
37
+ class TargetNotAllowed(ValueError):
38
+ """Raised when a target is not the local lab. Never caught to 'try anyway'."""
39
+
40
+
41
+ def is_allowed(target: str) -> bool:
42
+ """Return True iff ``target`` is loopback, the lab subnet, or a lab hostname."""
43
+ t = (target or "").strip()
44
+ if not t:
45
+ return False
46
+ try:
47
+ ip = ipaddress.ip_address(t)
48
+ except ValueError:
49
+ # Not an IP literal: accept only the fixed lab hostnames, verbatim.
50
+ return t.lower() in _LAB_HOSTNAMES
51
+ if any(ip in net for net in _LOOPBACK_NETS):
52
+ return True
53
+ return ip in _LAB_SUBNET
54
+
55
+
56
+ def resolve_and_check(target: str) -> str:
57
+ """Return the normalized target if allowed; otherwise raise TargetNotAllowed.
58
+
59
+ This is the only sanctioned way to obtain a target for the runner/lab.
60
+ """
61
+ t = (target or "").strip()
62
+ if not is_allowed(t):
63
+ raise TargetNotAllowed(
64
+ f"refusing target {target!r}: RedGap only acts against its own local lab "
65
+ f"(loopback, {_LAB_SUBNET}, or one of {sorted(_LAB_HOSTNAMES)}). "
66
+ f"There is no override — this is by design (see ETHICS.md)."
67
+ )
68
+ return t.lower() if not _is_ip(t) else t
69
+
70
+
71
+ def _is_ip(value: str) -> bool:
72
+ try:
73
+ ipaddress.ip_address(value)
74
+ return True
75
+ except ValueError:
76
+ return False
77
+
78
+
79
+ # Global flags that could point docker at a remote/other engine — never allowed.
80
+ _DAEMON_REDIRECT_FLAGS = frozenset({"-H", "--host", "--context", "-c"})
81
+ # Subcommands that create/launch a container (as `docker run` or `docker container run`).
82
+ _CONTAINER_CREATE_VERBS = frozenset({"run", "create"})
83
+ # Global flags that consume a following value, so that value is not the subcommand.
84
+ _VALUE_GLOBAL_FLAGS = frozenset(
85
+ {
86
+ "-H", "--host", "--context", "-c", "--config",
87
+ "--log-level", "-l", "--tlscacert", "--tlscert", "--tlskey",
88
+ }
89
+ ) # fmt: skip
90
+
91
+
92
+ def _network_values(docker_args: Sequence[str]) -> list[str]:
93
+ """Every ``--network``/``--net`` value in a docker argv. docker *appends* repeated
94
+ ``--network`` flags and attaches the container to all of them, so we collect all."""
95
+ vals: list[str] = []
96
+ args = list(docker_args)
97
+ for i, arg in enumerate(args):
98
+ if arg in ("--network", "--net"):
99
+ if i + 1 < len(args):
100
+ vals.append(args[i + 1])
101
+ elif arg.startswith("--network=") or arg.startswith("--net="):
102
+ vals.append(arg.split("=", 1)[1])
103
+ return vals
104
+
105
+
106
+ def _effective_verb_and_index(args: list[str]) -> tuple[str | None, int]:
107
+ """The docker subcommand verb and its index, skipping leading global flags (and any
108
+ values they consume) and an optional ``container`` management-group word."""
109
+ i = 0
110
+ while i < len(args):
111
+ a = args[i]
112
+ if a.startswith("-"):
113
+ i += 2 if (a in _VALUE_GLOBAL_FLAGS and "=" not in a) else 1
114
+ continue
115
+ if a == "container": # `docker container run/create` == `docker run/create`
116
+ i += 1
117
+ continue
118
+ return a, i
119
+ return None, len(args)
120
+
121
+
122
+ def assert_lab_only(docker_args: Sequence[str]) -> None:
123
+ """Gate a docker invocation so RedGap can only ever act on its own offline lab.
124
+
125
+ Refuses (1) any daemon-redirecting global flag (``-H``/``--host``/``--context``),
126
+ which could target a remote engine, and (2) any container-creating command
127
+ (``run``/``create``, including the ``docker container …`` form) whose networking is
128
+ not disabled — every ``--network``/``--net`` value must be ``none``. Non-creating
129
+ commands (build, exec, cp, rm, inspect) are allowed. :mod:`redgap.lab` routes every
130
+ docker call through here, so an edit that networks the lab fails at runtime."""
131
+ args = list(docker_args)
132
+ verb, verb_idx = _effective_verb_and_index(args)
133
+ # Global flags sit before the verb; none may redirect the daemon off the local box.
134
+ for a in args[:verb_idx]:
135
+ if a.split("=", 1)[0] in _DAEMON_REDIRECT_FLAGS:
136
+ raise TargetNotAllowed(
137
+ f"refusing docker invocation with {a!r}: RedGap uses the local docker "
138
+ f"daemon only, never a remote/redirected engine (see ETHICS.md)."
139
+ )
140
+ if verb not in _CONTAINER_CREATE_VERBS:
141
+ return
142
+ nets = _network_values(args[verb_idx:])
143
+ if not nets or any(n != "none" for n in nets):
144
+ raise TargetNotAllowed(
145
+ f"refusing to launch a container with networking {nets or ['(default bridge)']}: "
146
+ f"RedGap's lab must run with '--network none'. There is no override — by design."
147
+ )
redgap/catalog.py ADDED
@@ -0,0 +1,63 @@
1
+ """The v0.1 technique catalog (metadata only).
2
+
3
+ Five benign, MITRE ATT&CK-mapped techniques forming a small kill-chain. Execution
4
+ and cleanup live in the ``techniques/`` modules; this file is the pure-data catalog
5
+ the engine, coverage join, and reports use. Names and IDs verified against
6
+ attack.mitre.org on 2026-08-11.
7
+
8
+ The mix is deliberate — three detections and two *different kinds* of gap — so the
9
+ coverage report demonstrates real gap intelligence rather than an all-green list.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from redgap.models import GapType, Technique
15
+
16
+ CATALOG: tuple[Technique, ...] = (
17
+ Technique(
18
+ id="T1087.001",
19
+ name="Account Discovery: Local Account",
20
+ tactics=("Discovery",),
21
+ description="Enumerate local accounts by reading /etc/passwd.",
22
+ atomic_ref="T1087.001",
23
+ expected_gap_type=GapType.NONE, # detected
24
+ ),
25
+ Technique(
26
+ id="T1057",
27
+ name="Process Discovery",
28
+ tactics=("Discovery",),
29
+ description="List running processes with ps/top.",
30
+ atomic_ref="T1057",
31
+ # base-rate gap: ps/top are ubiquitous; a single-event rule would flood.
32
+ # Real signal needs correlation (roadmap). We deliberately ship no rule.
33
+ expected_gap_type=GapType.BASE_RATE,
34
+ ),
35
+ Technique(
36
+ id="T1136.001",
37
+ name="Create Account: Local Account",
38
+ tactics=("Persistence",),
39
+ description="Create a local account with useradd (nologin, no password).",
40
+ atomic_ref="T1136.001",
41
+ expected_gap_type=GapType.NONE, # detected
42
+ ),
43
+ Technique(
44
+ id="T1548.001",
45
+ name="Abuse Elevation Control Mechanism: Setuid and Setgid",
46
+ tactics=("Privilege Escalation", "Defense Evasion"),
47
+ description="Set the setuid bit on an inert /bin/true copy after chown root.",
48
+ atomic_ref="T1548.001",
49
+ expected_gap_type=GapType.NONE, # detected by a shipped SigmaHQ rule
50
+ ),
51
+ Technique(
52
+ id="T1070.006",
53
+ name="Indicator Removal: Timestomp",
54
+ tactics=("Defense Evasion",),
55
+ description="Alter file timestamps with touch -r/-t.",
56
+ atomic_ref="T1070.006",
57
+ # rule gap by default: telemetry is present but no rule ships. Closed in
58
+ # the remediation round-trip (rules/roundtrip/timestomp_touch.yml).
59
+ expected_gap_type=GapType.RULE,
60
+ ),
61
+ )
62
+
63
+ BY_ID: dict[str, Technique] = {t.id: t for t in CATALOG}
redgap/cli.py ADDED
@@ -0,0 +1,134 @@
1
+ """The ``redgap`` command — the only module that imports typer/rich.
2
+
3
+ Default run is REPLAY: re-evaluate the committed real-telemetry fixtures fully offline,
4
+ with no Docker and no API key. ``--live`` captures fresh telemetry from the Docker lab;
5
+ ``--fix`` loads the closing rule that flips the timestomp gap red -> green.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json as _json
11
+ from datetime import UTC, datetime
12
+ from pathlib import Path
13
+ from typing import Annotated
14
+
15
+ import typer
16
+ from rich import box
17
+ from rich.console import Console
18
+ from rich.table import Table
19
+
20
+ from redgap import __version__
21
+ from redgap.pipeline import exit_code_for, run_coverage
22
+ from redgap.target import ReplayTarget
23
+
24
+ app = typer.Typer(
25
+ add_completion=False,
26
+ no_args_is_help=True,
27
+ help="RedGap — automated MITRE ATT&CK offense<->detection coverage harness.",
28
+ )
29
+ console = Console()
30
+
31
+ _LIVE_BANNER = "LIVE — capturing real telemetry from the disposable lab"
32
+ _REPLAY_BANNER = "REPLAY — re-evaluating real captured telemetry; no live attack run"
33
+
34
+
35
+ def _now() -> str:
36
+ return datetime.now(UTC).replace(microsecond=0).isoformat()
37
+
38
+
39
+ def _render(verdicts, report: dict, mode: str) -> None:
40
+ summary = report["summary"]
41
+ table = Table(box=box.SIMPLE_HEAVY, title=f"RedGap coverage — {mode}", title_style="bold")
42
+ table.add_column("ATT&CK", style="bold")
43
+ table.add_column("Technique")
44
+ table.add_column("Tactic", style="dim")
45
+ table.add_column("Result", justify="left")
46
+ table.add_column("Rule / gap")
47
+ by_id = {t["id"]: t for t in report["techniques"]}
48
+ for v in verdicts:
49
+ t = by_id[v.technique_id]
50
+ if v.detected:
51
+ result = "[green]● detected[/green]"
52
+ rule = ", ".join(v.firing_rules)
53
+ else:
54
+ result = f"[red]● gap[/red] [dim]({v.gap_type.value})[/dim]"
55
+ rule = "[dim]—[/dim]"
56
+ if v.unexpected:
57
+ result += " [yellow](regression)[/yellow]"
58
+ table.add_row(v.technique_id, t["name"], " / ".join(t["tactics"]), result, rule)
59
+ console.print(table)
60
+ gaps_str = ", ".join(f"{k}:{n}" for k, n in summary["gaps_by_type"].items()) or "none"
61
+ gap_word = "gap" if summary["gaps"] == 1 else "gaps"
62
+ console.print(
63
+ f"[bold green]{summary['detected']}[/bold green]/{summary['techniques']} detected · "
64
+ f"[bold red]{summary['gaps']}[/bold red] {gap_word} [dim]({gaps_str})[/dim]"
65
+ )
66
+
67
+
68
+ @app.command()
69
+ def run(
70
+ live: Annotated[
71
+ bool, typer.Option("--live", help="Capture fresh telemetry from the Docker lab.")
72
+ ] = False,
73
+ fix: Annotated[
74
+ bool, typer.Option("--fix", help="Load the closing rule (flips the timestomp gap green).")
75
+ ] = False,
76
+ out: Annotated[
77
+ Path, typer.Option(help="Write coverage.json/.md/navigator-layer.json here.")
78
+ ] = Path("out"),
79
+ json_out: Annotated[
80
+ bool, typer.Option("--json", help="Print coverage JSON (for CI); no table.")
81
+ ] = False,
82
+ llm: Annotated[
83
+ bool, typer.Option("--llm", help="Use the optional LLM planner (needs ANTHROPIC_API_KEY).")
84
+ ] = False,
85
+ ) -> None:
86
+ """Run the coverage loop (REPLAY by default)."""
87
+ if live:
88
+ from redgap.target import LiveDockerTarget
89
+
90
+ target = LiveDockerTarget()
91
+ else:
92
+ target = ReplayTarget()
93
+
94
+ if not json_out:
95
+ console.print(f"[dim]{_LIVE_BANNER if live else _REPLAY_BANNER}[/dim]")
96
+
97
+ verdicts, report = run_coverage(
98
+ target, generated_at=_now(), out_dir=out, fix=fix, use_llm=(True if llm else None)
99
+ )
100
+
101
+ if json_out:
102
+ typer.echo(_json.dumps(report, indent=2, ensure_ascii=False))
103
+ else:
104
+ _render(verdicts, report, target.mode)
105
+ console.print(f"[dim]wrote coverage.json/.md + navigator-layer.json → {out}/[/dim]")
106
+
107
+ raise typer.Exit(exit_code_for(verdicts))
108
+
109
+
110
+ @app.command()
111
+ def capture(
112
+ at: Annotated[str, typer.Option(help="captured_at timestamp to stamp into provenance.")] = "",
113
+ git_commit: Annotated[
114
+ str, typer.Option(help="git commit to stamp into provenance.")
115
+ ] = "unknown",
116
+ ) -> None:
117
+ """Regenerate the committed real-telemetry fixtures from a live lab run (needs Docker)."""
118
+ from redgap import lab
119
+
120
+ console.print("[dim]building the lab image and capturing real telemetry (needs Docker)…[/dim]")
121
+ counts = lab.capture_all(at or _now(), git_commit=git_commit)
122
+ for tid, n in counts.items():
123
+ console.print(f" {tid}: [bold]{n}[/bold] events")
124
+ console.print("[green]fixtures regenerated.[/green]")
125
+
126
+
127
+ @app.command()
128
+ def version() -> None:
129
+ """Print the RedGap version."""
130
+ typer.echo(f"redgap {__version__}")
131
+
132
+
133
+ if __name__ == "__main__":
134
+ app()
@@ -0,0 +1,5 @@
1
+ """Deterministic detection: parse Sigma, evaluate it, compute coverage.
2
+
3
+ Nothing in this package depends on a language model. Every verdict is a pure
4
+ function of (events, rules).
5
+ """
@@ -0,0 +1,133 @@
1
+ """Coverage: join rules to techniques and compute the deterministic verdict.
2
+
3
+ For each technique we gather the events its execution produced, find the rules
4
+ tagged to that technique, and ask the engine whether any rule fires on any event.
5
+ ``detected`` is a pure boolean from that. The whole ``Verdict`` is computed here and
6
+ persisted before any LLM is invoked.
7
+
8
+ Rules that were EXCLUDED at load (unsupported feature / unreadable) are threaded in so a
9
+ dropped closing rule cannot masquerade as a "no rule shipped" base-rate gap — the report
10
+ distinguishes "you wrote a rule we could not evaluate" from "there is no rule".
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import Sequence
16
+
17
+ from redgap.detection.engine import Event, rule_matches
18
+ from redgap.detection.sigma_ast import LoadedRule
19
+ from redgap.models import Evidence, GapType, Technique, Verdict
20
+
21
+ #: Excluded rule as returned by load_rules_detailed: (path, reason, technique_ids).
22
+ ExcludedRule = tuple[str, str, tuple[str, ...]]
23
+
24
+
25
+ def _parent(technique_id: str) -> str:
26
+ return technique_id.split(".")[0]
27
+
28
+
29
+ def rule_covers(rule: LoadedRule, technique: Technique) -> bool:
30
+ """True if ``rule`` is tagged to ``technique`` (exact, or a sub-technique rule
31
+ covering its parent). A parent-only tag is NOT credited to a child sub-technique."""
32
+ for rid in rule.technique_ids:
33
+ if rid == technique.id:
34
+ return True
35
+ if _parent(rid) == technique.id: # a sub-technique rule covers its parent
36
+ return True
37
+ return False
38
+
39
+
40
+ def _exact_tagged(technique_ids: Sequence[str], technique: Technique) -> bool:
41
+ """A rule/exclusion is tagged EXACTLY to this technique (not merely via sub->parent)."""
42
+ return technique.id in technique_ids
43
+
44
+
45
+ def _gap_type(
46
+ technique: Technique,
47
+ detected: bool,
48
+ telemetry_present: bool,
49
+ exact_rule_present: bool,
50
+ ) -> GapType:
51
+ if detected:
52
+ return GapType.NONE
53
+ if not telemetry_present:
54
+ # Executed but the collector saw nothing: a visibility/data-source gap.
55
+ return GapType.VISIBILITY
56
+ if exact_rule_present:
57
+ # A rule specifically for THIS technique exists (loaded-but-not-firing, or
58
+ # excluded at load) — a rule gap, regardless of what the catalog expected. This
59
+ # is the remediation round-trip's signal and it must not be masked as base-rate.
60
+ return GapType.RULE
61
+ # No rule is tagged to this technique at all; the catalog says why (rule vs base-rate).
62
+ if technique.expected_gap_type in (GapType.RULE, GapType.BASE_RATE):
63
+ return technique.expected_gap_type
64
+ return GapType.RULE
65
+
66
+
67
+ def evaluate_technique(
68
+ technique: Technique,
69
+ events: list[Event],
70
+ rules: list[LoadedRule],
71
+ excluded: Sequence[ExcludedRule] = (),
72
+ ) -> Verdict:
73
+ """Compute the deterministic verdict for one technique execution."""
74
+ telemetry_present = len(events) > 0
75
+ candidates = [r for r in rules if rule_covers(r, technique)]
76
+ excluded_for_technique = [x for x in excluded if _exact_tagged(x[2], technique)]
77
+
78
+ firing_rules: list[str] = []
79
+ matched_event_ids: list[str] = []
80
+ evidence: list[Evidence] = []
81
+
82
+ for rule in candidates:
83
+ for event in events:
84
+ match = rule_matches(rule, event)
85
+ if match is None:
86
+ continue
87
+ if rule.id not in firing_rules:
88
+ firing_rules.append(rule.id)
89
+ if match.event_id not in matched_event_ids:
90
+ matched_event_ids.append(match.event_id)
91
+ evidence.append(
92
+ Evidence(
93
+ rule_id=match.rule_id,
94
+ rule_title=match.rule_title,
95
+ event_id=match.event_id,
96
+ matched_fields=match.matched_fields,
97
+ )
98
+ )
99
+
100
+ detected = len(firing_rules) > 0
101
+ # Gap typing keys on EXACT-tagged rules (loaded or excluded), so a sub->parent credit
102
+ # cannot downgrade a base-rate technique and an excluded closing rule still reads RULE.
103
+ exact_present = any(_exact_tagged(r.technique_ids, technique) for r in candidates) or bool(
104
+ excluded_for_technique
105
+ )
106
+ gap_type = _gap_type(technique, detected, telemetry_present, exact_present)
107
+ return Verdict(
108
+ technique_id=technique.id,
109
+ executed=True,
110
+ telemetry_present=telemetry_present,
111
+ detected=detected,
112
+ gap_type=gap_type,
113
+ firing_rules=tuple(firing_rules),
114
+ matched_event_ids=tuple(matched_event_ids),
115
+ evidence=tuple(evidence),
116
+ expected_gap_type=technique.expected_gap_type,
117
+ candidates_evaluated=len(candidates),
118
+ candidates_excluded=len(excluded_for_technique),
119
+ unexpected=(not detected and technique.expected_gap_type is GapType.NONE),
120
+ )
121
+
122
+
123
+ def evaluate_all(
124
+ techniques: list[Technique],
125
+ events_by_technique: dict[str, list[Event]],
126
+ rules: list[LoadedRule],
127
+ excluded: Sequence[ExcludedRule] = (),
128
+ ) -> list[Verdict]:
129
+ """Verdicts for every technique, in catalog order (deterministic)."""
130
+ return [
131
+ evaluate_technique(t, events_by_technique.get(t.id, []), rules, excluded)
132
+ for t in techniques
133
+ ]