use rayon::prelude::*;
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use syn::visit::Visit;
use syn::{BinOp, Expr, ExprBinary, ExprMethodCall, ExprCall, ExprStruct, ExprMatch, ExprClosure, ExprArray, ExprReturn, ExprAssign, ExprMacro, File, Item, ItemConst, ItemFn, ItemMod, ItemStatic, Local, Pat};
use crate::filters::ScopeFilter;
use crate::models::{
AnalysisContext, Comparison, ComparisonOp, ComparisonSide, Finding, FindingId, Score,
SourceLocation, SuspectValue, Usage,
};
use crate::registry::SuspectRegistry;
use crate::scoring::ScoringEngine;
fn truncate_str(s: &str, max_chars: usize) -> String {
let truncated: String = s.chars().take(max_chars).collect();
if truncated.len() < s.len() {
format!("{}...", truncated)
} else {
truncated
}
}
pub struct Analyzer<'a> {
registry: &'a SuspectRegistry,
engine: &'a ScoringEngine,
}
impl<'a> Analyzer<'a> {
pub fn new(registry: &'a SuspectRegistry, engine: &'a ScoringEngine) -> Self {
Self { registry, engine }
}
pub fn scan_files(&self, files: &[PathBuf]) -> Vec<Finding> {
let findings: Vec<Finding> = files
.par_iter()
.flat_map(|file| {
if let Ok(content) = std::fs::read_to_string(file) {
if let Ok(ast) = syn::parse_file(&content) {
return self.scan_file(&ast, file);
}
}
Vec::new()
})
.collect();
let mut seen = HashSet::new();
let mut unique_findings = Vec::new();
for finding in findings {
let signature = format!(
"{}:{}:{}",
finding.location.file.display(),
finding.location.line,
finding.suspect.name().unwrap_or("literal")
);
if seen.insert(signature) {
unique_findings.push(finding);
}
}
unique_findings
}
fn scan_file(&self, ast: &File, path: &PathBuf) -> Vec<Finding> {
let mut visitor = SecretVisitorWithRegistry::new(path.clone(), self.engine, self.registry);
visitor.visit_file(ast);
visitor.into_findings()
}
pub fn scan_ast(&self, path: &std::path::Path, ast: &File) -> Vec<Finding> {
let mut visitor =
SecretVisitorWithRegistry::new(path.to_path_buf(), self.engine, self.registry);
visitor.visit_file(ast);
visitor.into_findings()
}
}
pub struct SecretVisitorWithRegistry<'a> {
file_path: PathBuf,
context: AnalysisContext,
engine: &'a ScoringEngine,
global_registry: &'a SuspectRegistry,
file_local_constants: HashMap<String, String>,
function_local_vars: HashMap<String, String>,
findings: Vec<Finding>,
finding_counter: usize,
}
impl<'a> SecretVisitorWithRegistry<'a> {
pub fn new(
file_path: PathBuf,
engine: &'a ScoringEngine,
global_registry: &'a SuspectRegistry,
) -> Self {
let context = AnalysisContext::new(file_path.clone());
Self {
file_path,
context,
engine,
global_registry,
file_local_constants: HashMap::new(),
function_local_vars: HashMap::new(),
findings: Vec::new(),
finding_counter: 0,
}
}
pub fn visit_file(&mut self, file: &File) {
self.collect_constants(file);
for item in &file.items {
syn::visit::visit_item(self, item);
}
}
fn collect_constants(&mut self, file: &File) {
for item in &file.items {
if let Item::Const(c) = item {
if let Some(value) = crate::scanner::indexer::Indexer::extract_string_value(&c.expr)
{
self.file_local_constants
.insert(c.ident.to_string(), value);
}
}
if let Item::Static(s) = item {
if let Some(value) = crate::scanner::indexer::Indexer::extract_string_value(&s.expr)
{
self.file_local_constants
.insert(s.ident.to_string(), value);
}
}
}
}
fn detect_auth_indicators(block: &syn::Block) -> bool {
use syn::visit::Visit;
struct AuthIndicatorVisitor {
found: bool,
}
impl<'ast> Visit<'ast> for AuthIndicatorVisitor {
fn visit_expr_method_call(&mut self, call: &'ast syn::ExprMethodCall) {
let method = call.method.to_string().to_lowercase();
if method.contains("authenticat") || method.contains("unauthoriz") || method.contains("unauthenticat") {
self.found = true;
return;
}
for arg in &call.args {
if let syn::Expr::Lit(lit) = arg {
if let syn::Lit::Str(s) = &lit.lit {
let val = s.value().to_lowercase();
if val == "authorization" || val == "authenticate" || val == "bearer"
|| val == "x-auth-token" || val == "x-api-key" || val == "www-authenticate"
{
self.found = true;
return;
}
}
}
}
syn::visit::visit_expr_method_call(self, call);
}
fn visit_expr_call(&mut self, call: &'ast syn::ExprCall) {
let call_str = quote::quote!(#call).to_string().to_lowercase();
if call_str.contains("unauthenticat") || call_str.contains("unauthoriz") {
self.found = true;
return;
}
syn::visit::visit_expr_call(self, call);
}
}
let mut visitor = AuthIndicatorVisitor { found: false };
visitor.visit_block(block);
visitor.found
}
fn resolve_value(&self, name: &str) -> Option<String> {
if let Some(value) = self.function_local_vars.get(name) {
return Some(value.clone());
}
if let Some(value) = self.file_local_constants.get(name) {
return Some(value.clone());
}
self.global_registry
.get_value_for_file(name, &self.file_path)
}
fn extract_string_from_expr(expr: &Expr) -> Option<String> {
match expr {
Expr::Lit(lit) => {
if let syn::Lit::Str(s) = &lit.lit {
Some(s.value())
} else {
None
}
}
Expr::MethodCall(call) => {
let passthrough = [
"to_string", "to_owned", "into", "parse", "unwrap", "unwrap_or",
"unwrap_or_default", "expect", "ok", "as_str", "as_ref", "clone",
"trim", "trim_start", "trim_end",
];
if passthrough.contains(&call.method.to_string().as_str()) {
Self::extract_string_from_expr(&call.receiver)
} else {
None
}
}
Expr::Call(call) => {
let func_name = quote::quote!(#call.func).to_string();
if func_name.contains("String :: from") || func_name.contains("String::from") {
call.args.first().and_then(Self::extract_string_from_expr)
} else {
None
}
}
Expr::Try(try_expr) => Self::extract_string_from_expr(&try_expr.expr),
Expr::Paren(p) => Self::extract_string_from_expr(&p.expr),
Expr::Group(g) => Self::extract_string_from_expr(&g.expr),
_ => None,
}
}
fn record_finding(
&mut self,
suspect: SuspectValue,
usage: Option<Usage>,
span: proc_macro2::Span,
) {
let location = SourceLocation::from_span(self.file_path.clone(), span);
let score = self.engine.score(&suspect, usage.as_ref(), &self.context);
if self.engine.should_report(&score) {
self.finding_counter += 1;
let id = FindingId::generate(&location, suspect.name().unwrap_or("literal"));
let explanation = self.generate_explanation(&suspect, &usage, &score);
self.findings.push(Finding {
id,
suspect,
location,
usage,
context: self.context.clone(),
score,
explanation,
remediation: Some(self.generate_remediation()),
metadata: HashMap::new(),
});
}
}
fn generate_explanation(
&self,
suspect: &SuspectValue,
usage: &Option<Usage>,
score: &Score,
) -> String {
let mut parts = Vec::new();
match suspect {
SuspectValue::Constant { name, .. } => {
parts.push(format!(
"Constant '{}' may contain a hardcoded secret.",
name
));
}
SuspectValue::Static { name, .. } => {
parts.push(format!(
"Static '{}' may contain a hardcoded secret.",
name
));
}
SuspectValue::InlineLiteral { value } => {
let preview = truncate_str(value, 20);
parts.push(format!("Inline literal '{}' detected.", preview));
}
SuspectValue::EnvDefault { var_name, .. } => {
parts.push(format!(
"Environment variable '{}' has a suspicious default.",
var_name
));
}
}
match usage {
Some(Usage::Comparison(_)) => {
parts.push("Used in an equality check.".to_string());
}
Some(Usage::FunctionArgument { function_name, argument_position }) => {
parts.push(format!(
"Passed as argument {} to function '{}'.",
argument_position, function_name
));
}
Some(Usage::MethodArgument { method_name, receiver, argument_position }) => {
parts.push(format!(
"Passed as argument {} to method '{}' on '{}'.",
argument_position, method_name, receiver
));
}
Some(Usage::StructFieldInit { struct_name, field_name }) => {
if let Some(name) = struct_name {
parts.push(format!(
"Assigned to field '{}' in struct '{}'.",
field_name, name
));
} else {
parts.push(format!("Assigned to field '{}'.", field_name));
}
}
Some(Usage::FieldAssignment { field_name, struct_name }) => {
if let Some(name) = struct_name {
parts.push(format!(
"Assigned to field '{}' on '{}'.",
field_name, name
));
} else {
parts.push(format!("Assigned to field '{}'.", field_name));
}
}
Some(Usage::ReturnValue { function_name }) => {
parts.push(format!("Returned from function '{}'.", function_name));
}
Some(Usage::MatchArm { match_arm_index, in_guard }) => {
if *in_guard {
parts.push(format!(
"Used in guard of match arm {} (potential backdoor pattern).",
match_arm_index
));
} else {
parts.push(format!(
"Used in pattern of match arm {} (potential backdoor pattern).",
match_arm_index
));
}
}
Some(Usage::PatternMatch { match_arm_index }) => {
parts.push(format!("Used in match arm {}.", match_arm_index));
}
Some(Usage::ClosureCapture { passed_to }) => {
if let Some(func) = passed_to {
parts.push(format!("Used in closure passed to '{}'.", func));
} else {
parts.push("Used inside a closure.".to_string());
}
}
Some(Usage::ArrayElement { index, array_len }) => {
parts.push(format!(
"Element {} of {} in array literal.",
index, array_len
));
}
Some(Usage::MacroArgument { macro_name, argument_position }) => {
parts.push(format!(
"Passed as argument {} to macro '{}'.",
argument_position, macro_name
));
}
Some(Usage::BuilderMethod { method_name, builder_type }) => {
if let Some(typ) = builder_type {
parts.push(format!(
"Used in builder method '{}' on '{}'.",
method_name, typ
));
} else {
parts.push(format!("Used in builder method '{}'.", method_name));
}
}
Some(Usage::Definition) | None => {
parts.push("Suspicious constant definition.".to_string());
}
}
let top_factors: Vec<_> = score
.factors
.iter()
.filter(|f| f.contribution.abs() >= 15)
.take(3)
.collect();
if !top_factors.is_empty() {
parts.push("Key factors:".to_string());
for factor in top_factors {
let sign = if factor.contribution > 0 { "+" } else { "" };
parts.push(format!(
" {} ({}{})",
factor.reason, sign, factor.contribution
));
}
}
parts.join(" ")
}
fn generate_remediation(&self) -> String {
"Consider moving this value to an environment variable or secure configuration system."
.to_string()
}
pub fn into_findings(self) -> Vec<Finding> {
self.findings
}
fn analyze_binary_expr(&mut self, binary: &ExprBinary) {
let op = match &binary.op {
BinOp::Eq(_) => ComparisonOp::Eq,
BinOp::Ne(_) => ComparisonOp::Ne,
_ => return,
};
let left = self.extract_comparison_side(&binary.left);
let right = self.extract_comparison_side(&binary.right);
let (suspect, _variable, is_swapped) = match (&left, &right) {
(ComparisonSide::ConstantRef { name, resolved_value }, other) => {
if let Some(value) = resolved_value
.clone()
.or_else(|| self.resolve_value(name))
{
let suspect = SuspectValue::Constant {
name: name.clone(),
value,
type_annotation: None,
};
(suspect, other.clone(), false)
} else {
return;
}
}
(ComparisonSide::StringLiteral(value), other) => {
let suspect = SuspectValue::InlineLiteral {
value: value.clone(),
};
(suspect, other.clone(), false)
}
(other, ComparisonSide::ConstantRef { name, resolved_value }) => {
if let Some(value) = resolved_value
.clone()
.or_else(|| self.resolve_value(name))
{
let suspect = SuspectValue::Constant {
name: name.clone(),
value,
type_annotation: None,
};
(suspect, other.clone(), true)
} else {
return;
}
}
(other, ComparisonSide::StringLiteral(value)) => {
let suspect = SuspectValue::InlineLiteral {
value: value.clone(),
};
(suspect, other.clone(), true)
}
_ => return,
};
let comparison = Comparison {
left,
right,
operator: op,
consequence: None, is_swapped,
};
let usage = Usage::Comparison(comparison);
let span = match &binary.op {
BinOp::Eq(token) => token.spans[0],
BinOp::Ne(token) => token.spans[0],
_ => return,
};
self.record_finding(suspect, Some(usage), span);
}
fn analyze_method_call(&mut self, call: &ExprMethodCall) {
let method_name = call.method.to_string();
if method_name == "eq" && !call.args.is_empty() {
let receiver_side = self.extract_comparison_side(&call.receiver);
let arg_side = self.extract_comparison_side(&call.args[0]);
let suspect = match (&receiver_side, &arg_side) {
(ComparisonSide::ConstantRef { name, resolved_value }, _)
| (_, ComparisonSide::ConstantRef { name, resolved_value }) => {
if let Some(value) = resolved_value
.clone()
.or_else(|| self.resolve_value(name))
{
Some(SuspectValue::Constant {
name: name.clone(),
value,
type_annotation: None,
})
} else {
None
}
}
(ComparisonSide::StringLiteral(value), _)
| (_, ComparisonSide::StringLiteral(value)) => {
Some(SuspectValue::InlineLiteral {
value: value.clone(),
})
}
_ => None,
};
if let Some(suspect) = suspect {
let comparison = Comparison {
left: receiver_side,
right: arg_side,
operator: ComparisonOp::MethodEq,
consequence: None,
is_swapped: false,
};
let usage = Usage::Comparison(comparison);
self.record_finding(suspect, Some(usage), call.method.span());
}
return; }
let suspicious_methods = [
"insert", "set", "put", "add", "push",
"with_token", "with_key", "with_password", "with_secret", "with_auth",
"with_api_key", "with_bearer", "with_credentials",
"header", "set_header", "add_header", "insert_header",
"set_password", "set_token", "set_key", "set_secret",
"authenticate", "authorize", "auth",
"basic_auth", "bearer_auth", "token_auth",
"metadata_mut", "extensions_mut",
];
let is_suspicious_method = suspicious_methods.contains(&method_name.as_str())
|| method_name.starts_with("with_")
|| method_name.starts_with("set_")
|| method_name.contains("auth")
|| method_name.contains("token")
|| method_name.contains("secret")
|| method_name.contains("password")
|| method_name.contains("key");
for (pos, arg) in call.args.iter().enumerate() {
if let Some((suspect, span)) = self.extract_suspect_from_expr(arg) {
let receiver = quote::quote!(#call.receiver).to_string();
if is_suspicious_method || self.is_value_suspicious(&suspect) {
let usage = Usage::MethodArgument {
receiver,
method_name: method_name.clone(),
argument_position: pos,
};
self.record_finding(suspect, Some(usage), span);
}
}
}
}
fn analyze_function_call(&mut self, call: &ExprCall) {
let func_name = quote::quote!(#call.func).to_string();
let suspicious_functions = [
"authenticate", "authorize", "auth", "verify", "validate",
"set_password", "set_token", "set_secret", "set_key",
"with_password", "with_token", "with_secret", "with_key",
"basic_auth", "bearer_auth", "digest_auth",
"encrypt", "decrypt", "sign", "verify_signature",
"hmac", "hash",
];
let is_suspicious_func = suspicious_functions.iter().any(|f| func_name.contains(f))
|| func_name.contains("auth")
|| func_name.contains("token")
|| func_name.contains("secret")
|| func_name.contains("password")
|| func_name.contains("credential");
for (pos, arg) in call.args.iter().enumerate() {
if let Some((suspect, span)) = self.extract_suspect_from_expr(arg) {
if is_suspicious_func || self.is_value_suspicious(&suspect) {
let usage = Usage::FunctionArgument {
function_name: func_name.clone(),
argument_position: pos,
};
self.record_finding(suspect, Some(usage), span);
}
}
}
}
fn analyze_struct_expr(&mut self, expr: &ExprStruct) {
let struct_name = expr.path.segments.last()
.map(|s| s.ident.to_string());
let suspicious_fields = [
"password", "token", "secret", "key", "api_key", "apikey",
"auth", "credential", "credentials", "bearer", "access_token",
"refresh_token", "private_key", "secret_key", "api_secret",
"client_secret", "auth_token", "session_token",
];
for field in &expr.fields {
let field_name = match &field.member {
syn::Member::Named(ident) => ident.to_string(),
syn::Member::Unnamed(idx) => idx.index.to_string(),
};
let is_suspicious_field = suspicious_fields.iter()
.any(|f| field_name.to_lowercase().contains(f));
if let Some((suspect, span)) = self.extract_suspect_from_expr(&field.expr) {
if is_suspicious_field || self.is_value_suspicious(&suspect) {
let usage = Usage::StructFieldInit {
struct_name: struct_name.clone(),
field_name,
};
self.record_finding(suspect, Some(usage), span);
}
}
}
}
fn analyze_return_expr(&mut self, expr: &ExprReturn) {
if let Some(ret_expr) = &expr.expr {
if let Some((suspect, span)) = self.extract_suspect_from_expr(ret_expr) {
let func_name = self.context.current_function.clone().unwrap_or_default();
let is_suspicious_func = func_name.contains("token")
|| func_name.contains("secret")
|| func_name.contains("password")
|| func_name.contains("key")
|| func_name.contains("auth")
|| func_name.contains("credential")
|| func_name.starts_with("get_");
if is_suspicious_func || self.is_value_suspicious(&suspect) {
let usage = Usage::ReturnValue {
function_name: func_name,
};
self.record_finding(suspect, Some(usage), span);
}
}
}
}
fn analyze_match_expr(&mut self, expr: &ExprMatch) {
for (arm_idx, arm) in expr.arms.iter().enumerate() {
if let Some((suspect, span)) = self.extract_suspect_from_pat(&arm.pat) {
let usage = Usage::MatchArm {
match_arm_index: arm_idx,
in_guard: false,
};
self.record_finding(suspect, Some(usage), span);
}
if let Some(guard) = &arm.guard {
if let Some((suspect, span)) = self.extract_suspect_from_expr(&guard.1) {
let usage = Usage::MatchArm {
match_arm_index: arm_idx,
in_guard: true,
};
self.record_finding(suspect, Some(usage), span);
}
}
}
}
fn analyze_closure_expr(&mut self, expr: &ExprClosure, parent_func: Option<String>) {
if let Some((suspect, span)) = self.extract_suspect_from_expr(&expr.body) {
let usage = Usage::ClosureCapture {
passed_to: parent_func,
};
self.record_finding(suspect, Some(usage), span);
}
}
fn analyze_array_expr(&mut self, expr: &ExprArray) {
let array_len = expr.elems.len();
for (idx, elem) in expr.elems.iter().enumerate() {
if let Some((suspect, span)) = self.extract_suspect_from_expr(elem) {
if self.is_value_suspicious(&suspect) {
let usage = Usage::ArrayElement {
index: idx,
array_len,
};
self.record_finding(suspect, Some(usage), span);
}
}
}
}
fn analyze_macro_expr(&mut self, expr: &ExprMacro) {
let macro_name = expr.mac.path.segments.last()
.map(|s| s.ident.to_string())
.unwrap_or_default();
let tokens = expr.mac.tokens.clone();
let mut pos = 0;
for tt in tokens {
if let proc_macro2::TokenTree::Literal(lit) = tt {
let lit_str = lit.to_string();
if lit_str.starts_with('"') && lit_str.ends_with('"') && lit_str.len() > 2 {
let value = lit_str[1..lit_str.len()-1].to_string();
let suspect = SuspectValue::InlineLiteral { value: value.clone() };
if self.is_value_suspicious(&suspect) {
let usage = Usage::MacroArgument {
macro_name: macro_name.clone(),
argument_position: pos,
};
self.record_finding(suspect, Some(usage), lit.span());
}
}
pos += 1;
}
}
}
fn analyze_assign_expr(&mut self, expr: &ExprAssign) {
if let Expr::Field(field) = expr.left.as_ref() {
let field_name = match &field.member {
syn::Member::Named(ident) => ident.to_string(),
syn::Member::Unnamed(idx) => idx.index.to_string(),
};
let suspicious_fields = [
"password", "token", "secret", "key", "api_key",
"auth", "credential", "bearer", "access_token",
];
let is_suspicious = suspicious_fields.iter()
.any(|f| field_name.to_lowercase().contains(f));
if let Some((suspect, span)) = self.extract_suspect_from_expr(&expr.right) {
if is_suspicious || self.is_value_suspicious(&suspect) {
let struct_name = quote::quote!(#field.base).to_string();
let usage = Usage::FieldAssignment {
struct_name: Some(struct_name),
field_name,
};
self.record_finding(suspect, Some(usage), span);
}
}
}
}
fn extract_suspect_from_expr(&self, expr: &Expr) -> Option<(SuspectValue, proc_macro2::Span)> {
match expr {
Expr::Lit(lit) => {
if let syn::Lit::Str(s) = &lit.lit {
Some((
SuspectValue::InlineLiteral { value: s.value() },
s.span(),
))
} else {
None
}
}
Expr::Path(path) => {
let name = path.path.segments.last()
.map(|s| s.ident.to_string())?;
let span = path.path.segments.last()
.map(|s| s.ident.span())?;
if let Some(value) = self.resolve_value(&name) {
Some((
SuspectValue::Constant {
name,
value,
type_annotation: None,
},
span,
))
} else if name.chars().all(|c| c.is_uppercase() || c == '_') {
None
} else {
None
}
}
Expr::MethodCall(call) => {
if let Some(value) = Self::extract_string_from_expr(expr) {
return Some((
SuspectValue::InlineLiteral { value },
call.method.span(),
));
}
let passthrough_methods = [
"clone", "to_string", "to_owned", "into", "as_str", "as_ref",
];
if passthrough_methods.contains(&call.method.to_string().as_str()) {
return self.extract_suspect_from_expr(&call.receiver);
}
None
}
Expr::Call(call) => {
Self::extract_string_from_expr(expr).map(|value| {
let span = call.paren_token.span.open();
(SuspectValue::InlineLiteral { value }, span)
})
}
Expr::Reference(ref_expr) => {
self.extract_suspect_from_expr(&ref_expr.expr)
}
Expr::Paren(paren) => {
self.extract_suspect_from_expr(&paren.expr)
}
_ => None,
}
}
fn extract_suspect_from_pat(&self, pat: &Pat) -> Option<(SuspectValue, proc_macro2::Span)> {
match pat {
Pat::Lit(expr_lit) => {
if let syn::Lit::Str(s) = &expr_lit.lit {
return Some((
SuspectValue::InlineLiteral { value: s.value() },
s.span(),
));
}
None
}
Pat::Or(or_pat) => {
for case in &or_pat.cases {
if let Some(result) = self.extract_suspect_from_pat(case) {
return Some(result);
}
}
None
}
_ => None,
}
}
fn is_value_suspicious(&self, suspect: &SuspectValue) -> bool {
let value = suspect.value();
if value.len() < 8 {
return false;
}
if value.contains('/') || value.contains("://") {
return false; }
let entropy = Self::calculate_entropy(value);
if entropy > 4.0 {
return true;
}
let patterns = [
"sk_live_", "sk_test_", "pk_live_", "pk_test_", "ghp_", "gho_", "ghu_", "ghs_", "ghr_", "xox", "AKIA", "eyJ", "Bearer ", "Basic ",
];
if patterns.iter().any(|p| value.contains(p)) {
return true;
}
if value.len() >= 20 {
let is_base64 = value.chars().all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '=');
let is_hex = value.len() >= 32 && value.chars().all(|c| c.is_ascii_hexdigit());
if is_base64 || is_hex {
return true;
}
}
false
}
fn calculate_entropy(s: &str) -> f64 {
if s.is_empty() {
return 0.0;
}
let mut freq = [0u32; 256];
for b in s.bytes() {
freq[b as usize] += 1;
}
let len = s.len() as f64;
freq.iter()
.filter(|&&c| c > 0)
.map(|&c| {
let p = c as f64 / len;
-p * p.log2()
})
.sum()
}
fn extract_comparison_side(&self, expr: &Expr) -> ComparisonSide {
match expr {
Expr::Path(path) => {
let name = path
.path
.segments
.last()
.map(|s| s.ident.to_string())
.unwrap_or_default();
if let Some(value) = self.resolve_value(&name) {
ComparisonSide::ConstantRef {
name,
resolved_value: Some(value),
}
} else if name.chars().all(|c| c.is_uppercase() || c == '_') {
ComparisonSide::ConstantRef {
name,
resolved_value: None,
}
} else {
ComparisonSide::Variable { name, source: None }
}
}
Expr::Lit(lit) => {
if let syn::Lit::Str(s) = &lit.lit {
ComparisonSide::StringLiteral(s.value())
} else {
ComparisonSide::Other(quote::quote!(#expr).to_string())
}
}
Expr::Field(field) => {
let base = quote::quote!(#field.base).to_string();
let field_name = match &field.member {
syn::Member::Named(ident) => ident.to_string(),
syn::Member::Unnamed(index) => index.index.to_string(),
};
ComparisonSide::FieldAccess {
base,
field: field_name,
}
}
Expr::MethodCall(call) => {
let receiver = quote::quote!(#call.receiver).to_string();
let method = call.method.to_string();
let args: Vec<String> = call
.args
.iter()
.map(|a| quote::quote!(#a).to_string())
.collect();
ComparisonSide::MethodCall {
receiver,
method,
args,
}
}
Expr::Call(call) => {
let path = quote::quote!(#call.func).to_string();
let args: Vec<String> = call
.args
.iter()
.map(|a| quote::quote!(#a).to_string())
.collect();
ComparisonSide::FunctionCall { path, args }
}
_ => ComparisonSide::Other(quote::quote!(#expr).to_string()),
}
}
}
impl<'a, 'ast> Visit<'ast> for SecretVisitorWithRegistry<'a> {
fn visit_item(&mut self, item: &'ast Item) {
ScopeFilter::enter_scope(&mut self.context, item);
if self.context.should_skip() {
self.context.pop_scope();
return;
}
match item {
Item::Const(c) => self.visit_item_const(c),
Item::Static(s) => self.visit_item_static(s),
_ => syn::visit::visit_item(self, item),
}
self.context.pop_scope();
}
fn visit_item_const(&mut self, node: &'ast ItemConst) {
syn::visit::visit_item_const(self, node);
}
fn visit_item_static(&mut self, node: &'ast ItemStatic) {
syn::visit::visit_item_static(self, node);
}
fn visit_expr(&mut self, expr: &'ast Expr) {
match expr {
Expr::Binary(binary) => {
self.analyze_binary_expr(binary);
}
Expr::MethodCall(call) => {
self.analyze_method_call(call);
}
Expr::Call(call) => {
self.analyze_function_call(call);
}
Expr::Struct(struct_expr) => {
self.analyze_struct_expr(struct_expr);
}
Expr::Return(ret_expr) => {
self.analyze_return_expr(ret_expr);
}
Expr::Match(match_expr) => {
self.analyze_match_expr(match_expr);
}
Expr::Closure(closure) => {
let parent = self.context.current_function.clone();
self.analyze_closure_expr(closure, parent);
}
Expr::Array(array_expr) => {
self.analyze_array_expr(array_expr);
}
Expr::Macro(macro_expr) => {
self.analyze_macro_expr(macro_expr);
}
Expr::Assign(assign_expr) => {
self.analyze_assign_expr(assign_expr);
}
_ => {}
}
syn::visit::visit_expr(self, expr);
}
fn visit_local(&mut self, local: &'ast Local) {
let ident = match &local.pat {
Pat::Ident(pat_ident) => Some(&pat_ident.ident),
Pat::Type(pat_type) => {
if let Pat::Ident(pat_ident) = pat_type.pat.as_ref() {
Some(&pat_ident.ident)
} else {
None
}
}
_ => None,
};
if let Some(ident) = ident {
if let Some(init) = &local.init {
if let Some(value) = Self::extract_string_from_expr(&init.expr) {
self.function_local_vars.insert(ident.to_string(), value);
}
}
}
syn::visit::visit_local(self, local);
}
fn visit_item_fn(&mut self, node: &'ast ItemFn) {
let name = node.sig.ident.to_string();
let was_test = self.context.in_test_context;
let was_auth = self.context.has_auth_indicators;
if ScopeFilter::is_test_function(node) {
self.context.in_test_context = true;
}
self.context.current_function = Some(name);
self.context.has_auth_indicators = Self::detect_auth_indicators(&node.block);
syn::visit::visit_item_fn(self, node);
self.function_local_vars.clear();
self.context.current_function = None;
self.context.in_test_context = was_test;
self.context.has_auth_indicators = was_auth;
}
fn visit_item_mod(&mut self, node: &'ast ItemMod) {
let was_test = self.context.in_test_context;
if ScopeFilter::is_test_module(node) || node.ident == "tests" {
self.context.in_test_context = true;
}
syn::visit::visit_item_mod(self, node);
self.context.in_test_context = was_test;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_string_literal_side() {
let registry = SuspectRegistry::new();
let engine = ScoringEngine::new(Default::default()).unwrap();
let visitor =
SecretVisitorWithRegistry::new(PathBuf::from("test.rs"), &engine, ®istry);
let expr: syn::Expr = syn::parse_quote!("secret");
let side = visitor.extract_comparison_side(&expr);
assert!(matches!(side, ComparisonSide::StringLiteral(s) if s == "secret"));
}
#[test]
fn test_extract_constant_side() {
let registry = SuspectRegistry::new();
let engine = ScoringEngine::new(Default::default()).unwrap();
let visitor =
SecretVisitorWithRegistry::new(PathBuf::from("test.rs"), &engine, ®istry);
let expr: syn::Expr = syn::parse_quote!(SECRET_KEY);
let side = visitor.extract_comparison_side(&expr);
assert!(matches!(side, ComparisonSide::ConstantRef { name, .. } if name == "SECRET_KEY"));
}
#[test]
fn test_extract_string_from_method_chain() {
let expr: syn::Expr = syn::parse_quote!("rustfs rpc".parse().unwrap());
let value = SecretVisitorWithRegistry::extract_string_from_expr(&expr);
assert_eq!(value, Some("rustfs rpc".to_string()));
}
#[test]
fn test_local_variable_tracking() {
let registry = SuspectRegistry::new();
let engine = ScoringEngine::new(Default::default()).unwrap();
let mut visitor =
SecretVisitorWithRegistry::new(PathBuf::from("test.rs"), &engine, ®istry);
let stmt: syn::Stmt = syn::parse_quote!(let token = "secret_value".parse().unwrap(););
if let syn::Stmt::Local(local) = stmt {
visitor.visit_local(&local);
} else {
panic!("Expected Local statement");
}
assert_eq!(
visitor.function_local_vars.get("token"),
Some(&"secret_value".to_string())
);
assert_eq!(visitor.resolve_value("token"), Some("secret_value".to_string()));
}
#[test]
fn test_local_var_in_comparison() {
let registry = SuspectRegistry::new();
let engine = ScoringEngine::new(Default::default()).unwrap();
let mut visitor =
SecretVisitorWithRegistry::new(PathBuf::from("test.rs"), &engine, ®istry);
visitor.function_local_vars.insert("token".to_string(), "secret_value".to_string());
let expr: syn::Expr = syn::parse_quote!(token);
let side = visitor.extract_comparison_side(&expr);
assert!(matches!(
side,
ComparisonSide::ConstantRef { name, resolved_value: Some(value) }
if name == "token" && value == "secret_value"
));
}
#[test]
fn test_full_scan_local_var_comparison() {
use crate::scoring::ScoringConfig;
let code = r#"
fn check_auth() {
let token = "hardcoded_secret".to_string();
if input == token {
return true;
}
false
}
"#;
let ast: syn::File = syn::parse_str(code).unwrap();
let registry = SuspectRegistry::new();
let config = ScoringConfig {
threshold_config: crate::scoring::ThresholdConfig::from_preset(
crate::scoring::SensitivityPreset::Custom(0),
),
..Default::default()
};
let engine = ScoringEngine::new(config).unwrap();
let mut visitor = SecretVisitorWithRegistry::new(PathBuf::from("test.rs"), &engine, ®istry);
visitor.visit_file(&ast);
let findings = visitor.into_findings();
assert!(
!findings.is_empty(),
"Expected to find hardcoded secret in local variable comparison, but found none"
);
}
#[test]
fn test_local_var_with_type_annotation() {
use crate::scoring::ScoringConfig;
let code = r#"
fn check_auth() {
let token: SomeType = "hardcoded_secret".parse().unwrap();
match get_auth() {
Some(t) if token == t => println!("ok"),
_ => println!("fail"),
}
}
"#;
let ast: syn::File = syn::parse_str(code).unwrap();
let registry = SuspectRegistry::new();
let config = ScoringConfig {
threshold_config: crate::scoring::ThresholdConfig::from_preset(
crate::scoring::SensitivityPreset::Custom(0),
),
..Default::default()
};
let engine = ScoringEngine::new(config).unwrap();
let mut visitor = SecretVisitorWithRegistry::new(PathBuf::from("test.rs"), &engine, ®istry);
visitor.visit_file(&ast);
let findings = visitor.into_findings();
assert!(
!findings.is_empty(),
"Expected to find hardcoded secret in type-annotated local variable"
);
}
#[test]
fn test_method_argument_detection_insert() {
use crate::scoring::ScoringConfig;
let code = r#"
fn set_auth() {
let token = "sk_live_secret_key_12345678901234567890".to_string();
req.metadata_mut().insert("authorization", token.clone());
}
"#;
let ast: syn::File = syn::parse_str(code).unwrap();
let registry = SuspectRegistry::new();
let config = ScoringConfig {
threshold_config: crate::scoring::ThresholdConfig::from_preset(
crate::scoring::SensitivityPreset::Custom(0),
),
..Default::default()
};
let engine = ScoringEngine::new(config).unwrap();
let mut visitor = SecretVisitorWithRegistry::new(PathBuf::from("test.rs"), &engine, ®istry);
visitor.visit_file(&ast);
let findings = visitor.into_findings();
let method_arg_findings: Vec<_> = findings.iter()
.filter(|f| matches!(&f.usage, Some(Usage::MethodArgument { .. })))
.collect();
assert!(
!method_arg_findings.is_empty(),
"Expected to find secret passed to .insert() method"
);
}
#[test]
fn test_method_argument_detection_header() {
use crate::scoring::ScoringConfig;
let code = r#"
fn make_request() {
req.header("Authorization", "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9");
}
"#;
let ast: syn::File = syn::parse_str(code).unwrap();
let registry = SuspectRegistry::new();
let config = ScoringConfig {
threshold_config: crate::scoring::ThresholdConfig::from_preset(
crate::scoring::SensitivityPreset::Custom(0),
),
..Default::default()
};
let engine = ScoringEngine::new(config).unwrap();
let mut visitor = SecretVisitorWithRegistry::new(PathBuf::from("test.rs"), &engine, ®istry);
visitor.visit_file(&ast);
let findings = visitor.into_findings();
let header_findings: Vec<_> = findings.iter()
.filter(|f| matches!(&f.usage, Some(Usage::MethodArgument { method_name, .. }) if method_name == "header"))
.collect();
assert!(
!header_findings.is_empty(),
"Expected to find secret in .header() method call"
);
}
#[test]
fn test_function_argument_detection() {
use crate::scoring::ScoringConfig;
let code = r#"
fn check_auth() {
authenticate("sk_live_abcdef123456789012345678901234567890");
}
"#;
let ast: syn::File = syn::parse_str(code).unwrap();
let registry = SuspectRegistry::new();
let config = ScoringConfig {
threshold_config: crate::scoring::ThresholdConfig::from_preset(
crate::scoring::SensitivityPreset::Custom(0),
),
..Default::default()
};
let engine = ScoringEngine::new(config).unwrap();
let mut visitor = SecretVisitorWithRegistry::new(PathBuf::from("test.rs"), &engine, ®istry);
visitor.visit_file(&ast);
let findings = visitor.into_findings();
let func_arg_findings: Vec<_> = findings.iter()
.filter(|f| matches!(&f.usage, Some(Usage::FunctionArgument { .. })))
.collect();
assert!(
!func_arg_findings.is_empty(),
"Expected to find secret passed to authenticate() function"
);
}
#[test]
fn test_struct_field_init_detection() {
use crate::scoring::ScoringConfig;
let code = r#"
fn create_config() {
let config = Config {
api_key: "sk_live_abcdef123456789012345678901234567890",
name: "test",
};
}
"#;
let ast: syn::File = syn::parse_str(code).unwrap();
let registry = SuspectRegistry::new();
let config = ScoringConfig {
threshold_config: crate::scoring::ThresholdConfig::from_preset(
crate::scoring::SensitivityPreset::Custom(0),
),
..Default::default()
};
let engine = ScoringEngine::new(config).unwrap();
let mut visitor = SecretVisitorWithRegistry::new(PathBuf::from("test.rs"), &engine, ®istry);
visitor.visit_file(&ast);
let findings = visitor.into_findings();
let struct_findings: Vec<_> = findings.iter()
.filter(|f| matches!(&f.usage, Some(Usage::StructFieldInit { field_name, .. }) if field_name == "api_key"))
.collect();
assert!(
!struct_findings.is_empty(),
"Expected to find secret in struct field 'api_key'"
);
}
#[test]
fn test_return_value_detection() {
use crate::scoring::ScoringConfig;
let code = r#"
fn get_secret_token() -> &'static str {
let secret = "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
if condition {
return secret;
}
return "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
}
"#;
let ast: syn::File = syn::parse_str(code).unwrap();
let registry = SuspectRegistry::new();
let config = ScoringConfig {
threshold_config: crate::scoring::ThresholdConfig::from_preset(
crate::scoring::SensitivityPreset::Custom(0),
),
..Default::default()
};
let engine = ScoringEngine::new(config).unwrap();
let mut visitor = SecretVisitorWithRegistry::new(PathBuf::from("test.rs"), &engine, ®istry);
visitor.visit_file(&ast);
let findings = visitor.into_findings();
assert!(
!findings.is_empty(),
"Expected to find secret in get_secret_token(). All findings: {:?}",
findings.iter().map(|f| (&f.usage, &f.suspect)).collect::<Vec<_>>()
);
}
#[test]
fn test_field_assignment_detection() {
use crate::scoring::ScoringConfig;
let code = r#"
fn setup_auth() {
self.token = "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx".to_string();
}
"#;
let ast: syn::File = syn::parse_str(code).unwrap();
let registry = SuspectRegistry::new();
let config = ScoringConfig {
threshold_config: crate::scoring::ThresholdConfig::from_preset(
crate::scoring::SensitivityPreset::Custom(0),
),
..Default::default()
};
let engine = ScoringEngine::new(config).unwrap();
let mut visitor = SecretVisitorWithRegistry::new(PathBuf::from("test.rs"), &engine, ®istry);
visitor.visit_file(&ast);
let findings = visitor.into_findings();
let assign_findings: Vec<_> = findings.iter()
.filter(|f| matches!(&f.usage, Some(Usage::FieldAssignment { field_name, .. }) if field_name == "token"))
.collect();
assert!(
!assign_findings.is_empty(),
"Expected to find secret in field assignment to 'token'"
);
}
#[test]
fn test_array_element_detection() {
use crate::scoring::ScoringConfig;
let code = r#"
fn get_keys() {
let keys = [
"sk_live_key1_abcdef123456789012345678901234",
"sk_live_key2_abcdef123456789012345678901234",
];
}
"#;
let ast: syn::File = syn::parse_str(code).unwrap();
let registry = SuspectRegistry::new();
let config = ScoringConfig {
threshold_config: crate::scoring::ThresholdConfig::from_preset(
crate::scoring::SensitivityPreset::Custom(0),
),
..Default::default()
};
let engine = ScoringEngine::new(config).unwrap();
let mut visitor = SecretVisitorWithRegistry::new(PathBuf::from("test.rs"), &engine, ®istry);
visitor.visit_file(&ast);
let findings = visitor.into_findings();
let array_findings: Vec<_> = findings.iter()
.filter(|f| matches!(&f.usage, Some(Usage::ArrayElement { .. })))
.collect();
assert!(
array_findings.len() >= 2,
"Expected to find secrets in array elements, found {}",
array_findings.len()
);
}
#[test]
fn test_is_value_suspicious() {
let registry = SuspectRegistry::new();
let engine = ScoringEngine::new(Default::default()).unwrap();
let visitor = SecretVisitorWithRegistry::new(PathBuf::from("test.rs"), &engine, ®istry);
let stripe_key = SuspectValue::InlineLiteral {
value: "sk_live_abcdef123456789012345678901234567890".to_string(),
};
assert!(visitor.is_value_suspicious(&stripe_key), "Stripe key should be suspicious");
let github_token = SuspectValue::InlineLiteral {
value: "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx".to_string(),
};
assert!(visitor.is_value_suspicious(&github_token), "GitHub token should be suspicious");
let jwt = SuspectValue::InlineLiteral {
value: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.payload.signature".to_string(),
};
assert!(visitor.is_value_suspicious(&jwt), "JWT should be suspicious");
let short = SuspectValue::InlineLiteral {
value: "abc".to_string(),
};
assert!(!visitor.is_value_suspicious(&short), "Short string should not be suspicious");
let path = SuspectValue::InlineLiteral {
value: "/api/v1/users".to_string(),
};
assert!(!visitor.is_value_suspicious(&path), "URL path should not be suspicious");
}
#[test]
fn test_backdoor_detection_via_insert_method() {
use crate::scoring::ScoringConfig;
let code = r#"
fn authenticate_request() {
let token = "rustfs rpc".parse().unwrap();
req.metadata_mut().insert("authorization", token.clone());
}
"#;
let ast: syn::File = syn::parse_str(code).unwrap();
let registry = SuspectRegistry::new();
let config = ScoringConfig {
threshold_config: crate::scoring::ThresholdConfig::from_preset(
crate::scoring::SensitivityPreset::Custom(0),
),
..Default::default()
};
let engine = ScoringEngine::new(config).unwrap();
let mut visitor = SecretVisitorWithRegistry::new(PathBuf::from("test.rs"), &engine, ®istry);
visitor.visit_file(&ast);
let findings = visitor.into_findings();
let insert_findings: Vec<_> = findings.iter()
.filter(|f| matches!(&f.usage, Some(Usage::MethodArgument { method_name, .. }) if method_name == "insert"))
.collect();
assert!(
!insert_findings.is_empty(),
"Expected to detect token passed to .insert() method. Found findings: {:?}",
findings.iter().map(|f| &f.usage).collect::<Vec<_>>()
);
}
#[test]
fn test_closure_with_cloned_variable() {
use crate::scoring::ScoringConfig;
let code = r#"
fn node_service_client() {
let token = "rustfs rpc".parse().unwrap();
let interceptor = move |mut req: Request<()>| {
req.metadata_mut().insert("authorization", token.clone());
Ok(req)
};
}
"#;
let ast: syn::File = syn::parse_str(code).unwrap();
let registry = SuspectRegistry::new();
let config = ScoringConfig {
threshold_config: crate::scoring::ThresholdConfig::from_preset(
crate::scoring::SensitivityPreset::Custom(0),
),
..Default::default()
};
let engine = ScoringEngine::new(config).unwrap();
let mut visitor = SecretVisitorWithRegistry::new(PathBuf::from("test.rs"), &engine, ®istry);
visitor.visit_file(&ast);
let findings = visitor.into_findings();
assert!(
!findings.is_empty(),
"Expected to detect backdoor in closure. Found findings: {:?}",
findings.iter().map(|f| (&f.usage, &f.suspect)).collect::<Vec<_>>()
);
let insert_findings: Vec<_> = findings.iter()
.filter(|f| matches!(&f.usage, Some(Usage::MethodArgument { method_name, .. }) if method_name == "insert"))
.collect();
assert!(
!insert_findings.is_empty(),
"Expected to detect token.clone() passed to .insert() in closure. All findings: {:?}",
findings.iter().map(|f| (&f.usage, &f.suspect)).collect::<Vec<_>>()
);
}
#[test]
fn test_match_guard_comparison_detection() {
use crate::scoring::{ScoringConfig, ThresholdConfig, SensitivityPreset};
let code = r#"
fn intercept(req: Request<()>) -> Result<Request<()>, Status> {
let expected: MetadataValue<_> = "s3cr3t_t0k3n".parse().unwrap();
match req.metadata().get("authorization") {
Some(t) if expected == t => Ok(req),
_ => Err(Status::unauthenticated("No valid auth token")),
}
}
"#;
let ast: syn::File = syn::parse_str(code).unwrap();
let registry = SuspectRegistry::new();
let config = ScoringConfig {
threshold_config: ThresholdConfig::from_preset(SensitivityPreset::Custom(0)),
..Default::default()
};
let engine = ScoringEngine::new(config).unwrap();
let mut visitor = SecretVisitorWithRegistry::new(PathBuf::from("test.rs"), &engine, ®istry);
visitor.visit_file(&ast);
let findings = visitor.into_findings();
println!("\n=== Match Guard Detection Test ===");
println!("Total findings: {}", findings.len());
for f in &findings {
println!(" - suspect: {:?}, value: {:?}, usage: {:?}, score: {}",
f.suspect.name(), f.suspect.value(), f.usage, f.score.total);
println!(" factors: {:?}", f.score.factors.iter().map(|f| (&f.name, f.contribution)).collect::<Vec<_>>());
}
let comparison_findings: Vec<_> = findings.iter()
.filter(|f| matches!(&f.usage, Some(Usage::Comparison(_))))
.filter(|f| f.suspect.value() == "s3cr3t_t0k3n")
.collect();
assert!(
!comparison_findings.is_empty(),
"Expected to detect 's3cr3t_t0k3n' in match guard comparison. All findings: {:?}",
findings.iter().map(|f| (f.suspect.name(), f.suspect.value(), &f.usage, f.score.total)).collect::<Vec<_>>()
);
let high_score_findings: Vec<_> = comparison_findings.iter()
.filter(|f| f.score.total >= 70)
.collect();
assert!(
!high_score_findings.is_empty(),
"Expected 'rustfs rpc' finding to have score >= 70 (default threshold). Actual scores: {:?}",
comparison_findings.iter().map(|f| f.score.total).collect::<Vec<_>>()
);
}
}