Skip to main content

termivar_scanner/
compliance.rs

1//! Caller-supplied compliance and audit records.
2//!
3//! ## Runtime scope
4//!
5//! - **Build:** opt-in via `compliance`.
6//! - **Execution:** no repository runtime caller (not on any default path).
7//! - **Default `termivar scan`:** no.
8//! - **Support:** experimental data models.
9//!
10//! The collections in this module are in-memory catalogs. They do not perform
11//! an audit, determine legal compliance, generate a report, or persist data.
12
13use serde::{Deserialize, Serialize};
14use std::collections::BTreeMap;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17pub enum ComplianceFramework {
18    #[serde(rename = "gdpr")]
19    GDPR,
20    #[serde(rename = "hipaa")]
21    HIPAA,
22    #[serde(rename = "soc2")]
23    SOC2,
24    #[serde(rename = "pci_dss")]
25    PCIDSS,
26}
27
28impl ComplianceFramework {
29    pub fn as_str(self) -> &'static str {
30        match self {
31            Self::GDPR => "gdpr",
32            Self::HIPAA => "hipaa",
33            Self::SOC2 => "soc2",
34            Self::PCIDSS => "pci_dss",
35        }
36    }
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct ComplianceRequirement {
41    pub requirement_id: String,
42    pub framework: ComplianceFramework,
43    pub name: String,
44    pub description: String,
45    pub controls: Vec<String>,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
49pub enum AuditEventType {
50    #[serde(rename = "scan_initiated")]
51    ScanInitiated,
52    #[serde(rename = "finding_discovered")]
53    FindingDiscovered,
54    #[serde(rename = "data_accessed")]
55    DataAccessed,
56    #[serde(rename = "user_login")]
57    UserLogin,
58    #[serde(rename = "user_logout")]
59    UserLogout,
60    #[serde(rename = "config_changed")]
61    ConfigChanged,
62    #[serde(rename = "report_recorded")]
63    ReportRecorded,
64    #[serde(rename = "access_denied")]
65    AccessDenied,
66}
67
68impl AuditEventType {
69    pub fn as_str(self) -> &'static str {
70        match self {
71            Self::ScanInitiated => "scan_initiated",
72            Self::FindingDiscovered => "finding_discovered",
73            Self::DataAccessed => "data_accessed",
74            Self::UserLogin => "user_login",
75            Self::UserLogout => "user_logout",
76            Self::ConfigChanged => "config_changed",
77            Self::ReportRecorded => "report_recorded",
78            Self::AccessDenied => "access_denied",
79        }
80    }
81}
82
83/// Caller-supplied audit event record.
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85pub struct AuditLogEntry {
86    pub log_id: String,
87    pub timestamp: u64,
88    pub event_type: AuditEventType,
89    pub user_id: String,
90    pub resource: String,
91    pub action: String,
92    pub reported_status: String,
93    pub details: String,
94}
95
96/// In-memory audit record collection.
97#[derive(Debug, Clone, Default)]
98pub struct AuditTrail {
99    entries: Vec<AuditLogEntry>,
100}
101
102impl AuditTrail {
103    pub fn new() -> Self {
104        Self::default()
105    }
106
107    pub fn record_entry(&mut self, entry: AuditLogEntry) {
108        self.entries.push(entry);
109    }
110
111    pub fn entries_by_type(&self, event_type: AuditEventType) -> Vec<&AuditLogEntry> {
112        self.entries
113            .iter()
114            .filter(|entry| entry.event_type == event_type)
115            .collect()
116    }
117
118    pub fn entries_by_user(&self, user_id: &str) -> Vec<&AuditLogEntry> {
119        self.entries
120            .iter()
121            .filter(|entry| entry.user_id == user_id)
122            .collect()
123    }
124
125    pub fn entries_since(&self, timestamp: u64) -> Vec<&AuditLogEntry> {
126        self.entries
127            .iter()
128            .filter(|entry| entry.timestamp >= timestamp)
129            .collect()
130    }
131
132    pub fn len(&self) -> usize {
133        self.entries.len()
134    }
135
136    pub fn is_empty(&self) -> bool {
137        self.entries.is_empty()
138    }
139}
140
141/// Caller-supplied control counts for one assessment.
142#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
143pub struct ComplianceAssessment {
144    pub assessment_id: String,
145    pub framework: ComplianceFramework,
146    pub timestamp: u64,
147    pub total_controls: u32,
148    pub compliant_controls: u32,
149    pub non_compliant_controls: u32,
150}
151
152impl ComplianceAssessment {
153    pub fn has_consistent_control_counts(&self) -> bool {
154        u64::from(self.compliant_controls) + u64::from(self.non_compliant_controls)
155            == u64::from(self.total_controls)
156    }
157
158    /// Calculated percentage, absent for zero or inconsistent control counts.
159    pub fn compliance_percentage(&self) -> Option<f64> {
160        if self.total_controls == 0 || !self.has_consistent_control_counts() {
161            return None;
162        }
163        Some(f64::from(self.compliant_controls) / f64::from(self.total_controls) * 100.0)
164    }
165
166    /// Applies an explicit caller-supplied threshold.
167    pub fn meets_threshold(&self, threshold_percent: f64) -> Option<bool> {
168        if !threshold_percent.is_finite() || !(0.0..=100.0).contains(&threshold_percent) {
169            return None;
170        }
171        self.compliance_percentage()
172            .map(|percentage| percentage >= threshold_percent)
173    }
174}
175
176/// In-memory requirement and assessment catalog.
177#[derive(Debug, Clone, Default)]
178pub struct ComplianceCatalog {
179    requirements: BTreeMap<String, ComplianceRequirement>,
180    assessments: Vec<ComplianceAssessment>,
181}
182
183impl ComplianceCatalog {
184    pub fn new() -> Self {
185        Self::default()
186    }
187
188    pub fn record_requirement(
189        &mut self,
190        requirement: ComplianceRequirement,
191    ) -> Option<ComplianceRequirement> {
192        self.requirements
193            .insert(requirement.requirement_id.clone(), requirement)
194    }
195
196    /// Records only assessments whose control counts are internally consistent.
197    pub fn record_assessment(&mut self, assessment: ComplianceAssessment) -> bool {
198        if !assessment.has_consistent_control_counts() {
199            return false;
200        }
201        self.assessments.push(assessment);
202        true
203    }
204
205    pub fn requirements_for(&self, framework: ComplianceFramework) -> Vec<&ComplianceRequirement> {
206        self.requirements
207            .values()
208            .filter(|requirement| requirement.framework == framework)
209            .collect()
210    }
211
212    pub fn assessments_for(&self, framework: ComplianceFramework) -> Vec<&ComplianceAssessment> {
213        self.assessments
214            .iter()
215            .filter(|assessment| assessment.framework == framework)
216            .collect()
217    }
218
219    pub fn requirement_count(&self) -> usize {
220        self.requirements.len()
221    }
222
223    pub fn assessment_count(&self) -> usize {
224        self.assessments.len()
225    }
226}
227
228#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
229pub enum DataClassification {
230    #[serde(rename = "public")]
231    Public,
232    #[serde(rename = "internal")]
233    Internal,
234    #[serde(rename = "confidential")]
235    Confidential,
236    #[serde(rename = "restricted")]
237    Restricted,
238}
239
240impl DataClassification {
241    pub fn as_str(self) -> &'static str {
242        match self {
243            Self::Public => "public",
244            Self::Internal => "internal",
245            Self::Confidential => "confidential",
246            Self::Restricted => "restricted",
247        }
248    }
249}
250
251#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
252pub struct DataProtectionRecord {
253    pub record_id: String,
254    pub data_type: String,
255    pub classification: DataClassification,
256    pub owner_id: String,
257    pub last_accessed: u64,
258    pub access_count: u32,
259    pub reported_encrypted: bool,
260}
261
262/// In-memory catalog of caller-supplied data protection records.
263#[derive(Debug, Clone, Default)]
264pub struct DataProtectionCatalog {
265    records: BTreeMap<String, DataProtectionRecord>,
266}
267
268impl DataProtectionCatalog {
269    pub fn new() -> Self {
270        Self::default()
271    }
272
273    pub fn record(&mut self, record: DataProtectionRecord) -> Option<DataProtectionRecord> {
274        self.records.insert(record.record_id.clone(), record)
275    }
276
277    pub fn records_by_classification(
278        &self,
279        classification: DataClassification,
280    ) -> Vec<&DataProtectionRecord> {
281        self.records
282            .values()
283            .filter(|record| record.classification == classification)
284            .collect()
285    }
286
287    pub fn records_reported_unencrypted_at_or_above(
288        &self,
289        minimum: DataClassification,
290    ) -> Vec<&DataProtectionRecord> {
291        self.records
292            .values()
293            .filter(|record| !record.reported_encrypted && record.classification >= minimum)
294            .collect()
295    }
296
297    pub fn len(&self) -> usize {
298        self.records.len()
299    }
300
301    pub fn is_empty(&self) -> bool {
302        self.records.is_empty()
303    }
304}
305
306/// Caller-supplied report record; no report generation is performed.
307#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
308pub struct ComplianceReport {
309    pub report_id: String,
310    pub framework: ComplianceFramework,
311    pub generated_at: u64,
312    pub assessment_period_days: u32,
313    #[serde(with = "optional_percent_f32")]
314    pub reported_compliance_score_percent: Option<f32>,
315    pub reported_critical_findings: u32,
316    pub proposed_remediation_actions: Vec<String>,
317}
318
319impl ComplianceReport {
320    pub fn has_valid_reported_score(&self) -> bool {
321        self.reported_compliance_score_percent
322            .is_none_or(|score| score.is_finite() && (0.0..=100.0).contains(&score))
323    }
324}
325
326mod optional_percent_f32 {
327    use serde::{Deserialize, Deserializer, Serializer};
328
329    pub fn serialize<S>(value: &Option<f32>, serializer: S) -> Result<S::Ok, S::Error>
330    where
331        S: Serializer,
332    {
333        match value {
334            Some(value) if value.is_finite() && (0.0..=100.0).contains(value) => {
335                serializer.serialize_some(value)
336            },
337            Some(_) => Err(serde::ser::Error::custom(
338                "reported compliance score must be finite and between 0 and 100",
339            )),
340            None => serializer.serialize_none(),
341        }
342    }
343
344    pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<f32>, D::Error>
345    where
346        D: Deserializer<'de>,
347    {
348        let value = Option::<f32>::deserialize(deserializer)?;
349        Ok(match value {
350            Some(value) if value.is_finite() && (0.0..=100.0).contains(&value) => Some(value),
351            Some(_) => {
352                return Err(serde::de::Error::custom(
353                    "reported compliance score must be finite and between 0 and 100",
354                ));
355            },
356            None => None,
357        })
358    }
359}
360
361/// In-memory catalog of caller-supplied report records.
362#[derive(Debug, Clone, Default)]
363pub struct ComplianceReportCatalog {
364    reports: Vec<ComplianceReport>,
365}
366
367impl ComplianceReportCatalog {
368    pub fn new() -> Self {
369        Self::default()
370    }
371
372    /// Records only reports whose optional percentage is finite and in range.
373    pub fn record_report(&mut self, report: ComplianceReport) -> bool {
374        if !report.has_valid_reported_score() {
375            return false;
376        }
377        self.reports.push(report);
378        true
379    }
380
381    pub fn reports_for(&self, framework: ComplianceFramework) -> Vec<&ComplianceReport> {
382        self.reports
383            .iter()
384            .filter(|report| report.framework == framework)
385            .collect()
386    }
387
388    /// Returns all reports tied for the greatest caller-supplied timestamp.
389    pub fn most_recent_reports(&self, framework: ComplianceFramework) -> Vec<&ComplianceReport> {
390        let reports = self.reports_for(framework);
391        let Some(timestamp) = reports.iter().map(|report| report.generated_at).max() else {
392            return Vec::new();
393        };
394        reports
395            .into_iter()
396            .filter(|report| report.generated_at == timestamp)
397            .collect()
398    }
399
400    pub fn len(&self) -> usize {
401        self.reports.len()
402    }
403
404    pub fn is_empty(&self) -> bool {
405        self.reports.is_empty()
406    }
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412
413    fn assessment(compliant: u32, non_compliant: u32) -> ComplianceAssessment {
414        ComplianceAssessment {
415            assessment_id: "assessment".into(),
416            framework: ComplianceFramework::GDPR,
417            timestamp: 1,
418            total_controls: 100,
419            compliant_controls: compliant,
420            non_compliant_controls: non_compliant,
421        }
422    }
423
424    #[test]
425    fn threshold_is_caller_supplied_not_hardcoded() {
426        let assessment = assessment(94, 6);
427        assert_eq!(assessment.meets_threshold(90.0), Some(true));
428        assert_eq!(assessment.meets_threshold(95.0), Some(false));
429        assert_eq!(assessment.meets_threshold(f64::NAN), None);
430    }
431
432    #[test]
433    fn inconsistent_counts_do_not_produce_a_score() {
434        let inconsistent = assessment(90, 5);
435        assert!(!inconsistent.has_consistent_control_counts());
436        assert_eq!(inconsistent.compliance_percentage(), None);
437        assert_eq!(inconsistent.meets_threshold(80.0), None);
438    }
439
440    #[test]
441    fn catalogs_are_empty_until_records_are_supplied() {
442        assert!(AuditTrail::new().is_empty());
443        assert!(DataProtectionCatalog::new().is_empty());
444        assert!(ComplianceReportCatalog::new().is_empty());
445    }
446
447    #[test]
448    fn most_recent_report_preserves_timestamp_ties() {
449        let mut catalog = ComplianceReportCatalog::new();
450        for id in ["one", "two"] {
451            assert!(catalog.record_report(ComplianceReport {
452                report_id: id.into(),
453                framework: ComplianceFramework::SOC2,
454                generated_at: 10,
455                assessment_period_days: 30,
456                reported_compliance_score_percent: None,
457                reported_critical_findings: 0,
458                proposed_remediation_actions: Vec::new(),
459            }));
460        }
461        assert_eq!(
462            catalog.most_recent_reports(ComplianceFramework::SOC2).len(),
463            2
464        );
465    }
466
467    #[test]
468    fn catalogs_reject_inconsistent_and_nonfinite_records() {
469        let mut assessments = ComplianceCatalog::new();
470        assert!(!assessments.record_assessment(assessment(90, 5)));
471        assert_eq!(assessments.assessment_count(), 0);
472
473        let mut reports = ComplianceReportCatalog::new();
474        let invalid = ComplianceReport {
475            report_id: "invalid".into(),
476            framework: ComplianceFramework::SOC2,
477            generated_at: 1,
478            assessment_period_days: 1,
479            reported_compliance_score_percent: Some(f32::NAN),
480            reported_critical_findings: 0,
481            proposed_remediation_actions: Vec::new(),
482        };
483        assert!(!reports.record_report(invalid.clone()));
484        assert!(serde_json::to_string(&invalid).is_err());
485        assert!(reports.is_empty());
486    }
487
488    #[test]
489    fn keyed_catalog_queries_are_stably_ordered() {
490        let mut catalog = ComplianceCatalog::new();
491        for id in ["zeta", "alpha"] {
492            let _ = catalog.record_requirement(ComplianceRequirement {
493                requirement_id: id.into(),
494                framework: ComplianceFramework::SOC2,
495                name: "fixture".into(),
496                description: "fixture".into(),
497                controls: Vec::new(),
498            });
499        }
500        assert_eq!(
501            catalog
502                .requirements_for(ComplianceFramework::SOC2)
503                .into_iter()
504                .map(|requirement| requirement.requirement_id.as_str())
505                .collect::<Vec<_>>(),
506            vec!["alpha", "zeta"]
507        );
508    }
509}