use crate::detectors::ast_fingerprint::parse_root_ext;
use crate::detectors::ast_walk::AstWalkCtx;
use crate::detectors::base::{Detector, DetectorConfig};
use crate::detectors::security::ast_helpers::{
collect_named_args, node_text, receiver_chain_label as receiver_chain_label_shared,
};
use crate::detectors::security::scan_inputs::{ScanAstInputs, ScanInputs};
use crate::graph::GraphQueryExt;
use crate::models::{Finding, Severity};
use crate::parsers::lightweight::Language;
use anyhow::Result;
use regex::Regex;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use tracing::info;
static CRED_NAME_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)(^|_)(password|passwd|pwd|secret|api[_-]?key|apikey|auth[_-]?token|access[_-]?token|private[_-]?key|credential)($|_)")
.expect("valid regex")
});
const NAME_FP_SUBSTRINGS: &[&str] = &[
"_path",
"_file",
"_dir",
"tokenizer",
"token_path",
"password_field",
"password_input",
"password_hash",
"password_reset",
"no_password",
"without_password",
"hide_password",
"mask_password",
];
fn is_credential_name(name: &str) -> bool {
let lower = name.to_lowercase();
if is_credential_name_fp(&lower) {
return false;
}
CRED_NAME_REGEX.is_match(&lower)
}
fn is_credential_name_fp(name_lower: &str) -> bool {
NAME_FP_SUBSTRINGS.iter().any(|s| name_lower.contains(s))
}
fn categorize_credential_name(name_lower: &str) -> (&'static str, &'static str) {
if name_lower.contains("api_key")
|| name_lower.contains("apikey")
|| name_lower.contains("api-key")
{
return ("API Key", "🔑");
}
if name_lower.contains("auth_token")
|| name_lower.contains("access_token")
|| name_lower.contains("authtoken")
|| name_lower.contains("accesstoken")
{
return ("Auth Token", "🎫");
}
if name_lower.contains("private_key") || name_lower.contains("privatekey") {
return ("Private Key", "🔐");
}
if name_lower.contains("password")
|| name_lower.contains("passwd")
|| name_lower.contains("pwd")
{
return ("Password", "🔒");
}
if name_lower.contains("secret") {
return ("Secret", "🤫");
}
if name_lower.contains("credential") {
return ("Credentials", "👤");
}
if name_lower.contains("token") {
return ("Auth Token", "🎫");
}
("Sensitive Data", "⚠️")
}
static LOG_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?i)\b(console\.(log|warn|error|info|debug)|print[fl]?n?|logger\.(log|warn|error|info|debug|trace)|logging\.(log|warn|error|info|debug)|log\.(debug|info|warn|error|trace)|System\.out\.print|fmt\.Print|puts|p\s)\s*[\.(]\s*[^;\n]*\b(password|passwd|secret|api_key|apikey|auth_token|access_token|private_key|credentials?)\b"
).expect("valid regex")
});
fn is_false_positive_line(line: &str) -> bool {
let lower = line.to_lowercase();
if NAME_FP_SUBSTRINGS.iter().any(|s| lower.contains(s)) {
return true;
}
credential_only_in_string_literal(line)
}
fn credential_only_in_string_literal(line: &str) -> bool {
static CRED_KEYWORDS: &[&str] = &[
"password",
"passwd",
"secret",
"api_key",
"apikey",
"auth_token",
"access_token",
"private_key",
"credential",
];
let lower = line.to_lowercase();
let mut cred_positions: Vec<(usize, usize)> = Vec::new();
for keyword in CRED_KEYWORDS {
let mut start = 0;
while let Some(pos) = lower[start..].find(keyword) {
let abs_pos = start + pos;
cred_positions.push((abs_pos, abs_pos + keyword.len()));
start = abs_pos + keyword.len();
}
}
if cred_positions.is_empty() {
return false;
}
let bytes = line.as_bytes();
let mut in_string = vec![false; bytes.len()];
let mut i = 0;
while i < bytes.len() {
let ch = bytes[i];
if ch == b'"' || ch == b'\'' || ch == b'`' {
let is_fstring = {
let prev1 = if i >= 1 { bytes[i - 1] } else { 0 };
let prev2 = if i >= 2 { bytes[i - 2] } else { 0 };
prev1 == b'f' || prev1 == b'F' || prev2 == b'f' || prev2 == b'F'
};
let is_template_literal = ch == b'`';
let quote = ch;
i += 1;
let mut brace_depth: u32 = 0;
while i < bytes.len() {
if bytes[i] == b'\\' {
i += 2;
continue;
}
if bytes[i] == quote && brace_depth == 0 {
break;
}
if (is_fstring || is_template_literal) && bytes[i] == b'{' {
if is_template_literal && i >= 1 && bytes[i - 1] == b'$' {
brace_depth += 1;
i += 1;
continue;
}
if is_fstring {
brace_depth += 1;
i += 1;
continue;
}
}
if (is_fstring || is_template_literal) && bytes[i] == b'}' && brace_depth > 0 {
brace_depth -= 1;
i += 1;
continue;
}
if brace_depth == 0 {
in_string[i] = true;
}
i += 1;
}
}
i += 1;
}
cred_positions
.iter()
.all(|&(start, end)| (start..end).all(|pos| pos < in_string.len() && in_string[pos]))
}
const SUPPORTED_EXTS: &[&str] = &[
"py", "js", "ts", "jsx", "tsx", "java", "go", "cs", "rs", "c", "cpp", "h", "hpp", "cc", "cxx",
"hh", "rb", "php", "kt", "swift", "scala",
];
pub struct CleartextCredentialsDetector {
#[allow(dead_code)] config: DetectorConfig,
#[allow(dead_code)] repository_path: PathBuf,
max_findings: usize,
}
impl CleartextCredentialsDetector {
pub fn new(repository_path: impl Into<PathBuf>) -> Self {
Self {
config: DetectorConfig::default(),
repository_path: repository_path.into(),
max_findings: 50,
}
}
fn find_function_context(
graph: &dyn crate::graph::GraphQuery,
file_path: &str,
line: u32,
) -> Option<(String, usize, bool)> {
let i = graph.interner();
graph.find_function_at(file_path, line).map(|f| {
let callers = graph.get_callers(f.qn(i));
let name_lower = f.node_name(i).to_lowercase();
let is_auth_related = name_lower.contains("auth")
|| name_lower.contains("login")
|| name_lower.contains("signin")
|| name_lower.contains("register")
|| name_lower.contains("password")
|| name_lower.contains("credential")
|| name_lower.contains("token")
|| name_lower.contains("session");
(f.node_name(i).to_string(), callers.len(), is_auth_related)
})
}
fn is_production_log_method(method_name: &str) -> bool {
let lower = method_name.to_lowercase();
lower == "error" || lower == "warn" || lower == "warning"
}
fn scan_file_line(&self, inputs: &ScanInputs<'_>) -> Vec<Finding> {
let path = inputs.path;
let content = inputs.content;
let ext = inputs.ext;
let mut findings = vec![];
let lines: Vec<&str> = content.lines().collect();
for (i, line) in lines.iter().enumerate() {
if findings.len() >= self.max_findings {
break;
}
let prev_line = if i > 0 { Some(lines[i - 1]) } else { None };
if crate::detectors::is_line_suppressed(line, prev_line) {
continue;
}
if LOG_PATTERN.is_match(line) && !is_false_positive_line(line) {
let line_num = (i + 1) as u32;
let (cred_type, emoji) = categorize_credential_line(line);
let is_prod_log = {
let lower = line.to_lowercase();
lower.contains(".error")
|| lower.contains(".warn")
|| lower.contains(".warning")
};
findings.push(self.build_finding(
path,
line_num,
cred_type,
emoji,
is_prod_log,
ext,
));
}
}
findings
}
fn scan_file_ast(&self, inputs: &ScanAstInputs<'_>) -> Vec<Finding> {
let path = inputs.path();
let content = inputs.content();
let ext = inputs.ext();
let lang = inputs.lang;
let cached_tree = inputs.cached_tree;
let mut findings = vec![];
if content.contains('\0') {
return findings;
}
let owned;
let root = match cached_tree {
Some(tree) => tree.root_node(),
None => match parse_root_ext(content, lang, ext) {
Some(t) => {
owned = t;
owned.root_node()
}
None => return findings,
},
};
let bytes = content.as_bytes();
let lines: Vec<&str> = content.lines().collect();
let mut log_calls: Vec<LogCall> = Vec::new();
let ctx = AstWalkCtx {
lang,
source: bytes,
};
collect_log_calls(&ctx, root, &mut log_calls);
for call in log_calls {
if findings.len() >= self.max_findings {
break;
}
let line_idx = call.call_node.start_position().row;
if let Some(line) = lines.get(line_idx) {
let prev = if line_idx > 0 {
Some(lines[line_idx - 1])
} else {
None
};
if crate::detectors::is_line_suppressed(line, prev) {
continue;
}
}
let mut hit: Option<CredentialHit> = None;
for arg in &call.args {
if let Some(h) = examine_argument(*arg, bytes) {
hit = Some(h);
break;
}
}
let hit = match hit {
Some(h) => h,
None => continue,
};
let (cred_type, emoji) = categorize_credential_name(&hit.name_lower);
let line_num = (line_idx + 1) as u32;
let is_prod_log = Self::is_production_log_method(&call.method_name);
findings.push(self.build_finding(path, line_num, cred_type, emoji, is_prod_log, ext));
}
findings
}
fn build_finding(
&self,
path: &Path,
line_num: u32,
cred_type: &str,
emoji: &str,
is_prod_log: bool,
ext: &str,
) -> Finding {
let mut severity = Severity::High;
if is_prod_log {
severity = Severity::Critical;
}
let mut notes = Vec::new();
notes.push(format!("{} Credential type: {}", emoji, cred_type));
if is_prod_log {
notes.push("🚨 Production log level (error/warn)".to_string());
}
let context_notes = format!("\n\n**Analysis:**\n{}", notes.join("\n"));
let suggestion = match ext {
"py" => "Mask or remove credentials from logs:\n\
```python\n\
# Instead of:\n\
logger.info(f\"Login attempt with password: {password}\")\n\
\n\
# Use:\n\
logger.info(f\"Login attempt for user: {username}\")\n\
# Or mask:\n\
logger.debug(f\"Password length: {len(password)}\")\n\
```"
.to_string(),
"js" | "ts" | "jsx" | "tsx" => "Mask or remove credentials from logs:\n\
```javascript\n\
// Instead of:\n\
console.log('API Key:', apiKey);\n\
\n\
// Use:\n\
console.log('API Key set:', !!apiKey);\n\
// Or redact:\n\
console.log('API Key:', apiKey.slice(0, 4) + '****');\n\
```"
.to_string(),
_ => "Remove sensitive data from logs or use masking.".to_string(),
};
Finding {
id: String::new(),
detector: "CleartextCredentialsDetector".to_string(),
severity,
title: format!("{} may be logged in cleartext", cred_type),
description: format!(
"Sensitive data ({}) appears in logging statement.{}",
cred_type, context_notes
),
affected_files: vec![path.to_path_buf()],
line_start: Some(line_num),
line_end: Some(line_num),
suggested_fix: Some(suggestion),
estimated_effort: Some("10 minutes".to_string()),
category: Some("security".to_string()),
cwe_id: Some("CWE-312".to_string()),
why_it_matters: Some(
"Credentials logged in cleartext can be:\n\
• Exposed in log files accessible to attackers\n\
• Sent to centralized logging systems\n\
• Visible in monitoring dashboards\n\
• Captured in crash reports"
.to_string(),
),
..Default::default()
}
}
}
fn categorize_credential_line(line: &str) -> (&'static str, &'static str) {
static LINE_KEYWORDS: &[&str] = &[
"private_key",
"privatekey",
"auth_token",
"access_token",
"authtoken",
"accesstoken",
"api_key",
"apikey",
"api-key",
"credential",
"password",
"passwd",
"pwd",
"secret",
"token",
];
let lower = line.to_lowercase();
if let Some(kw) = LINE_KEYWORDS.iter().find(|k| lower.contains(*k)) {
return categorize_credential_name(kw);
}
("Sensitive Data", "⚠️")
}
struct LogCall<'a> {
call_node: tree_sitter::Node<'a>,
method_name: String,
args: Vec<tree_sitter::Node<'a>>,
}
struct CredentialHit {
name_lower: String,
}
fn collect_log_calls<'a>(
ctx: &AstWalkCtx<'a>,
node: tree_sitter::Node<'a>,
out: &mut Vec<LogCall<'a>>,
) {
if let Some(call) = match_log_call(node, ctx.source, ctx.lang) {
out.push(call);
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
collect_log_calls(ctx, child, out);
}
}
fn match_log_call<'a>(
node: tree_sitter::Node<'a>,
source: &'a [u8],
lang: Language,
) -> Option<LogCall<'a>> {
match node.kind() {
"call" => {
let func = node.child_by_field_name("function")?;
let (object, method) = decompose_callee(func, source);
if !is_log_callee(object.as_deref(), &method, lang) {
return None;
}
let arg_list = node.child_by_field_name("arguments")?;
let args = collect_named_args(arg_list);
Some(LogCall {
call_node: node,
method_name: method,
args,
})
}
"call_expression" => {
let func = node.child_by_field_name("function")?;
let (object, method) = decompose_callee(func, source);
if !is_log_callee(object.as_deref(), &method, lang) {
return None;
}
let arg_list = node.child_by_field_name("arguments")?;
let args = collect_named_args(arg_list);
Some(LogCall {
call_node: node,
method_name: method,
args,
})
}
"method_invocation" => {
let name_node = node.child_by_field_name("name")?;
let method = node_text(name_node, source)?.to_lowercase();
let object = node
.child_by_field_name("object")
.map(|o| receiver_chain_label(o, source))
.or_else(|| {
node.child_by_field_name("object")
.and_then(|o| node_text(o, source))
.map(|s| s.to_lowercase())
});
if !is_log_callee(object.as_deref(), &method, lang) {
return None;
}
let arg_list = node.child_by_field_name("arguments")?;
let args = collect_named_args(arg_list);
Some(LogCall {
call_node: node,
method_name: method,
args,
})
}
"invocation_expression" => {
let func = node.child_by_field_name("function")?;
let (object, method) = decompose_callee(func, source);
if !is_log_callee(object.as_deref(), &method, lang) {
return None;
}
let arg_list = node.child_by_field_name("arguments")?;
let args = collect_named_args(arg_list);
Some(LogCall {
call_node: node,
method_name: method,
args,
})
}
"macro_invocation" => {
let macro_node = node.child_by_field_name("macro")?;
let macro_name = node_text(macro_node, source)?.to_lowercase();
let last_segment = macro_name.rsplit("::").next().unwrap_or(¯o_name);
if !is_log_callee(None, last_segment, lang) {
return None;
}
let token_tree = match node.child_by_field_name("arguments") {
Some(t) => t,
None => {
let mut cursor = node.walk();
let found = node
.children(&mut cursor)
.find(|c| c.kind() == "token_tree");
found?
}
};
let args = collect_named_args(token_tree);
Some(LogCall {
call_node: node,
method_name: last_segment.to_string(),
args,
})
}
_ => None,
}
}
fn decompose_callee(node: tree_sitter::Node, source: &[u8]) -> (Option<String>, String) {
match node.kind() {
"attribute" | "member_expression" => {
let object = node
.child_by_field_name("object")
.map(|o| receiver_chain_label(o, source));
let attr = node
.child_by_field_name("attribute")
.or_else(|| node.child_by_field_name("property"))
.and_then(|a| node_text(a, source))
.unwrap_or("")
.to_lowercase();
(object, attr)
}
"selector_expression" => {
let operand = node
.child_by_field_name("operand")
.map(|o| receiver_chain_label(o, source));
let field = node
.child_by_field_name("field")
.and_then(|a| node_text(a, source))
.unwrap_or("")
.to_lowercase();
(operand, field)
}
"member_access_expression" => {
let object = node
.child_by_field_name("expression")
.map(|o| receiver_chain_label(o, source));
let name = node
.child_by_field_name("name")
.and_then(|a| node_text(a, source))
.unwrap_or("")
.to_lowercase();
(object, name)
}
"identifier" => (None, node_text(node, source).unwrap_or("").to_lowercase()),
_ => {
let txt = node_text(node, source).unwrap_or("").to_lowercase();
(None, txt)
}
}
}
fn receiver_chain_label(node: tree_sitter::Node, source: &[u8]) -> String {
receiver_chain_label_shared(node, source, None)
}
fn is_log_callee(object: Option<&str>, method: &str, lang: Language) -> bool {
let method = method.trim();
if object.is_none() {
match lang {
Language::Python => {
if matches!(method, "print" | "pprint") {
return true;
}
}
Language::JavaScript | Language::TypeScript => {
if matches!(method, "log" | "info" | "warn" | "error" | "debug") {
return false;
}
}
Language::Go => {
}
Language::Rust => {
if matches!(
method,
"println"
| "eprintln"
| "print"
| "eprint"
| "info"
| "warn"
| "error"
| "debug"
| "trace"
| "log"
| "dbg"
) {
return true;
}
}
Language::Java | Language::CSharp => {
}
Language::C | Language::Cpp => {
if matches!(
method,
"printf"
| "fprintf"
| "sprintf"
| "snprintf"
| "vprintf"
| "vfprintf"
| "syslog"
| "vsyslog"
| "printk"
| "perror"
| "puts"
) {
return true;
}
}
_ => {}
}
return false;
}
let object = object.expect("checked is_some on the line above");
let is_log_object = matches!(
object,
"console"
| "log"
| "logger"
| "_logger"
| "ilogger"
| "logging"
| "fmt"
| "slog"
| "klog"
| "zap"
| "logrus"
| "winston"
| "pino"
| "bunyan"
| "out" | "err" | "tracing"
) || object.ends_with("logger")
|| object.ends_with("_log");
if !is_log_object {
return false;
}
matches!(
method,
"log"
| "info"
| "warn"
| "warning"
| "error"
| "errorf"
| "debug"
| "trace"
| "fatal"
| "println"
| "printf"
| "print"
| "printline"
| "write"
| "writeline"
| "logtrace"
| "logdebug"
| "loginformation"
| "logwarning"
| "logerror"
| "logcritical"
)
}
fn examine_argument(node: tree_sitter::Node, source: &[u8]) -> Option<CredentialHit> {
match node.kind() {
"identifier" | "property_identifier" | "field_identifier" => {
check_identifier_name(node_text(node, source)?)
}
"attribute"
| "member_expression"
| "field_expression"
| "selector_expression"
| "member_access_expression" => {
let last = rightmost_name(node, source)?;
check_identifier_name(&last)
}
"keyword_argument" => {
let name_node = node.child_by_field_name("name")?;
let name = node_text(name_node, source)?;
if let Some(hit) = check_identifier_name(name) {
return Some(hit);
}
if matches!(name, "file" | "sep" | "end" | "flush" | "stream") {
return None;
}
let value = node.child_by_field_name("value")?;
examine_argument(value, source)
}
"argument" => {
for i in 0..node.named_child_count() {
if let Some(c) = node.named_child(i) {
if let Some(h) = examine_argument(c, source) {
return Some(h);
}
}
}
None
}
"string" | "interpreted_string_literal" | "interpreted_string_literal_content" => {
walk_interpolations(node, source)
}
"string_literal" | "raw_string_literal" => {
if let Some(h) = walk_interpolations(node, source) {
return Some(h);
}
walk_rust_format_captures(node, source)
}
"interpolated_string_expression" | "interpolated_verbatim_string_expression" => {
walk_interpolations(node, source)
}
"template_string" => walk_interpolations(node, source),
"binary_expression" | "binary_operator" => {
let left = node.child_by_field_name("left");
let right = node.child_by_field_name("right");
if let Some(l) = left {
if let Some(h) = examine_argument(l, source) {
return Some(h);
}
}
if let Some(r) = right {
if let Some(h) = examine_argument(r, source) {
return Some(h);
}
}
None
}
"parenthesized_expression" | "expression" | "primary_expression" => {
for i in 0..node.named_child_count() {
if let Some(c) = node.named_child(i) {
if let Some(h) = examine_argument(c, source) {
return Some(h);
}
}
}
None
}
"call" | "call_expression" | "method_invocation" | "invocation_expression" => None,
_ => None,
}
}
fn walk_interpolations(node: tree_sitter::Node, source: &[u8]) -> Option<CredentialHit> {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
match child.kind() {
"interpolation" => {
let expr = child.child_by_field_name("expression").or_else(|| {
(0..child.named_child_count())
.filter_map(|i| child.named_child(i))
.find(|n| {
!matches!(
n.kind(),
"interpolation_brace"
| "interpolation_format_clause"
| "interpolation_alignment_clause"
)
})
});
if let Some(e) = expr {
if let Some(h) = examine_argument(e, source) {
return Some(h);
}
}
}
"template_substitution" => {
if let Some(e) = child.named_child(0) {
if let Some(h) = examine_argument(e, source) {
return Some(h);
}
}
}
_ => {}
}
}
None
}
fn walk_rust_format_captures(node: tree_sitter::Node, source: &[u8]) -> Option<CredentialHit> {
static CAPTURE_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\{([A-Za-z_][A-Za-z0-9_]*)[:}]").expect("valid regex"));
let text = node_text(node, source)?;
let cleaned = text.replace("{{", " ").replace("}}", " ");
for cap in CAPTURE_REGEX.captures_iter(&cleaned) {
if let Some(m) = cap.get(1) {
if let Some(hit) = check_identifier_name(m.as_str()) {
return Some(hit);
}
}
}
None
}
fn rightmost_name(node: tree_sitter::Node, source: &[u8]) -> Option<String> {
let count = node.named_child_count();
if count == 0 {
return node_text(node, source).map(|s| s.to_string());
}
let last = node.named_child(count - 1)?;
match last.kind() {
"identifier" | "property_identifier" | "field_identifier" => {
node_text(last, source).map(|s| s.to_string())
}
_ => rightmost_name(last, source),
}
}
fn check_identifier_name(name: &str) -> Option<CredentialHit> {
if !is_credential_name(name) {
return None;
}
Some(CredentialHit {
name_lower: name.to_lowercase(),
})
}
impl Detector for CleartextCredentialsDetector {
fn name(&self) -> &'static str {
"cleartext-credentials"
}
fn description(&self) -> &'static str {
"Detects credentials in logs"
}
fn bypass_postprocessor(&self) -> bool {
true
}
fn file_extensions(&self) -> &'static [&'static str] {
SUPPORTED_EXTS
}
fn content_requirements(&self) -> crate::detectors::detector_context::ContentFlags {
crate::detectors::detector_context::ContentFlags::HAS_SECRET_PATTERN
}
fn detect(
&self,
ctx: &crate::detectors::analysis_context::AnalysisContext,
) -> Result<Vec<Finding>> {
let graph = ctx.graph;
let files = &ctx.as_file_provider();
let mut findings = vec![];
for path in files.files_with_extensions(SUPPORTED_EXTS) {
if findings.len() >= self.max_findings {
break;
}
let path_str = path.to_string_lossy().to_string();
if crate::detectors::base::is_test_path(&path_str) || path_str.contains("spec") {
continue;
}
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
let content = match files.content(path) {
Some(c) => c,
None => continue,
};
let lang = Language::from_path(path);
let has_ast_grammar = matches!(
lang,
Language::Python
| Language::JavaScript
| Language::TypeScript
| Language::Rust
| Language::Go
| Language::Java
| Language::CSharp
| Language::C
| Language::Cpp
);
let scan = ScanInputs::new(path, &content, ext);
let new_findings = if has_ast_grammar {
let cached = files.tree(path);
let ast_inputs = ScanAstInputs::new(scan, lang, cached.as_deref());
self.scan_file_ast(&ast_inputs)
} else {
self.scan_file_line(&scan)
};
findings.extend(new_findings);
}
for finding in &mut findings {
if let (Some(file_path), Some(line)) =
(finding.affected_files.first(), finding.line_start)
{
let path_str = file_path.to_string_lossy().to_string();
if let Some((func_name, callers, is_auth)) =
Self::find_function_context(graph, &path_str, line)
{
let mut extra_notes = Vec::new();
extra_notes.push(format!(
"📦 In function: `{}` ({} callers)",
func_name, callers
));
if is_auth {
extra_notes.push("🔐 In authentication-related code".to_string());
if finding.severity != Severity::Critical {
finding.severity = Severity::Critical;
}
}
finding.description =
format!("{}\n{}", finding.description, extra_notes.join("\n"));
}
}
}
info!(
"CleartextCredentialsDetector found {} findings (graph-aware)",
findings.len()
);
Ok(findings)
}
}
impl crate::detectors::RegisteredDetector for CleartextCredentialsDetector {
fn create(init: &crate::detectors::DetectorInit) -> std::sync::Arc<dyn Detector> {
std::sync::Arc::new(Self::new(init.repo_path))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::builder::GraphBuilder;
#[test]
fn test_detects_password_in_log() {
let store = GraphBuilder::new().freeze();
let detector = CleartextCredentialsDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(&store, vec![
("app.py", "def login(user, password):\n logger.info(f\"Authenticating with password: {password}\")\n return authenticate(user, password)\n"),
]);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
!findings.is_empty(),
"Should detect password logged in cleartext"
);
assert!(
findings.iter().any(|f| f.title.contains("Password")),
"Finding should mention Password. Titles: {:?}",
findings.iter().map(|f| &f.title).collect::<Vec<_>>()
);
}
#[test]
fn test_no_finding_for_safe_code() {
let store = GraphBuilder::new().freeze();
let detector = CleartextCredentialsDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(&store, vec![
("app.py", "def login(user, password):\n result = authenticate(user, password)\n logger.info(f\"User {user} logged in successfully\")\n return result\n"),
]);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
findings.is_empty(),
"Should not detect anything in safe code. Found: {:?}",
findings.iter().map(|f| &f.title).collect::<Vec<_>>()
);
}
#[test]
fn test_audit_repro_multiline_python_log() {
let store = GraphBuilder::new().freeze();
let detector = CleartextCredentialsDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![(
"app.py",
"def login(user, password):\n logger.info(\n f\"Login attempt with password: {password}\"\n )\n",
)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
!findings.is_empty(),
"Should detect the multi-line logger.info call referencing `password`"
);
}
#[test]
fn test_detects_python_password_in_logger_info() {
let store = GraphBuilder::new().freeze();
let detector = CleartextCredentialsDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![(
"app.py",
"def login(user, password):\n logger.info(f\"pwd: {password}\")\n",
)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
!findings.is_empty(),
"Expected a finding for f-string log of `password`"
);
}
#[test]
fn test_skips_string_literal_only_message() {
let store = GraphBuilder::new().freeze();
let detector = CleartextCredentialsDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![(
"app.py",
"def boot():\n logger.error(\"Using credentials from ~/.config/app\")\n",
)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
findings.is_empty(),
"Static descriptive message should not trigger; got: {:?}",
findings.iter().map(|f| &f.title).collect::<Vec<_>>()
);
}
#[test]
fn test_skips_password_reset_keyword() {
let store = GraphBuilder::new().freeze();
let detector = CleartextCredentialsDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![(
"app.py",
"def notify(user):\n logger.info(\"Password reset email sent\")\n",
)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
findings.is_empty(),
"`password reset` skip-word should suppress; got: {:?}",
findings.iter().map(|f| &f.title).collect::<Vec<_>>()
);
}
#[test]
fn test_detects_js_template_literal_with_interpolated_secret() {
let store = GraphBuilder::new().freeze();
let detector = CleartextCredentialsDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![(
"app.js",
"function debug(apiKey) {\n console.log(`API key is ${apiKey}`);\n}\n",
)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
!findings.is_empty(),
"Interpolated apiKey in template literal should fire"
);
}
#[test]
fn test_categorize_correctly_when_multiple_credential_words() {
let store = GraphBuilder::new().freeze();
let detector = CleartextCredentialsDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![(
"app.js",
"function fail(apiKey) {\n console.error(\"auth_token failure for api_key:\", apiKey);\n}\n",
)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
!findings.is_empty(),
"Expected at least one finding for multi-keyword log"
);
let title_blob = findings
.iter()
.map(|f| f.title.to_lowercase())
.collect::<Vec<_>>()
.join(" | ");
assert!(
title_blob.contains("token")
|| title_blob.contains("key")
|| title_blob.contains("password")
|| title_blob.contains("secret")
|| title_blob.contains("credential"),
"Title should reference some credential word; got: {}",
title_blob
);
}
#[test]
fn test_skips_when_credential_word_is_a_function_name() {
let store = GraphBuilder::new().freeze();
let detector = CleartextCredentialsDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![(
"app.js",
"function start() {\n logger.info(getPassword());\n}\n",
)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
findings.is_empty(),
"Bare getPassword() call should not be treated as a credential log; got: {:?}",
findings.iter().map(|f| &f.title).collect::<Vec<_>>()
);
}
#[test]
fn test_severity_critical_in_auth_function() {
let store = GraphBuilder::new().freeze();
let detector = CleartextCredentialsDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![(
"app.py",
"def login(user, password):\n logger.info(f\"login with {password}\")\n",
)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(!findings.is_empty(), "Expected a finding inside login()");
let max_sev = findings
.iter()
.map(|f| f.severity)
.max_by_key(|s| match s {
Severity::Critical => 4,
Severity::High => 3,
Severity::Medium => 2,
Severity::Low => 1,
_ => 0,
})
.expect("at least one finding");
assert!(
matches!(max_sev, Severity::Critical | Severity::High),
"Severity should be Critical (auth context) or at least High; got {:?}",
max_sev
);
}
#[test]
fn test_does_not_double_count_password_kwargs() {
let store = GraphBuilder::new().freeze();
let detector = CleartextCredentialsDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![(
"app.py",
"def go(user):\n logger.info(\"login\", password=user.password)\n",
)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert_eq!(
findings.len(),
1,
"Expected exactly 1 finding for kwarg-form credential log; got {}: {:?}",
findings.len(),
findings.iter().map(|f| &f.title).collect::<Vec<_>>()
);
}
#[test]
fn test_audit_repro_password_in_unrelated_string() {
let store = GraphBuilder::new().freeze();
let detector = CleartextCredentialsDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![(
"app.js",
"function save() {\n console.log(\"Saved to /etc/password.conf\");\n}\n",
)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
findings.is_empty(),
"Path-like literal containing `password` should not fire; got: {:?}",
findings.iter().map(|f| &f.title).collect::<Vec<_>>()
);
}
#[test]
fn test_csharp_ilogger_extension_method_detected() {
let store = GraphBuilder::new().freeze();
let detector = CleartextCredentialsDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![(
"app.cs",
"class C { void M(string password) { _logger.LogInformation($\"login with password: {password}\"); } }\n",
)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
!findings.is_empty(),
"C# `_logger.LogInformation` with interpolated password should fire"
);
}
#[test]
fn test_csharp_logerror_with_identifier_arg() {
let store = GraphBuilder::new().freeze();
let detector = CleartextCredentialsDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![(
"app.cs",
"class C { void M(string password) { _logger.LogError(password); } }\n",
)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
!findings.is_empty(),
"C# `_logger.LogError(password)` should fire"
);
}
#[test]
fn test_python_self_logger_member_chain_detected() {
let store = GraphBuilder::new().freeze();
let detector = CleartextCredentialsDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![(
"app.py",
"class C:\n def m(self, password):\n self.logger.info(f\"login {password}\")\n",
)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
!findings.is_empty(),
"Python `self.logger.info(...)` member chain should fire"
);
}
#[test]
fn test_js_this_logger_member_chain_detected() {
let store = GraphBuilder::new().freeze();
let detector = CleartextCredentialsDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![(
"app.js",
"class C { m(password){ this.logger.info(`login ${password}`); } }\n",
)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
!findings.is_empty(),
"JS `this.logger.info(...)` member chain should fire"
);
}
#[test]
fn test_python_self_underscore_logger_member_chain_detected() {
let store = GraphBuilder::new().freeze();
let detector = CleartextCredentialsDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![(
"app.py",
"class C:\n def m(self, password):\n self._logger.info(f\"login {password}\")\n",
)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
!findings.is_empty(),
"Python `self._logger.info(...)` should fire (underscore-prefixed)"
);
}
#[test]
fn test_line_path_kotlin_template_literal_dollar_brace_does_not_suppress() {
let store = GraphBuilder::new().freeze();
let detector = CleartextCredentialsDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![(
"app.kt",
"fun login(password: String) { logger.error(\"login with password: ${password}\") }\n",
)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
!findings.is_empty(),
"Kotlin template-string `${{password}}` should NOT be suppressed by `credential_only_in_string_literal`"
);
}
#[test]
fn test_pre_filter_admits_pwd_only_file() {
let store = GraphBuilder::new().freeze();
let detector = CleartextCredentialsDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![(
"app.py",
"def f(pwd):\n logger.info(f\"login: {pwd}\")\n",
)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
!findings.is_empty(),
"Pre-filter must admit files containing only `pwd`; AST should fire"
);
}
#[test]
fn test_pre_filter_admits_apikey_only_file() {
let store = GraphBuilder::new().freeze();
let detector = CleartextCredentialsDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![(
"app.py",
"def f(apikey):\n logger.info(f\"key: {apikey}\")\n",
)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
!findings.is_empty(),
"Pre-filter must admit files containing only `apikey` (no underscore)"
);
}
#[test]
fn test_pre_filter_admits_credential_only_file() {
let store = GraphBuilder::new().freeze();
let detector = CleartextCredentialsDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![(
"app.py",
"def f(credential):\n logger.info(f\"creds: {credential}\")\n",
)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
!findings.is_empty(),
"Pre-filter must admit files containing only `credential`"
);
}
#[test]
fn test_content_flags_has_secret_pattern_for_audit_b4_keywords() {
use crate::detectors::detector_context::{compute_content_flags, ContentFlags};
for kw in ["passwd", "pwd", "apikey", "credential"] {
let flags = compute_content_flags(&format!("let {} = 1;", kw));
assert!(
flags.has(ContentFlags::HAS_SECRET_PATTERN),
"ContentFlags should set HAS_SECRET_PATTERN for keyword `{}`",
kw
);
}
}
#[test]
fn test_rust_println_positional_password_detected() {
let store = GraphBuilder::new().freeze();
let detector = CleartextCredentialsDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![(
"app.rs",
"fn f(password: &str){ println!(\"login: {}\", password); }\n",
)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
!findings.is_empty(),
"Rust positional `println!(\"{{}}\", password)` should fire"
);
}
#[test]
fn test_rust_println_captured_identifier_detected() {
let store = GraphBuilder::new().freeze();
let detector = CleartextCredentialsDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![(
"app.rs",
"fn f(password: &str){ println!(\"login: {password}\"); }\n",
)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
!findings.is_empty(),
"Rust captured-identifier `println!(\"{{password}}\")` should fire"
);
}
#[test]
fn test_python_loguru_logger_member_chain_detected() {
let store = GraphBuilder::new().freeze();
let detector = CleartextCredentialsDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![(
"app.py",
"from loguru import logger\ndef f(password):\n loguru.logger.info(f\"login {password}\")\n",
)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
!findings.is_empty(),
"Python `loguru.logger.info(...)` should fire"
);
}
#[test]
fn test_rust_dbg_macro_detected() {
let store = GraphBuilder::new().freeze();
let detector = CleartextCredentialsDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![("app.rs", "fn f(password: &str){ dbg!(password); }\n")],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(!findings.is_empty(), "Rust `dbg!(password)` should fire");
}
#[test]
fn test_go_fmt_errorf_detected() {
let store = GraphBuilder::new().freeze();
let detector = CleartextCredentialsDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![(
"app.go",
"package main\nfunc f(password string){ fmt.Errorf(\"auth failed: %v\", password) }\n",
)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
!findings.is_empty(),
"Go `fmt.Errorf(\"...\", password)` should fire"
);
}
#[test]
fn test_go_user_defined_logger_receiver_detected() {
let store = GraphBuilder::new().freeze();
let detector = CleartextCredentialsDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![(
"app.go",
"package main\nfunc f(password string){ myLogger.Info(password) }\n",
)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
!findings.is_empty(),
"Go `myLogger.Info(password)` (user-defined receiver) should fire"
);
}
#[test]
fn test_csharp_all_log_extension_methods_detected() {
for method in [
"LogTrace",
"LogDebug",
"LogInformation",
"LogWarning",
"LogError",
"LogCritical",
] {
let store = GraphBuilder::new().freeze();
let detector = CleartextCredentialsDetector::new("/mock/repo");
let src = format!(
"class C {{ void M(string password) {{ _logger.{}(password); }} }}\n",
method
);
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![("app.cs", src.as_str())],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
!findings.is_empty(),
"C# `_logger.{}(password)` should fire",
method
);
}
}
#[test]
fn test_c_printf_password_argument_detected() {
let store = GraphBuilder::new().freeze();
let detector = CleartextCredentialsDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![(
"app.c",
"#include <stdio.h>\nvoid f(const char* password) {\n printf(\"login: %s\\n\", password);\n}\n",
)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
!findings.is_empty(),
"C `printf(\"%s\", password)` should fire"
);
}
#[test]
fn test_c_fprintf_password_argument_detected() {
let store = GraphBuilder::new().freeze();
let detector = CleartextCredentialsDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![(
"app.c",
"#include <stdio.h>\nvoid f(const char* password) {\n fprintf(stderr, \"x%s\", password);\n}\n",
)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
!findings.is_empty(),
"C `fprintf(stderr, \"%s\", password)` should fire"
);
}
#[test]
fn test_c_syslog_password_argument_detected() {
let store = GraphBuilder::new().freeze();
let detector = CleartextCredentialsDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![(
"app.c",
"#include <syslog.h>\nvoid f(const char* password) {\n syslog(0, \"x%s\", password);\n}\n",
)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
!findings.is_empty(),
"C `syslog(LOG_INFO, \"%s\", password)` should fire"
);
}
#[test]
fn test_cpp_printf_password_argument_detected() {
let store = GraphBuilder::new().freeze();
let detector = CleartextCredentialsDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![(
"app.cpp",
"#include <cstdio>\nvoid f(const char* password) {\n printf(\"login: %s\\n\", password);\n}\n",
)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
!findings.is_empty(),
"C++ `printf(\"%s\", password)` should fire"
);
}
#[test]
fn test_line_path_categorize_pwd_returns_password() {
let (cat, _) = super::categorize_credential_line("logger.info(pwd)");
assert_eq!(
cat, "Password",
"Line-path categorise of `pwd` should match AST-path category"
);
}
}