1use serde::{de::Error as _, ser::Error as _, Deserialize, Deserializer, Serialize, Serializer};
4
5#[repr(u8)]
7#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
8#[serde(rename_all = "kebab-case")]
9pub enum EvidenceTier {
10 Review,
12 Likely,
14 Confirmed,
16}
17
18impl EvidenceTier {
19 pub const fn as_str(self) -> &'static str {
21 match self {
22 Self::Review => "review",
23 Self::Likely => "likely",
24 Self::Confirmed => "confirmed",
25 }
26 }
27
28 pub const fn blocks(self, paranoid: bool) -> bool {
30 paranoid || !matches!(self, Self::Review)
31 }
32}
33
34#[repr(u8)]
36#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
37#[serde(rename_all = "kebab-case")]
38pub enum EvidenceReasonCode {
39 Unattributed,
41 UnsupportedContext,
43 RequiredEvidenceMissing,
45 WeakAnchor,
47 GenericDetector,
49 GenericAssignment,
51 EntropyOnly,
53 TestFixture,
55 Documentation,
57 RuleDefinition,
59 Identifier,
61 OptionDeclaration,
63 GeneratedMaterial,
65 SourceRoleMismatch,
67 VendorPattern,
69 StructuralGrammar,
71 RequiredCompanion,
73 ChecksumValid,
75 LiveVerification,
77}
78
79impl EvidenceReasonCode {
80 pub const fn tier(self) -> EvidenceTier {
82 match self {
83 Self::VendorPattern => EvidenceTier::Likely,
84 Self::StructuralGrammar
85 | Self::RequiredCompanion
86 | Self::ChecksumValid
87 | Self::LiveVerification => EvidenceTier::Confirmed,
88 Self::Unattributed
89 | Self::UnsupportedContext
90 | Self::RequiredEvidenceMissing
91 | Self::WeakAnchor
92 | Self::GenericDetector
93 | Self::GenericAssignment
94 | Self::EntropyOnly
95 | Self::TestFixture
96 | Self::Documentation
97 | Self::RuleDefinition
98 | Self::Identifier
99 | Self::OptionDeclaration
100 | Self::GeneratedMaterial
101 | Self::SourceRoleMismatch => EvidenceTier::Review,
102 }
103 }
104
105 pub const fn as_str(self) -> &'static str {
107 match self {
108 Self::Unattributed => "unattributed",
109 Self::UnsupportedContext => "unsupported-context",
110 Self::RequiredEvidenceMissing => "required-evidence-missing",
111 Self::WeakAnchor => "weak-anchor",
112 Self::GenericDetector => "generic-detector",
113 Self::GenericAssignment => "generic-assignment",
114 Self::EntropyOnly => "entropy-only",
115 Self::TestFixture => "test-fixture",
116 Self::Documentation => "documentation",
117 Self::RuleDefinition => "rule-definition",
118 Self::Identifier => "identifier",
119 Self::OptionDeclaration => "option-declaration",
120 Self::GeneratedMaterial => "generated-material",
121 Self::SourceRoleMismatch => "source-role-mismatch",
122 Self::VendorPattern => "vendor-pattern",
123 Self::StructuralGrammar => "structural-grammar",
124 Self::RequiredCompanion => "required-companion",
125 Self::ChecksumValid => "checksum-valid",
126 Self::LiveVerification => "live-verification",
127 }
128 }
129}
130
131#[repr(u8)]
133#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
134#[serde(rename_all = "kebab-case")]
135pub enum FindingCandidateChannel {
136 Pattern,
138 GenericAssignment,
140 Entropy,
142 Unattributed,
144}
145
146impl FindingCandidateChannel {
147 pub const fn as_str(self) -> &'static str {
149 match self {
150 Self::Pattern => "pattern",
151 Self::GenericAssignment => "generic-assignment",
152 Self::Entropy => "entropy",
153 Self::Unattributed => "unattributed",
154 }
155 }
156}
157
158#[repr(C)]
160#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
161pub struct FindingProvenance {
162 detector_digest: u64,
163 pattern_index: u32,
164 candidate_channel: FindingCandidateChannel,
165 source_role: crate::SemanticSourceRole,
166 context_class: EvidenceReasonCode,
167}
168
169impl FindingProvenance {
170 pub const SCHEMA_VERSION: u8 = 1;
172
173 const fn has_scanner_context(context_class: EvidenceReasonCode) -> bool {
174 !matches!(
175 context_class,
176 EvidenceReasonCode::Unattributed | EvidenceReasonCode::LiveVerification
177 )
178 }
179
180 const fn fields_are_consistent(self) -> bool {
181 match self.candidate_channel {
182 FindingCandidateChannel::Pattern
183 | FindingCandidateChannel::GenericAssignment
184 | FindingCandidateChannel::Entropy => Self::has_scanner_context(self.context_class),
185 FindingCandidateChannel::Unattributed => {
186 self.detector_digest == 0
187 && self.pattern_index == 0
188 && matches!(self.source_role, crate::SemanticSourceRole::Unknown)
189 && matches!(self.context_class, EvidenceReasonCode::Unattributed)
190 }
191 }
192 }
193
194 pub const fn pattern(
196 detector_digest: u64,
197 pattern_index: u32,
198 source_role: crate::SemanticSourceRole,
199 context_class: EvidenceReasonCode,
200 ) -> Self {
201 Self {
202 detector_digest,
203 pattern_index,
204 candidate_channel: FindingCandidateChannel::Pattern,
205 source_role,
206 context_class,
207 }
208 }
209
210 pub const fn generic_assignment(
212 detector_digest: u64,
213 source_role: crate::SemanticSourceRole,
214 context_class: EvidenceReasonCode,
215 ) -> Self {
216 Self::lane(
217 detector_digest,
218 FindingCandidateChannel::GenericAssignment,
219 source_role,
220 context_class,
221 )
222 }
223
224 pub const fn entropy(
226 detector_digest: u64,
227 source_role: crate::SemanticSourceRole,
228 context_class: EvidenceReasonCode,
229 ) -> Self {
230 Self::lane(
231 detector_digest,
232 FindingCandidateChannel::Entropy,
233 source_role,
234 context_class,
235 )
236 }
237
238 const fn lane(
239 detector_digest: u64,
240 candidate_channel: FindingCandidateChannel,
241 source_role: crate::SemanticSourceRole,
242 context_class: EvidenceReasonCode,
243 ) -> Self {
244 Self {
245 detector_digest,
246 pattern_index: 0,
247 candidate_channel,
248 source_role,
249 context_class,
250 }
251 }
252
253 pub const fn unattributed() -> Self {
255 Self {
256 detector_digest: 0,
257 pattern_index: 0,
258 candidate_channel: FindingCandidateChannel::Unattributed,
259 source_role: crate::SemanticSourceRole::Unknown,
260 context_class: EvidenceReasonCode::Unattributed,
261 }
262 }
263
264 pub const fn detector_digest(self) -> Option<u64> {
266 if matches!(
267 self.candidate_channel,
268 FindingCandidateChannel::Unattributed
269 ) {
270 None
271 } else {
272 Some(self.detector_digest)
273 }
274 }
275
276 pub const fn pattern_index(self) -> Option<u32> {
278 if matches!(self.candidate_channel, FindingCandidateChannel::Pattern) {
279 Some(self.pattern_index)
280 } else {
281 None
282 }
283 }
284
285 pub const fn candidate_channel(self) -> FindingCandidateChannel {
287 self.candidate_channel
288 }
289
290 pub const fn source_role(self) -> crate::SemanticSourceRole {
292 self.source_role
293 }
294
295 pub const fn context_class(self) -> EvidenceReasonCode {
297 self.context_class
298 }
299}
300
301impl Serialize for FindingProvenance {
302 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
303 where
304 S: Serializer,
305 {
306 if !self.fields_are_consistent() {
307 return Err(S::Error::custom(
308 "finding provenance fields are inconsistent with candidate_channel",
309 ));
310 }
311 #[derive(Serialize)]
312 struct WireProvenance<'a> {
313 schema_version: u8,
314 detector_digest: Option<&'a str>,
315 pattern_index: Option<u32>,
316 candidate_channel: FindingCandidateChannel,
317 source_role: crate::SemanticSourceRole,
318 context_class: EvidenceReasonCode,
319 }
320
321 const HEX: &[u8; 16] = b"0123456789abcdef";
322 let mut digest_hex = [b'0'; 16];
323 let detector_digest = if let Some(digest) = self.detector_digest() {
324 for (index, digit) in digest_hex.iter_mut().enumerate() {
325 let shift = (15 - index) * 4;
326 *digit = HEX[((digest >> shift) & 0x0f) as usize];
327 }
328 Some(std::str::from_utf8(&digest_hex).map_err(S::Error::custom)?)
329 } else {
330 None
331 };
332 WireProvenance {
333 schema_version: Self::SCHEMA_VERSION,
334 detector_digest,
335 pattern_index: self.pattern_index(),
336 candidate_channel: self.candidate_channel,
337 source_role: self.source_role,
338 context_class: self.context_class,
339 }
340 .serialize(serializer)
341 }
342}
343
344impl<'de> Deserialize<'de> for FindingProvenance {
345 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
346 where
347 D: Deserializer<'de>,
348 {
349 fn required_nullable<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
350 where
351 D: Deserializer<'de>,
352 T: Deserialize<'de>,
353 {
354 Option::<T>::deserialize(deserializer)
355 }
356
357 #[derive(Deserialize)]
358 #[serde(deny_unknown_fields)]
359 struct WireProvenance {
360 schema_version: u8,
361 #[serde(deserialize_with = "required_nullable")]
362 detector_digest: Option<String>,
363 #[serde(deserialize_with = "required_nullable")]
364 pattern_index: Option<u32>,
365 candidate_channel: FindingCandidateChannel,
366 source_role: crate::SemanticSourceRole,
367 context_class: EvidenceReasonCode,
368 }
369
370 let wire = WireProvenance::deserialize(deserializer)?;
371 if wire.schema_version != Self::SCHEMA_VERSION {
372 return Err(D::Error::custom(format!(
373 "unsupported finding provenance schema {}; expected {}",
374 wire.schema_version,
375 Self::SCHEMA_VERSION
376 )));
377 }
378 let detector_digest = wire
379 .detector_digest
380 .map(|digest| {
381 if digest.len() != 16
382 || !digest
383 .bytes()
384 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
385 {
386 return Err(D::Error::custom(
387 "finding provenance detector_digest must be 16 lowercase hex digits",
388 ));
389 }
390 u64::from_str_radix(&digest, 16).map_err(D::Error::custom)
391 })
392 .transpose()?;
393 let has_scanner_context = Self::has_scanner_context(wire.context_class);
394 let fields_are_consistent = match wire.candidate_channel {
395 FindingCandidateChannel::Pattern => {
396 detector_digest.is_some() && wire.pattern_index.is_some() && has_scanner_context
397 }
398 FindingCandidateChannel::GenericAssignment | FindingCandidateChannel::Entropy => {
399 detector_digest.is_some() && wire.pattern_index.is_none() && has_scanner_context
400 }
401 FindingCandidateChannel::Unattributed => {
402 detector_digest.is_none()
403 && wire.pattern_index.is_none()
404 && matches!(wire.source_role, crate::SemanticSourceRole::Unknown)
405 && matches!(wire.context_class, EvidenceReasonCode::Unattributed)
406 }
407 };
408 if !fields_are_consistent {
409 return Err(D::Error::custom(
410 "finding provenance fields are inconsistent with candidate_channel",
411 ));
412 }
413 Ok(Self {
414 detector_digest: detector_digest.unwrap_or(0),
415 pattern_index: wire.pattern_index.unwrap_or(0),
416 candidate_channel: wire.candidate_channel,
417 source_role: wire.source_role,
418 context_class: wire.context_class,
419 })
420 }
421}
422
423#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
425pub struct EvidenceVerdict {
426 reason_code: EvidenceReasonCode,
427 provenance: FindingProvenance,
428}
429
430impl EvidenceVerdict {
431 pub const fn from_reason(reason_code: EvidenceReasonCode) -> Self {
433 Self {
434 reason_code,
435 provenance: FindingProvenance::unattributed(),
436 }
437 }
438
439 pub const fn review_unattributed() -> Self {
441 Self::from_reason(EvidenceReasonCode::Unattributed)
442 }
443
444 pub const fn with_provenance(mut self, provenance: FindingProvenance) -> Self {
446 self.provenance = provenance;
447 self
448 }
449
450 pub const fn with_reason(mut self, reason_code: EvidenceReasonCode) -> Self {
452 self.reason_code = reason_code;
453 self
454 }
455
456 pub const fn tier(self) -> EvidenceTier {
458 self.reason_code.tier()
459 }
460
461 pub const fn reason_code(self) -> EvidenceReasonCode {
463 self.reason_code
464 }
465
466 pub const fn provenance(self) -> FindingProvenance {
468 self.provenance
469 }
470
471 pub fn stronger(self, other: Self) -> Self {
473 let self_tier = self.tier() as u8;
474 let other_tier = other.tier() as u8;
475 let same_reason = other_tier == self_tier && other.reason_code == self.reason_code;
476 let self_attributed = !matches!(
477 self.provenance.candidate_channel(),
478 FindingCandidateChannel::Unattributed
479 );
480 let other_attributed = !matches!(
481 other.provenance.candidate_channel(),
482 FindingCandidateChannel::Unattributed
483 );
484 if other_tier > self_tier
485 || (other_tier == self_tier && other.reason_code as u8 > self.reason_code as u8)
486 || (same_reason && other_attributed && !self_attributed)
487 || (same_reason
488 && other_attributed == self_attributed
489 && other.provenance > self.provenance)
490 {
491 other
492 } else {
493 self
494 }
495 }
496}
497
498impl Serialize for EvidenceVerdict {
499 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
500 where
501 S: Serializer,
502 {
503 #[derive(Serialize)]
504 struct WireVerdict {
505 tier: EvidenceTier,
506 reason_code: EvidenceReasonCode,
507 provenance: FindingProvenance,
508 }
509
510 WireVerdict {
511 tier: self.tier(),
512 reason_code: self.reason_code,
513 provenance: self.provenance,
514 }
515 .serialize(serializer)
516 }
517}
518
519impl<'de> Deserialize<'de> for EvidenceVerdict {
520 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
521 where
522 D: Deserializer<'de>,
523 {
524 #[derive(Deserialize)]
525 #[serde(deny_unknown_fields)]
526 struct WireVerdict {
527 tier: EvidenceTier,
528 reason_code: EvidenceReasonCode,
529 provenance: FindingProvenance,
530 }
531
532 let wire = WireVerdict::deserialize(deserializer)?;
533 let verdict = Self::from_reason(wire.reason_code).with_provenance(wire.provenance);
534 if verdict.tier() != wire.tier {
535 return Err(D::Error::custom(format!(
536 "evidence tier {:?} does not match reason code {:?}",
537 wire.tier, wire.reason_code
538 )));
539 }
540 Ok(verdict)
541 }
542}