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

v0.1.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    )