lean_ctx/core/compliance_evidence/
mod.rs1pub mod aggregate;
16pub mod facts;
17pub mod frameworks;
18pub mod model;
19pub mod pdf;
20pub mod render;
21
22use std::path::{Path, PathBuf};
23
24pub use model::{ComplianceReportV1, ReportVerifyResult};
25
26use crate::core::owasp_alignment;
27use crate::core::policy::{self, ResolvedPolicy};
28use facts::ComplianceFactsV1;
29use frameworks::FrameworksAnswer;
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>,
42 pub pack: Option<String>,
44}
45
46pub fn build(
50 spec: &ReportSpec,
51 assess: impl FnOnce(&ComplianceFactsV1) -> Result<FrameworksAnswer, String>,
52) -> Result<ComplianceReportV1, String> {
53 let from = chrono::DateTime::parse_from_rfc3339(&spec.from)
54 .map_err(|e| format!("--from is not RFC 3339: {e}"))?;
55 let to = chrono::DateTime::parse_from_rfc3339(&spec.to)
56 .map_err(|e| format!("--to is not RFC 3339: {e}"))?;
57 if from > to {
58 return Err("--from must not be after --to".to_string());
59 }
60
61 let resolved = resolve_pack(spec.pack.as_deref())?;
62 let frameworks = assess_frameworks(&spec.frameworks, Some(&resolved), assess)?;
63 let agg = aggregate::aggregate(from, to)?;
64 let chain_valid = crate::core::audit_trail::verify_chain_full().valid;
65
66 let project = std::env::current_dir()
67 .ok()
68 .and_then(|d| d.file_name().map(|n| n.to_string_lossy().into_owned()))
69 .unwrap_or_else(|| "unknown".to_string());
70
71 Ok(ComplianceReportV1 {
72 schema_version: SCHEMA_VERSION,
73 kind: KIND.to_string(),
74 created_at: chrono::Utc::now().to_rfc3339(),
75 lean_ctx_version: env!("CARGO_PKG_VERSION").to_string(),
76 agent_id: crate::core::agent_identity::current_agent_id().to_string(),
77 project,
78 period: Period {
79 from: spec.from.clone(),
80 to: spec.to.clone(),
81 },
82 owasp: build_owasp(),
83 frameworks: frameworks.frameworks,
84 enforcement: EnforcementSection {
85 blocked: agg.blocked,
86 redacted: agg.redacted,
87 tool_calls: agg.tool_calls,
88 other_security: agg.other_security,
89 by_event: agg.by_event,
90 by_tool_blocked: agg.by_tool_blocked,
91 },
92 audit: AuditSection {
93 entries_in_period: agg.entries,
94 chain_valid,
95 anchor_prev_hash: agg.anchor_prev_hash,
96 head_hash: agg.head_hash,
97 },
98 retention: build_retention(&resolved),
99 signer_public_key: None,
100 signature: None,
101 })
102}
103
104pub fn assess_frameworks(
107 requested: &[String],
108 policy: Option<&ResolvedPolicy>,
109 assess: impl FnOnce(&ComplianceFactsV1) -> Result<FrameworksAnswer, String>,
110) -> Result<FrameworksAnswer, String> {
111 if let Some(bad) = requested.iter().find(|id| !frameworks::valid_id(id)) {
112 return Err(format!("invalid framework id '{bad}'"));
113 }
114 let projection = facts::facts(requested, policy);
115 let answer = assess(&projection)?;
116 let label = policy.map(facts::pack_label);
117 answer.check(requested, label.as_deref())?;
118 Ok(answer)
119}
120
121pub fn resolve_pack(pack: Option<&str>) -> Result<ResolvedPolicy, String> {
124 let pack_name = pack.map_or_else(
125 || {
126 if Path::new(".lean-ctx/policy.toml").exists() {
127 ".lean-ctx/policy.toml".to_string()
128 } else {
129 "baseline".to_string()
130 }
131 },
132 str::to_owned,
133 );
134 let pack = if Path::new(&pack_name)
135 .extension()
136 .is_some_and(|e| e.eq_ignore_ascii_case("toml"))
137 {
138 policy::parse_file(Path::new(&pack_name)).map_err(|e| format!("pack {pack_name}: {e}"))?
139 } else {
140 policy::builtin::get(&pack_name).ok_or_else(|| {
141 format!(
142 "pack {pack_name}: {}",
143 policy::builtin::unknown_pack_hint(&pack_name)
144 )
145 })?
146 };
147 policy::resolve(&pack).map_err(|e| format!("pack {pack_name}: {e}"))
148}
149
150fn build_owasp() -> OwaspSection {
153 let mappings = owasp_alignment::alignment();
154 let label = |c: owasp_alignment::Coverage| match c {
155 owasp_alignment::Coverage::Full => "full",
156 owasp_alignment::Coverage::Partial => "partial",
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: 0,
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: label(m.coverage).to_string(),
169 })
170 .collect(),
171 }
172}
173
174fn build_retention(resolved: &ResolvedPolicy) -> RetentionSection {
181 let plan = crate::core::product_capabilities::Plan::Community;
182 RetentionSection {
183 policy_pack: Some(facts::pack_label(resolved)),
184 policy_audit_retention_days: resolved.audit_retention_days,
185 plan: plan.as_str().to_string(),
186 plan_source: UNVERIFIED_PLAN.to_string(),
187 plan_audit_retention_days: 0,
188 plan_covers_policy: None,
189 }
190}
191
192pub(crate) const UNVERIFIED_PLAN: &str = "unverified";
194
195pub fn default_artifact_path() -> Result<PathBuf, String> {
197 let dir = crate::core::data_dir::lean_ctx_data_dir()?.join("compliance");
198 std::fs::create_dir_all(&dir).map_err(|e| format!("mkdir compliance: {e}"))?;
199 let stamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ");
200 Ok(dir.join(format!("report-v1_{stamp}.json")))
201}
202
203pub fn write_artifact(report: &ComplianceReportV1, out: &Path) -> Result<PathBuf, String> {
205 if let Some(parent) = out.parent() {
206 std::fs::create_dir_all(parent).map_err(|e| format!("mkdir: {e}"))?;
207 }
208 let json = serde_json::to_string_pretty(report).map_err(|e| format!("serialize: {e}"))?;
209 std::fs::write(out, json).map_err(|e| format!("write {}: {e}", out.display()))?;
210 Ok(out.to_path_buf())
211}
212
213pub fn load_artifact(path: &Path) -> Result<ComplianceReportV1, String> {
215 let raw = std::fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?;
216 let report: ComplianceReportV1 =
217 serde_json::from_str(&raw).map_err(|e| format!("parse {}: {e}", path.display()))?;
218 if report.kind != KIND {
219 return Err(format!("not a {KIND} artifact (kind = {:?})", report.kind));
220 }
221 Ok(report)
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227
228 fn spec(from: &str, to: &str, frameworks: &[&str]) -> ReportSpec {
229 ReportSpec {
230 from: from.into(),
231 to: to.into(),
232 frameworks: frameworks.iter().map(|s| (*s).to_string()).collect(),
233 pack: Some("strict-redaction".into()),
234 }
235 }
236
237 #[test]
240 fn build_binds_assessment_to_the_measured_pack() {
241 let _data = crate::core::data_dir::isolated_data_dir();
242 let label = facts::pack_label(&resolve_pack(Some("strict-redaction")).unwrap());
243 let ok = |facts: &ComplianceFactsV1| {
244 assert_eq!(
245 facts.policy.as_ref().map(|p| p.pack.as_str()),
246 Some(label.as_str())
247 );
248 Ok(frameworks::tests::answer(Some(&label)))
249 };
250 let report = build(
251 &spec(
252 "2026-01-01T00:00:00+00:00",
253 "2026-12-31T00:00:00+00:00",
254 &["framework-a"],
255 ),
256 ok,
257 )
258 .unwrap();
259 assert_eq!(report.kind, KIND);
260 assert_eq!(report.frameworks.len(), 1);
261 assert_eq!(report.owasp.rows.len(), 10);
262 assert!(
263 report.signature.is_none(),
264 "build yields an unsigned report"
265 );
266
267 let foreign = |_: &ComplianceFactsV1| Ok(frameworks::tests::answer(Some("other v9")));
268 let inverted = |_: &ComplianceFactsV1| -> Result<FrameworksAnswer, String> {
269 unreachable!("an inverted period never reaches the runtime")
270 };
271 let year = ("2026-01-01T00:00:00+00:00", "2026-12-31T00:00:00+00:00");
272 assert!(build(&spec(year.0, year.1, &["framework-a"]), foreign).is_err());
273 assert!(build(&spec(year.1, year.0, &[]), inverted).is_err());
274 assert!(build(&spec(year.0, year.1, &["../etc"]), inverted).is_err());
275 }
276
277 #[test]
280 fn unverified_plan_makes_no_retention_coverage_claim() {
281 let resolved = resolve_pack(Some("strict-redaction")).unwrap();
282 assert_eq!(resolved.audit_retention_days, Some(180));
283 let retention = build_retention(&resolved);
284 assert_eq!(retention.plan_source, UNVERIFIED_PLAN);
285 assert_eq!(retention.plan_covers_policy, None);
286
287 let mut report = model::tests::sample();
288 report.retention = retention;
289 let text = render::to_text(&report);
290 assert!(text.contains("Plan retention: unverified"), "{text}");
291 assert!(!text.contains("Plan covers intent"), "{text}");
292 assert!(render::to_csv(&report).contains("plan=unverified"));
293 }
294}