#![allow(missing_docs)]
use serde::ser::SerializeStruct;
use serde::{Deserialize, Serialize, Serializer};
use std::borrow::Cow;
use std::collections::{BTreeMap, HashMap};
use std::sync::Arc;
use crate::{SensitiveString, Severity};
#[derive(Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[repr(transparent)]
#[serde(transparent)]
pub struct CredentialHash(#[serde(with = "serde_hash_hex")] [u8; 32]);
impl CredentialHash {
pub const ZERO: Self = Self([0; 32]);
#[inline]
pub const fn from_bytes(bytes: [u8; 32]) -> Self {
Self(bytes)
}
#[inline]
pub const fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
#[inline]
pub const fn into_bytes(self) -> [u8; 32] {
self.0
}
#[inline]
pub const fn is_zero(self) -> bool {
let mut idx = 0;
while idx < self.0.len() {
if self.0[idx] != 0 {
return false;
}
idx += 1;
}
true
}
}
impl From<[u8; 32]> for CredentialHash {
#[inline]
fn from(bytes: [u8; 32]) -> Self {
Self::from_bytes(bytes)
}
}
impl From<CredentialHash> for [u8; 32] {
#[inline]
fn from(hash: CredentialHash) -> Self {
hash.into_bytes()
}
}
impl AsRef<[u8; 32]> for CredentialHash {
#[inline]
fn as_ref(&self) -> &[u8; 32] {
self.as_bytes()
}
}
impl AsRef<[u8]> for CredentialHash {
#[inline]
fn as_ref(&self) -> &[u8] {
self.as_bytes()
}
}
impl std::fmt::Debug for CredentialHash {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&hex_encode(self))
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct RawMatchDedupKey<'a> {
pub detector_id: &'a str,
pub credential: &'a str,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct RawMatch {
#[serde(with = "serde_arc_str")]
pub detector_id: Arc<str>,
#[serde(with = "serde_arc_str")]
pub detector_name: Arc<str>,
#[serde(with = "serde_arc_str")]
pub service: Arc<str>,
pub severity: Severity,
pub credential: SensitiveString,
pub credential_hash: CredentialHash,
pub companions: std::collections::HashMap<String, String>,
pub location: MatchLocation,
#[serde(skip_serializing_if = "Option::is_none")]
pub entropy: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub confidence: Option<f64>,
}
impl RawMatch {
pub(crate) fn sanitize_floats(mut self) -> Self {
if self.entropy.is_some_and(f64::is_nan) {
self.entropy = None;
}
if self.confidence.is_some_and(f64::is_nan) {
self.confidence = None;
}
self
}
}
impl PartialEq for RawMatch {
fn eq(&self, other: &Self) -> bool {
self.detector_id == other.detector_id
&& self.detector_name == other.detector_name
&& self.service == other.service
&& self.severity == other.severity
&& self.credential == other.credential
&& self.credential_hash == other.credential_hash
&& self.companions == other.companions
&& self.location == other.location
&& opt_f64_total_eq(self.entropy, other.entropy)
&& opt_f64_total_eq(self.confidence, other.confidence)
}
}
impl Eq for RawMatch {}
impl std::fmt::Debug for RawMatch {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RawMatch")
.field("detector_id", &self.detector_id)
.field("detector_name", &self.detector_name)
.field("service", &self.service)
.field("severity", &self.severity)
.field(
"credential",
&format_args!("<redacted {} bytes>", self.credential.len()),
)
.field(
"credential_hash",
&format_args!("{}", hex_encode(self.credential_hash)),
)
.field(
"companions",
&format_args!("<{} redacted companions>", self.companions.len()),
)
.field("location", &self.location)
.field("entropy", &self.entropy)
.field("confidence", &self.confidence)
.finish()
}
}
#[inline]
fn opt_f64_total_eq(a: Option<f64>, b: Option<f64>) -> bool {
match (a, b) {
(None, None) => true,
(Some(x), Some(y)) => x.total_cmp(&y) == std::cmp::Ordering::Equal,
_ => false,
}
}
#[inline]
fn opt_f64_total_cmp(a: Option<f64>, b: Option<f64>) -> std::cmp::Ordering {
match (a, b) {
(None, None) => std::cmp::Ordering::Equal,
(None, Some(_)) => std::cmp::Ordering::Less,
(Some(_), None) => std::cmp::Ordering::Greater,
(Some(x), Some(y)) => x.total_cmp(&y),
}
}
fn companion_map_cmp(
a: &std::collections::HashMap<String, String>,
b: &std::collections::HashMap<String, String>,
) -> std::cmp::Ordering {
if a == b {
return std::cmp::Ordering::Equal;
}
match a.len().cmp(&b.len()) {
std::cmp::Ordering::Equal => {}
ordering => return ordering,
}
let mut a_after: Option<&str> = None;
let mut b_after: Option<&str> = None;
for _ in 0..a.len() {
let Some(a_entry) = a
.iter()
.filter(|(key, _)| a_after.is_none_or(|after| key.as_str() > after))
.min_by(|left, right| left.0.cmp(right.0))
else {
return std::cmp::Ordering::Equal;
};
let Some(b_entry) = b
.iter()
.filter(|(key, _)| b_after.is_none_or(|after| key.as_str() > after))
.min_by(|left, right| left.0.cmp(right.0))
else {
return std::cmp::Ordering::Equal;
};
match a_entry.cmp(&b_entry) {
std::cmp::Ordering::Equal => {
a_after = Some(a_entry.0.as_str());
b_after = Some(b_entry.0.as_str());
}
ordering => return ordering,
}
}
std::cmp::Ordering::Equal
}
impl PartialOrd for RawMatch {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for RawMatch {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
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) {
std::cmp::Ordering::Equal => {}
ord => return ord,
}
match other.severity.cmp(&self.severity) {
std::cmp::Ordering::Equal => {}
ord => return ord,
}
match self.detector_id.cmp(&other.detector_id) {
std::cmp::Ordering::Equal => {}
ord => return ord,
}
match self.credential.cmp(&other.credential) {
std::cmp::Ordering::Equal => {}
ord => return ord,
}
match self.location.offset.cmp(&other.location.offset) {
std::cmp::Ordering::Equal => {}
ord => return ord,
}
match self.location.line.cmp(&other.location.line) {
std::cmp::Ordering::Equal => {}
ord => return ord,
}
self.detector_name
.cmp(&other.detector_name)
.then_with(|| self.service.cmp(&other.service))
.then_with(|| self.credential_hash.cmp(&other.credential_hash))
.then_with(|| companion_map_cmp(&self.companions, &other.companions))
.then_with(|| self.location.source.cmp(&other.location.source))
.then_with(|| self.location.file_path.cmp(&other.location.file_path))
.then_with(|| self.location.commit.cmp(&other.location.commit))
.then_with(|| self.location.author.cmp(&other.location.author))
.then_with(|| self.location.date.cmp(&other.location.date))
.then_with(|| opt_f64_total_cmp(self.entropy, other.entropy))
.then_with(|| opt_f64_total_cmp(self.confidence, other.confidence))
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct MatchLocation {
#[serde(with = "serde_arc_str")]
pub source: Arc<str>,
#[serde(with = "serde_arc_str_opt")]
pub file_path: Option<Arc<str>>,
pub line: Option<usize>,
pub offset: usize,
#[serde(with = "serde_arc_str_opt")]
pub commit: Option<Arc<str>>,
#[serde(with = "serde_arc_str_opt")]
pub author: Option<Arc<str>>,
#[serde(with = "serde_arc_str_opt")]
pub date: Option<Arc<str>>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct VerifiedFinding {
#[serde(with = "serde_arc_str")]
pub detector_id: Arc<str>,
#[serde(with = "serde_arc_str")]
pub detector_name: Arc<str>,
#[serde(with = "serde_arc_str")]
pub service: Arc<str>,
pub severity: Severity,
pub credential_redacted: Cow<'static, str>,
pub credential_hash: CredentialHash,
#[serde(default)]
pub companions_redacted: HashMap<String, String>,
pub location: MatchLocation,
pub verification: VerificationResult,
pub metadata: HashMap<String, String>,
pub additional_locations: Vec<MatchLocation>,
#[serde(skip_serializing_if = "Option::is_none")]
pub entropy: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub confidence: Option<f64>,
}
impl VerifiedFinding {
pub fn from_deduped(
group: crate::DedupedMatch,
severity: Severity,
verification: VerificationResult,
metadata: HashMap<String, String>,
) -> Self {
Self {
detector_id: group.detector_id,
detector_name: group.detector_name,
service: group.service,
severity,
credential_redacted: crate::redact(&group.credential),
credential_hash: group.credential_hash,
companions_redacted: redact_companions(&group.companions),
location: group.primary_location,
verification,
metadata,
additional_locations: group.additional_locations,
entropy: group.entropy,
confidence: group.confidence,
}
}
}
impl Serialize for VerifiedFinding {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let remediation =
crate::auto_fix::remediation_for(&self.detector_id, &self.service, self.severity);
let mut field_count = 12;
if self.entropy.is_some() {
field_count += 1;
}
if self.confidence.is_some() {
field_count += 1;
}
let mut state = serializer.serialize_struct("VerifiedFinding", field_count)?;
state.serialize_field("detector_id", self.detector_id.as_ref())?;
state.serialize_field("detector_name", self.detector_name.as_ref())?;
state.serialize_field("service", self.service.as_ref())?;
state.serialize_field("severity", &self.severity)?;
state.serialize_field("credential_redacted", self.credential_redacted.as_ref())?;
state.serialize_field("credential_hash", &hex_encode(self.credential_hash))?;
let sorted_companions: BTreeMap<&str, &str> = self
.companions_redacted
.iter()
.map(|(key, value)| (key.as_str(), value.as_str()))
.collect();
state.serialize_field("companions_redacted", &sorted_companions)?;
state.serialize_field("location", &self.location)?;
state.serialize_field("verification", &self.verification)?;
let sorted_metadata: BTreeMap<&str, &str> = self
.metadata
.iter()
.map(|(key, value)| (key.as_str(), value.as_str()))
.collect();
state.serialize_field("metadata", &sorted_metadata)?;
state.serialize_field("additional_locations", &self.additional_locations)?;
if let Some(entropy) = self.entropy {
state.serialize_field("entropy", &entropy)?;
}
if let Some(confidence) = self.confidence {
state.serialize_field("confidence", &confidence)?;
}
state.serialize_field("remediation", &remediation)?;
state.end()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum VerificationResult {
Live,
Revoked,
Dead,
RateLimited,
Error(String),
Unverifiable,
Skipped,
}
impl RawMatch {
pub(crate) fn deduplication_key(&self) -> RawMatchDedupKey<'_> {
RawMatchDedupKey {
detector_id: &self.detector_id,
credential: &self.credential,
}
}
pub fn to_redacted(&self) -> RedactedFinding {
RedactedFinding {
detector_id: self.detector_id.clone(),
detector_name: self.detector_name.clone(),
service: self.service.clone(),
severity: self.severity,
credential_redacted: crate::redact(&self.credential),
credential_hash: self.credential_hash,
companions_redacted: redact_companions(&self.companions),
location: self.location.clone(),
entropy: self.entropy,
confidence: self.confidence,
}
}
}
pub fn redact_companions(
companions: &std::collections::HashMap<String, String>,
) -> std::collections::HashMap<String, String> {
companions
.iter()
.map(|(key, value)| (key.clone(), crate::redact(value).into_owned()))
.collect()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RedactedFinding {
#[serde(with = "serde_arc_str")]
pub detector_id: Arc<str>,
#[serde(with = "serde_arc_str")]
pub detector_name: Arc<str>,
#[serde(with = "serde_arc_str")]
pub service: Arc<str>,
pub severity: Severity,
pub credential_redacted: Cow<'static, str>,
pub credential_hash: CredentialHash,
pub companions_redacted: HashMap<String, String>,
pub location: MatchLocation,
#[serde(skip_serializing_if = "Option::is_none")]
pub entropy: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub confidence: Option<f64>,
}
#[inline]
pub fn hex_encode(bytes: impl AsRef<[u8]>) -> String {
hex::encode(bytes.as_ref())
}
#[inline]
pub fn sha256_hash(s: &str) -> CredentialHash {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(s.as_bytes());
CredentialHash::from_bytes(hasher.finalize().into())
}
pub(crate) mod serde_hash_hex {
use std::borrow::Cow;
use serde::{Deserialize, Deserializer, Serializer};
pub(crate) fn serialize<S>(val: &[u8; 32], serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&super::hex_encode(val))
}
pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result<[u8; 32], D::Error>
where
D: Deserializer<'de>,
{
let s = Cow::<'de, str>::deserialize(deserializer)?;
if s.len() != crate::git_lfs::SHA256_HEX_LEN {
return Err(serde::de::Error::invalid_length(
s.len(),
&"64-char hex SHA-256 digest",
));
}
let mut bytes = [0_u8; 32];
hex::decode_to_slice(s.as_bytes(), &mut bytes).map_err(serde::de::Error::custom)?;
Ok(bytes)
}
}
#[inline]
fn arc_from_cow(value: Cow<'_, str>) -> Arc<str> {
match value {
Cow::Borrowed(value) => Arc::from(value),
Cow::Owned(value) => Arc::from(value),
}
}
pub(crate) mod serde_arc_str {
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::borrow::Cow;
use std::sync::Arc;
pub(crate) fn serialize<S>(val: &Arc<str>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
val.as_ref().serialize(serializer)
}
pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result<Arc<str>, D::Error>
where
D: Deserializer<'de>,
{
Cow::<'de, str>::deserialize(deserializer).map(super::arc_from_cow)
}
}
pub(crate) mod serde_arc_str_opt {
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::borrow::Cow;
use std::sync::Arc;
pub(crate) fn serialize<S>(val: &Option<Arc<str>>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
val.as_ref().map(|s| s.as_ref()).serialize(serializer)
}
pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result<Option<Arc<str>>, D::Error>
where
D: Deserializer<'de>,
{
Option::<Cow<'de, str>>::deserialize(deserializer).map(|opt| opt.map(super::arc_from_cow))
}
}