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    )