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/loader.py · raw
1"""Load an audit-tools evidence package from disk.
2
3An evidence package is a directory of CSV files produced by audit-tools, named
4like ``github_audit_<org>_<date>/`` or ``aws_audit_<profile>_<date>/``. Each CSV
5becomes a *table* keyed by its filename stem (``iam_users.csv`` -> ``iam_users``).
6"""
7
8from __future__ import annotations
9
10import csv
11from dataclasses import dataclass, field
12from pathlib import Path
13
14# Directory-name prefixes audit-tools uses, mapped to a platform key.
15_PLATFORM_PREFIXES = {
16 "aws_audit_": "aws",
17 "github_audit_": "github",
18 "gitlab_audit_": "gitlab",
19}
20
21Row = dict[str, str]
22Table = list[Row]
23
24
25@dataclass
26class Package:
27 """A loaded evidence package."""
28
29 path: Path
30 platform: str
31 subject: str # the org / profile the audit was run against
32 date: str = "" # trailing YYYY-MM-DD from the dir name, if present
33 tables: dict[str, Table] = field(default_factory=dict)
34
35 def table(self, name: str) -> Table:
36 """Return a table by name, or an empty list if it is absent."""
37 return self.tables.get(name, [])
38
39 def has(self, name: str) -> bool:
40 """True if the named CSV was present in the package."""
41 return name in self.tables
42
43
44def detect_platform(dir_name: str) -> tuple[str, str]:
45 """Infer ``(platform, subject)`` from a package directory name.
46
47 ``github_audit_audit-labs_2026-07-29`` -> ``("github", "audit-labs")``.
48 Falls back to ``("unknown", <dir_name>)`` when no prefix matches.
49 """
50 for prefix, platform in _PLATFORM_PREFIXES.items():
51 if dir_name.startswith(prefix):
52 rest = dir_name[len(prefix) :]
53 # Strip a trailing ISO date (…_YYYY-MM-DD) to recover the subject.
54 parts = rest.rsplit("_", 1)
55 subject = parts[0] if len(parts) == 2 and _looks_like_date(parts[1]) else rest
56 return platform, subject or "unknown"
57 return "unknown", dir_name
58
59
60def _looks_like_date(token: str) -> bool:
61 bits = token.split("-")
62 return len(bits) == 3 and all(b.isdigit() for b in bits)
63
64
65def package_date(dir_name: str) -> str:
66 """Return the trailing ``YYYY-MM-DD`` in a package dir name, or ''."""
67 tail = dir_name.rsplit("_", 1)[-1]
68 return tail if _looks_like_date(tail) else ""
69
70
71def load_package(path: str | Path) -> Package:
72 """Load every ``*.csv`` in *path* into a :class:`Package`.
73
74 Raises ``FileNotFoundError`` if the directory does not exist and
75 ``ValueError`` if it contains no CSV files.
76 """
77 directory = Path(path)
78 if not directory.is_dir():
79 raise FileNotFoundError(f"not a directory: {directory}")
80
81 platform, subject = detect_platform(directory.name)
82 tables: dict[str, Table] = {}
83 for csv_path in sorted(directory.glob("*.csv")):
84 with csv_path.open(newline="", encoding="utf-8") as handle:
85 tables[csv_path.stem] = list(csv.DictReader(handle))
86
87 if not tables:
88 raise ValueError(f"no CSV files found in {directory}")
89
90 return Package(
91 path=directory,
92 platform=platform,
93 subject=subject,
94 date=package_date(directory.name),
95 tables=tables,
96 )