Skip to main content

lean_ctx/core/compliance_report/
mod.rs

1//! Signed CISO compliance report (GL #677) — "the deliverable a CISO shows
2//! auditors".
3//!
4//! Composes the engine's existing evidence surfaces into one signed,
5//! exportable artifact over a date range:
6//! - **OWASP** Top-10-for-Agents alignment ([`crate::core::owasp_alignment`]);
7//! - **Framework** coverage — EU AI Act / ISO 42001 / SOC 2
8//!   ([`crate::core::compliance`]), verified live against a resolved pack;
9//! - **Enforcement** — what was *blocked* / *redacted* over the period, folded
10//!   from the append-only audit chain ([`aggregate`]);
11//! - **Retention** — the pack's `audit_retention_days` intent vs. the effective
12//!   plan entitlement.
13//!
14//! The result is **Ed25519-signed** ([`model::ComplianceReportV1`]) and
15//! exportable as JSON (the signed artifact), CSV or PDF ([`render`], [`pdf`]).
16//! Verification is offline — no audit trail, no LeanCTX install required.
17
18pub mod aggregate;
19pub mod model;
20pub mod pdf;
21pub mod render;
22
23use std::path::{Path, PathBuf};
24
25pub use model::{ComplianceReportV1, ReportVerifyResult};
26
27use crate::core::compliance;
28use crate::core::owasp_alignment;
29use crate::core::policy::{self, ResolvedPolicy};
30use model::{
31    AuditSection, EnforcementSection, KIND, OwaspRow, OwaspSection, Period, RetentionSection,
32    SCHEMA_VERSION,
33};
34
35/// What to attest. Empty `frameworks` ⇒ every built-in framework.
36pub struct ReportSpec {
37    /// RFC 3339 inclusive lower bound.
38    pub from: String,
39    /// RFC 3339 inclusive upper bound.
40    pub to: String,
41    /// Framework ids (`eu-ai-act`, `iso42001`, `soc2`); empty ⇒ all.
42    pub frameworks: Vec<String>,
43    /// Pack name/path override; defaults to the project pack, else `baseline`.
44    pub pack: Option<String>,
45}
46
47/// Build the unsigned report. Sign it with [`ComplianceReportV1::sign`] before
48/// exporting. Fails loudly on any inconsistency — a compliance artifact with a
49/// silently missing part is worse than none.
50pub fn build(spec: &ReportSpec) -> Result<ComplianceReportV1, String> {
51    let from = chrono::DateTime::parse_from_rfc3339(&spec.from)
52        .map_err(|e| format!("--from is not RFC 3339: {e}"))?;
53    let to = chrono::DateTime::parse_from_rfc3339(&spec.to)
54        .map_err(|e| format!("--to is not RFC 3339: {e}"))?;
55    if from > to {
56        return Err("--from must not be after --to".to_string());
57    }
58
59    let resolved = resolve_pack(spec)?;
60    let agg = aggregate::aggregate(from, to)?;
61    let chain_valid = crate::core::audit_trail::verify_chain().valid;
62
63    let frameworks = build_frameworks(&spec.frameworks, &resolved)?;
64    let owasp = build_owasp();
65    let retention = build_retention(&resolved);
66
67    let project = std::env::current_dir()
68        .ok()
69        .and_then(|d| d.file_name().map(|n| n.to_string_lossy().into_owned()))
70        .unwrap_or_else(|| "unknown".to_string());
71
72    Ok(ComplianceReportV1 {
73        schema_version: SCHEMA_VERSION,
74        kind: KIND.to_string(),
75        created_at: chrono::Utc::now().to_rfc3339(),
76        lean_ctx_version: env!("CARGO_PKG_VERSION").to_string(),
77        agent_id: crate::core::agent_identity::current_agent_id().to_string(),
78        project,
79        period: Period {
80            from: spec.from.clone(),
81            to: spec.to.clone(),
82        },
83        owasp,
84        frameworks,
85        enforcement: EnforcementSection {
86            blocked: agg.blocked,
87            redacted: agg.redacted,
88            tool_calls: agg.tool_calls,
89            other_security: agg.other_security,
90            by_event: agg.by_event,
91            by_tool_blocked: agg.by_tool_blocked,
92        },
93        audit: AuditSection {
94            entries_in_period: agg.entries,
95            chain_valid,
96            anchor_prev_hash: agg.anchor_prev_hash,
97            head_hash: agg.head_hash,
98        },
99        retention,
100        signer_public_key: None,
101        signature: None,
102    })
103}
104
105/// Resolve the pack to assess: explicit `--pack` (name or `.toml` path), else
106/// the project pack (`.lean-ctx/policy.toml`), else `baseline`.
107fn resolve_pack(spec: &ReportSpec) -> Result<ResolvedPolicy, String> {
108    let pack_name = spec.pack.clone().unwrap_or_else(|| {
109        if Path::new(".lean-ctx/policy.toml").exists() {
110            ".lean-ctx/policy.toml".to_string()
111        } else {
112            "baseline".to_string()
113        }
114    });
115    let pack = if Path::new(&pack_name)
116        .extension()
117        .is_some_and(|e| e.eq_ignore_ascii_case("toml"))
118    {
119        policy::parse_file(Path::new(&pack_name)).map_err(|e| format!("pack {pack_name}: {e}"))?
120    } else {
121        policy::builtin::get(&pack_name)
122            .ok_or_else(|| format!("unknown builtin pack '{pack_name}'"))?
123    };
124    policy::resolve(&pack).map_err(|e| format!("pack {pack_name}: {e}"))
125}
126
127/// Build one [`compliance::FrameworkReport`] per requested framework (or all).
128fn build_frameworks(
129    requested: &[String],
130    resolved: &ResolvedPolicy,
131) -> Result<Vec<compliance::FrameworkReport>, String> {
132    let ids: Vec<String> = if requested.is_empty() {
133        compliance::names().into_iter().map(String::from).collect()
134    } else {
135        requested.to_vec()
136    };
137    ids.iter()
138        .map(|fw| {
139            let mapping = compliance::get(fw).ok_or_else(|| {
140                format!(
141                    "unknown framework '{fw}' (supported: {})",
142                    compliance::names().join(", ")
143                )
144            })?;
145            Ok(compliance::report(mapping, Some(resolved)))
146        })
147        .collect()
148}
149
150/// Project the static OWASP alignment table into a report section.
151fn build_owasp() -> OwaspSection {
152    let mappings = owasp_alignment::alignment();
153    let coverage_label = |c: owasp_alignment::Coverage| match c {
154        owasp_alignment::Coverage::Full => "full",
155        owasp_alignment::Coverage::Partial => "partial",
156        owasp_alignment::Coverage::Minimal => "minimal",
157    };
158    let count = |c: owasp_alignment::Coverage| mappings.iter().filter(|m| m.coverage == c).count();
159    OwaspSection {
160        full: count(owasp_alignment::Coverage::Full),
161        partial: count(owasp_alignment::Coverage::Partial),
162        minimal: count(owasp_alignment::Coverage::Minimal),
163        rows: mappings
164            .iter()
165            .map(|m| OwaspRow {
166                id: m.owasp_id.to_string(),
167                title: m.owasp_title.to_string(),
168                coverage: coverage_label(m.coverage).to_string(),
169            })
170            .collect(),
171    }
172}
173
174/// Retention posture: the pack's declared intent vs. the effective plan window.
175fn build_retention(resolved: &ResolvedPolicy) -> RetentionSection {
176    let eff = crate::cloud_client::resolve_effective_plan_cached();
177    let plan_days = eff.plan.entitlements().audit_retention_days;
178    let plan_covers_policy = resolved
179        .audit_retention_days
180        .map(|declared| plan_days >= declared);
181    RetentionSection {
182        policy_pack: Some(format!("{} v{}", resolved.name, resolved.version)),
183        policy_audit_retention_days: resolved.audit_retention_days,
184        plan: eff.plan.as_str().to_string(),
185        plan_source: plan_source_label(eff.source).to_string(),
186        plan_audit_retention_days: plan_days,
187        plan_covers_policy,
188    }
189}
190
191fn plan_source_label(s: crate::cloud_client::PlanSource) -> &'static str {
192    use crate::cloud_client::PlanSource;
193    match s {
194        PlanSource::Live => "live",
195        PlanSource::Cached => "cached",
196        PlanSource::Expired => "expired",
197        PlanSource::None => "unverified",
198    }
199}
200
201/// Default artifact location: `<data_dir>/compliance/report-v1_<utc-stamp>.json`.
202pub fn default_artifact_path() -> Result<PathBuf, String> {
203    let dir = crate::core::data_dir::lean_ctx_data_dir()?.join("compliance");
204    std::fs::create_dir_all(&dir).map_err(|e| format!("mkdir compliance: {e}"))?;
205    let stamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ");
206    Ok(dir.join(format!("report-v1_{stamp}.json")))
207}
208
209/// Pretty-prints the signed JSON artifact to `out` (creating parent dirs).
210pub fn write_artifact(report: &ComplianceReportV1, out: &Path) -> Result<PathBuf, String> {
211    if let Some(parent) = out.parent() {
212        std::fs::create_dir_all(parent).map_err(|e| format!("mkdir: {e}"))?;
213    }
214    let json = serde_json::to_string_pretty(report).map_err(|e| format!("serialize: {e}"))?;
215    std::fs::write(out, json).map_err(|e| format!("write {}: {e}", out.display()))?;
216    Ok(out.to_path_buf())
217}
218
219/// Loads and parses a signed report artifact, rejecting unrelated JSON by `kind`.
220pub fn load_artifact(path: &Path) -> Result<ComplianceReportV1, String> {
221    let raw = std::fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?;
222    let report: ComplianceReportV1 =
223        serde_json::from_str(&raw).map_err(|e| format!("parse {}: {e}", path.display()))?;
224    if report.kind != KIND {
225        return Err(format!("not a {KIND} artifact (kind = {:?})", report.kind));
226    }
227    Ok(report)
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    #[test]
235    fn build_with_all_frameworks_against_baseline() {
236        let spec = ReportSpec {
237            from: "2026-01-01T00:00:00+00:00".to_string(),
238            to: "2026-12-31T23:59:59+00:00".to_string(),
239            frameworks: vec![],
240            pack: Some("strict-redaction".to_string()),
241        };
242        let report = build(&spec).expect("build succeeds");
243        assert_eq!(report.kind, KIND);
244        assert_eq!(report.frameworks.len(), compliance::names().len());
245        assert_eq!(report.owasp.rows.len(), 10);
246        assert!(
247            report.signature.is_none(),
248            "build yields an unsigned report"
249        );
250    }
251
252    #[test]
253    fn build_rejects_unknown_framework() {
254        let spec = ReportSpec {
255            from: "2026-01-01T00:00:00+00:00".to_string(),
256            to: "2026-12-31T23:59:59+00:00".to_string(),
257            frameworks: vec!["nonexistent".to_string()],
258            pack: Some("baseline".to_string()),
259        };
260        assert!(build(&spec).is_err());
261    }
262
263    #[test]
264    fn build_rejects_inverted_period() {
265        let spec = ReportSpec {
266            from: "2026-12-31T00:00:00+00:00".to_string(),
267            to: "2026-01-01T00:00:00+00:00".to_string(),
268            frameworks: vec![],
269            pack: Some("baseline".to_string()),
270        };
271        assert!(build(&spec).is_err());
272    }
273
274    #[test]
275    fn retention_reflects_pack_declaration() {
276        let spec = ReportSpec {
277            from: "2026-01-01T00:00:00+00:00".to_string(),
278            to: "2026-12-31T00:00:00+00:00".to_string(),
279            frameworks: vec!["soc2".to_string()],
280            pack: Some("soc2-context".to_string()),
281        };
282        let report = build(&spec).unwrap();
283        assert_eq!(report.retention.policy_audit_retention_days, Some(365));
284    }
285}