use crate::linter::{Diagnostic, LintResult, Severity, Span};
const SECRET_PATTERNS: &[(&str, &str)] = &[
("API_KEY=", "API key assignment"),
("SECRET=", "Secret assignment"),
("PASSWORD=", "Password assignment"),
("TOKEN=", "Token assignment"),
("AWS_SECRET", "AWS secret"),
("GITHUB_TOKEN=", "GitHub token"),
("PRIVATE_KEY=", "Private key"),
("sk-", "OpenAI API key pattern"),
("ghp_", "GitHub personal access token"),
("gho_", "GitHub OAuth token"),
];
fn is_comment_line(line: &str) -> bool {
line.trim_start().starts_with('#')
}
fn extract_after_equals(line: &str) -> Option<&str> {
line.find('=').map(|eq_pos| &line[eq_pos + 1..])
}
fn is_literal_assignment(after_eq: &str) -> bool {
let trimmed = after_eq.trim_start();
(trimmed.starts_with('"') && !trimmed.starts_with("\"$")) || trimmed.starts_with('\'')
}
fn find_pattern_position(line: &str, pattern: &str) -> Option<usize> {
line.find(pattern)
}
fn calculate_span(line_num: usize, col: usize, line_len: usize, pattern_len: usize) -> Span {
Span::new(
line_num + 1,
col + 1,
line_num + 1,
line_len.min(col + pattern_len + 10),
)
}
fn create_hardcoded_secret_diagnostic(description: &str, span: Span) -> Diagnostic {
Diagnostic::new(
"SEC005",
Severity::Error,
format!(
"Hardcoded secret detected: {} - use environment variables",
description
),
span,
)
}
pub fn check(source: &str) -> LintResult {
if source.is_empty() { return LintResult::new(); }
contract_pre_classify_secrets!(source);
let mut result = LintResult::new();
for (line_num, line) in source.lines().enumerate() {
if is_comment_line(line) {
continue;
}
for (pattern, description) in SECRET_PATTERNS {
if line.contains(pattern) {
if let Some(after_eq) = extract_after_equals(line) {
if is_literal_assignment(after_eq) {
if let Some(col) = find_pattern_position(line, pattern) {
let span = calculate_span(line_num, col, line.len(), pattern.len());
let diag = create_hardcoded_secret_diagnostic(description, span);
result.add(diag);
break; }
}
}
}
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn prop_sec005_comments_never_diagnosed() {
let test_cases = vec![
"# API_KEY=\"sk-1234567890abcdef\"",
" # PASSWORD='MyP@ssw0rd'",
"\t# TOKEN=\"ghp_xxxxxxxxxxxxxxxxxxxx\"",
];
for code in test_cases {
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
}
#[test]
fn prop_sec005_env_vars_never_diagnosed() {
let test_cases = vec![
"API_KEY=\"${API_KEY:-}\"",
"PASSWORD=\"${PASSWORD:-}\"",
"TOKEN=\"${GITHUB_TOKEN:-}\"",
"SECRET=\"${SECRET:-default}\"",
];
for code in test_cases {
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
}
#[test]
fn prop_sec005_variable_expansions_never_diagnosed() {
let test_cases = vec![
"API_KEY=\"$MY_API_KEY\"",
"PASSWORD=\"$MY_PASSWORD\"",
"TOKEN=\"$GITHUB_TOKEN\"",
"SECRET=\"$MY_SECRET\"",
];
for code in test_cases {
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
}
#[test]
fn prop_sec005_hardcoded_literals_always_diagnosed() {
let test_cases = vec![
"API_KEY=\"sk-1234567890abcdef\"",
"PASSWORD='MyP@ssw0rd'",
"TOKEN=\"ghp_xxxxxxxxxxxxxxxxxxxx\"",
"SECRET=\"my-secret-value\"",
"AWS_SECRET_ACCESS_KEY=\"AKIAIOSFODNN7EXAMPLE\"",
];
for code in test_cases {
let result = check(code);
assert_eq!(result.diagnostics.len(), 1, "Should diagnose: {}", code);
assert!(result.diagnostics[0].message.contains("Hardcoded secret"));
}
}
#[test]
fn prop_sec005_diagnostic_code_always_sec005() {
let code = "API_KEY=\"sk-123\"\nPASSWORD='pass123'";
let result = check(code);
for diagnostic in &result.diagnostics {
assert_eq!(&diagnostic.code, "SEC005");
}
}
#[test]
fn prop_sec005_diagnostic_severity_always_error() {
let code = "SECRET=\"hardcoded-secret\"";
let result = check(code);
for diagnostic in &result.diagnostics {
assert_eq!(diagnostic.severity, Severity::Error);
}
}
#[test]
fn prop_sec005_no_auto_fix_provided() {
let test_cases = vec![
"API_KEY=\"sk-123\"",
"PASSWORD='pass'",
"TOKEN=\"ghp_xxx\"",
"SECRET=\"secret\"",
];
for code in test_cases {
let result = check(code);
if !result.diagnostics.is_empty() {
for diag in &result.diagnostics {
assert!(
diag.fix.is_none(),
"SEC005 should not provide auto-fix for: {}",
code
);
}
}
}
}
#[test]
fn prop_sec005_one_diagnostic_per_line() {
let code = "API_KEY=\"sk-123\" PASSWORD='pass'"; let result = check(code);
assert_eq!(
result.diagnostics.len(),
1,
"Should only diagnose once per line"
);
}
#[test]
fn prop_sec005_multiple_lines_all_diagnosed() {
let code = "API_KEY=\"sk-123\"\nPASSWORD='pass'\nTOKEN=\"ghp_xxx\"";
let result = check(code);
assert_eq!(result.diagnostics.len(), 3);
}
#[test]
fn prop_sec005_empty_source_no_diagnostics() {
let result = check("");
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_SEC005_detects_hardcoded_api_key() {
let script = r#"API_KEY="sk-1234567890abcdef""#;
let result = check(script);
assert_eq!(result.diagnostics.len(), 1);
let diag = &result.diagnostics[0];
assert_eq!(diag.code, "SEC005");
assert_eq!(diag.severity, Severity::Error);
assert!(diag.message.contains("Hardcoded"));
}
#[test]
fn test_SEC005_detects_hardcoded_password() {
let script = "PASSWORD='MyP@ssw0rd'";
let result = check(script);
assert_eq!(result.diagnostics.len(), 1);
}
#[test]
fn test_SEC005_detects_github_token() {
let script = r#"TOKEN="ghp_xxxxxxxxxxxxxxxxxxxx""#;
let result = check(script);
assert_eq!(result.diagnostics.len(), 1);
}
#[test]
fn test_SEC005_no_warning_env_var() {
let script = r#"API_KEY="${API_KEY:-}""#;
let result = check(script);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_SEC005_no_warning_variable_expansion() {
let script = "PASSWORD=\"$MY_PASSWORD\"";
let result = check(script);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_SEC005_no_warning_comment() {
let script = r#"# API_KEY="secret123""#;
let result = check(script);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_SEC005_no_auto_fix() {
let script = r#"SECRET="my-secret-value""#;
let result = check(script);
assert_eq!(result.diagnostics.len(), 1);
let diag = &result.diagnostics[0];
assert!(diag.fix.is_none(), "SEC005 should not provide auto-fix");
}
#[test]
fn test_mutation_sec005_calculate_span_start_col_exact() {
let bash_code = r#"API_KEY="sk-1234567890abcdef""#;
let result = check(bash_code);
assert_eq!(result.diagnostics.len(), 1);
let span = result.diagnostics[0].span;
assert_eq!(
span.start_col, 1,
"Start column must use col + 1, not col * 1"
);
}
#[test]
fn test_mutation_sec005_calculate_span_line_num_exact() {
let bash_code = "# comment\nAPI_KEY=\"sk-1234567890abcdef\"";
let result = check(bash_code);
assert_eq!(result.diagnostics.len(), 1);
assert_eq!(
result.diagnostics[0].span.start_line, 2,
"Line number must use +1, not *1"
);
}
#[test]
fn test_mutation_sec005_calculate_span_end_col_complex() {
let bash_code = r#"API_KEY="sk-123""#;
let result = check(bash_code);
assert_eq!(result.diagnostics.len(), 1);
let span = result.diagnostics[0].span;
assert!(
span.end_col > span.start_col,
"End column must be greater than start column"
);
assert!(
span.end_col <= bash_code.len(),
"End column must not exceed line length"
);
}
#[test]
fn test_mutation_sec005_column_with_leading_whitespace() {
let bash_code = r#" SECRET="hardcoded""#;
let result = check(bash_code);
assert_eq!(result.diagnostics.len(), 1);
let span = result.diagnostics[0].span;
assert_eq!(span.start_col, 5, "Must account for leading whitespace");
}
#[test]
fn test_mutation_sec005_multiple_patterns_first_detected() {
let bash_code = r#"PASSWORD="pass123""#;
let result = check(bash_code);
assert_eq!(result.diagnostics.len(), 1);
let span = result.diagnostics[0].span;
assert_eq!(span.start_col, 1, "Should detect first pattern correctly");
}
}