Skip to content

Commit 1482964

Browse files
authored
feat: Add packaging + vendor-neutral DetectionRule schema and secret provider abstraction (#2)
* Remove legacy interactive scripts, superseded by the detection_as_code package * Add packaging + vendor-neutral DetectionRule schema and secret provider abstraction
1 parent afbf44b commit 1482964

6 files changed

Lines changed: 248 additions & 0 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,9 @@ venv.bak/
145145
.dmypy.json
146146
dmypy.json
147147

148+
# ruff
149+
.ruff_cache/
150+
148151
# Pyre type checker
149152
.pyre/
150153

pyproject.toml

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
[build-system]
2+
requires = ["setuptools>=68", "wheel"]
3+
build-backend = "setuptools.build_meta"
4+
5+
[project]
6+
name = "detection-as-code"
7+
version = "0.2.0"
8+
description = "A schema-first detection engineering pipeline: author rules once, lint them deterministically, export to Kibana or Sigma, and get an advisory AI review before they ship."
9+
readme = "README.md"
10+
requires-python = ">=3.10"
11+
license = { file = "LICENSE" }
12+
authors = [{ name = "SentinelByte" }]
13+
dependencies = [
14+
"requests>=2.31",
15+
"elasticsearch>=8.13,<9",
16+
"PyYAML>=6.0",
17+
]
18+
19+
[project.optional-dependencies]
20+
ai = ["anthropic>=0.34"]
21+
dev = [
22+
"pytest>=8.0",
23+
"pytest-cov>=5.0",
24+
"ruff>=0.5",
25+
"mypy>=1.10",
26+
"bandit>=1.7",
27+
"pip-audit>=2.7",
28+
"types-requests",
29+
"types-PyYAML",
30+
"anthropic>=0.34",
31+
]
32+
33+
[project.scripts]
34+
detection-as-code = "detection_as_code.cli:main"
35+
36+
[project.urls]
37+
Repository = "https://github.com/SentinelByte/detection-as-code"
38+
39+
[tool.setuptools.packages.find]
40+
where = ["src"]
41+
42+
[tool.ruff]
43+
line-length = 100
44+
target-version = "py310"
45+
46+
[tool.ruff.lint]
47+
select = ["E", "F", "I", "UP", "B"]
48+
49+
[tool.mypy]
50+
python_version = "3.10"
51+
ignore_missing_imports = true
52+
disallow_untyped_defs = false
53+
54+
[tool.pytest.ini_options]
55+
testpaths = ["tests"]
56+
57+
[tool.bandit]
58+
exclude_dirs = ["tests"]

src/detection_as_code/__init__.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
"""A schema-first detection engineering pipeline.
2+
3+
Rules are authored once as declarative YAML, checked deterministically,
4+
exported to whichever backend needs them, and optionally reviewed by an
5+
LLM before a human ships them. See docs/architecture.md for the full flow.
6+
"""
7+
8+
from .models import DetectionRule, Severity, Tactic
9+
10+
__all__ = ["DetectionRule", "Severity", "Tactic"]
11+
__version__ = "0.2.0"

src/detection_as_code/models.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass, field
4+
from enum import Enum
5+
from typing import Any
6+
7+
8+
class Severity(str, Enum):
9+
LOW = "low"
10+
MEDIUM = "medium"
11+
HIGH = "high"
12+
CRITICAL = "critical"
13+
14+
15+
class Tactic(str, Enum):
16+
"""MITRE ATT&CK Enterprise tactics, keyed by their official ID."""
17+
18+
INITIAL_ACCESS = "TA0001"
19+
EXECUTION = "TA0002"
20+
PERSISTENCE = "TA0003"
21+
PRIVILEGE_ESCALATION = "TA0004"
22+
DEFENSE_EVASION = "TA0005"
23+
CREDENTIAL_ACCESS = "TA0006"
24+
DISCOVERY = "TA0007"
25+
LATERAL_MOVEMENT = "TA0008"
26+
COLLECTION = "TA0009"
27+
EXFILTRATION = "TA0010"
28+
COMMAND_AND_CONTROL = "TA0011"
29+
IMPACT = "TA0040"
30+
31+
32+
@dataclass
33+
class DetectionRule:
34+
"""The vendor-neutral internal representation of a detection rule.
35+
36+
Everything downstream (Kibana JSON, Sigma YAML, the deterministic linter,
37+
the AI critic) works off this one schema, so adding a new export target
38+
or a new check never requires touching the rule authoring format.
39+
"""
40+
41+
name: str
42+
platform: str
43+
query: str
44+
index_patterns: list[str]
45+
description: str
46+
severity: Severity
47+
risk_score: int
48+
tactics: list[Tactic] = field(default_factory=list)
49+
techniques: list[str] = field(default_factory=list)
50+
tags: list[str] = field(default_factory=list)
51+
false_positives: list[str] = field(default_factory=list)
52+
author: str = ""
53+
interval: str = "30m"
54+
lookback: str = "45m"
55+
enabled: bool = False
56+
rule_id: str | None = None
57+
58+
def __post_init__(self) -> None:
59+
if not 0 < self.risk_score <= 100:
60+
raise ValueError(f"risk_score must be in (0, 100], got {self.risk_score}")
61+
if not self.name.strip():
62+
raise ValueError("name must not be empty")
63+
64+
@classmethod
65+
def from_dict(cls, data: dict[str, Any]) -> DetectionRule:
66+
try:
67+
severity = Severity(data["severity"])
68+
except ValueError as exc:
69+
allowed = ", ".join(s.value for s in Severity)
70+
raise ValueError(f"severity must be one of: {allowed}") from exc
71+
72+
tactics = []
73+
for raw in data.get("tactics", []):
74+
try:
75+
tactics.append(Tactic[str(raw).upper()])
76+
except KeyError as exc:
77+
allowed = ", ".join(t.name for t in Tactic)
78+
raise ValueError(f"Unknown tactic {raw!r}, expected one of: {allowed}") from exc
79+
80+
return cls(
81+
name=data["name"],
82+
platform=data["platform"],
83+
query=data["query"],
84+
index_patterns=list(data.get("index_patterns", [])),
85+
description=data.get("description", ""),
86+
severity=severity,
87+
risk_score=int(data["risk_score"]),
88+
tactics=tactics,
89+
techniques=list(data.get("techniques", [])),
90+
tags=list(data.get("tags", [])),
91+
false_positives=list(data.get("false_positives", [])),
92+
author=data.get("author", ""),
93+
interval=data.get("interval", "30m"),
94+
lookback=data.get("lookback", "45m"),
95+
enabled=bool(data.get("enabled", False)),
96+
rule_id=data.get("rule_id"),
97+
)

src/detection_as_code/secrets.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from __future__ import annotations
2+
3+
import os
4+
from typing import Protocol
5+
6+
7+
class SecretProvider(Protocol):
8+
"""Anything that can resolve a named secret. Implement this against your
9+
org's actual vault (AWS Secrets Manager, Akeyless, CyberArk, ...) and the
10+
rest of the pipeline needs no changes - clients only depend on this shape.
11+
"""
12+
13+
def get(self, key: str) -> str: ...
14+
15+
16+
class EnvSecretProvider:
17+
"""Reads secrets from environment variables. This is the only provider
18+
shipped here; production deployments should swap in a real vault-backed
19+
provider rather than storing long-lived credentials in process env vars.
20+
"""
21+
22+
def __init__(self, prefix: str = "DAC_"):
23+
self.prefix = prefix
24+
25+
def get(self, key: str) -> str:
26+
env_key = f"{self.prefix}{key.upper()}"
27+
value = os.environ.get(env_key)
28+
if not value:
29+
raise KeyError(f"Missing required secret: {env_key}")
30+
return value

tests/test_models.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import pytest
2+
3+
from detection_as_code.models import DetectionRule, Severity, Tactic
4+
5+
VALID_RULE = {
6+
"name": "windows_suspicious_scheduled_task",
7+
"platform": "windows",
8+
"query": 'process.name:"schtasks.exe"',
9+
"index_patterns": ["winlogbeat-*"],
10+
"description": "Detects scheduled task creation from a suspicious path.",
11+
"severity": "high",
12+
"risk_score": 62,
13+
"tactics": ["persistence", "privilege_escalation"],
14+
"techniques": ["T1053.005"],
15+
"false_positives": ["Legitimate installer scheduling maintenance tasks"],
16+
}
17+
18+
19+
def test_from_dict_builds_a_valid_rule():
20+
rule = DetectionRule.from_dict(VALID_RULE)
21+
22+
assert rule.severity == Severity.HIGH
23+
assert Tactic.PERSISTENCE in rule.tactics
24+
assert Tactic.PRIVILEGE_ESCALATION in rule.tactics
25+
assert rule.risk_score == 62
26+
27+
28+
def test_risk_score_out_of_range_rejected():
29+
data = {**VALID_RULE, "risk_score": 150}
30+
with pytest.raises(ValueError, match="risk_score"):
31+
DetectionRule.from_dict(data)
32+
33+
34+
def test_unknown_severity_rejected():
35+
data = {**VALID_RULE, "severity": "apocalyptic"}
36+
with pytest.raises(ValueError, match="severity"):
37+
DetectionRule.from_dict(data)
38+
39+
40+
def test_unknown_tactic_rejected():
41+
data = {**VALID_RULE, "tactics": ["quantum_leap"]}
42+
with pytest.raises(ValueError, match="Unknown tactic"):
43+
DetectionRule.from_dict(data)
44+
45+
46+
def test_empty_name_rejected():
47+
data = {**VALID_RULE, "name": " "}
48+
with pytest.raises(ValueError, match="name"):
49+
DetectionRule.from_dict(data)

0 commit comments

Comments
 (0)