1use std::collections::BTreeSet;
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use utoipa::ToSchema;
7
8use crate::extraction_input_digest;
9
10pub const SECURITY_REVIEW_PROTOCOL: &str = "lenso.security-review-evidence.v1";
11
12#[derive(
13 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, ToSchema,
14)]
15#[serde(rename_all = "snake_case")]
16pub enum ThreatSurface {
17 WorkloadIdentity,
18 TransportBinding,
19 Delegation,
20 Tenancy,
21 EventReplayAndPoisoning,
22 ExtractionAndCutover,
23 WorkflowControls,
24 ReleaseSigning,
25 Secrets,
26 BackupAndRestore,
27 AdminActions,
28 EmbeddedConsole,
29 PolicyBypass,
30 StaleEvidence,
31 AgentBoundaries,
32}
33
34#[derive(
35 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, ToSchema,
36)]
37#[serde(rename_all = "snake_case")]
38pub enum SecuritySeverity {
39 Critical,
40 High,
41 Medium,
42 Low,
43 Informational,
44}
45
46#[derive(
47 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, ToSchema,
48)]
49#[serde(rename_all = "snake_case")]
50pub enum FindingDisposition {
51 Open,
52 Remediated,
53 AcceptedRisk,
54 FalsePositive,
55}
56
57#[derive(
58 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, ToSchema,
59)]
60#[serde(rename_all = "snake_case")]
61pub enum SecurityReviewDecision {
62 Passed,
63 Blocked,
64}
65
66#[derive(
67 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, ToSchema,
68)]
69#[serde(rename_all = "snake_case")]
70pub enum SecurityReviewIssueCode {
71 ThreatModelIncomplete,
72 ReleaseSubjectInvalid,
73 FindingUnresolved,
74 RiskAcceptanceInvalid,
75 ScanEvidenceIncomplete,
76 ReviewStale,
77 SensitiveMaterialPresent,
78}
79
80impl SecurityReviewIssueCode {
81 #[must_use]
82 pub const fn as_str(self) -> &'static str {
83 match self {
84 Self::ThreatModelIncomplete => "security_threat_model_incomplete",
85 Self::ReleaseSubjectInvalid => "security_release_subject_invalid",
86 Self::FindingUnresolved => "security_finding_unresolved",
87 Self::RiskAcceptanceInvalid => "security_risk_acceptance_invalid",
88 Self::ScanEvidenceIncomplete => "security_scan_evidence_incomplete",
89 Self::ReviewStale => "security_review_stale",
90 Self::SensitiveMaterialPresent => "security_sensitive_material_present",
91 }
92 }
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
96#[serde(rename_all = "camelCase")]
97pub struct SecurityReviewIssue {
98 pub code: SecurityReviewIssueCode,
99 pub message: String,
100 pub remediation: String,
101 pub next_actions: Vec<String>,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
105#[serde(rename_all = "camelCase")]
106pub struct ThreatModelEvidence {
107 pub surface: ThreatSurface,
108 pub model_version: String,
109 pub model_digest: String,
110 pub reviewed: bool,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
114#[serde(rename_all = "camelCase")]
115pub struct SecurityReleaseSubject {
116 pub component_id: String,
117 pub version: String,
118 pub source_commit: String,
119 pub artifact_digest: String,
120 pub provenance_digest: String,
121 pub sbom_digest: String,
122}
123
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
125#[serde(rename_all = "camelCase")]
126pub struct RiskAcceptance {
127 pub approver: String,
128 pub reason: String,
129 pub expires_at_unix_ms: u64,
130 pub finding_digest: String,
131}
132
133#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
134#[serde(rename_all = "camelCase")]
135pub struct SecurityFinding {
136 pub finding_id: String,
137 pub finding_digest: String,
138 pub severity: SecuritySeverity,
139 pub surface: ThreatSurface,
140 pub affected_subject_digests: Vec<String>,
141 pub owner: String,
142 pub disposition: FindingDisposition,
143 pub remediation_reference: Option<String>,
144 pub risk_acceptance: Option<RiskAcceptance>,
145 pub contains_sensitive_material: bool,
146}
147
148#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
149#[serde(rename_all = "camelCase")]
150pub struct SecurityScanEvidence {
151 pub scanner_id: String,
152 pub scanner_version: String,
153 pub subject_digest: String,
154 pub result_digest: String,
155 pub completed: bool,
156}
157
158#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
159#[serde(rename_all = "camelCase")]
160pub struct SecurityReviewInput {
161 pub support_manifest_digest: String,
162 pub release_subjects: Vec<SecurityReleaseSubject>,
163 pub threat_models: Vec<ThreatModelEvidence>,
164 pub findings: Vec<SecurityFinding>,
165 pub scans: Vec<SecurityScanEvidence>,
166 pub reviewer: String,
167 pub reviewed_at_unix_ms: u64,
168 pub freshness_horizon_unix_ms: u64,
169}
170
171#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
172#[serde(rename_all = "camelCase")]
173pub struct SecurityReviewEvidence {
174 pub protocol: String,
175 pub review_id: String,
176 pub review_digest: String,
177 pub support_manifest_digest: String,
178 pub release_subjects: Vec<SecurityReleaseSubject>,
179 pub threat_models: Vec<ThreatModelEvidence>,
180 pub findings: Vec<SecurityFinding>,
181 pub scans: Vec<SecurityScanEvidence>,
182 pub reviewer: String,
183 pub reviewed_at_unix_ms: u64,
184 pub freshness_horizon_unix_ms: u64,
185 pub decision: SecurityReviewDecision,
186 pub issues: Vec<SecurityReviewIssue>,
187 pub next_actions: Vec<String>,
188}
189
190#[must_use]
191pub fn evaluate_security_review(
192 mut input: SecurityReviewInput,
193 observed_at_unix_ms: u64,
194) -> SecurityReviewEvidence {
195 input
196 .release_subjects
197 .sort_by(|left, right| left.component_id.cmp(&right.component_id));
198 input.threat_models.sort_by_key(|evidence| evidence.surface);
199 input
200 .findings
201 .sort_by(|left, right| left.finding_id.cmp(&right.finding_id));
202 input
203 .scans
204 .sort_by(|left, right| left.scanner_id.cmp(&right.scanner_id));
205
206 let mut issues = Vec::new();
207 let required_surfaces = all_threat_surfaces();
208 let observed_surfaces = input
209 .threat_models
210 .iter()
211 .filter(|model| model.reviewed && valid_digest(&model.model_digest))
212 .map(|model| model.surface)
213 .collect::<BTreeSet<_>>();
214 if observed_surfaces != required_surfaces
215 || input.threat_models.len() != required_surfaces.len()
216 {
217 issues.push(issue(
218 SecurityReviewIssueCode::ThreatModelIncomplete,
219 "The versioned threat model does not cover every M6 security surface.",
220 "Review every declared surface and bind it to immutable model evidence.",
221 "Complete the missing threat-model review.",
222 ));
223 }
224
225 let release_digests = input
226 .release_subjects
227 .iter()
228 .map(|subject| subject.artifact_digest.as_str())
229 .collect::<BTreeSet<_>>();
230 if input.release_subjects.is_empty()
231 || !valid_digest(&input.support_manifest_digest)
232 || input.release_subjects.iter().any(|subject| {
233 subject.component_id.trim().is_empty()
234 || subject.version.trim().is_empty()
235 || subject.source_commit.len() != 40
236 || !subject
237 .source_commit
238 .bytes()
239 .all(|byte| byte.is_ascii_hexdigit())
240 || !valid_digest(&subject.artifact_digest)
241 || !valid_digest(&subject.provenance_digest)
242 || !valid_digest(&subject.sbom_digest)
243 })
244 || release_digests.len() != input.release_subjects.len()
245 {
246 issues.push(issue(
247 SecurityReviewIssueCode::ReleaseSubjectInvalid,
248 "Security review subjects are missing exact version, source, artifact, provenance, or SBOM identity.",
249 "Review the immutable release subjects from the GA Support Manifest.",
250 "Refresh the exact release subject set.",
251 ));
252 }
253
254 for finding in &input.findings {
255 let subject_set_valid = !finding.affected_subject_digests.is_empty()
256 && finding
257 .affected_subject_digests
258 .iter()
259 .all(|digest| release_digests.contains(digest.as_str()));
260 if finding.finding_id.trim().is_empty()
261 || !valid_digest(&finding.finding_digest)
262 || finding.owner.trim().is_empty()
263 || !subject_set_valid
264 {
265 issues.push(issue(
266 SecurityReviewIssueCode::ReleaseSubjectInvalid,
267 format!(
268 "Finding `{}` is not bound to exact release subjects.",
269 finding.finding_id
270 ),
271 "Bind every finding to stable identity, owner, surface, and artifact subjects.",
272 "Correct the finding record.",
273 ));
274 }
275 if matches!(
276 finding.severity,
277 SecuritySeverity::Critical | SecuritySeverity::High
278 ) && finding.disposition == FindingDisposition::Open
279 {
280 issues.push(issue(
281 SecurityReviewIssueCode::FindingUnresolved,
282 format!("Finding `{}` remains unresolved.", finding.finding_id),
283 "Remediate or explicitly accept the exact reviewed risk before GA.",
284 "Block the component release until the finding is disposed.",
285 ));
286 }
287 if finding.disposition == FindingDisposition::AcceptedRisk {
288 let valid_acceptance = finding.risk_acceptance.as_ref().is_some_and(|acceptance| {
289 acceptance.finding_digest == finding.finding_digest
290 && !acceptance.approver.trim().is_empty()
291 && !acceptance.reason.trim().is_empty()
292 && acceptance.expires_at_unix_ms > observed_at_unix_ms
293 });
294 if !valid_acceptance {
295 issues.push(issue(
296 SecurityReviewIssueCode::RiskAcceptanceInvalid,
297 format!(
298 "Finding `{}` lacks current exact risk acceptance.",
299 finding.finding_id
300 ),
301 "Bind named approval and expiry to the finding digest.",
302 "Renew or remove the invalid acceptance.",
303 ));
304 }
305 }
306 if finding.contains_sensitive_material {
307 issues.push(issue(
308 SecurityReviewIssueCode::SensitiveMaterialPresent,
309 format!(
310 "Finding `{}` contains sensitive material.",
311 finding.finding_id
312 ),
313 "Store only redacted evidence and protected references.",
314 "Remove the sensitive content and rotate it if exposed.",
315 ));
316 }
317 }
318
319 let required_scanners = BTreeSet::from([
320 "dependency-audit",
321 "provenance-verification",
322 "secret-scan",
323 "static-analysis",
324 ]);
325 let scans_complete = required_scanners.iter().all(|scanner_id| {
326 release_digests.iter().all(|release_digest| {
327 input.scans.iter().any(|scan| {
328 scan.scanner_id == *scanner_id
329 && scan.subject_digest == *release_digest
330 && scan.completed
331 && valid_digest(&scan.result_digest)
332 && !scan.scanner_version.trim().is_empty()
333 })
334 })
335 });
336 if !scans_complete {
337 issues.push(issue(
338 SecurityReviewIssueCode::ScanEvidenceIncomplete,
339 "Required dependency, provenance, secret, or static-analysis evidence is missing.",
340 "Run the versioned scanners against the exact release subject set.",
341 "Collect the missing scan result.",
342 ));
343 }
344 if input.reviewer.trim().is_empty()
345 || input.reviewed_at_unix_ms == 0
346 || input.freshness_horizon_unix_ms < observed_at_unix_ms
347 || input.reviewed_at_unix_ms > observed_at_unix_ms
348 {
349 issues.push(issue(
350 SecurityReviewIssueCode::ReviewStale,
351 "Security review is stale, future-dated, or lacks a named reviewer.",
352 "Repeat the review for the current component set and time window.",
353 "Refresh the review before GA evaluation.",
354 ));
355 }
356
357 let decision = if issues.is_empty() {
358 SecurityReviewDecision::Passed
359 } else {
360 SecurityReviewDecision::Blocked
361 };
362 let next_actions = if issues.is_empty() {
363 vec!["Bind this security gate to the exact shadow release plan.".to_owned()]
364 } else {
365 issues
366 .iter()
367 .flat_map(|issue| issue.next_actions.iter().cloned())
368 .collect()
369 };
370 let mut evidence = SecurityReviewEvidence {
371 protocol: SECURITY_REVIEW_PROTOCOL.to_owned(),
372 review_id: String::new(),
373 review_digest: String::new(),
374 support_manifest_digest: input.support_manifest_digest,
375 release_subjects: input.release_subjects,
376 threat_models: input.threat_models,
377 findings: input.findings,
378 scans: input.scans,
379 reviewer: input.reviewer,
380 reviewed_at_unix_ms: input.reviewed_at_unix_ms,
381 freshness_horizon_unix_ms: input.freshness_horizon_unix_ms,
382 decision,
383 issues,
384 next_actions,
385 };
386 evidence.review_digest = digest_without_identity(&evidence);
387 evidence.review_id = format!("security-review:{}", &evidence.review_digest[7..23]);
388 evidence
389}
390
391#[must_use]
392pub fn security_review_evidence_schema() -> Value {
393 let mut schema = serde_json::to_value(schemars::schema_for!(SecurityReviewEvidence))
394 .expect("security review schema serializes");
395 schema["$id"] = Value::String(
396 "https://contracts.lenso.local/ga/lenso.security-review-evidence.v1.schema.json".to_owned(),
397 );
398 schema
399}
400
401fn all_threat_surfaces() -> BTreeSet<ThreatSurface> {
402 [
403 ThreatSurface::WorkloadIdentity,
404 ThreatSurface::TransportBinding,
405 ThreatSurface::Delegation,
406 ThreatSurface::Tenancy,
407 ThreatSurface::EventReplayAndPoisoning,
408 ThreatSurface::ExtractionAndCutover,
409 ThreatSurface::WorkflowControls,
410 ThreatSurface::ReleaseSigning,
411 ThreatSurface::Secrets,
412 ThreatSurface::BackupAndRestore,
413 ThreatSurface::AdminActions,
414 ThreatSurface::EmbeddedConsole,
415 ThreatSurface::PolicyBypass,
416 ThreatSurface::StaleEvidence,
417 ThreatSurface::AgentBoundaries,
418 ]
419 .into_iter()
420 .collect()
421}
422
423fn issue(
424 code: SecurityReviewIssueCode,
425 message: impl Into<String>,
426 remediation: impl Into<String>,
427 next_action: impl Into<String>,
428) -> SecurityReviewIssue {
429 SecurityReviewIssue {
430 code,
431 message: message.into(),
432 remediation: remediation.into(),
433 next_actions: vec![next_action.into()],
434 }
435}
436
437fn valid_digest(value: &str) -> bool {
438 value.strip_prefix("sha256:").is_some_and(|digest| {
439 digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_hexdigit())
440 })
441}
442
443fn digest_without_identity(evidence: &SecurityReviewEvidence) -> String {
444 let mut canonical = evidence.clone();
445 canonical.review_id.clear();
446 canonical.review_digest.clear();
447 extraction_input_digest(
448 &serde_json::to_vec(&canonical).expect("security review evidence serializes"),
449 )
450}
451
452#[must_use]
453pub fn security_review_integrity_is_valid(evidence: &SecurityReviewEvidence) -> bool {
454 valid_digest(&evidence.review_digest)
455 && evidence.review_digest == digest_without_identity(evidence)
456 && evidence.review_id == format!("security-review:{}", &evidence.review_digest[7..23])
457}