1use crate::{FindingCandidateChannel, FindingProvenance};
4use serde::{Deserialize, Serialize};
5
6pub const TRIAGE_ENVELOPE_VERSION: u32 = 1;
8pub const TRIAGE_SUPPRESSION_VERSION: u32 = 1;
10pub const PATTERN_FEEDBACK_VERSION: u32 = 1;
12pub const MAX_TRIAGE_RECORDS: usize = 4096;
14pub const MAX_TRIAGE_INPUT_BYTES: usize = 4 * 1024 * 1024;
16pub const MAX_TRIAGE_OUTPUT_BYTES: usize = 8 * 1024 * 1024;
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(deny_unknown_fields)]
22pub struct TriageEnvelope {
23 pub version: u32,
25 pub detector_digest: String,
27 pub records: Vec<TriageRecord>,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(deny_unknown_fields)]
34pub struct TriageRecord {
35 pub finding_hash: String,
37 pub detector_id: String,
39 pub provenance: FindingProvenance,
41 pub context_digest: String,
43 pub disposition: TriageDisposition,
45 pub reason: TriageReason,
47 pub scope: TriageScope,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(rename_all = "kebab-case")]
54pub enum TriageDisposition {
55 Dismissed,
57 Confirmed,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename_all = "kebab-case")]
64pub enum TriageReason {
65 FalsePositive,
67 TestFixture,
69 ApprovedExample,
71 Duplicate,
73 RevokedOrRotated,
75 ConfirmedActive,
77 ConfirmedSecret,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(rename_all = "kebab-case")]
84pub enum TriageScope {
85 Exact,
87 Path {
89 path_hash: String,
91 },
92 Repository {
94 repository_hash: String,
96 },
97 PatternFeedbackOnly,
99}
100
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(deny_unknown_fields)]
104pub struct RuntimeSuppressions {
105 pub suppression_version: u32,
107 pub detector_digest: String,
109 pub suppressions: Vec<RuntimeSuppression>,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115#[serde(deny_unknown_fields)]
116pub struct RuntimeSuppression {
117 pub finding_hash: String,
119 pub detector_id: String,
121 pub provenance: FindingProvenance,
123 pub context_digest: String,
125 pub scope: RuntimeSuppressionScope,
127 pub reason: TriageReason,
129}
130
131#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
133#[serde(rename_all = "kebab-case")]
134pub enum RuntimeSuppressionScope {
135 Exact,
137 Path {
139 path_hash: String,
141 },
142 Repository {
144 repository_hash: String,
146 },
147}
148
149#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
151#[serde(deny_unknown_fields)]
152pub struct PatternFeedback {
153 pub pattern_feedback_version: u32,
155 pub detector_digest: String,
157 pub feedback: Vec<PatternFeedbackRecord>,
159}
160
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
163#[serde(deny_unknown_fields)]
164pub struct PatternFeedbackRecord {
165 pub finding_hash: String,
167 pub detector_id: String,
169 pub provenance: FindingProvenance,
171 pub context_digest: String,
173 pub disposition: TriageDisposition,
175 pub reason: TriageReason,
177 pub scope: TriageScope,
179}
180
181impl TriageEnvelope {
182 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 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 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 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 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}