Skip to main content

lenso_service/
security_review.rs

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}