lean_ctx/core/compliance_report/
mod.rs1pub 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
35pub struct ReportSpec {
37 pub from: String,
39 pub to: String,
41 pub frameworks: Vec<String>,
43 pub pack: Option<String>,
45}
46
47pub 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
105fn 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
127fn 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
150fn 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
174fn 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
201pub 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
209pub 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
219pub 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}