audit-labs/audit-report
Turn audit-tools evidence packages into control-mapped, auditor-ready reports.
clone: git clone https://gitbay.org/audit-labs/audit-report.git
main: audit_report/rules.py · raw
1"""Rule model and the small condition language rulesets are written in.
2
3A **ruleset** is a YAML file: a platform name plus a list of rules. Each rule
4names a table, a check, the controls it maps to, and human text. Checks are
5evaluated by :mod:`audit_report.engine`.
6
7The condition language is intentionally tiny. A *condition* is one of:
8
9* a leaf ``{column, op, value}`` — test one column of a row
10* ``{all: [cond, ...]}`` — every sub-condition holds
11* ``{any: [cond, ...]}`` — at least one holds
12* ``{not: cond}`` — the sub-condition does not hold
13
14Operators (``op``): ``equals``, ``not_equals``, ``is_true``, ``is_false``,
15``in``, ``not_in``, ``gt``, ``gte``, ``lt``, ``lte``, ``empty``, ``not_empty``.
16"""
17
18from __future__ import annotations
19
20import hashlib
21from dataclasses import dataclass, field
22from pathlib import Path
23
24import yaml
25
26_TRUTHY = {"true", "yes", "1", "y", "t", "enabled", "on"}
27_FALSY = {"false", "no", "0", "n", "f", "disabled", "off", ""}
28
29VALID_CHECK_TYPES = {
30 "fail_rows_where",
31 "fail_if_any_rows",
32 "assert_row",
33 "require_any_row",
34}
35
36
37@dataclass
38class Rule:
39 """A single control check declared in a ruleset."""
40
41 id: str
42 title: str
43 table: str
44 check: dict
45 severity: str = "medium"
46 controls: list[str] = field(default_factory=list)
47 rationale: str = ""
48 remediation: str = ""
49
50
51@dataclass
52class Ruleset:
53 """A named collection of rules for one platform.
54
55 ``version`` and ``sha256`` identify *which* ruleset produced a report, so an
56 auditor can re-perform against the exact mapping used. ``sha256`` is the
57 digest of the ruleset file's bytes as loaded.
58 """
59
60 platform: str
61 rules: list[Rule]
62 name: str = ""
63 version: str = ""
64 sha256: str = ""
65
66
67def _as_number(value: str) -> float | None:
68 try:
69 return float(value)
70 except (TypeError, ValueError):
71 return None
72
73
74def _compare_numeric(op: str, raw: str, value) -> bool:
75 left, right = _as_number(raw), _as_number(str(value))
76 if left is None or right is None:
77 return False
78 return {
79 "gt": left > right,
80 "gte": left >= right,
81 "lt": left < right,
82 "lte": left <= right,
83 }[op]
84
85
86def _match_leaf(condition: dict, row: dict[str, str]) -> bool:
87 column = condition.get("column")
88 op = condition.get("op")
89 if column is None or op is None:
90 raise ValueError(f"malformed condition: {condition!r}")
91
92 raw = row.get(column, "")
93 value = condition.get("value")
94 norm = raw.strip().lower()
95
96 if op == "equals":
97 return norm == str(value).strip().lower()
98 if op == "not_equals":
99 return norm != str(value).strip().lower()
100 if op == "is_true":
101 return norm in _TRUTHY
102 if op == "is_false":
103 return norm in _FALSY
104 if op == "empty":
105 return raw.strip() == ""
106 if op == "not_empty":
107 return raw.strip() != ""
108 if op in ("in", "not_in"):
109 choices = {str(v).strip().lower() for v in (value or [])}
110 return (norm in choices) if op == "in" else (norm not in choices)
111 if op in ("gt", "gte", "lt", "lte"):
112 return _compare_numeric(op, raw, value)
113
114 raise ValueError(f"unknown operator: {op!r}")
115
116
117def match(condition: dict, row: dict[str, str]) -> bool:
118 """Return True if *condition* holds for *row*.
119
120 Raises ``ValueError`` on a malformed condition so ruleset bugs surface
121 loudly rather than silently evaluating to False.
122 """
123 if "all" in condition:
124 return all(match(c, row) for c in condition["all"])
125 if "any" in condition:
126 return any(match(c, row) for c in condition["any"])
127 if "not" in condition:
128 return not match(condition["not"], row)
129 return _match_leaf(condition, row)
130
131
132def load_ruleset(path: str | Path) -> Ruleset:
133 """Parse a ruleset YAML file into a :class:`Ruleset`, validating each rule."""
134 text = Path(path).read_text(encoding="utf-8")
135 data = yaml.safe_load(text) or {}
136 platform = data.get("platform")
137 if not platform:
138 raise ValueError(f"{path}: ruleset is missing a 'platform'")
139
140 rules: list[Rule] = []
141 for entry in data.get("rules", []):
142 rule = Rule(
143 id=entry["id"],
144 title=entry["title"],
145 table=entry["table"],
146 check=entry["check"],
147 severity=entry.get("severity", "medium"),
148 controls=entry.get("controls", []),
149 rationale=entry.get("rationale", ""),
150 remediation=entry.get("remediation", ""),
151 )
152 check_type = rule.check.get("type")
153 if check_type not in VALID_CHECK_TYPES:
154 raise ValueError(f"{rule.id}: unknown check type {check_type!r}")
155 rules.append(rule)
156
157 return Ruleset(
158 platform=platform,
159 rules=rules,
160 name=data.get("name", platform),
161 version=str(data.get("version", "")),
162 sha256=hashlib.sha256(text.encode("utf-8")).hexdigest(),
163 )