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
v1.0.0: 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 match(condition: dict, row: dict[str, str]) -> bool:
75 """Return True if *condition* holds for *row*.
76
77 Raises ``ValueError`` on a malformed condition so ruleset bugs surface
78 loudly rather than silently evaluating to False.
79 """
80 if "all" in condition:
81 return all(match(c, row) for c in condition["all"])
82 if "any" in condition:
83 return any(match(c, row) for c in condition["any"])
84 if "not" in condition:
85 return not match(condition["not"], row)
86
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 left, right = _as_number(raw), _as_number(str(value))
113 if left is None or right is None:
114 return False
115 return {
116 "gt": left > right,
117 "gte": left >= right,
118 "lt": left < right,
119 "lte": left <= right,
120 }[op]
121
122 raise ValueError(f"unknown operator: {op!r}")
123
124
125def load_ruleset(path: str | Path) -> Ruleset:
126 """Parse a ruleset YAML file into a :class:`Ruleset`, validating each rule."""
127 text = Path(path).read_text(encoding="utf-8")
128 data = yaml.safe_load(text) or {}
129 platform = data.get("platform")
130 if not platform:
131 raise ValueError(f"{path}: ruleset is missing a 'platform'")
132
133 rules: list[Rule] = []
134 for entry in data.get("rules", []):
135 rule = Rule(
136 id=entry["id"],
137 title=entry["title"],
138 table=entry["table"],
139 check=entry["check"],
140 severity=entry.get("severity", "medium"),
141 controls=entry.get("controls", []),
142 rationale=entry.get("rationale", ""),
143 remediation=entry.get("remediation", ""),
144 )
145 check_type = rule.check.get("type")
146 if check_type not in VALID_CHECK_TYPES:
147 raise ValueError(f"{rule.id}: unknown check type {check_type!r}")
148 rules.append(rule)
149
150 return Ruleset(
151 platform=platform,
152 rules=rules,
153 name=data.get("name", platform),
154 version=str(data.get("version", "")),
155 sha256=hashlib.sha256(text.encode("utf-8")).hexdigest(),
156 )