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.
- redgap/__init__.py +9 -0
- redgap/_resources.py +44 -0
- redgap/allowlist.py +147 -0
- redgap/catalog.py +63 -0
- redgap/cli.py +134 -0
- redgap/detection/__init__.py +5 -0
- redgap/detection/coverage.py +133 -0
- redgap/detection/engine.py +276 -0
- redgap/detection/sigma_ast.py +238 -0
- redgap/engine_facade.py +75 -0
- redgap/fixtures/replay/T1057/events.jsonl +3 -0
- redgap/fixtures/replay/T1057/provenance.json +16 -0
- redgap/fixtures/replay/T1057/raw/exec.log +3 -0
- redgap/fixtures/replay/T1070.006/events.jsonl +5 -0
- redgap/fixtures/replay/T1070.006/provenance.json +18 -0
- redgap/fixtures/replay/T1070.006/raw/exec.log +5 -0
- redgap/fixtures/replay/T1087.001/events.jsonl +3 -0
- redgap/fixtures/replay/T1087.001/provenance.json +16 -0
- redgap/fixtures/replay/T1087.001/raw/exec.log +3 -0
- redgap/fixtures/replay/T1136.001/events.jsonl +5 -0
- redgap/fixtures/replay/T1136.001/provenance.json +18 -0
- redgap/fixtures/replay/T1136.001/raw/exec.log +5 -0
- redgap/fixtures/replay/T1548.001/events.jsonl +9 -0
- redgap/fixtures/replay/T1548.001/provenance.json +19 -0
- redgap/fixtures/replay/T1548.001/raw/exec.log +9 -0
- redgap/lab/Dockerfile +30 -0
- redgap/lab/collector/redgap_exec.c +82 -0
- redgap/lab/compose.yaml +24 -0
- redgap/lab.py +118 -0
- redgap/models.py +149 -0
- redgap/pipeline.py +68 -0
- redgap/planner.py +179 -0
- redgap/report/__init__.py +10 -0
- redgap/report/build.py +87 -0
- redgap/report/markdown.py +84 -0
- redgap/report/navigator.py +92 -0
- redgap/rules/proc_creation_lnx_setgid_setuid.yml +32 -0
- redgap/rules/redgap/account_discovery_etc_passwd.yml +36 -0
- redgap/rules/redgap/create_account_useradd.yml +32 -0
- redgap/rules/roundtrip/timestomp_touch.yml +32 -0
- redgap/target.py +119 -0
- redgap/techniques/__init__.py +11 -0
- redgap/techniques/base.py +21 -0
- redgap/techniques/registry.py +277 -0
- redgap/telemetry/__init__.py +1 -0
- redgap/telemetry/schema.py +104 -0
- redgap/telemetry/snoopy.py +73 -0
- redgap-0.1.0.dist-info/METADATA +153 -0
- redgap-0.1.0.dist-info/RECORD +53 -0
- redgap-0.1.0.dist-info/WHEEL +4 -0
- redgap-0.1.0.dist-info/entry_points.txt +2 -0
- redgap-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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,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
|
+
]
|