1#![allow(missing_docs)]
7
8use serde::ser::SerializeStruct;
9use serde::{Deserialize, Serialize, Serializer};
10use std::borrow::Cow;
11use std::collections::{BTreeMap, HashMap};
12use std::sync::Arc;
13
14use crate::{SensitiveString, Severity};
15
16#[derive(Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
24#[repr(transparent)]
25#[serde(transparent)]
26pub struct CredentialHash(#[serde(with = "serde_hash_hex")] [u8; 32]);
27
28impl CredentialHash {
29 pub const ZERO: Self = Self([0; 32]);
32
33 #[inline]
35 pub const fn from_bytes(bytes: [u8; 32]) -> Self {
36 Self(bytes)
37 }
38
39 #[inline]
41 pub const fn as_bytes(&self) -> &[u8; 32] {
42 &self.0
43 }
44
45 #[inline]
47 pub const fn into_bytes(self) -> [u8; 32] {
48 self.0
49 }
50
51 #[inline]
53 pub const fn is_zero(self) -> bool {
54 let mut idx = 0;
55 while idx < self.0.len() {
56 if self.0[idx] != 0 {
57 return false;
58 }
59 idx += 1;
60 }
61 true
62 }
63}
64
65impl From<[u8; 32]> for CredentialHash {
66 #[inline]
67 fn from(bytes: [u8; 32]) -> Self {
68 Self::from_bytes(bytes)
69 }
70}
71
72impl From<CredentialHash> for [u8; 32] {
73 #[inline]
74 fn from(hash: CredentialHash) -> Self {
75 hash.into_bytes()
76 }
77}
78
79impl AsRef<[u8; 32]> for CredentialHash {
80 #[inline]
81 fn as_ref(&self) -> &[u8; 32] {
82 self.as_bytes()
83 }
84}
85
86impl AsRef<[u8]> for CredentialHash {
87 #[inline]
88 fn as_ref(&self) -> &[u8] {
89 self.as_bytes()
90 }
91}
92
93impl std::fmt::Debug for CredentialHash {
94 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95 f.write_str(&hex_encode(self))
96 }
97}
98
99#[derive(Clone, Copy, Debug, PartialEq, Eq)]
101pub struct RawMatchDedupKey<'a> {
102 pub detector_id: &'a str,
103 pub credential: &'a str,
104}
105
106pub type CompanionMap = HashMap<Arc<str>, String>;
112
113mod serde_companion_map {
114 use super::CompanionMap;
115 use serde::ser::SerializeMap;
116 use serde::{Deserialize, Serializer};
117 use std::collections::HashMap;
118 use std::sync::Arc;
119
120 pub(super) fn serialize<S>(companions: &CompanionMap, serializer: S) -> Result<S::Ok, S::Error>
121 where
122 S: Serializer,
123 {
124 let mut map = serializer.serialize_map(Some(companions.len()))?;
125 for (name, value) in companions {
126 map.serialize_entry(name.as_ref(), value)?;
127 }
128 map.end()
129 }
130
131 pub(super) fn deserialize<'de, D>(deserializer: D) -> Result<CompanionMap, D::Error>
132 where
133 D: serde::Deserializer<'de>,
134 {
135 HashMap::<String, String>::deserialize(deserializer).map(|companions| {
136 companions
137 .into_iter()
138 .map(|(name, value)| (Arc::from(name), value))
139 .collect()
140 })
141 }
142}
143
144#[derive(Clone, Serialize, Deserialize)]
156pub struct RawMatch {
157 #[serde(with = "serde_arc_str")]
159 pub detector_id: Arc<str>,
160 #[serde(with = "serde_arc_str")]
162 pub detector_name: Arc<str>,
163 #[serde(with = "serde_arc_str")]
165 pub service: Arc<str>,
166 pub severity: Severity,
168 pub credential: SensitiveString,
170 pub credential_hash: CredentialHash,
177 #[serde(with = "serde_companion_map")]
179 pub companions: CompanionMap,
180 pub location: MatchLocation,
182 #[serde(skip_serializing_if = "Option::is_none")]
184 pub entropy: Option<f64>,
185 #[serde(skip_serializing_if = "Option::is_none")]
187 pub confidence: Option<f64>,
188}
189
190impl RawMatch {
191 pub(crate) fn sanitize_floats(mut self) -> Self {
195 if self.entropy.is_some_and(f64::is_nan) {
196 self.entropy = None;
197 }
198 if self.confidence.is_some_and(f64::is_nan) {
199 self.confidence = None;
200 }
201 self
202 }
203}
204
205impl PartialEq for RawMatch {
206 fn eq(&self, other: &Self) -> bool {
207 self.detector_id == other.detector_id
212 && self.detector_name == other.detector_name
213 && self.service == other.service
214 && self.severity == other.severity
215 && self.credential == other.credential
216 && self.credential_hash == other.credential_hash
217 && self.companions == other.companions
218 && self.location == other.location
219 && opt_f64_total_eq(self.entropy, other.entropy)
220 && opt_f64_total_eq(self.confidence, other.confidence)
221 }
222}
223
224impl Eq for RawMatch {}
225
226impl std::fmt::Debug for RawMatch {
227 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
231 f.debug_struct("RawMatch")
232 .field("detector_id", &self.detector_id)
233 .field("detector_name", &self.detector_name)
234 .field("service", &self.service)
235 .field("severity", &self.severity)
236 .field(
237 "credential",
238 &format_args!("<redacted {} bytes>", self.credential.len()),
239 )
240 .field(
241 "credential_hash",
242 &format_args!("{}", hex_encode(self.credential_hash)),
243 )
244 .field(
245 "companions",
246 &format_args!("<{} redacted companions>", self.companions.len()),
247 )
248 .field("location", &self.location)
249 .field("entropy", &self.entropy)
250 .field("confidence", &self.confidence)
251 .finish()
252 }
253}
254
255#[inline]
256fn opt_f64_total_eq(a: Option<f64>, b: Option<f64>) -> bool {
257 match (a, b) {
258 (None, None) => true,
259 (Some(x), Some(y)) => x.total_cmp(&y) == std::cmp::Ordering::Equal,
260 _ => false,
261 }
262}
263
264#[inline]
265fn opt_f64_total_cmp(a: Option<f64>, b: Option<f64>) -> std::cmp::Ordering {
266 match (a, b) {
267 (None, None) => std::cmp::Ordering::Equal,
268 (None, Some(_)) => std::cmp::Ordering::Less,
269 (Some(_), None) => std::cmp::Ordering::Greater,
270 (Some(x), Some(y)) => x.total_cmp(&y),
271 }
272}
273
274fn companion_map_cmp(a: &CompanionMap, b: &CompanionMap) -> std::cmp::Ordering {
275 if a == b {
276 return std::cmp::Ordering::Equal;
277 }
278 match a.len().cmp(&b.len()) {
279 std::cmp::Ordering::Equal => {}
280 ordering => return ordering,
281 }
282
283 let mut a_after: Option<&str> = None;
288 let mut b_after: Option<&str> = None;
289 for _ in 0..a.len() {
290 let Some(a_entry) = a
291 .iter()
292 .filter(|(key, _)| a_after.is_none_or(|after| key.as_ref() > after))
293 .min_by(|left, right| left.0.cmp(right.0))
294 else {
295 return std::cmp::Ordering::Equal;
296 };
297 let Some(b_entry) = b
298 .iter()
299 .filter(|(key, _)| b_after.is_none_or(|after| key.as_ref() > after))
300 .min_by(|left, right| left.0.cmp(right.0))
301 else {
302 return std::cmp::Ordering::Equal;
303 };
304 match a_entry.cmp(&b_entry) {
305 std::cmp::Ordering::Equal => {
306 a_after = Some(a_entry.0.as_ref());
307 b_after = Some(b_entry.0.as_ref());
308 }
309 ordering => return ordering,
310 }
311 }
312 std::cmp::Ordering::Equal
313}
314
315impl PartialOrd for RawMatch {
316 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
317 Some(self.cmp(other))
318 }
319}
320
321impl Ord for RawMatch {
322 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
323 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) {
331 std::cmp::Ordering::Equal => {}
332 ord => return ord,
333 }
334
335 match other.severity.cmp(&self.severity) {
337 std::cmp::Ordering::Equal => {}
338 ord => return ord,
339 }
340
341 match self.detector_id.cmp(&other.detector_id) {
343 std::cmp::Ordering::Equal => {}
344 ord => return ord,
345 }
346 match self.credential.cmp(&other.credential) {
347 std::cmp::Ordering::Equal => {}
348 ord => return ord,
349 }
350
351 match self.location.offset.cmp(&other.location.offset) {
363 std::cmp::Ordering::Equal => {}
364 ord => return ord,
365 }
366 match self.location.line.cmp(&other.location.line) {
367 std::cmp::Ordering::Equal => {}
368 ord => return ord,
369 }
370
371 self.detector_name
377 .cmp(&other.detector_name)
378 .then_with(|| self.service.cmp(&other.service))
379 .then_with(|| self.credential_hash.cmp(&other.credential_hash))
380 .then_with(|| companion_map_cmp(&self.companions, &other.companions))
381 .then_with(|| self.location.source.cmp(&other.location.source))
382 .then_with(|| self.location.file_path.cmp(&other.location.file_path))
383 .then_with(|| self.location.commit.cmp(&other.location.commit))
384 .then_with(|| self.location.author.cmp(&other.location.author))
385 .then_with(|| self.location.date.cmp(&other.location.date))
386 .then_with(|| opt_f64_total_cmp(self.entropy, other.entropy))
387 .then_with(|| opt_f64_total_cmp(self.confidence, other.confidence))
388 }
389}
390
391#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
393pub struct MatchLocation {
394 #[serde(with = "serde_arc_str")]
396 pub source: Arc<str>,
397 #[serde(with = "serde_arc_str_opt")]
403 pub file_path: Option<Arc<str>>,
404 pub line: Option<usize>,
406 pub offset: usize,
408 #[serde(with = "serde_arc_str_opt")]
410 pub commit: Option<Arc<str>>,
411 #[serde(with = "serde_arc_str_opt")]
413 pub author: Option<Arc<str>>,
414 #[serde(with = "serde_arc_str_opt")]
416 pub date: Option<Arc<str>>,
417}
418
419#[derive(Debug, Clone, Deserialize)]
421pub struct VerifiedFinding {
422 #[serde(with = "serde_arc_str")]
424 pub detector_id: Arc<str>,
425 #[serde(with = "serde_arc_str")]
427 pub detector_name: Arc<str>,
428 #[serde(with = "serde_arc_str")]
430 pub service: Arc<str>,
431 pub severity: Severity,
433 pub credential_redacted: Cow<'static, str>,
435 pub credential_hash: CredentialHash,
438 #[serde(default)]
443 pub companions_redacted: HashMap<String, String>,
444 pub location: MatchLocation,
446 pub verification: VerificationResult,
448 pub metadata: HashMap<String, String>,
450 pub additional_locations: Vec<MatchLocation>,
452 #[serde(skip_serializing_if = "Option::is_none")]
454 pub entropy: Option<f64>,
455 #[serde(skip_serializing_if = "Option::is_none")]
457 pub confidence: Option<f64>,
458}
459
460impl VerifiedFinding {
461 pub fn from_deduped(
467 group: crate::DedupedMatch,
468 severity: Severity,
469 verification: VerificationResult,
470 metadata: HashMap<String, String>,
471 ) -> Self {
472 Self {
473 detector_id: group.detector_id,
474 detector_name: group.detector_name,
475 service: group.service,
476 severity,
477 credential_redacted: crate::redact(&group.credential),
478 credential_hash: group.credential_hash,
479 companions_redacted: redact_companions(&group.companions),
480 location: group.primary_location,
481 verification,
482 metadata,
483 additional_locations: group.additional_locations,
484 entropy: group.entropy,
485 confidence: group.confidence,
486 }
487 }
488}
489
490impl Serialize for VerifiedFinding {
491 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
492 where
493 S: Serializer,
494 {
495 let remediation =
496 crate::auto_fix::remediation_for(&self.detector_id, &self.service, self.severity);
497 let mut field_count = 12;
498 if self.entropy.is_some() {
499 field_count += 1;
500 }
501 if self.confidence.is_some() {
502 field_count += 1;
503 }
504 let mut state = serializer.serialize_struct("VerifiedFinding", field_count)?;
505 state.serialize_field("detector_id", self.detector_id.as_ref())?;
506 state.serialize_field("detector_name", self.detector_name.as_ref())?;
507 state.serialize_field("service", self.service.as_ref())?;
508 state.serialize_field("severity", &self.severity)?;
509 state.serialize_field("credential_redacted", self.credential_redacted.as_ref())?;
510 state.serialize_field("credential_hash", &hex_encode(self.credential_hash))?;
511 let sorted_companions: BTreeMap<&str, &str> = self
512 .companions_redacted
513 .iter()
514 .map(|(key, value)| (key.as_str(), value.as_str()))
515 .collect();
516 state.serialize_field("companions_redacted", &sorted_companions)?;
517 state.serialize_field("location", &self.location)?;
518 state.serialize_field("verification", &self.verification)?;
519 let sorted_metadata: BTreeMap<&str, &str> = self
520 .metadata
521 .iter()
522 .map(|(key, value)| (key.as_str(), value.as_str()))
523 .collect();
524 state.serialize_field("metadata", &sorted_metadata)?;
525 state.serialize_field("additional_locations", &self.additional_locations)?;
526 if let Some(entropy) = self.entropy {
527 state.serialize_field("entropy", &entropy)?;
528 }
529 if let Some(confidence) = self.confidence {
530 state.serialize_field("confidence", &confidence)?;
531 }
532 state.serialize_field("remediation", &remediation)?;
533 state.end()
534 }
535}
536
537#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
539#[serde(rename_all = "snake_case")]
540pub enum VerificationResult {
541 Live,
543 Revoked,
545 Dead,
547 RateLimited,
549 Error(String),
551 Unverifiable,
553 Skipped,
555}
556
557impl RawMatch {
558 pub(crate) fn deduplication_key(&self) -> RawMatchDedupKey<'_> {
564 RawMatchDedupKey {
565 detector_id: &self.detector_id,
566 credential: &self.credential,
567 }
568 }
569
570 pub fn to_redacted(&self) -> RedactedFinding {
575 RedactedFinding {
576 detector_id: self.detector_id.clone(),
577 detector_name: self.detector_name.clone(),
578 service: self.service.clone(),
579 severity: self.severity,
580 credential_redacted: crate::redact(&self.credential),
581 credential_hash: self.credential_hash,
582 companions_redacted: redact_companions(&self.companions),
583 location: self.location.clone(),
584 entropy: self.entropy,
585 confidence: self.confidence,
586 }
587 }
588}
589
590pub fn redact_companions<K>(companions: &HashMap<K, String>) -> HashMap<String, String>
595where
596 K: AsRef<str> + Eq + std::hash::Hash,
597{
598 companions
599 .iter()
600 .map(|(key, value)| (key.as_ref().to_string(), crate::redact(value).into_owned()))
601 .collect()
602}
603
604#[derive(Debug, Clone, Serialize, Deserialize)]
608pub struct RedactedFinding {
609 #[serde(with = "serde_arc_str")]
610 pub detector_id: Arc<str>,
611 #[serde(with = "serde_arc_str")]
612 pub detector_name: Arc<str>,
613 #[serde(with = "serde_arc_str")]
614 pub service: Arc<str>,
615 pub severity: Severity,
616 pub credential_redacted: Cow<'static, str>,
617 pub credential_hash: CredentialHash,
619 pub companions_redacted: HashMap<String, String>,
620 pub location: MatchLocation,
621 #[serde(skip_serializing_if = "Option::is_none")]
622 pub entropy: Option<f64>,
623 #[serde(skip_serializing_if = "Option::is_none")]
624 pub confidence: Option<f64>,
625}
626
627#[inline]
630pub fn hex_encode(bytes: impl AsRef<[u8]>) -> String {
631 hex::encode(bytes.as_ref())
632}
633
634#[inline]
639pub fn sha256_hash(s: &str) -> CredentialHash {
640 use sha2::{Digest, Sha256};
641 let mut hasher = Sha256::new();
642 hasher.update(s.as_bytes());
643 CredentialHash::from_bytes(hasher.finalize().into())
644}
645
646pub(crate) mod serde_hash_hex {
651 use std::borrow::Cow;
652
653 use serde::{Deserialize, Deserializer, Serializer};
654
655 pub(crate) fn serialize<S>(val: &[u8; 32], serializer: S) -> Result<S::Ok, S::Error>
656 where
657 S: Serializer,
658 {
659 serializer.serialize_str(&super::hex_encode(val))
660 }
661
662 pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result<[u8; 32], D::Error>
663 where
664 D: Deserializer<'de>,
665 {
666 let s = Cow::<'de, str>::deserialize(deserializer)?;
667 if s.len() != crate::git_lfs::SHA256_HEX_LEN {
668 return Err(serde::de::Error::invalid_length(
669 s.len(),
670 &"64-char hex SHA-256 digest",
671 ));
672 }
673 let mut bytes = [0_u8; 32];
674 hex::decode_to_slice(s.as_bytes(), &mut bytes).map_err(serde::de::Error::custom)?;
675 Ok(bytes)
676 }
677}
678
679#[inline]
683fn arc_from_cow(value: Cow<'_, str>) -> Arc<str> {
684 match value {
685 Cow::Borrowed(value) => Arc::from(value),
686 Cow::Owned(value) => Arc::from(value),
687 }
688}
689
690pub(crate) mod serde_arc_str {
691 use serde::{Deserialize, Deserializer, Serialize, Serializer};
692 use std::borrow::Cow;
693 use std::sync::Arc;
694
695 pub(crate) fn serialize<S>(val: &Arc<str>, serializer: S) -> Result<S::Ok, S::Error>
696 where
697 S: Serializer,
698 {
699 val.as_ref().serialize(serializer)
700 }
701
702 pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result<Arc<str>, D::Error>
703 where
704 D: Deserializer<'de>,
705 {
706 Cow::<'de, str>::deserialize(deserializer).map(super::arc_from_cow)
707 }
708}
709
710pub(crate) mod serde_arc_str_opt {
711 use serde::{Deserialize, Deserializer, Serialize, Serializer};
712 use std::borrow::Cow;
713 use std::sync::Arc;
714
715 pub(crate) fn serialize<S>(val: &Option<Arc<str>>, serializer: S) -> Result<S::Ok, S::Error>
716 where
717 S: Serializer,
718 {
719 val.as_ref().map(|s| s.as_ref()).serialize(serializer)
720 }
721
722 pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result<Option<Arc<str>>, D::Error>
723 where
724 D: Deserializer<'de>,
725 {
726 Option::<Cow<'de, str>>::deserialize(deserializer).map(|opt| opt.map(super::arc_from_cow))
727 }
728}
729
730