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()))