use serde::Serialize;
use std::collections::HashMap;
use crate::Severity;
#[derive(Debug, Clone, Serialize)]
pub struct RawMatch {
pub detector_id: String,
pub detector_name: String,
pub service: String,
pub severity: Severity,
pub credential: String,
pub companion: Option<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>,
}
#[derive(Debug, Clone, Serialize)]
pub struct MatchLocation {
pub source: String,
pub file_path: Option<String>,
pub line: Option<usize>,
pub offset: usize,
pub commit: Option<String>,
pub author: Option<String>,
pub date: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct VerifiedFinding {
pub detector_id: String,
pub detector_name: String,
pub service: String,
pub severity: Severity,
pub credential_redacted: String,
pub location: MatchLocation,
pub verification: VerificationResult,
#[serde(skip_serializing_if = "HashMap::is_empty")]
pub metadata: HashMap<String, String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub additional_locations: Vec<MatchLocation>,
#[serde(skip_serializing_if = "Option::is_none")]
pub confidence: Option<f64>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum VerificationResult {
Live,
Dead,
RateLimited,
Error(String),
Unverifiable,
Skipped,
}
impl RawMatch {
pub fn deduplication_key(&self) -> (String, String) {
if self.location.source == "git-history" {
(
format!(
"{}:{}",
self.detector_id,
self.location.commit.clone().unwrap_or_default()
),
self.credential.clone(),
)
} else {
(self.detector_id.clone(), self.credential.clone())
}
}
}
pub fn redact(credential: &str) -> String {
if credential.is_empty() {
return "*".repeat(8);
}
if credential.len() <= SHORT_SECRET_MAX_LEN {
return redact_short_secret(credential);
}
redact_with_prefix_preservation(credential)
}
const SHORT_SECRET_MAX_LEN: usize = 8;
const SHORT_SECRET_EDGE_CHARS: usize = 2;
const DEFAULT_REDACTION_EDGE_CHARS: usize = 4;
const MAX_VISIBLE_PREFIX_CHARS: usize = 8;
const REDACTION_SEPARATOR: &str = "...";
fn redact_short_secret(credential: &str) -> String {
let start = first_chars(credential, SHORT_SECRET_EDGE_CHARS);
let end = last_chars(credential, SHORT_SECRET_EDGE_CHARS);
format!("{start}{REDACTION_SEPARATOR}{end}")
}
fn redact_with_prefix_preservation(credential: &str) -> String {
let prefix_len = visible_prefix_len(credential);
let suffix_len = last_chars(credential, DEFAULT_REDACTION_EDGE_CHARS).len();
if prefix_len == 0 || credential.len() <= prefix_len + suffix_len {
return redact_without_prefix_preservation(credential);
}
let prefix = &credential[..prefix_len];
let suffix = &credential[credential.len() - suffix_len..];
format!("{prefix}{REDACTION_SEPARATOR}{suffix}")
}
fn visible_prefix_len(credential: &str) -> usize {
credential
.char_indices()
.take_while(|(_, ch)| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
.take(MAX_VISIBLE_PREFIX_CHARS)
.last()
.map(|(idx, ch)| idx + ch.len_utf8())
.unwrap_or(0)
.min(
credential
.len()
.saturating_sub(DEFAULT_REDACTION_EDGE_CHARS),
)
}
fn redact_without_prefix_preservation(credential: &str) -> String {
let start = first_chars(credential, DEFAULT_REDACTION_EDGE_CHARS);
let end = last_chars(credential, DEFAULT_REDACTION_EDGE_CHARS);
if start == end {
format!("{start}{REDACTION_SEPARATOR}")
} else {
format!("{start}{REDACTION_SEPARATOR}{end}")
}
}
fn first_chars(value: &str, count: usize) -> String {
value.chars().take(count).collect()
}
fn last_chars(value: &str, count: usize) -> String {
let total = value.chars().count();
value.chars().skip(total.saturating_sub(count)).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn redaction() {
assert_eq!(redact("xoxb-1234567890-abc"), "xoxb-123...-abc");
assert_eq!(redact("short"), "sh...rt");
assert_eq!(redact("AKIA1234567890ABCDEF"), "AKIA1234...CDEF");
assert_eq!(
redact("sk-proj-abcdefghijklmnopqrstuvwxyz1234"),
"sk-proj-...1234"
);
}
#[test]
fn deduplication_key_groups_same_credential() {
let m1 = RawMatch {
detector_id: "aws".into(),
detector_name: "AWS".into(),
service: "aws".into(),
severity: Severity::Critical,
credential: "AKIAIOSFODNN7EXAMPLE".into(),
companion: None,
location: MatchLocation {
source: "fs".into(),
file_path: Some("file1.py".into()),
line: Some(10),
offset: 0,
commit: None,
author: None,
date: None,
},
entropy: None,
confidence: None,
};
let m2 = RawMatch {
location: MatchLocation {
file_path: Some("file2.py".into()),
line: Some(20),
..m1.location.clone()
},
..m1.clone()
};
assert_eq!(m1.deduplication_key(), m2.deduplication_key());
}
macro_rules! redaction_case {
($name:ident, $input:expr, $expected:expr) => {
#[test]
fn $name() {
assert_eq!(redact($input), $expected);
}
};
}
redaction_case!(redact_empty_secret, "", "********");
redaction_case!(redact_single_char_secret, "a", "a...a");
redaction_case!(redact_two_char_secret, "ab", "ab...ab");
redaction_case!(redact_eight_char_secret, "12345678", "12...78");
redaction_case!(
redact_prefixless_long_secret,
"@@@@abcdefgh1234",
"@@@@...1234"
);
redaction_case!(redact_unicode_secret, "пароль-супер-длинный", "паро...нный");
redaction_case!(
redact_secret_with_preserved_ascii_prefix,
"token_value_1234567890",
"token_va...7890"
);
redaction_case!(
redact_repeated_edges_compacts_suffix,
"aaaaabbbbb",
"aaaa...bbbb"
);
#[test]
fn git_history_deduplication_includes_commit_id() {
let matched = RawMatch {
detector_id: "aws".into(),
detector_name: "AWS".into(),
service: "aws".into(),
severity: Severity::Critical,
credential: "AKIAIOSFODNN7EXAMPLE".into(),
companion: None,
location: MatchLocation {
source: "git-history".into(),
file_path: Some("history.env".into()),
line: Some(1),
offset: 0,
commit: Some("abc123".into()),
author: None,
date: None,
},
entropy: None,
confidence: None,
};
let (detector, credential) = matched.deduplication_key();
assert_eq!(detector, "aws:abc123");
assert_eq!(credential, "AKIAIOSFODNN7EXAMPLE");
}
}