1#![allow(missing_docs)]
6
7use serde::ser::SerializeStruct;
8use serde::{Deserialize, Serialize, Serializer};
9use std::borrow::Cow;
10use std::collections::{BTreeMap, HashMap};
11use std::sync::Arc;
12
13use crate::{EvidenceReasonCode, EvidenceVerdict, SensitiveString, Severity};
14
15#[derive(Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
23#[repr(transparent)]
24#[serde(transparent)]
25pub struct CredentialHash(#[serde(with = "serde_hash_hex")] [u8; 32]);
26
27impl CredentialHash {
28 pub const ZERO: Self = Self([0; 32]);
31
32 #[inline]
34 pub const fn from_bytes(bytes: [u8; 32]) -> Self {
35 Self(bytes)
36 }
37
38 #[inline]
40 pub const fn as_bytes(&self) -> &[u8; 32] {
41 &self.0
42 }
43
44 #[inline]
46 pub const fn into_bytes(self) -> [u8; 32] {
47 self.0
48 }
49
50 #[inline]
52 pub const fn is_zero(self) -> bool {
53 let mut idx = 0;
54 while idx < self.0.len() {
55 if self.0[idx] != 0 {
56 return false;
57 }
58 idx += 1;
59 }
60 true
61 }
62}
63
64impl From<[u8; 32]> for CredentialHash {
65 #[inline]
66 fn from(bytes: [u8; 32]) -> Self {
67 Self::from_bytes(bytes)
68 }
69}
70
71impl From<CredentialHash> for [u8; 32] {
72 #[inline]
73 fn from(hash: CredentialHash) -> Self {
74 hash.into_bytes()
75 }
76}
77
78impl AsRef<[u8; 32]> for CredentialHash {
79 #[inline]
80 fn as_ref(&self) -> &[u8; 32] {
81 self.as_bytes()
82 }
83}
84
85impl AsRef<[u8]> for CredentialHash {
86 #[inline]
87 fn as_ref(&self) -> &[u8] {
88 self.as_bytes()
89 }
90}
91
92impl std::fmt::Debug for CredentialHash {
93 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94 f.write_str(&hex_encode(self))
95 }
96}
97
98#[derive(Clone, Copy, Debug, PartialEq, Eq)]
100pub struct RawMatchDedupKey<'a> {
101 pub detector_id: &'a str,
102 pub credential: &'a str,
103}
104
105pub type CompanionMap = HashMap<Arc<str>, String>;
111
112mod serde_companion_map {
113 use super::CompanionMap;
114 use serde::ser::SerializeMap;
115 use serde::{Deserialize, Serializer};
116 use std::collections::HashMap;
117 use std::sync::Arc;
118
119 pub(super) fn serialize<S>(companions: &CompanionMap, serializer: S) -> Result<S::Ok, S::Error>
120 where
121 S: Serializer,
122 {
123 let mut sorted: Vec<(&str, &str)> = companions
124 .iter()
125 .map(|(name, value)| (name.as_ref(), value.as_str()))
126 .collect();
127 sorted.sort_by_key(|&(k, _)| k);
128 let mut map = serializer.serialize_map(Some(sorted.len()))?;
129 for (name, value) in sorted {
130 map.serialize_entry(name, value)?;
131 }
132 map.end()
133 }
134
135 pub(super) fn deserialize<'de, D>(deserializer: D) -> Result<CompanionMap, D::Error>
136 where
137 D: serde::Deserializer<'de>,
138 {
139 HashMap::<String, String>::deserialize(deserializer).map(|companions| {
140 companions
141 .into_iter()
142 .map(|(name, value)| (Arc::from(name), value))
143 .collect()
144 })
145 }
146}
147
148#[derive(Clone, Serialize, Deserialize)]
160pub struct RawMatch {
161 #[serde(with = "serde_arc_str")]
163 pub detector_id: Arc<str>,
164 #[serde(with = "serde_arc_str")]
166 pub detector_name: Arc<str>,
167 #[serde(with = "serde_arc_str")]
169 pub service: Arc<str>,
170 pub severity: Severity,
172 pub credential: SensitiveString,
174 pub credential_hash: CredentialHash,
181 #[serde(with = "serde_companion_map")]
183 pub companions: CompanionMap,
184 pub location: MatchLocation,
186 #[serde(skip_serializing_if = "Option::is_none")]
188 pub entropy: Option<f64>,
189 #[serde(skip_serializing_if = "Option::is_none")]
191 pub confidence: Option<f64>,
192 pub evidence: EvidenceVerdict,
194}
195
196impl RawMatch {
197 pub(crate) fn sanitize_floats(mut self) -> Self {
201 if self.entropy.is_some_and(f64::is_nan) {
202 self.entropy = None;
203 }
204 if self.confidence.is_some_and(f64::is_nan) {
205 self.confidence = None;
206 }
207 self
208 }
209}
210
211impl PartialEq for RawMatch {
212 fn eq(&self, other: &Self) -> bool {
213 self.detector_id == other.detector_id
218 && self.detector_name == other.detector_name
219 && self.service == other.service
220 && self.severity == other.severity
221 && self.credential == other.credential
222 && self.credential_hash == other.credential_hash
223 && self.companions == other.companions
224 && self.location == other.location
225 && opt_f64_total_eq(self.entropy, other.entropy)
226 && opt_f64_total_eq(self.confidence, other.confidence)
227 && self.evidence == other.evidence
228 }
229}
230
231impl Eq for RawMatch {}
232
233impl std::fmt::Debug for RawMatch {
234 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
238 f.debug_struct("RawMatch")
239 .field("detector_id", &self.detector_id)
240 .field("detector_name", &self.detector_name)
241 .field("service", &self.service)
242 .field("severity", &self.severity)
243 .field(
244 "credential",
245 &format_args!("<redacted {} bytes>", self.credential.len()),
246 )
247 .field(
248 "credential_hash",
249 &format_args!("{}", hex_encode(self.credential_hash)),
250 )
251 .field(
252 "companions",
253 &format_args!("<{} redacted companions>", self.companions.len()),
254 )
255 .field("location", &self.location)
256 .field("entropy", &self.entropy)
257 .field("confidence", &self.confidence)
258 .field("evidence", &self.evidence)
259 .finish()
260 }
261}
262
263#[inline]
264fn opt_f64_total_eq(a: Option<f64>, b: Option<f64>) -> bool {
265 match (a, b) {
266 (None, None) => true,
267 (Some(x), Some(y)) => x.total_cmp(&y) == std::cmp::Ordering::Equal,
268 _ => false,
269 }
270}
271
272#[inline]
273fn opt_f64_total_cmp(a: Option<f64>, b: Option<f64>) -> std::cmp::Ordering {
274 match (a, b) {
275 (None, None) => std::cmp::Ordering::Equal,
276 (None, Some(_)) => std::cmp::Ordering::Less,
277 (Some(_), None) => std::cmp::Ordering::Greater,
278 (Some(x), Some(y)) => x.total_cmp(&y),
279 }
280}
281
282fn companion_map_cmp(a: &CompanionMap, b: &CompanionMap) -> std::cmp::Ordering {
283 if a == b {
284 return std::cmp::Ordering::Equal;
285 }
286 match a.len().cmp(&b.len()) {
287 std::cmp::Ordering::Equal => {}
288 ordering => return ordering,
289 }
290
291 let mut a_after: Option<&str> = None;
296 let mut b_after: Option<&str> = None;
297 for _ in 0..a.len() {
298 let Some(a_entry) = a
299 .iter()
300 .filter(|(key, _)| a_after.is_none_or(|after| key.as_ref() > after))
301 .min_by(|left, right| left.0.cmp(right.0))
302 else {
303 return std::cmp::Ordering::Equal;
304 };
305 let Some(b_entry) = b
306 .iter()
307 .filter(|(key, _)| b_after.is_none_or(|after| key.as_ref() > after))
308 .min_by(|left, right| left.0.cmp(right.0))
309 else {
310 return std::cmp::Ordering::Equal;
311 };
312 match a_entry.cmp(&b_entry) {
313 std::cmp::Ordering::Equal => {
314 a_after = Some(a_entry.0.as_ref());
315 b_after = Some(b_entry.0.as_ref());
316 }
317 ordering => return ordering,
318 }
319 }
320 std::cmp::Ordering::Equal
321}
322
323impl PartialOrd for RawMatch {
324 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
325 Some(self.cmp(other))
326 }
327}
328
329impl Ord for RawMatch {
330 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
331 let self_conf = self.confidence.unwrap_or(0.0); let other_conf = other.confidence.unwrap_or(0.0); match other_conf.total_cmp(&self_conf) {
339 std::cmp::Ordering::Equal => {}
340 ord => return ord,
341 }
342
343 match other.severity.cmp(&self.severity) {
345 std::cmp::Ordering::Equal => {}
346 ord => return ord,
347 }
348
349 match self.detector_id.cmp(&other.detector_id) {
351 std::cmp::Ordering::Equal => {}
352 ord => return ord,
353 }
354 match self.credential.cmp(&other.credential) {
355 std::cmp::Ordering::Equal => {}
356 ord => return ord,
357 }
358
359 match self.location.offset.cmp(&other.location.offset) {
371 std::cmp::Ordering::Equal => {}
372 ord => return ord,
373 }
374 match self.location.line.cmp(&other.location.line) {
375 std::cmp::Ordering::Equal => {}
376 ord => return ord,
377 }
378
379 self.detector_name
385 .cmp(&other.detector_name)
386 .then_with(|| self.service.cmp(&other.service))
387 .then_with(|| self.credential_hash.cmp(&other.credential_hash))
388 .then_with(|| companion_map_cmp(&self.companions, &other.companions))
389 .then_with(|| self.location.source.cmp(&other.location.source))
390 .then_with(|| self.location.file_path.cmp(&other.location.file_path))
391 .then_with(|| self.location.commit.cmp(&other.location.commit))
392 .then_with(|| self.location.author.cmp(&other.location.author))
393 .then_with(|| self.location.date.cmp(&other.location.date))
394 .then_with(|| opt_f64_total_cmp(self.entropy, other.entropy))
395 .then_with(|| opt_f64_total_cmp(self.confidence, other.confidence))
396 .then_with(|| {
397 (other.evidence.reason_code() as u8).cmp(&(self.evidence.reason_code() as u8))
398 })
399 .then_with(|| self.evidence.provenance().cmp(&other.evidence.provenance()))
400 }
401}
402
403#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
405pub struct MatchLocation {
406 #[serde(with = "serde_arc_str")]
408 pub source: Arc<str>,
409 #[serde(with = "serde_arc_str_opt")]
415 pub file_path: Option<Arc<str>>,
416 pub line: Option<usize>,
418 pub offset: usize,
420 #[serde(with = "serde_arc_str_opt")]
422 pub commit: Option<Arc<str>>,
423 #[serde(with = "serde_arc_str_opt")]
425 pub author: Option<Arc<str>>,
426 #[serde(with = "serde_arc_str_opt")]
428 pub date: Option<Arc<str>>,
429}
430
431#[derive(Debug, Clone, Deserialize)]
433pub struct VerifiedFinding {
434 #[serde(with = "serde_arc_str")]
436 pub detector_id: Arc<str>,
437 #[serde(with = "serde_arc_str")]
439 pub detector_name: Arc<str>,
440 #[serde(with = "serde_arc_str")]
442 pub service: Arc<str>,
443 pub severity: Severity,
445 pub credential_redacted: Cow<'static, str>,
447 pub credential_hash: CredentialHash,
450 #[serde(default)]
455 pub companions_redacted: HashMap<String, String>,
456 pub location: MatchLocation,
458 pub verification: VerificationResult,
460 pub metadata: HashMap<String, String>,
462 pub additional_locations: Vec<MatchLocation>,
464 #[serde(skip_serializing_if = "Option::is_none")]
466 pub entropy: Option<f64>,
467 #[serde(skip_serializing_if = "Option::is_none")]
469 pub evidence_score: Option<f64>,
470 pub evidence: EvidenceVerdict,
472}
473
474impl VerifiedFinding {
475 pub fn from_deduped(
481 group: crate::DedupedMatch,
482 severity: Severity,
483 verification: VerificationResult,
484 metadata: HashMap<String, String>,
485 ) -> Self {
486 let evidence = if matches!(verification, VerificationResult::Live) {
487 group
488 .evidence
489 .with_reason(EvidenceReasonCode::LiveVerification)
490 } else {
491 group.evidence
492 };
493 Self {
494 detector_id: group.detector_id,
495 detector_name: group.detector_name,
496 service: group.service,
497 severity,
498 credential_redacted: crate::redact(&group.credential),
499 credential_hash: group.credential_hash,
500 companions_redacted: redact_companions(&group.companions),
501 location: group.primary_location,
502 verification,
503 metadata,
504 additional_locations: group.additional_locations,
505 entropy: group.entropy,
506 evidence_score: group.confidence,
507 evidence,
508 }
509 }
510}
511
512impl Serialize for VerifiedFinding {
513 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
514 where
515 S: Serializer,
516 {
517 let remediation =
518 crate::auto_fix::remediation_for(&self.detector_id, &self.service, self.severity);
519 let mut field_count = 13;
520 if self.entropy.is_some() {
521 field_count += 1;
522 }
523 if self.evidence_score.is_some() {
524 field_count += 1;
525 }
526 let mut state = serializer.serialize_struct("VerifiedFinding", field_count)?;
527 state.serialize_field("detector_id", self.detector_id.as_ref())?;
528 state.serialize_field("detector_name", self.detector_name.as_ref())?;
529 state.serialize_field("service", self.service.as_ref())?;
530 state.serialize_field("severity", &self.severity)?;
531 state.serialize_field("credential_redacted", self.credential_redacted.as_ref())?;
532 state.serialize_field("credential_hash", &hex_encode(self.credential_hash))?;
533 let sorted_companions: BTreeMap<&str, &str> = self
534 .companions_redacted
535 .iter()
536 .map(|(key, value)| (key.as_str(), value.as_str()))
537 .collect();
538 state.serialize_field("companions_redacted", &sorted_companions)?;
539 state.serialize_field("location", &self.location)?;
540 state.serialize_field("verification", &self.verification)?;
541 let sorted_metadata: BTreeMap<&str, &str> = self
542 .metadata
543 .iter()
544 .map(|(key, value)| (key.as_str(), value.as_str()))
545 .collect();
546 state.serialize_field("metadata", &sorted_metadata)?;
547 state.serialize_field("additional_locations", &self.additional_locations)?;
548 state.serialize_field("evidence", &self.evidence)?;
549 if let Some(entropy) = self.entropy {
550 state.serialize_field("entropy", &entropy)?;
551 }
552 if let Some(evidence_score) = self.evidence_score {
553 state.serialize_field("evidence_score", &evidence_score)?;
554 }
555 state.serialize_field("remediation", &remediation)?;
556 state.end()
557 }
558}
559
560#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
562#[serde(rename_all = "snake_case")]
563pub enum VerificationResult {
564 Live,
566 Revoked,
568 Dead,
570 RateLimited,
572 Error(String),
574 Unverifiable,
576 Skipped,
578}
579
580impl RawMatch {
581 pub(crate) fn deduplication_key(&self) -> RawMatchDedupKey<'_> {
587 RawMatchDedupKey {
588 detector_id: &self.detector_id,
589 credential: &self.credential,
590 }
591 }
592
593 pub fn to_redacted(&self) -> RedactedFinding {
598 RedactedFinding {
599 detector_id: self.detector_id.clone(),
600 detector_name: self.detector_name.clone(),
601 service: self.service.clone(),
602 severity: self.severity,
603 credential_redacted: crate::redact(&self.credential),
604 credential_hash: self.credential_hash,
605 companions_redacted: redact_companions(&self.companions),
606 location: self.location.clone(),
607 entropy: self.entropy,
608 evidence_score: self.confidence,
609 evidence: self.evidence,
610 }
611 }
612}
613
614pub fn redact_companions<K>(companions: &HashMap<K, String>) -> HashMap<String, String>
619where
620 K: AsRef<str> + Eq + std::hash::Hash,
621{
622 companions
623 .iter()
624 .map(|(key, value)| (key.as_ref().to_string(), crate::redact(value).into_owned()))
625 .collect()
626}
627
628mod serde_sorted_string_map {
629 use serde::ser::SerializeMap;
630 use serde::Serializer;
631 use std::collections::HashMap;
632
633 pub fn serialize<S>(map: &HashMap<String, String>, serializer: S) -> Result<S::Ok, S::Error>
634 where
635 S: Serializer,
636 {
637 let mut sorted: Vec<(&str, &str)> =
638 map.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
639 sorted.sort_by_key(|&(k, _)| k);
640 let mut ser_map = serializer.serialize_map(Some(sorted.len()))?;
641 for (k, v) in sorted {
642 ser_map.serialize_entry(k, v)?;
643 }
644 ser_map.end()
645 }
646}
647
648#[derive(Debug, Clone, Serialize, Deserialize)]
652pub struct RedactedFinding {
653 #[serde(with = "serde_arc_str")]
654 pub detector_id: Arc<str>,
655 #[serde(with = "serde_arc_str")]
656 pub detector_name: Arc<str>,
657 #[serde(with = "serde_arc_str")]
658 pub service: Arc<str>,
659 pub severity: Severity,
660 pub credential_redacted: Cow<'static, str>,
661 pub credential_hash: CredentialHash,
663 #[serde(serialize_with = "serde_sorted_string_map::serialize", default)]
664 pub companions_redacted: HashMap<String, String>,
665 pub location: MatchLocation,
666 #[serde(skip_serializing_if = "Option::is_none")]
667 pub entropy: Option<f64>,
668 #[serde(skip_serializing_if = "Option::is_none")]
670 pub evidence_score: Option<f64>,
671 pub evidence: EvidenceVerdict,
673}
674
675#[inline]
678pub fn hex_encode(bytes: impl AsRef<[u8]>) -> String {
679 hex::encode(bytes.as_ref())
680}
681
682#[inline]
687pub fn sha256_hash(s: &str) -> CredentialHash {
688 use sha2::{Digest, Sha256};
689 let mut hasher = Sha256::new();
690 hasher.update(s.as_bytes());
691 CredentialHash::from_bytes(hasher.finalize().into())
692}
693
694pub(crate) mod serde_hash_hex {
699 use std::borrow::Cow;
700
701 use serde::{Deserialize, Deserializer, Serializer};
702
703 pub(crate) fn serialize<S>(val: &[u8; 32], serializer: S) -> Result<S::Ok, S::Error>
704 where
705 S: Serializer,
706 {
707 serializer.serialize_str(&super::hex_encode(val))
708 }
709
710 pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result<[u8; 32], D::Error>
711 where
712 D: Deserializer<'de>,
713 {
714 let s = Cow::<'de, str>::deserialize(deserializer)?;
715 if s.len() != crate::git_lfs::SHA256_HEX_LEN {
716 return Err(serde::de::Error::invalid_length(
717 s.len(),
718 &"64-char hex SHA-256 digest",
719 ));
720 }
721 let mut bytes = [0_u8; 32];
722 hex::decode_to_slice(s.as_bytes(), &mut bytes).map_err(serde::de::Error::custom)?;
723 Ok(bytes)
724 }
725}
726
727#[inline]
731fn arc_from_cow(value: Cow<'_, str>) -> Arc<str> {
732 match value {
733 Cow::Borrowed(value) => Arc::from(value),
734 Cow::Owned(value) => Arc::from(value),
735 }
736}
737
738pub(crate) mod serde_arc_str {
739 use serde::{Deserialize, Deserializer, Serialize, Serializer};
740 use std::borrow::Cow;
741 use std::sync::Arc;
742
743 pub(crate) fn serialize<S>(val: &Arc<str>, serializer: S) -> Result<S::Ok, S::Error>
744 where
745 S: Serializer,
746 {
747 val.as_ref().serialize(serializer)
748 }
749
750 pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result<Arc<str>, D::Error>
751 where
752 D: Deserializer<'de>,
753 {
754 Cow::<'de, str>::deserialize(deserializer).map(super::arc_from_cow)
755 }
756}
757
758pub(crate) mod serde_arc_str_opt {
759 use serde::{Deserialize, Deserializer, Serialize, Serializer};
760 use std::borrow::Cow;
761 use std::sync::Arc;
762
763 pub(crate) fn serialize<S>(val: &Option<Arc<str>>, serializer: S) -> Result<S::Ok, S::Error>
764 where
765 S: Serializer,
766 {
767 val.as_ref().map(|s| s.as_ref()).serialize(serializer)
768 }
769
770 pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result<Option<Arc<str>>, D::Error>
771 where
772 D: Deserializer<'de>,
773 {
774 Option::<Cow<'de, str>>::deserialize(deserializer).map(|opt| opt.map(super::arc_from_cow))
775 }
776}
777
778