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/engine.py · raw

  1"""Evaluate a ruleset against a loaded package to produce findings."""
  2
  3from __future__ import annotations
  4
  5from dataclasses import dataclass, field
  6
  7from .loader import Package, Row
  8from .rules import Rule, Ruleset, match
  9
 10PASS = "pass"
 11FAIL = "fail"
 12NOT_APPLICABLE = "not_applicable"
 13
 14
 15@dataclass
 16class Finding:
 17    """The outcome of evaluating one rule against one package."""
 18
 19    rule: Rule
 20    status: str
 21    reason: str
 22    evidence: list[Row] = field(default_factory=list)
 23
 24    @property
 25    def controls(self) -> list[str]:
 26        return self.rule.controls
 27
 28
 29def _evaluate_rule(rule: Rule, package: Package) -> Finding:
 30    if not package.has(rule.table):
 31        return Finding(rule, NOT_APPLICABLE, f"table '{rule.table}' not in package")
 32
 33    rows = package.table(rule.table)
 34    check = rule.check
 35    ctype = check["type"]
 36
 37    if ctype == "assert_row":
 38        return _assert_row(rule, rows)
 39
 40    if ctype == "require_any_row":
 41        return _require_any_row(rule, rows)
 42
 43    # fail_rows_where / fail_if_any_rows both scan rows for failures.
 44    condition = check.get("when")
 45    if ctype == "fail_if_any_rows" and condition is None:
 46        failing = list(rows)
 47    else:
 48        # fail_rows_where requires a condition; fail_if_any_rows may filter too.
 49        if condition is None:
 50            raise ValueError(f"{rule.id}: check needs a 'when' condition")
 51        failing = [r for r in rows if match(condition, r)]
 52
 53    if failing:
 54        noun = "row" if len(failing) == 1 else "rows"
 55        return Finding(rule, FAIL, f"{len(failing)} {noun} failed the check", failing)
 56
 57    # A present-but-empty table means the scan found nothing to fault — the
 58    # absence of bad rows is a pass (e.g. no publicly readable buckets).
 59    if not rows:
 60        return Finding(rule, PASS, "no rows present — no violations found")
 61    return Finding(rule, PASS, f"all {len(rows)} rows passed the check")
 62
 63
 64def _assert_row(rule: Rule, rows: list[Row]) -> Finding:
 65    """Assert conditions against a single-row configuration table."""
 66    if not rows:
 67        return Finding(rule, NOT_APPLICABLE, f"table '{rule.table}' is empty")
 68
 69    row = rows[0]
 70    failed = [req for req in rule.check.get("require", []) if not match(req, row)]
 71    if failed:
 72        columns = ", ".join(req.get("column", "?") for req in failed)
 73        return Finding(rule, FAIL, f"configuration failed on: {columns}", [row])
 74    return Finding(rule, PASS, "configuration meets all requirements")
 75
 76
 77def _require_any_row(rule: Rule, rows: list[Row]) -> Finding:
 78    """Pass if at least one row satisfies the condition; fail otherwise."""
 79    condition = rule.check.get("when")
 80    if condition is None:
 81        raise ValueError(f"{rule.id}: require_any_row needs a 'when' condition")
 82    if any(match(condition, row) for row in rows):
 83        return Finding(rule, PASS, "at least one row satisfies the requirement")
 84    reason = "no row satisfies the requirement" if rows else f"table '{rule.table}' is empty"
 85    return Finding(rule, FAIL, reason, rows)
 86
 87
 88def evaluate(package: Package, ruleset: Ruleset) -> list[Finding]:
 89    """Run every rule in *ruleset* against *package*."""
 90    return [_evaluate_rule(rule, package) for rule in ruleset.rules]
 91
 92
 93def summarize(findings: list[Finding]) -> dict[str, int]:
 94    """Count findings by status."""
 95    counts = {PASS: 0, FAIL: 0, NOT_APPLICABLE: 0}
 96    for finding in findings:
 97        counts[finding.status] += 1
 98    return counts
 99
100
101# Worst-wins ordering when rolling several findings up to one control.
102_STATUS_RANK = {FAIL: 2, PASS: 1, NOT_APPLICABLE: 0}
103
104
105def control_coverage(findings: list[Finding]) -> dict[str, dict]:
106    """Roll findings up to a per-control view.
107
108    Returns ``{control: {"status": ..., "rules": [rule_id, ...]}}`` where a
109    control's status is the worst status among the rules that cite it — one
110    failing rule means the control is not fully evidenced.
111    """
112    coverage: dict[str, dict] = {}
113    for finding in findings:
114        for control in finding.controls:
115            entry = coverage.setdefault(control, {"status": NOT_APPLICABLE, "rules": []})
116            entry["rules"].append(finding.rule.id)
117            if _STATUS_RANK[finding.status] > _STATUS_RANK[entry["status"]]:
118                entry["status"] = finding.status
119    return dict(sorted(coverage.items()))