rai-audit-core 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rai_audit_core-0.1.0/.gitignore +55 -0
- rai_audit_core-0.1.0/LICENSE +21 -0
- rai_audit_core-0.1.0/PKG-INFO +43 -0
- rai_audit_core-0.1.0/README.md +1 -0
- rai_audit_core-0.1.0/pyproject.toml +37 -0
- rai_audit_core-0.1.0/src/rai_audit/core/__init__.py +19 -0
- rai_audit_core-0.1.0/src/rai_audit/core/cli.py +224 -0
- rai_audit_core-0.1.0/src/rai_audit/core/engine.py +22 -0
- rai_audit_core-0.1.0/src/rai_audit/core/findings.py +133 -0
- rai_audit_core-0.1.0/src/rai_audit/core/history.py +115 -0
- rai_audit_core-0.1.0/src/rai_audit/core/privacy.py +74 -0
- rai_audit_core-0.1.0/src/rai_audit/core/registry.py +35 -0
- rai_audit_core-0.1.0/src/rai_audit/core/report.py +239 -0
- rai_audit_core-0.1.0/src/rai_audit/core/reproducibility.py +107 -0
- rai_audit_core-0.1.0/src/rai_audit/core/scoring.py +74 -0
- rai_audit_core-0.1.0/src/rai_audit/core/standards.py +35 -0
- rai_audit_core-0.1.0/tests/test_findings.py +105 -0
- rai_audit_core-0.1.0/tests/test_history.py +54 -0
- rai_audit_core-0.1.0/tests/test_scoring.py +59 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.so
|
|
6
|
+
.Python
|
|
7
|
+
|
|
8
|
+
# Virtual environments
|
|
9
|
+
.venv/
|
|
10
|
+
venv/
|
|
11
|
+
env/
|
|
12
|
+
|
|
13
|
+
# Packaging / build
|
|
14
|
+
build/
|
|
15
|
+
dist/
|
|
16
|
+
*.egg-info/
|
|
17
|
+
*.egg
|
|
18
|
+
pip-wheel-metadata/
|
|
19
|
+
*.whl
|
|
20
|
+
|
|
21
|
+
# Test / coverage / type checking
|
|
22
|
+
.pytest_cache/
|
|
23
|
+
.coverage
|
|
24
|
+
.coverage.*
|
|
25
|
+
htmlcov/
|
|
26
|
+
.mypy_cache/
|
|
27
|
+
.ruff_cache/
|
|
28
|
+
.hypothesis/
|
|
29
|
+
|
|
30
|
+
# Audit runtime artifacts
|
|
31
|
+
.rai-audit/
|
|
32
|
+
*_audit_report.html
|
|
33
|
+
*_audit_report.md
|
|
34
|
+
*_audit_report.json
|
|
35
|
+
loan_audit_report.*
|
|
36
|
+
|
|
37
|
+
# Environment / secrets
|
|
38
|
+
.env
|
|
39
|
+
.env.*
|
|
40
|
+
!.env.example
|
|
41
|
+
|
|
42
|
+
# IDE / editor
|
|
43
|
+
.vscode/
|
|
44
|
+
.idea/
|
|
45
|
+
*.swp
|
|
46
|
+
*.swo
|
|
47
|
+
*~
|
|
48
|
+
|
|
49
|
+
# OS
|
|
50
|
+
.DS_Store
|
|
51
|
+
Thumbs.db
|
|
52
|
+
Desktop.ini
|
|
53
|
+
|
|
54
|
+
# Typical dev data
|
|
55
|
+
dev-data/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sai Teja Erukude
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rai-audit-core
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Shared audit engine, findings, reports, and CLI for the RAI Audit Kit
|
|
5
|
+
Author: Sai Teja Erukude
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 Sai Teja Erukude
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Keywords: audit,fairness,responsible-ai,trustworthy-ai
|
|
29
|
+
Classifier: Development Status :: 3 - Alpha
|
|
30
|
+
Classifier: Intended Audience :: Developers
|
|
31
|
+
Classifier: Intended Audience :: Science/Research
|
|
32
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
33
|
+
Classifier: Programming Language :: Python :: 3
|
|
34
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
35
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
37
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
38
|
+
Requires-Python: >=3.10
|
|
39
|
+
Requires-Dist: rich>=13.0
|
|
40
|
+
Requires-Dist: typer>=0.12
|
|
41
|
+
Description-Content-Type: text/markdown
|
|
42
|
+
|
|
43
|
+
# rai-audit-core
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# rai-audit-core
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "rai-audit-core"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Shared audit engine, findings, reports, and CLI for the RAI Audit Kit"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { file = "LICENSE" }
|
|
11
|
+
authors = [{ name = "Sai Teja Erukude" }]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
keywords = ["responsible-ai", "audit", "fairness", "trustworthy-ai"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Intended Audience :: Science/Research",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"typer>=0.12",
|
|
27
|
+
"rich>=13.0",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
rai-audit = "rai_audit.core.cli:app"
|
|
32
|
+
|
|
33
|
+
[tool.hatch.build.targets.wheel]
|
|
34
|
+
packages = ["src/rai_audit"]
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.wheel.sources]
|
|
37
|
+
"src" = ""
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from rai_audit.core.findings import (
|
|
2
|
+
AuditFinding,
|
|
3
|
+
AuditReport,
|
|
4
|
+
CategoryRisk,
|
|
5
|
+
RemediationEffort,
|
|
6
|
+
RiskLevel,
|
|
7
|
+
Severity,
|
|
8
|
+
)
|
|
9
|
+
from rai_audit.core.scoring import compute_risk_matrix
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"AuditFinding",
|
|
13
|
+
"AuditReport",
|
|
14
|
+
"CategoryRisk",
|
|
15
|
+
"RemediationEffort",
|
|
16
|
+
"RiskLevel",
|
|
17
|
+
"Severity",
|
|
18
|
+
"compute_risk_matrix",
|
|
19
|
+
]
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
from rai_audit.core.history import diff_runs, list_runs, load_run, render_diff_text
|
|
13
|
+
from rai_audit.core.scoring import gate_check
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(
|
|
16
|
+
name="rai-audit",
|
|
17
|
+
help="Responsible AI (RAI) Audit Kit — evidence-grade audits for responsible AI systems.",
|
|
18
|
+
no_args_is_help=True,
|
|
19
|
+
)
|
|
20
|
+
console = Console()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@app.command()
|
|
24
|
+
def init(
|
|
25
|
+
project: str = typer.Option("my-project", help="Project name"),
|
|
26
|
+
output: Path = typer.Option(Path("audit.yaml"), help="Output config path"),
|
|
27
|
+
) -> None:
|
|
28
|
+
"""Scaffold a starter audit.yaml config file."""
|
|
29
|
+
config = f"""project:
|
|
30
|
+
name: {project}
|
|
31
|
+
owner: ""
|
|
32
|
+
version: 0.1.0
|
|
33
|
+
|
|
34
|
+
audit:
|
|
35
|
+
output_dir: ./audit-report
|
|
36
|
+
report_formats:
|
|
37
|
+
- html
|
|
38
|
+
- markdown
|
|
39
|
+
- json
|
|
40
|
+
|
|
41
|
+
checks:
|
|
42
|
+
fairness:
|
|
43
|
+
enabled: true
|
|
44
|
+
max_demographic_parity_difference: 0.10
|
|
45
|
+
max_equal_opportunity_difference: 0.10
|
|
46
|
+
robustness:
|
|
47
|
+
enabled: true
|
|
48
|
+
data_quality:
|
|
49
|
+
enabled: true
|
|
50
|
+
drift:
|
|
51
|
+
enabled: false
|
|
52
|
+
|
|
53
|
+
gate:
|
|
54
|
+
min_score: null
|
|
55
|
+
fail_on_critical: true
|
|
56
|
+
"""
|
|
57
|
+
output.write_text(config, encoding="utf-8")
|
|
58
|
+
console.print(f"[green]✓[/green] Created {output}")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@app.command()
|
|
62
|
+
def report(
|
|
63
|
+
input: Path = typer.Argument(..., help="Path to a saved audit JSON run"),
|
|
64
|
+
format: str = typer.Option("html", help="Output format: html | markdown | json"),
|
|
65
|
+
output: Optional[Path] = typer.Option(None, help="Output file path"),
|
|
66
|
+
) -> None:
|
|
67
|
+
"""Render a report from a saved audit run JSON file."""
|
|
68
|
+
if not input.exists():
|
|
69
|
+
console.print(f"[red]Error:[/red] {input} not found")
|
|
70
|
+
raise typer.Exit(1)
|
|
71
|
+
|
|
72
|
+
run = load_run(input)
|
|
73
|
+
|
|
74
|
+
if output is None:
|
|
75
|
+
output = input.with_suffix(f".{format}" if format != "json" else ".out.json")
|
|
76
|
+
|
|
77
|
+
if format == "json":
|
|
78
|
+
output.write_text(json.dumps(run, indent=2), encoding="utf-8")
|
|
79
|
+
elif format == "markdown":
|
|
80
|
+
from rai_audit.core.findings import AuditReport, AuditFinding, CategoryRisk, Severity, RiskLevel, RemediationEffort
|
|
81
|
+
from rai_audit.core.report import render_markdown
|
|
82
|
+
report_obj = _dict_to_report(run)
|
|
83
|
+
output.write_text(render_markdown(report_obj), encoding="utf-8")
|
|
84
|
+
elif format == "html":
|
|
85
|
+
from rai_audit.core.report import render_html
|
|
86
|
+
report_obj = _dict_to_report(run)
|
|
87
|
+
output.write_text(render_html(report_obj), encoding="utf-8")
|
|
88
|
+
else:
|
|
89
|
+
console.print(f"[red]Unknown format:[/red] {format}")
|
|
90
|
+
raise typer.Exit(1)
|
|
91
|
+
|
|
92
|
+
console.print(f"[green]✓[/green] Report written to {output}")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@app.command()
|
|
96
|
+
def gate(
|
|
97
|
+
input: Path = typer.Argument(..., help="Path to saved audit JSON run"),
|
|
98
|
+
min_score: Optional[float] = typer.Option(None, help="Minimum required score"),
|
|
99
|
+
fail_on_critical: bool = typer.Option(True, help="Fail if any critical findings exist"),
|
|
100
|
+
output_json: Optional[Path] = typer.Option(None, help="Write gate result to JSON file"),
|
|
101
|
+
) -> None:
|
|
102
|
+
"""
|
|
103
|
+
CI/CD deployment gate. Exits with code 1 on failure, 0 on pass.
|
|
104
|
+
"""
|
|
105
|
+
if not input.exists():
|
|
106
|
+
console.print(f"[red]Error:[/red] {input} not found")
|
|
107
|
+
raise typer.Exit(1)
|
|
108
|
+
|
|
109
|
+
run = load_run(input)
|
|
110
|
+
passed, reason = gate_check(run, min_score=min_score, fail_on_critical=fail_on_critical)
|
|
111
|
+
|
|
112
|
+
risk_matrix = {r["category"]: r["risk_level"] for r in run.get("risk_matrix", [])}
|
|
113
|
+
critical_count = sum(
|
|
114
|
+
1 for f in run.get("findings", []) if f.get("severity") == "critical"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
result = {
|
|
118
|
+
"passed": passed,
|
|
119
|
+
"reason": reason,
|
|
120
|
+
"critical_count": critical_count,
|
|
121
|
+
"risk_matrix": risk_matrix,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if output_json:
|
|
125
|
+
output_json.write_text(json.dumps(result, indent=2), encoding="utf-8")
|
|
126
|
+
|
|
127
|
+
if passed:
|
|
128
|
+
console.print(f"[green]✓ GATE PASSED:[/green] {reason}")
|
|
129
|
+
else:
|
|
130
|
+
console.print(f"[red]✗ GATE FAILED:[/red] {reason}")
|
|
131
|
+
raise typer.Exit(1)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@app.command()
|
|
135
|
+
def diff(
|
|
136
|
+
run_a: Path = typer.Argument(..., help="Older audit run JSON"),
|
|
137
|
+
run_b: Path = typer.Argument(..., help="Newer audit run JSON"),
|
|
138
|
+
output_json: Optional[Path] = typer.Option(None, help="Write diff to JSON file"),
|
|
139
|
+
) -> None:
|
|
140
|
+
"""Compare two audit runs and show what changed."""
|
|
141
|
+
for p in [run_a, run_b]:
|
|
142
|
+
if not p.exists():
|
|
143
|
+
console.print(f"[red]Error:[/red] {p} not found")
|
|
144
|
+
raise typer.Exit(1)
|
|
145
|
+
|
|
146
|
+
result = diff_runs(run_a, run_b)
|
|
147
|
+
|
|
148
|
+
if output_json:
|
|
149
|
+
output_json.write_text(json.dumps(result, indent=2), encoding="utf-8")
|
|
150
|
+
|
|
151
|
+
console.print(render_diff_text(result))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@app.command()
|
|
155
|
+
def history(
|
|
156
|
+
directory: Path = typer.Option(Path(".rai-audit/history"), help="History directory"),
|
|
157
|
+
) -> None:
|
|
158
|
+
"""List past audit runs."""
|
|
159
|
+
runs = list_runs(directory)
|
|
160
|
+
if not runs:
|
|
161
|
+
console.print("No audit runs found.")
|
|
162
|
+
return
|
|
163
|
+
|
|
164
|
+
table = Table(title="Audit History")
|
|
165
|
+
table.add_column("File", style="cyan")
|
|
166
|
+
table.add_column("Project")
|
|
167
|
+
table.add_column("Risk")
|
|
168
|
+
table.add_column("Findings", justify="right")
|
|
169
|
+
|
|
170
|
+
for run_path in runs:
|
|
171
|
+
try:
|
|
172
|
+
run = load_run(run_path)
|
|
173
|
+
risk_levels = [r["risk_level"] for r in run.get("risk_matrix", [])]
|
|
174
|
+
worst = max(risk_levels, key=lambda r: ["low","medium","high","critical"].index(r)) if risk_levels else "n/a"
|
|
175
|
+
count = sum(1 for f in run.get("findings", []) if f.get("severity") not in ("passed", "info"))
|
|
176
|
+
table.add_row(run_path.name, run.get("project_name", "?"), worst.upper(), str(count))
|
|
177
|
+
except Exception:
|
|
178
|
+
table.add_row(run_path.name, "?", "?", "?")
|
|
179
|
+
|
|
180
|
+
console.print(table)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _dict_to_report(d: dict):
|
|
184
|
+
"""Reconstruct an AuditReport from a saved dict (for re-rendering)."""
|
|
185
|
+
from rai_audit.core.findings import (
|
|
186
|
+
AuditFinding, AuditReport, CategoryRisk,
|
|
187
|
+
RemediationEffort, RiskLevel, Severity,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
findings = [
|
|
191
|
+
AuditFinding(
|
|
192
|
+
check_id=f["check_id"],
|
|
193
|
+
title=f["title"],
|
|
194
|
+
severity=Severity(f["severity"]),
|
|
195
|
+
description=f["description"],
|
|
196
|
+
evidence=f.get("evidence", {}),
|
|
197
|
+
recommendation=f.get("recommendation", ""),
|
|
198
|
+
category=f.get("category", ""),
|
|
199
|
+
affected_group=f.get("affected_group"),
|
|
200
|
+
remediation_effort=RemediationEffort(f.get("remediation_effort", "medium")),
|
|
201
|
+
standards_refs=f.get("standards_refs", []),
|
|
202
|
+
timestamp=f.get("timestamp"),
|
|
203
|
+
)
|
|
204
|
+
for f in d.get("findings", [])
|
|
205
|
+
]
|
|
206
|
+
|
|
207
|
+
risk_matrix = [
|
|
208
|
+
CategoryRisk(
|
|
209
|
+
category=r["category"],
|
|
210
|
+
risk_level=RiskLevel(r["risk_level"]),
|
|
211
|
+
finding_count=r["finding_count"],
|
|
212
|
+
passed_count=r["passed_count"],
|
|
213
|
+
)
|
|
214
|
+
for r in d.get("risk_matrix", [])
|
|
215
|
+
]
|
|
216
|
+
|
|
217
|
+
return AuditReport(
|
|
218
|
+
project_name=d.get("project_name", "Audit"),
|
|
219
|
+
audit_type=d.get("audit_type", ""),
|
|
220
|
+
risk_matrix=risk_matrix,
|
|
221
|
+
findings=findings,
|
|
222
|
+
metadata=d.get("metadata", {}),
|
|
223
|
+
overall_score=d.get("overall_score"),
|
|
224
|
+
)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
6
|
+
|
|
7
|
+
from rai_audit.core.findings import AuditReport
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BaseAudit(ABC):
|
|
11
|
+
"""Abstract base class for all audit modules."""
|
|
12
|
+
|
|
13
|
+
@abstractmethod
|
|
14
|
+
def run(self) -> AuditReport:
|
|
15
|
+
"""Execute the audit synchronously and return a report."""
|
|
16
|
+
...
|
|
17
|
+
|
|
18
|
+
async def run_async(self) -> AuditReport:
|
|
19
|
+
"""Execute the audit asynchronously (default: runs sync in a thread pool)."""
|
|
20
|
+
loop = asyncio.get_event_loop()
|
|
21
|
+
with ThreadPoolExecutor() as pool:
|
|
22
|
+
return await loop.run_in_executor(pool, self.run)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Severity(str, Enum):
|
|
11
|
+
CRITICAL = "critical"
|
|
12
|
+
HIGH = "high"
|
|
13
|
+
MEDIUM = "medium"
|
|
14
|
+
LOW = "low"
|
|
15
|
+
INFO = "info"
|
|
16
|
+
PASSED = "passed"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RiskLevel(str, Enum):
|
|
20
|
+
CRITICAL = "critical"
|
|
21
|
+
HIGH = "high"
|
|
22
|
+
MEDIUM = "medium"
|
|
23
|
+
LOW = "low"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class RemediationEffort(str, Enum):
|
|
27
|
+
LOW = "low"
|
|
28
|
+
MEDIUM = "medium"
|
|
29
|
+
HIGH = "high"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class AuditFinding:
|
|
34
|
+
check_id: str
|
|
35
|
+
title: str
|
|
36
|
+
severity: Severity
|
|
37
|
+
description: str
|
|
38
|
+
evidence: dict[str, Any]
|
|
39
|
+
recommendation: str
|
|
40
|
+
category: str = ""
|
|
41
|
+
affected_group: str | None = None
|
|
42
|
+
remediation_effort: RemediationEffort = RemediationEffort.MEDIUM
|
|
43
|
+
standards_refs: list[str] = field(default_factory=list)
|
|
44
|
+
timestamp: str | None = None
|
|
45
|
+
|
|
46
|
+
def to_dict(self) -> dict[str, Any]:
|
|
47
|
+
return {
|
|
48
|
+
"check_id": self.check_id,
|
|
49
|
+
"title": self.title,
|
|
50
|
+
"severity": self.severity.value,
|
|
51
|
+
"description": self.description,
|
|
52
|
+
"evidence": self.evidence,
|
|
53
|
+
"recommendation": self.recommendation,
|
|
54
|
+
"category": self.category,
|
|
55
|
+
"affected_group": self.affected_group,
|
|
56
|
+
"remediation_effort": self.remediation_effort.value,
|
|
57
|
+
"standards_refs": self.standards_refs,
|
|
58
|
+
"timestamp": self.timestamp,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class CategoryRisk:
|
|
64
|
+
category: str
|
|
65
|
+
risk_level: RiskLevel
|
|
66
|
+
finding_count: int
|
|
67
|
+
passed_count: int
|
|
68
|
+
|
|
69
|
+
def to_dict(self) -> dict[str, Any]:
|
|
70
|
+
return {
|
|
71
|
+
"category": self.category,
|
|
72
|
+
"risk_level": self.risk_level.value,
|
|
73
|
+
"finding_count": self.finding_count,
|
|
74
|
+
"passed_count": self.passed_count,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class AuditReport:
|
|
80
|
+
project_name: str
|
|
81
|
+
audit_type: str
|
|
82
|
+
risk_matrix: list[CategoryRisk]
|
|
83
|
+
findings: list[AuditFinding]
|
|
84
|
+
metadata: dict[str, Any]
|
|
85
|
+
overall_score: float | None = None
|
|
86
|
+
|
|
87
|
+
def to_dict(self) -> dict[str, Any]:
|
|
88
|
+
return {
|
|
89
|
+
"project_name": self.project_name,
|
|
90
|
+
"audit_type": self.audit_type,
|
|
91
|
+
"risk_matrix": [r.to_dict() for r in self.risk_matrix],
|
|
92
|
+
"findings": [f.to_dict() for f in self.findings],
|
|
93
|
+
"metadata": self.metadata,
|
|
94
|
+
"overall_score": self.overall_score,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
def to_json(self, path: str) -> None:
|
|
98
|
+
Path(path).write_text(json.dumps(self.to_dict(), indent=2), encoding="utf-8")
|
|
99
|
+
|
|
100
|
+
def to_markdown(self, path: str) -> None:
|
|
101
|
+
from rai_audit.core.report import render_markdown
|
|
102
|
+
|
|
103
|
+
Path(path).write_text(render_markdown(self), encoding="utf-8")
|
|
104
|
+
|
|
105
|
+
def to_html(self, path: str) -> None:
|
|
106
|
+
from rai_audit.core.report import render_html
|
|
107
|
+
|
|
108
|
+
Path(path).write_text(render_html(self), encoding="utf-8")
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def critical_findings(self) -> list[AuditFinding]:
|
|
112
|
+
return [f for f in self.findings if f.severity == Severity.CRITICAL]
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def high_findings(self) -> list[AuditFinding]:
|
|
116
|
+
return [f for f in self.findings if f.severity == Severity.HIGH]
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def passed_findings(self) -> list[AuditFinding]:
|
|
120
|
+
return [f for f in self.findings if f.severity == Severity.PASSED]
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def overall_risk_level(self) -> RiskLevel:
|
|
124
|
+
for cat in self.risk_matrix:
|
|
125
|
+
if cat.risk_level == RiskLevel.CRITICAL:
|
|
126
|
+
return RiskLevel.CRITICAL
|
|
127
|
+
for cat in self.risk_matrix:
|
|
128
|
+
if cat.risk_level == RiskLevel.HIGH:
|
|
129
|
+
return RiskLevel.HIGH
|
|
130
|
+
for cat in self.risk_matrix:
|
|
131
|
+
if cat.risk_level == RiskLevel.MEDIUM:
|
|
132
|
+
return RiskLevel.MEDIUM
|
|
133
|
+
return RiskLevel.LOW
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
DEFAULT_HISTORY_DIR = Path(".rai-audit") / "history"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def save_run(report_dict: dict, directory: Path | None = None) -> Path:
|
|
11
|
+
"""Persist an audit run as a timestamped JSON file."""
|
|
12
|
+
directory = directory or DEFAULT_HISTORY_DIR
|
|
13
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
14
|
+
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
|
|
15
|
+
slug = report_dict.get("project_name", "audit").replace(" ", "_").lower()
|
|
16
|
+
path = directory / f"{slug}_{ts}.json"
|
|
17
|
+
path.write_text(json.dumps(report_dict, indent=2), encoding="utf-8")
|
|
18
|
+
return path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def load_run(path: Path) -> dict:
|
|
22
|
+
"""Load a persisted audit run."""
|
|
23
|
+
return json.loads(Path(path).read_text(encoding="utf-8"))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def list_runs(directory: Path | None = None) -> list[Path]:
|
|
27
|
+
"""List all persisted audit run files, newest first."""
|
|
28
|
+
directory = directory or DEFAULT_HISTORY_DIR
|
|
29
|
+
if not directory.exists():
|
|
30
|
+
return []
|
|
31
|
+
return sorted(directory.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def diff_runs(path_a: Path, path_b: Path) -> dict:
|
|
35
|
+
"""
|
|
36
|
+
Compare two audit runs and return a structured diff.
|
|
37
|
+
path_a is the older run, path_b is the newer run.
|
|
38
|
+
"""
|
|
39
|
+
a = load_run(Path(path_a))
|
|
40
|
+
b = load_run(Path(path_b))
|
|
41
|
+
|
|
42
|
+
risk_a = {r["category"]: r["risk_level"] for r in a.get("risk_matrix", [])}
|
|
43
|
+
risk_b = {r["category"]: r["risk_level"] for r in b.get("risk_matrix", [])}
|
|
44
|
+
|
|
45
|
+
risk_order = {"critical": 3, "high": 2, "medium": 1, "low": 0}
|
|
46
|
+
risk_changes: list[dict] = []
|
|
47
|
+
all_cats = sorted(set(risk_a) | set(risk_b))
|
|
48
|
+
for cat in all_cats:
|
|
49
|
+
old = risk_a.get(cat, "n/a")
|
|
50
|
+
new = risk_b.get(cat, "n/a")
|
|
51
|
+
if old != new:
|
|
52
|
+
old_rank = risk_order.get(old, -1)
|
|
53
|
+
new_rank = risk_order.get(new, -1)
|
|
54
|
+
direction = "REGRESSION" if new_rank > old_rank else "IMPROVED"
|
|
55
|
+
risk_changes.append(
|
|
56
|
+
{"category": cat, "from": old, "to": new, "direction": direction}
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
ids_a = {f["check_id"] for f in a.get("findings", []) if f.get("severity") != "passed"}
|
|
60
|
+
ids_b = {f["check_id"] for f in b.get("findings", []) if f.get("severity") != "passed"}
|
|
61
|
+
|
|
62
|
+
new_findings = [
|
|
63
|
+
f for f in b.get("findings", [])
|
|
64
|
+
if f["check_id"] not in ids_a and f.get("severity") != "passed"
|
|
65
|
+
]
|
|
66
|
+
resolved_findings = [
|
|
67
|
+
f for f in a.get("findings", [])
|
|
68
|
+
if f["check_id"] not in ids_b and f.get("severity") != "passed"
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
"project_name": b.get("project_name", "unknown"),
|
|
73
|
+
"run_a": str(path_a),
|
|
74
|
+
"run_b": str(path_b),
|
|
75
|
+
"risk_changes": risk_changes,
|
|
76
|
+
"new_findings": new_findings,
|
|
77
|
+
"resolved_findings": resolved_findings,
|
|
78
|
+
"summary": {
|
|
79
|
+
"regressions": sum(1 for c in risk_changes if c["direction"] == "REGRESSION"),
|
|
80
|
+
"improvements": sum(1 for c in risk_changes if c["direction"] == "IMPROVED"),
|
|
81
|
+
"new_finding_count": len(new_findings),
|
|
82
|
+
"resolved_finding_count": len(resolved_findings),
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def render_diff_text(diff: dict) -> str:
|
|
88
|
+
"""Render a diff dict as a human-readable text report."""
|
|
89
|
+
lines: list[str] = []
|
|
90
|
+
lines.append(f"Audit Diff: {diff['project_name']}")
|
|
91
|
+
lines.append("─" * 60)
|
|
92
|
+
|
|
93
|
+
if diff["risk_changes"]:
|
|
94
|
+
for c in diff["risk_changes"]:
|
|
95
|
+
arrow = "⚠ REGRESSION" if c["direction"] == "REGRESSION" else "✓ IMPROVED"
|
|
96
|
+
lines.append(f"{c['category']:30s} {c['from'].upper()} → {c['to'].upper()} {arrow}")
|
|
97
|
+
else:
|
|
98
|
+
lines.append("No risk level changes.")
|
|
99
|
+
|
|
100
|
+
if diff["new_findings"]:
|
|
101
|
+
lines.append("\nNew findings:")
|
|
102
|
+
for f in diff["new_findings"]:
|
|
103
|
+
lines.append(f" [{f['severity'].upper()}] {f['check_id']}: {f['title']}")
|
|
104
|
+
|
|
105
|
+
if diff["resolved_findings"]:
|
|
106
|
+
lines.append("\nResolved findings:")
|
|
107
|
+
for f in diff["resolved_findings"]:
|
|
108
|
+
lines.append(f" ✓ {f['check_id']}: {f['title']}")
|
|
109
|
+
|
|
110
|
+
s = diff["summary"]
|
|
111
|
+
lines.append(
|
|
112
|
+
f"\nSummary: {s['regressions']} regression(s), {s['improvements']} improvement(s), "
|
|
113
|
+
f"{s['new_finding_count']} new, {s['resolved_finding_count']} resolved."
|
|
114
|
+
)
|
|
115
|
+
return "\n".join(lines)
|