Skip to main content

keyhog_core/
triage.rs

1//! Versioned, redacted triage interchange and derived feedback artifacts.
2
3use crate::{FindingCandidateChannel, FindingProvenance};
4use serde::{Deserialize, Serialize};
5
6/// Current redacted finding-envelope version.
7pub const TRIAGE_ENVELOPE_VERSION: u32 = 1;
8/// Current immediate runtime-suppression artifact version.
9pub const TRIAGE_SUPPRESSION_VERSION: u32 = 1;
10/// Current pattern-training feedback artifact version.
11pub const PATTERN_FEEDBACK_VERSION: u32 = 1;
12/// Maximum accepted records in one envelope.
13pub const MAX_TRIAGE_RECORDS: usize = 4096;
14/// Maximum serialized envelope size.
15pub const MAX_TRIAGE_INPUT_BYTES: usize = 4 * 1024 * 1024;
16/// Maximum serialized size of either derived artifact.
17pub const MAX_TRIAGE_OUTPUT_BYTES: usize = 8 * 1024 * 1024;
18
19/// Redacted findings submitted for triage.
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(deny_unknown_fields)]
22pub struct TriageEnvelope {
23    /// Schema version. Only the current version is accepted.
24    pub version: u32,
25    /// Exact effective detector corpus identity emitted by the producing binary.
26    pub detector_digest: String,
27    /// Bounded redacted records.
28    pub records: Vec<TriageRecord>,
29}
30
31/// One redacted triage decision.
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(deny_unknown_fields)]
34pub struct TriageRecord {
35    /// BLAKE3 finding identity. Never the credential.
36    pub finding_hash: String,
37    /// Stable embedded detector identifier.
38    pub detector_id: String,
39    /// Exact public scanner provenance from `evidence.provenance`.
40    pub provenance: FindingProvenance,
41    /// BLAKE3 digest of bounded context. Never context bytes.
42    pub context_digest: String,
43    /// Human triage disposition.
44    pub disposition: TriageDisposition,
45    /// Typed reason carrying no free-form text.
46    pub reason: TriageReason,
47    /// Exactly one typed scope.
48    pub scope: TriageScope,
49}
50
51/// Whether the finding was dismissed or confirmed.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(rename_all = "kebab-case")]
54pub enum TriageDisposition {
55    /// Do not treat this occurrence as a secret.
56    Dismissed,
57    /// Treat this occurrence as a secret.
58    Confirmed,
59}
60
61/// Closed reason vocabulary. Free-form text is deliberately not accepted.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename_all = "kebab-case")]
64pub enum TriageReason {
65    /// Detector matched non-credential material.
66    FalsePositive,
67    /// Credential is an intentional test fixture.
68    TestFixture,
69    /// Credential is an approved public example.
70    ApprovedExample,
71    /// Finding duplicates another finding.
72    Duplicate,
73    /// Credential was revoked or rotated.
74    RevokedOrRotated,
75    /// Credential is confirmed active.
76    ConfirmedActive,
77    /// Credential is confirmed but activity is unknown.
78    ConfirmedSecret,
79}
80
81/// Scope of one decision. The enum representation makes scopes mutually exclusive.
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(rename_all = "kebab-case")]
84pub enum TriageScope {
85    /// Suppress only this finding identity.
86    Exact,
87    /// Suppress matching findings at one redacted path identity.
88    Path {
89        /// BLAKE3 path identity, never a filesystem path.
90        path_hash: String,
91    },
92    /// Suppress matching findings in one redacted repository identity.
93    Repository {
94        /// BLAKE3 repository identity, never a repository path or URL.
95        repository_hash: String,
96    },
97    /// Feed training only and never create runtime suppression.
98    PatternFeedbackOnly,
99}
100
101/// Versioned immediate runtime-suppression artifact.
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(deny_unknown_fields)]
104pub struct RuntimeSuppressions {
105    /// Runtime-suppression schema version.
106    pub suppression_version: u32,
107    /// Exact detector corpus identity.
108    pub detector_digest: String,
109    /// Dismissed, runtime-applicable records only.
110    pub suppressions: Vec<RuntimeSuppression>,
111}
112
113/// One immediate scoped suppression decision.
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115#[serde(deny_unknown_fields)]
116pub struct RuntimeSuppression {
117    /// Finding identity.
118    pub finding_hash: String,
119    /// Stable detector identifier.
120    pub detector_id: String,
121    /// Exact public scanner provenance.
122    pub provenance: FindingProvenance,
123    /// Bounded context digest.
124    pub context_digest: String,
125    /// Runtime scope. Pattern-feedback-only is unrepresentable here.
126    pub scope: RuntimeSuppressionScope,
127    /// Typed dismissal reason.
128    pub reason: TriageReason,
129}
130
131/// Runtime-applicable suppression scope.
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
133#[serde(rename_all = "kebab-case")]
134pub enum RuntimeSuppressionScope {
135    /// One finding.
136    Exact,
137    /// One redacted path identity.
138    Path {
139        /// BLAKE3 path identity.
140        path_hash: String,
141    },
142    /// One redacted repository identity.
143    Repository {
144        /// BLAKE3 repository identity.
145        repository_hash: String,
146    },
147}
148
149/// Versioned pattern-training feedback artifact.
150#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
151#[serde(deny_unknown_fields)]
152pub struct PatternFeedback {
153    /// Pattern-feedback schema version.
154    pub pattern_feedback_version: u32,
155    /// Exact detector corpus identity.
156    pub detector_digest: String,
157    /// Validated redacted feedback records.
158    pub feedback: Vec<PatternFeedbackRecord>,
159}
160
161/// One pattern-training observation.
162#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
163#[serde(deny_unknown_fields)]
164pub struct PatternFeedbackRecord {
165    /// Finding identity.
166    pub finding_hash: String,
167    /// Stable detector identifier.
168    pub detector_id: String,
169    /// Exact public scanner provenance.
170    pub provenance: FindingProvenance,
171    /// Bounded context digest.
172    pub context_digest: String,
173    /// Triage disposition.
174    pub disposition: TriageDisposition,
175    /// Typed reason.
176    pub reason: TriageReason,
177    /// Original typed scope, retained for training policy.
178    pub scope: TriageScope,
179}
180
181impl TriageEnvelope {
182    /// Parse and validate the current redacted contract for one active corpus.
183    pub fn from_json(bytes: &[u8], expected_detector_digest: &str) -> Result<Self, String> {
184        if bytes.len() > MAX_TRIAGE_INPUT_BYTES {
185            return Err("triage envelope exceeds the byte limit".to_owned());
186        }
187        let envelope: Self = serde_json::from_slice(bytes).map_err(|_| {
188            "triage envelope is malformed or contains unsupported fields".to_owned()
189        })?;
190        envelope.validate(expected_detector_digest)?;
191        Ok(envelope)
192    }
193
194    /// Validate versions, bounds, identities, and reason/disposition coherence.
195    pub fn validate(&self, expected_detector_digest: &str) -> Result<(), String> {
196        if self.version != TRIAGE_ENVELOPE_VERSION {
197            return Err("unsupported triage envelope version".to_owned());
198        }
199        let detector_digest =
200            validate_active_detector_digest(&self.detector_digest, expected_detector_digest)?;
201        if self.records.len() > MAX_TRIAGE_RECORDS {
202            return Err("triage envelope exceeds the record limit".to_owned());
203        }
204        for record in &self.records {
205            validate_record(record, detector_digest)?;
206        }
207        Ok(())
208    }
209
210    /// Derive distinct runtime-suppression and pattern-feedback artifacts.
211    pub fn into_outputs(self) -> (RuntimeSuppressions, PatternFeedback) {
212        let mut suppressions = Vec::with_capacity(self.records.len());
213        let mut feedback = Vec::with_capacity(self.records.len());
214        for record in self.records {
215            if record.disposition == TriageDisposition::Dismissed {
216                let runtime_scope = match &record.scope {
217                    TriageScope::Exact => Some(RuntimeSuppressionScope::Exact),
218                    TriageScope::Path { path_hash } => Some(RuntimeSuppressionScope::Path {
219                        path_hash: path_hash.clone(),
220                    }),
221                    TriageScope::Repository { repository_hash } => {
222                        Some(RuntimeSuppressionScope::Repository {
223                            repository_hash: repository_hash.clone(),
224                        })
225                    }
226                    TriageScope::PatternFeedbackOnly => None,
227                };
228                if let Some(scope) = runtime_scope {
229                    suppressions.push(RuntimeSuppression {
230                        finding_hash: record.finding_hash.clone(),
231                        detector_id: record.detector_id.clone(),
232                        provenance: record.provenance,
233                        context_digest: record.context_digest.clone(),
234                        scope,
235                        reason: record.reason,
236                    });
237                }
238            }
239            feedback.push(PatternFeedbackRecord {
240                finding_hash: record.finding_hash,
241                detector_id: record.detector_id,
242                provenance: record.provenance,
243                context_digest: record.context_digest,
244                disposition: record.disposition,
245                reason: record.reason,
246                scope: record.scope,
247            });
248        }
249        (
250            RuntimeSuppressions {
251                suppression_version: TRIAGE_SUPPRESSION_VERSION,
252                detector_digest: self.detector_digest.clone(),
253                suppressions,
254            },
255            PatternFeedback {
256                pattern_feedback_version: PATTERN_FEEDBACK_VERSION,
257                detector_digest: self.detector_digest,
258                feedback,
259            },
260        )
261    }
262}
263
264impl RuntimeSuppressions {
265    /// Parse only the runtime-suppression contract for one active corpus.
266    pub fn from_json(bytes: &[u8], expected_detector_digest: &str) -> Result<Self, String> {
267        if bytes.len() > MAX_TRIAGE_OUTPUT_BYTES {
268            return Err("runtime suppression artifact exceeds the byte limit".to_owned());
269        }
270        let value: Self = serde_json::from_slice(bytes)
271            .map_err(|_| "runtime suppression artifact is malformed".to_owned())?;
272        if value.suppression_version != TRIAGE_SUPPRESSION_VERSION {
273            return Err("unsupported runtime suppression version".to_owned());
274        }
275        let detector_digest =
276            validate_active_detector_digest(&value.detector_digest, expected_detector_digest)?;
277        if value.suppressions.len() > MAX_TRIAGE_RECORDS {
278            return Err("runtime suppression artifact exceeds the record limit".to_owned());
279        }
280        for record in &value.suppressions {
281            validate_digest(&record.finding_hash, "finding")?;
282            validate_provenance(&record.detector_id, record.provenance, detector_digest)?;
283            validate_digest(&record.context_digest, "context")?;
284            if !matches!(
285                record.reason,
286                TriageReason::FalsePositive
287                    | TriageReason::TestFixture
288                    | TriageReason::ApprovedExample
289                    | TriageReason::Duplicate
290                    | TriageReason::RevokedOrRotated
291            ) {
292                return Err("runtime suppression contains a confirmation reason".to_owned());
293            }
294            match &record.scope {
295                RuntimeSuppressionScope::Path { path_hash } => validate_digest(path_hash, "path")?,
296                RuntimeSuppressionScope::Repository { repository_hash } => {
297                    validate_digest(repository_hash, "repository")?
298                }
299                RuntimeSuppressionScope::Exact => {}
300            }
301        }
302        Ok(value)
303    }
304}
305
306impl PatternFeedback {
307    /// Parse only the pattern-feedback contract for one active corpus.
308    pub fn from_json(bytes: &[u8], expected_detector_digest: &str) -> Result<Self, String> {
309        if bytes.len() > MAX_TRIAGE_OUTPUT_BYTES {
310            return Err("pattern feedback artifact exceeds the byte limit".to_owned());
311        }
312        let value: Self = serde_json::from_slice(bytes)
313            .map_err(|_| "pattern feedback artifact is malformed".to_owned())?;
314        if value.pattern_feedback_version != PATTERN_FEEDBACK_VERSION {
315            return Err("unsupported pattern feedback version".to_owned());
316        }
317        let detector_digest =
318            validate_active_detector_digest(&value.detector_digest, expected_detector_digest)?;
319        if value.feedback.len() > MAX_TRIAGE_RECORDS {
320            return Err("pattern feedback artifact exceeds the record limit".to_owned());
321        }
322        for record in &value.feedback {
323            validate_record_fields(TriageRecordFields::from(record), detector_digest)?;
324        }
325        Ok(value)
326    }
327}
328
329struct TriageRecordFields<'a> {
330    finding_hash: &'a str,
331    detector_id: &'a str,
332    provenance: FindingProvenance,
333    context_digest: &'a str,
334    disposition: TriageDisposition,
335    reason: TriageReason,
336    scope: &'a TriageScope,
337}
338
339impl<'a> From<&'a TriageRecord> for TriageRecordFields<'a> {
340    fn from(record: &'a TriageRecord) -> Self {
341        Self {
342            finding_hash: &record.finding_hash,
343            detector_id: &record.detector_id,
344            provenance: record.provenance,
345            context_digest: &record.context_digest,
346            disposition: record.disposition,
347            reason: record.reason,
348            scope: &record.scope,
349        }
350    }
351}
352
353impl<'a> From<&'a PatternFeedbackRecord> for TriageRecordFields<'a> {
354    fn from(record: &'a PatternFeedbackRecord) -> Self {
355        Self {
356            finding_hash: &record.finding_hash,
357            detector_id: &record.detector_id,
358            provenance: record.provenance,
359            context_digest: &record.context_digest,
360            disposition: record.disposition,
361            reason: record.reason,
362            scope: &record.scope,
363        }
364    }
365}
366
367fn validate_record(record: &TriageRecord, expected_detector_digest: u64) -> Result<(), String> {
368    validate_record_fields(TriageRecordFields::from(record), expected_detector_digest)
369}
370
371fn validate_record_fields(
372    record: TriageRecordFields<'_>,
373    expected_detector_digest: u64,
374) -> Result<(), String> {
375    validate_digest(record.finding_hash, "finding")?;
376    validate_digest(record.context_digest, "context")?;
377    validate_provenance(
378        record.detector_id,
379        record.provenance,
380        expected_detector_digest,
381    )?;
382    let coherent = matches!(
383        (record.disposition, record.reason),
384        (
385            TriageDisposition::Dismissed,
386            TriageReason::FalsePositive
387                | TriageReason::TestFixture
388                | TriageReason::ApprovedExample
389                | TriageReason::Duplicate
390                | TriageReason::RevokedOrRotated
391        ) | (
392            TriageDisposition::Confirmed,
393            TriageReason::ConfirmedActive | TriageReason::ConfirmedSecret
394        )
395    );
396    if !coherent {
397        return Err("triage disposition and reason disagree".to_owned());
398    }
399    match record.scope {
400        TriageScope::Path { path_hash } => validate_digest(path_hash, "path")?,
401        TriageScope::Repository { repository_hash } => {
402            validate_digest(repository_hash, "repository")?
403        }
404        TriageScope::Exact | TriageScope::PatternFeedbackOnly => {}
405    }
406    Ok(())
407}
408
409fn validate_provenance(
410    detector_id: &str,
411    provenance: FindingProvenance,
412    expected_detector_digest: u64,
413) -> Result<(), String> {
414    let canonical_detector_id = canonical_report_detector_id(detector_id)?;
415    if provenance.detector_digest() != Some(expected_detector_digest) {
416        return Err("stale or unattributed finding provenance".to_owned());
417    }
418    if matches!(
419        provenance.context_class(),
420        crate::EvidenceReasonCode::Unattributed | crate::EvidenceReasonCode::LiveVerification
421    ) {
422        return Err("invalid scanner provenance context".to_owned());
423    }
424    match provenance.candidate_channel() {
425        FindingCandidateChannel::Pattern => {
426            let detector = crate::detector_spec_by_id(canonical_detector_id)
427                .ok_or_else(|| "stale detector identifier".to_owned())?;
428            let pattern_index = provenance
429                .pattern_index()
430                .ok_or_else(|| "missing pattern identity".to_owned())?;
431            let current = usize::try_from(pattern_index)
432                .ok()
433                .is_some_and(|index| index < detector.patterns.len());
434            if !current {
435                return Err("stale pattern identity".to_owned());
436            }
437        }
438        FindingCandidateChannel::GenericAssignment => {
439            let detector = crate::detector_spec_by_id(canonical_detector_id)
440                .ok_or_else(|| "stale detector identifier".to_owned())?;
441            if detector.kind != crate::DetectorKind::Phase2Generic {
442                return Err("detector does not own generic-assignment findings".to_owned());
443            }
444        }
445        FindingCandidateChannel::Entropy => {
446            let owns_entropy_id = crate::embedded_detector_specs().iter().any(|detector| {
447                detector
448                    .entropy_fallback
449                    .as_ref()
450                    .is_some_and(|fallback| fallback.id == canonical_detector_id)
451            });
452            if !owns_entropy_id {
453                return Err("stale entropy detector identifier".to_owned());
454            }
455        }
456        FindingCandidateChannel::Unattributed => {
457            return Err("unattributed findings cannot produce triage feedback".to_owned());
458        }
459    }
460    Ok(())
461}
462
463fn canonical_report_detector_id(detector_id: &str) -> Result<&str, String> {
464    let canonical = detector_id
465        .strip_suffix(crate::REASSEMBLED_DETECTOR_SUFFIX)
466        .unwrap_or(detector_id);
467    if canonical.is_empty()
468        || canonical.len() > 128
469        || canonical.contains(':')
470        || !canonical
471            .bytes()
472            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
473    {
474        return Err("invalid detector identifier".to_owned());
475    }
476    Ok(canonical)
477}
478
479fn validate_active_detector_digest(actual: &str, expected: &str) -> Result<u64, String> {
480    let actual = parse_detector_digest(actual)?;
481    let expected = parse_detector_digest(expected)?;
482    if actual != expected {
483        return Err("stale detector corpus identity".to_owned());
484    }
485    Ok(expected)
486}
487
488fn validate_detector_digest(value: &str) -> Result<(), String> {
489    if value.len() == 16
490        && value
491            .bytes()
492            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
493    {
494        Ok(())
495    } else {
496        Err("invalid detector corpus digest".to_owned())
497    }
498}
499
500fn parse_detector_digest(value: &str) -> Result<u64, String> {
501    validate_detector_digest(value)?;
502    u64::from_str_radix(value, 16).map_err(|_| "invalid detector corpus digest".to_owned())
503}
504
505fn validate_digest(value: &str, label: &str) -> Result<(), String> {
506    let valid = value.len() == 71
507        && value.starts_with("blake3:")
508        && value[7..]
509            .bytes()
510            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte));
511    if valid {
512        Ok(())
513    } else {
514        Err(format!("invalid {label} digest"))
515    }
516}