use std::borrow::Cow;
use std::collections::HashMap;
use std::sync::OnceLock;
use regex::Regex;
use crate::{Finding, Severity};
pub fn shannon_entropy(s: &str) -> f32 {
if s.is_empty() {
return 0.0;
}
let mut counts = [0u32; 256];
for &b in s.as_bytes() {
counts[b as usize] += 1;
}
let len = s.len() as f32;
let mut entropy: f32 = 0.0;
for &c in &counts {
if c > 0 {
let p = c as f32 / len;
entropy -= p * p.log2();
}
}
entropy
}
pub const HARDCODED_SECRET_PATTERN: &str = r"(?i)(?:^|[^A-Za-z])(password|secret|api_?key|token|auth|credential|private_?key)(?:s?(?:$|[^A-Za-z]))";
pub const CSHARP_HARDCODED_SECRET_PATTERN: &str = r"(?i)(?:^|[^A-Za-z])(password|secret|api_?key|token|auth|credential|private_?key|connection_?string|connectionstring)(?:s?(?:$|[^A-Za-z]))";
pub fn hardcoded_secret_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(HARDCODED_SECRET_PATTERN).expect("static hardcoded secret regex should compile")
})
}
pub fn csharp_hardcoded_secret_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(CSHARP_HARDCODED_SECRET_PATTERN)
.expect("static C# hardcoded secret regex should compile")
})
}
pub const DEFAULT_HARDCODED_SECRET_MIN_LENGTH: usize = 4;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SecretScanThresholds {
pub min_length: usize,
pub min_entropy: Option<f32>,
}
impl SecretScanThresholds {
pub fn new(min_length: Option<usize>, min_entropy: Option<f32>) -> Self {
Self {
min_length: min_length.unwrap_or(DEFAULT_HARDCODED_SECRET_MIN_LENGTH),
min_entropy,
}
}
}
impl Default for SecretScanThresholds {
fn default() -> Self {
Self::new(None, None)
}
}
pub fn is_secret_value_long_enough(inner: &str, thresholds: SecretScanThresholds) -> bool {
if inner.len() < thresholds.min_length {
return false;
}
if let Some(min_ent) = thresholds.min_entropy {
if shannon_entropy(inner) < min_ent {
return false;
}
}
true
}
pub fn looks_like_secret_value(inner: &str) -> bool {
if inner.contains(' ') {
return false;
}
if inner.starts_with("http://") || inner.starts_with("https://") {
return false;
}
if inner.starts_with('/') || inner.starts_with("./") || inner.starts_with("../") {
return false;
}
true
}
fn identifier_components(name: &str) -> Vec<String> {
let mut components = Vec::new();
let mut current = String::new();
let mut prev_lower = false;
for ch in name.chars() {
if ch.is_ascii_alphanumeric() {
if prev_lower && ch.is_ascii_uppercase() && !current.is_empty() {
components.push(std::mem::take(&mut current));
}
current.push(ch.to_ascii_lowercase());
prev_lower = ch.is_ascii_lowercase();
} else {
if !current.is_empty() {
components.push(std::mem::take(&mut current));
}
prev_lower = false;
}
}
if !current.is_empty() {
components.push(current);
}
for c in &mut components {
if c.len() > 1 && c.ends_with('s') {
c.pop();
}
}
components
}
pub fn is_high_signal_secret_name(name: &str) -> bool {
let components = identifier_components(name);
let has = |kw: &str| components.iter().any(|c| c == kw);
if has("password") || has("passwd") || has("secret") || has("credential") {
return true;
}
components
.windows(2)
.any(|w| w[1] == "key" && (w[0] == "api" || w[0] == "private"))
}
#[derive(Debug, Default, Clone)]
pub struct AliasTable {
pub(crate) map: HashMap<String, String>,
}
impl AliasTable {
pub fn new() -> Self {
Self::default()
}
pub fn insert(&mut self, local: String, canonical: String) {
self.map.insert(local, canonical);
}
pub fn entry_or_insert(&mut self, local: String, canonical: String) {
self.map.entry(local).or_insert(canonical);
}
pub fn resolve<'a>(&'a self, callee: &'a str) -> Cow<'a, str> {
if let Some((head, tail)) = callee.split_once('.') {
if let Some(canonical_root) = self.map.get(head) {
if canonical_root == head {
return Cow::Borrowed(callee);
}
return Cow::Owned(format!("{}.{}", canonical_root, tail));
}
return Cow::Borrowed(callee);
}
if let Some(canonical) = self.map.get(callee) {
return Cow::Borrowed(canonical.as_str());
}
Cow::Borrowed(callee)
}
#[cfg(test)]
pub fn get(&self, local: &str) -> Option<&str> {
self.map.get(local).map(String::as_str)
}
}
pub fn get_source_line(source: &str, byte_offset: usize) -> String {
if byte_offset > source.len() {
return String::new();
}
let byte_offset = byte_offset.min(source.len());
let start = source[..byte_offset].rfind('\n').map_or(0, |p| p + 1);
let end = source[byte_offset..]
.find('\n')
.map_or(source.len(), |p| byte_offset + p);
source[start..end].to_string()
}
pub fn walk_tree(
node: tree_sitter::Node,
source: &str,
callback: &mut dyn FnMut(tree_sitter::Node, &str),
) {
let mut stack: Vec<tree_sitter::Node> = vec![node];
while let Some(current) = stack.pop() {
callback(current, source);
let start = stack.len();
let mut cursor = current.walk();
if cursor.goto_first_child() {
loop {
stack.push(cursor.node());
if !cursor.goto_next_sibling() {
break;
}
}
stack[start..].reverse();
}
}
}
pub fn make_finding(
rule_id: &str,
severity: Severity,
cwe: Option<&str>,
description: &str,
node: tree_sitter::Node,
source: &str,
) -> Finding {
let start = node.start_position();
let end = node.end_position();
Finding {
rule_id: rule_id.to_string(),
severity,
cwe: cwe.map(|s| s.to_string()),
description: description.to_string(),
file: String::new(),
line: start.row + 1,
column: start.column + 1,
end_line: end.row + 1,
end_column: end.column + 1,
snippet: get_source_line(source, node.start_byte()),
source_line: None,
source_description: None,
sink_line: None,
sink_description: None,
fix_suggestion: None,
sink_start_byte: None,
sink_end_byte: None,
confidence: crate::default_confidence(),
taint_hops: None,
tags: vec![],
crypto_algorithm: None,
cnsa2_deadline: None,
dep_name: None,
dep_version: None,
dep_ecosystem: None,
dep_purl: None,
dep_vulnerability_id: None,
dep_fixed_version: None,
dep_source: None,
dep_vulnerability_severity: None,
dep_path: vec![],
crypto_material: None,
}
}
pub fn make_finding_from_offsets(
rule_id: &str,
severity: Severity,
cwe: Option<&str>,
description: &str,
source: &str,
start_byte: usize,
end_byte: usize,
) -> Finding {
let start_byte = start_byte.min(source.len());
let end_byte = end_byte.min(source.len());
let line = source[..start_byte].bytes().filter(|b| *b == b'\n').count() + 1;
let line_start = source[..start_byte].rfind('\n').map_or(0, |idx| idx + 1);
let column = source[line_start..start_byte].chars().count() + 1;
let end_line = source[..end_byte].bytes().filter(|b| *b == b'\n').count() + 1;
let end_line_start = source[..end_byte].rfind('\n').map_or(0, |idx| idx + 1);
let end_column = source[end_line_start..end_byte].chars().count() + 1;
Finding {
rule_id: rule_id.to_string(),
severity,
cwe: cwe.map(|s| s.to_string()),
description: description.to_string(),
file: String::new(),
line,
column,
end_line,
end_column,
snippet: get_source_line(source, start_byte),
source_line: None,
source_description: None,
sink_line: None,
sink_description: None,
fix_suggestion: None,
sink_start_byte: None,
sink_end_byte: None,
confidence: crate::default_confidence(),
taint_hops: None,
tags: vec![],
crypto_algorithm: None,
cnsa2_deadline: None,
dep_name: None,
dep_version: None,
dep_ecosystem: None,
dep_purl: None,
dep_vulnerability_id: None,
dep_fixed_version: None,
dep_source: None,
dep_vulnerability_severity: None,
dep_path: vec![],
crypto_material: None,
}
}
pub fn confidence_for_hops(hops: u8) -> f32 {
match hops {
0 | 1 => 1.0,
2 => 0.8,
_ => 0.6,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn get_source_line_basic() {
let src = "line one\nline two\nline three";
assert_eq!(get_source_line(src, 0), "line one");
assert_eq!(get_source_line(src, 9), "line two");
assert_eq!(get_source_line(src, 18), "line three");
}
#[test]
fn get_source_line_empty_source() {
assert_eq!(get_source_line("", 0), "");
}
#[test]
fn get_source_line_out_of_bounds() {
assert_eq!(get_source_line("hello", 100), "");
}
#[test]
fn confidence_for_hops_curve_matches_documented_values() {
assert_eq!(confidence_for_hops(0), 1.0);
assert_eq!(confidence_for_hops(1), 1.0);
assert_eq!(confidence_for_hops(2), 0.8);
assert_eq!(confidence_for_hops(3), 0.6);
assert_eq!(confidence_for_hops(10), 0.6);
}
#[test]
fn secret_thresholds_default_to_legacy_min_length() {
let thresholds = SecretScanThresholds::default();
assert_eq!(thresholds.min_length, DEFAULT_HARDCODED_SECRET_MIN_LENGTH);
assert_eq!(thresholds.min_entropy, None);
assert!(is_secret_value_long_enough("abcd", thresholds));
assert!(!is_secret_value_long_enough("abc", thresholds));
}
#[test]
fn secret_thresholds_apply_min_entropy_per_scan() {
let thresholds = SecretScanThresholds::new(Some(4), Some(1.9));
assert!(is_secret_value_long_enough("Ab9$", thresholds));
assert!(!is_secret_value_long_enough("aaaa", thresholds));
}
#[test]
fn csharp_pattern_is_superset_of_base() {
let base = regex::Regex::new(HARDCODED_SECRET_PATTERN).unwrap();
let extended = regex::Regex::new(CSHARP_HARDCODED_SECRET_PATTERN).unwrap();
for kw in &[
"password",
"secret",
"api_key",
"apikey",
"token",
"auth",
"credential",
"private_key",
"privatekey",
] {
assert!(base.is_match(kw), "base should match {kw}");
assert!(extended.is_match(kw), "csharp should match {kw}");
}
for kw in &["connection_string", "connectionstring"] {
assert!(!base.is_match(kw), "base should NOT match {kw}");
assert!(extended.is_match(kw), "csharp should match {kw}");
}
}
#[test]
fn secret_pattern_ignores_benign_substring_names() {
let re = hardcoded_secret_re();
for name in &[
"author",
"authors",
"authored",
"authenticate",
"authenticated",
"authentication",
"authorize",
"authorization",
"tokenizer",
"tokenize",
"retokenize",
"passwordField",
"secretarial",
"credentialsHelper", ] {
assert!(
!re.is_match(name),
"benign name {name:?} should NOT match the secret pattern"
);
}
}
#[test]
fn secret_pattern_still_matches_real_secret_names() {
let re = hardcoded_secret_re();
for name in &[
"password",
"SECRET_KEY",
"secret_key",
"secret_token",
"api_key",
"apiKey", "api_token",
"token",
"auth",
"credential",
"credentials", "secrets", "private_key",
"app.secret_key",
"user_password",
] {
assert!(
re.is_match(name),
"real secret name {name:?} should still match the secret pattern"
);
}
}
#[test]
fn high_signal_name_is_component_aware() {
for name in &[
"password",
"PASSWORD",
"userPassword",
"passwd",
"SECRET_KEY",
"secret",
"apiKey",
"api_key",
"API_KEY",
"credential",
"credentials",
"private_key",
"privateKey",
] {
assert!(
is_high_signal_secret_name(name),
"{name:?} should be high-signal"
);
}
for name in &[
"auth",
"token",
"author",
"authenticated",
"tokenizer",
"secretarial",
"api",
"key",
] {
assert!(
!is_high_signal_secret_name(name),
"{name:?} should NOT be high-signal"
);
}
}
#[test]
fn identifier_components_splits_on_case_and_separators() {
assert_eq!(identifier_components("SECRET_KEY"), vec!["secret", "key"]);
assert_eq!(
identifier_components("apiKeyValue"),
vec!["api", "key", "value"]
);
assert_eq!(
identifier_components("app.secret_token"),
vec!["app", "secret", "token"]
);
assert_eq!(identifier_components("author"), vec!["author"]);
assert_eq!(identifier_components("passwords"), vec!["password"]);
}
}