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
106#[derive(Clone, Serialize, Deserialize)]
117pub struct RawMatch {
118 #[serde(with = "serde_arc_str")]
120 pub detector_id: Arc<str>,
121 #[serde(with = "serde_arc_str")]
123 pub detector_name: Arc<str>,
124 #[serde(with = "serde_arc_str")]
126 pub service: Arc<str>,
127 pub severity: Severity,
129 pub credential: SensitiveString,
131 pub credential_hash: CredentialHash,
138 pub companions: std::collections::HashMap<String, String>,
140 pub location: MatchLocation,
142 #[serde(skip_serializing_if = "Option::is_none")]
144 pub entropy: Option<f64>,
145 #[serde(skip_serializing_if = "Option::is_none")]
147 pub confidence: Option<f64>,
148}
149
150impl RawMatch {
151 pub(crate) fn sanitize_floats(mut self) -> Self {
155 if self.entropy.is_some_and(f64::is_nan) {
156 self.entropy = None;
157 }
158 if self.confidence.is_some_and(f64::is_nan) {
159 self.confidence = None;
160 }
161 self
162 }
163}
164
165impl PartialEq for RawMatch {
166 fn eq(&self, other: &Self) -> bool {
167 self.detector_id == other.detector_id
172 && self.detector_name == other.detector_name
173 && self.service == other.service
174 && self.severity == other.severity
175 && self.credential == other.credential
176 && self.credential_hash == other.credential_hash
177 && self.companions == other.companions
178 && self.location == other.location
179 && opt_f64_total_eq(self.entropy, other.entropy)
180 && opt_f64_total_eq(self.confidence, other.confidence)
181 }
182}
183
184impl Eq for RawMatch {}
185
186impl std::fmt::Debug for RawMatch {
187 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
191 f.debug_struct("RawMatch")
192 .field("detector_id", &self.detector_id)
193 .field("detector_name", &self.detector_name)
194 .field("service", &self.service)
195 .field("severity", &self.severity)
196 .field(
197 "credential",
198 &format_args!("<redacted {} bytes>", self.credential.len()),
199 )
200 .field(
201 "credential_hash",
202 &format_args!("{}", hex_encode(self.credential_hash)),
203 )
204 .field(
205 "companions",
206 &format_args!("<{} redacted companions>", self.companions.len()),
207 )
208 .field("location", &self.location)
209 .field("entropy", &self.entropy)
210 .field("confidence", &self.confidence)
211 .finish()
212 }
213}
214
215#[inline]
216fn opt_f64_total_eq(a: Option<f64>, b: Option<f64>) -> bool {
217 match (a, b) {
218 (None, None) => true,
219 (Some(x), Some(y)) => x.total_cmp(&y) == std::cmp::Ordering::Equal,
220 _ => false,
221 }
222}
223
224#[inline]
225fn opt_f64_total_cmp(a: Option<f64>, b: Option<f64>) -> std::cmp::Ordering {
226 match (a, b) {
227 (None, None) => std::cmp::Ordering::Equal,
228 (None, Some(_)) => std::cmp::Ordering::Less,
229 (Some(_), None) => std::cmp::Ordering::Greater,
230 (Some(x), Some(y)) => x.total_cmp(&y),
231 }
232}
233
234fn companion_map_cmp(
235 a: &std::collections::HashMap<String, String>,
236 b: &std::collections::HashMap<String, String>,
237) -> std::cmp::Ordering {
238 if a == b {
239 return std::cmp::Ordering::Equal;
240 }
241 match a.len().cmp(&b.len()) {
242 std::cmp::Ordering::Equal => {}
243 ordering => return ordering,
244 }
245
246 let mut a_after: Option<&str> = None;
251 let mut b_after: Option<&str> = None;
252 for _ in 0..a.len() {
253 let Some(a_entry) = a
254 .iter()
255 .filter(|(key, _)| a_after.is_none_or(|after| key.as_str() > after))
256 .min_by(|left, right| left.0.cmp(right.0))
257 else {
258 return std::cmp::Ordering::Equal;
259 };
260 let Some(b_entry) = b
261 .iter()
262 .filter(|(key, _)| b_after.is_none_or(|after| key.as_str() > after))
263 .min_by(|left, right| left.0.cmp(right.0))
264 else {
265 return std::cmp::Ordering::Equal;
266 };
267 match a_entry.cmp(&b_entry) {
268 std::cmp::Ordering::Equal => {
269 a_after = Some(a_entry.0.as_str());
270 b_after = Some(b_entry.0.as_str());
271 }
272 ordering => return ordering,
273 }
274 }
275 std::cmp::Ordering::Equal
276}
277
278impl PartialOrd for RawMatch {
279 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
280 Some(self.cmp(other))
281 }
282}
283
284impl Ord for RawMatch {
285 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
286 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) {
294 std::cmp::Ordering::Equal => {}
295 ord => return ord,
296 }
297
298 match other.severity.cmp(&self.severity) {
300 std::cmp::Ordering::Equal => {}
301 ord => return ord,
302 }
303
304 match self.detector_id.cmp(&other.detector_id) {
306 std::cmp::Ordering::Equal => {}
307 ord => return ord,
308 }
309 match self.credential.cmp(&other.credential) {
310 std::cmp::Ordering::Equal => {}
311 ord => return ord,
312 }
313
314 match self.location.offset.cmp(&other.location.offset) {
326 std::cmp::Ordering::Equal => {}
327 ord => return ord,
328 }
329 match self.location.line.cmp(&other.location.line) {
330 std::cmp::Ordering::Equal => {}
331 ord => return ord,
332 }
333
334 self.detector_name
340 .cmp(&other.detector_name)
341 .then_with(|| self.service.cmp(&other.service))
342 .then_with(|| self.credential_hash.cmp(&other.credential_hash))
343 .then_with(|| companion_map_cmp(&self.companions, &other.companions))
344 .then_with(|| self.location.source.cmp(&other.location.source))
345 .then_with(|| self.location.file_path.cmp(&other.location.file_path))
346 .then_with(|| self.location.commit.cmp(&other.location.commit))
347 .then_with(|| self.location.author.cmp(&other.location.author))
348 .then_with(|| self.location.date.cmp(&other.location.date))
349 .then_with(|| opt_f64_total_cmp(self.entropy, other.entropy))
350 .then_with(|| opt_f64_total_cmp(self.confidence, other.confidence))
351 }
352}
353
354#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
356pub struct MatchLocation {
357 #[serde(with = "serde_arc_str")]
359 pub source: Arc<str>,
360 #[serde(with = "serde_arc_str_opt")]
366 pub file_path: Option<Arc<str>>,
367 pub line: Option<usize>,
369 pub offset: usize,
371 #[serde(with = "serde_arc_str_opt")]
373 pub commit: Option<Arc<str>>,
374 #[serde(with = "serde_arc_str_opt")]
376 pub author: Option<Arc<str>>,
377 #[serde(with = "serde_arc_str_opt")]
379 pub date: Option<Arc<str>>,
380}
381
382#[derive(Debug, Clone, Deserialize)]
384pub struct VerifiedFinding {
385 #[serde(with = "serde_arc_str")]
387 pub detector_id: Arc<str>,
388 #[serde(with = "serde_arc_str")]
390 pub detector_name: Arc<str>,
391 #[serde(with = "serde_arc_str")]
393 pub service: Arc<str>,
394 pub severity: Severity,
396 pub credential_redacted: Cow<'static, str>,
398 pub credential_hash: CredentialHash,
401 #[serde(default)]
406 pub companions_redacted: HashMap<String, String>,
407 pub location: MatchLocation,
409 pub verification: VerificationResult,
411 pub metadata: HashMap<String, String>,
413 pub additional_locations: Vec<MatchLocation>,
415 #[serde(skip_serializing_if = "Option::is_none")]
417 pub entropy: Option<f64>,
418 #[serde(skip_serializing_if = "Option::is_none")]
420 pub confidence: Option<f64>,
421}
422
423impl VerifiedFinding {
424 pub fn from_deduped(
430 group: crate::DedupedMatch,
431 severity: Severity,
432 verification: VerificationResult,
433 metadata: HashMap<String, String>,
434 ) -> Self {
435 Self {
436 detector_id: group.detector_id,
437 detector_name: group.detector_name,
438 service: group.service,
439 severity,
440 credential_redacted: crate::redact(&group.credential),
441 credential_hash: group.credential_hash,
442 companions_redacted: redact_companions(&group.companions),
443 location: group.primary_location,
444 verification,
445 metadata,
446 additional_locations: group.additional_locations,
447 entropy: group.entropy,
448 confidence: group.confidence,
449 }
450 }
451}
452
453impl Serialize for VerifiedFinding {
454 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
455 where
456 S: Serializer,
457 {
458 let remediation =
459 crate::auto_fix::remediation_for(&self.detector_id, &self.service, self.severity);
460 let mut field_count = 12;
461 if self.entropy.is_some() {
462 field_count += 1;
463 }
464 if self.confidence.is_some() {
465 field_count += 1;
466 }
467 let mut state = serializer.serialize_struct("VerifiedFinding", field_count)?;
468 state.serialize_field("detector_id", self.detector_id.as_ref())?;
469 state.serialize_field("detector_name", self.detector_name.as_ref())?;
470 state.serialize_field("service", self.service.as_ref())?;
471 state.serialize_field("severity", &self.severity)?;
472 state.serialize_field("credential_redacted", self.credential_redacted.as_ref())?;
473 state.serialize_field("credential_hash", &hex_encode(self.credential_hash))?;
474 let sorted_companions: BTreeMap<&str, &str> = self
475 .companions_redacted
476 .iter()
477 .map(|(key, value)| (key.as_str(), value.as_str()))
478 .collect();
479 state.serialize_field("companions_redacted", &sorted_companions)?;
480 state.serialize_field("location", &self.location)?;
481 state.serialize_field("verification", &self.verification)?;
482 let sorted_metadata: BTreeMap<&str, &str> = self
483 .metadata
484 .iter()
485 .map(|(key, value)| (key.as_str(), value.as_str()))
486 .collect();
487 state.serialize_field("metadata", &sorted_metadata)?;
488 state.serialize_field("additional_locations", &self.additional_locations)?;
489 if let Some(entropy) = self.entropy {
490 state.serialize_field("entropy", &entropy)?;
491 }
492 if let Some(confidence) = self.confidence {
493 state.serialize_field("confidence", &confidence)?;
494 }
495 state.serialize_field("remediation", &remediation)?;
496 state.end()
497 }
498}
499
500#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
502#[serde(rename_all = "snake_case")]
503pub enum VerificationResult {
504 Live,
506 Revoked,
508 Dead,
510 RateLimited,
512 Error(String),
514 Unverifiable,
516 Skipped,
518}
519
520impl RawMatch {
521 pub(crate) fn deduplication_key(&self) -> RawMatchDedupKey<'_> {
527 RawMatchDedupKey {
528 detector_id: &self.detector_id,
529 credential: &self.credential,
530 }
531 }
532
533 pub fn to_redacted(&self) -> RedactedFinding {
538 RedactedFinding {
539 detector_id: self.detector_id.clone(),
540 detector_name: self.detector_name.clone(),
541 service: self.service.clone(),
542 severity: self.severity,
543 credential_redacted: crate::redact(&self.credential),
544 credential_hash: self.credential_hash,
545 companions_redacted: redact_companions(&self.companions),
546 location: self.location.clone(),
547 entropy: self.entropy,
548 confidence: self.confidence,
549 }
550 }
551}
552
553pub fn redact_companions(
558 companions: &std::collections::HashMap<String, String>,
559) -> std::collections::HashMap<String, String> {
560 companions
561 .iter()
562 .map(|(key, value)| (key.clone(), crate::redact(value).into_owned()))
563 .collect()
564}
565
566#[derive(Debug, Clone, Serialize, Deserialize)]
570pub struct RedactedFinding {
571 #[serde(with = "serde_arc_str")]
572 pub detector_id: Arc<str>,
573 #[serde(with = "serde_arc_str")]
574 pub detector_name: Arc<str>,
575 #[serde(with = "serde_arc_str")]
576 pub service: Arc<str>,
577 pub severity: Severity,
578 pub credential_redacted: Cow<'static, str>,
579 pub credential_hash: CredentialHash,
581 pub companions_redacted: HashMap<String, String>,
582 pub location: MatchLocation,
583 #[serde(skip_serializing_if = "Option::is_none")]
584 pub entropy: Option<f64>,
585 #[serde(skip_serializing_if = "Option::is_none")]
586 pub confidence: Option<f64>,
587}
588
589#[inline]
592pub fn hex_encode(bytes: impl AsRef<[u8]>) -> String {
593 hex::encode(bytes.as_ref())
594}
595
596#[inline]
601pub fn sha256_hash(s: &str) -> CredentialHash {
602 use sha2::{Digest, Sha256};
603 let mut hasher = Sha256::new();
604 hasher.update(s.as_bytes());
605 CredentialHash::from_bytes(hasher.finalize().into())
606}
607
608pub(crate) mod serde_hash_hex {
613 use std::borrow::Cow;
614
615 use serde::{Deserialize, Deserializer, Serializer};
616
617 pub(crate) fn serialize<S>(val: &[u8; 32], serializer: S) -> Result<S::Ok, S::Error>
618 where
619 S: Serializer,
620 {
621 serializer.serialize_str(&super::hex_encode(val))
622 }
623
624 pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result<[u8; 32], D::Error>
625 where
626 D: Deserializer<'de>,
627 {
628 let s = Cow::<'de, str>::deserialize(deserializer)?;
629 if s.len() != crate::git_lfs::SHA256_HEX_LEN {
630 return Err(serde::de::Error::invalid_length(
631 s.len(),
632 &"64-char hex SHA-256 digest",
633 ));
634 }
635 let mut bytes = [0_u8; 32];
636 hex::decode_to_slice(s.as_bytes(), &mut bytes).map_err(serde::de::Error::custom)?;
637 Ok(bytes)
638 }
639}
640
641#[inline]
645fn arc_from_cow(value: Cow<'_, str>) -> Arc<str> {
646 match value {
647 Cow::Borrowed(value) => Arc::from(value),
648 Cow::Owned(value) => Arc::from(value),
649 }
650}
651
652pub(crate) mod serde_arc_str {
653 use serde::{Deserialize, Deserializer, Serialize, Serializer};
654 use std::borrow::Cow;
655 use std::sync::Arc;
656
657 pub(crate) fn serialize<S>(val: &Arc<str>, serializer: S) -> Result<S::Ok, S::Error>
658 where
659 S: Serializer,
660 {
661 val.as_ref().serialize(serializer)
662 }
663
664 pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result<Arc<str>, D::Error>
665 where
666 D: Deserializer<'de>,
667 {
668 Cow::<'de, str>::deserialize(deserializer).map(super::arc_from_cow)
669 }
670}
671
672pub(crate) mod serde_arc_str_opt {
673 use serde::{Deserialize, Deserializer, Serialize, Serializer};
674 use std::borrow::Cow;
675 use std::sync::Arc;
676
677 pub(crate) fn serialize<S>(val: &Option<Arc<str>>, serializer: S) -> Result<S::Ok, S::Error>
678 where
679 S: Serializer,
680 {
681 val.as_ref().map(|s| s.as_ref()).serialize(serializer)
682 }
683
684 pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result<Option<Arc<str>>, D::Error>
685 where
686 D: Deserializer<'de>,
687 {
688 Option::<Cow<'de, str>>::deserialize(deserializer).map(|opt| opt.map(super::arc_from_cow))
689 }
690}
691
692