use std::borrow::Cow;
use std::collections::HashMap;
use std::sync::atomic::{AtomicUsize, Ordering};
use crate::{Finding, Severity};
static HARDCODED_SECRET_MIN_ENTROPY_OVERRIDE: std::sync::atomic::AtomicU32 =
std::sync::atomic::AtomicU32::new(0);
pub fn set_hardcoded_secret_min_entropy_override(value: Option<f32>) {
let bits = match value {
Some(v) => v.to_bits(),
None => 0,
};
HARDCODED_SECRET_MIN_ENTROPY_OVERRIDE.store(bits, Ordering::Relaxed);
}
fn hardcoded_secret_min_entropy() -> Option<f32> {
let bits = HARDCODED_SECRET_MIN_ENTROPY_OVERRIDE.load(Ordering::Relaxed);
if bits == 0 {
None
} else {
Some(f32::from_bits(bits))
}
}
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)(password|secret|api_?key|token|auth|credential|private_?key)";
pub const CSHARP_HARDCODED_SECRET_PATTERN: &str = r"(?i)(password|secret|api_?key|token|auth|credential|private_?key|connection_?string|connectionstring)";
pub const DEFAULT_HARDCODED_SECRET_MIN_LENGTH: usize = 4;
static HARDCODED_SECRET_MIN_LENGTH_OVERRIDE: AtomicUsize = AtomicUsize::new(0);
pub fn set_hardcoded_secret_min_length_override(value: Option<usize>) {
HARDCODED_SECRET_MIN_LENGTH_OVERRIDE.store(value.unwrap_or(0), Ordering::Relaxed);
}
pub fn hardcoded_secret_min_length() -> usize {
let override_value = HARDCODED_SECRET_MIN_LENGTH_OVERRIDE.load(Ordering::Relaxed);
if override_value == 0 {
DEFAULT_HARDCODED_SECRET_MIN_LENGTH
} else {
override_value
}
}
pub fn is_secret_value_long_enough(inner: &str) -> bool {
if inner.len() < hardcoded_secret_min_length() {
return false;
}
if let Some(min_ent) = hardcoded_secret_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
}
pub fn is_high_signal_secret_name(name: &str) -> bool {
let lower = name.to_ascii_lowercase();
lower.contains("password")
|| lower.contains("passwd")
|| lower.contains("secret")
|| lower.contains("api_key")
|| lower.contains("apikey")
|| lower.contains("credential")
|| lower.contains("private_key")
|| lower.contains("privatekey")
}
#[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,
}
}
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,
}
}
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 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}");
}
}
}