use std::collections::HashMap;
use std::path::{Path, PathBuf};
use syn::spanned::Spanned;
use syn::visit::{self, Visit};
use syn::{
Attribute, Expr, ExprCast, ExprIndex, ExprLit, ExprMacro, ExprMethodCall, ExprUnsafe,
ImplItemFn, ItemConst, ItemFn, ItemImpl, ItemMod, ItemStatic, Lit, Local, Pat, TraitItemFn,
Type, Visibility,
};
use crate::finding::{EvidenceClass, Finding, Location, OneBasedLine, Origin, Severity};
use crate::functions::{
has_test_attr, qualified_item_path, read_and_parse_source, type_name, walk_functions,
};
use crate::ingest::SourceFile;
use crate::rules::slop_text::{CommentSpan, extract_comments};
pub const UNSAFE_SURFACE_RULE: &str = "unsafe-surface";
pub const UNSAFE_SURFACE_RULE_REVISION: u32 = 1;
pub const UNSAFE_DENSITY_RULE: &str = "unsafe-density";
pub const UNSAFE_DENSITY_RULE_REVISION: u32 = 1;
pub const UNSAFE_DENSITY_THRESHOLD: f64 = 0.1;
pub const MAX_UNSAFE_BLOCK_SIZE_THRESHOLD: u32 = 30;
pub const INTEGER_CAST_RISK_RULE: &str = "integer-cast-risk";
pub const INTEGER_CAST_RISK_RULE_REVISION: u32 = 2;
pub const PANIC_IN_LIB_RULE: &str = "panic-in-lib";
pub const PANIC_IN_LIB_RULE_REVISION: u32 = 2;
pub const HARDCODED_SECRET_RULE: &str = "hardcoded-secret";
pub const HARDCODED_SECRET_RULE_REVISION: u32 = 1;
const SUSPICIOUS_NAME_MARKERS: &[&str] = &[
"secret",
"password",
"passwd",
"token",
"apikey",
"api_key",
"access_key",
"private_key",
"client_secret",
"auth_token",
"credential",
];
const MIN_SECRET_LENGTH: usize = 16;
const MIN_SECRET_ENTROPY: f64 = 3.5;
const RISKY_CAST_TARGETS: &[&str] = &["u8", "i8", "u16", "i16", "u32", "i32", "usize", "isize"];
const CLAMP_GUARD_METHODS: &[&str] = &[
"clamp",
"min",
"max",
"saturating_add",
"saturating_sub",
"saturating_mul",
"saturating_div",
"saturating_pow",
"saturating_abs",
"saturating_neg",
];
#[derive(Debug)]
pub enum SecurityError {
Io(PathBuf, std::io::Error),
Parse(PathBuf, syn::Error),
}
impl std::fmt::Display for SecurityError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(path, err) => write!(f, "{}: failed to read file: {err}", path.display()),
Self::Parse(path, err) => write!(f, "{}: failed to parse: {err}", path.display()),
}
}
}
impl std::error::Error for SecurityError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io(_, err) => Some(err),
Self::Parse(_, err) => Some(err),
}
}
}
#[derive(Debug, Default)]
pub struct WorkspaceSecurity {
pub findings: Vec<Finding>,
pub errors: Vec<SecurityError>,
pub excluded_generated: usize,
}
pub fn analyze_file(path: &Path) -> Result<Vec<Finding>, SecurityError> {
let (source, ast) = read_and_parse_source(
path,
|err| SecurityError::Io(path.to_path_buf(), err),
|err| SecurityError::Parse(path.to_path_buf(), err),
)?;
let comments = extract_comments(&source);
let mut findings = Vec::new();
walk_functions(&ast, |site| {
let mut unsafe_visitor = UnsafeVisitor {
file: path,
item_path: &site.qualified_name,
comments: &comments,
findings: Vec::new(),
};
unsafe_visitor.visit_block(site.block);
findings.append(&mut unsafe_visitor.findings);
let mut cast_visitor = CastVisitor {
file: path,
item_path: &site.qualified_name,
findings: Vec::new(),
};
cast_visitor.visit_block(site.block);
findings.append(&mut cast_visitor.findings);
if is_public_and_not_test(site.vis, site.attrs) {
let mut panic_visitor = PanicVisitor {
file: path,
item_path: &site.qualified_name,
findings: Vec::new(),
};
panic_visitor.visit_block(site.block);
findings.append(&mut panic_visitor.findings);
}
});
let mut secret_visitor = SecretVisitor {
file: path,
path: Vec::new(),
cfg_test_depth: 0,
binding_name: None,
findings: Vec::new(),
};
secret_visitor.visit_file(&ast);
findings.append(&mut secret_visitor.findings);
if let Some(finding) = unsafe_density_finding_for_file(path, &ast, &source) {
findings.push(finding);
}
Ok(findings)
}
fn is_public_and_not_test(vis: Option<&Visibility>, attrs: &[Attribute]) -> bool {
matches!(vis, Some(Visibility::Public(_))) && !has_test_attr(attrs)
}
pub fn analyze_workspace<'a>(
source_files: impl IntoIterator<Item = &'a SourceFile>,
include_generated: bool,
) -> WorkspaceSecurity {
let mut report = WorkspaceSecurity::default();
for file in source_files {
if !include_generated && !file.kind.is_locally_reportable() {
report.excluded_generated += 1;
continue;
}
match analyze_file(&file.path) {
Ok(mut findings) => report.findings.append(&mut findings),
Err(err) => report.errors.push(err),
}
}
report
}
fn has_adjacent_safety_comment(comments: &[CommentSpan], unsafe_start_line: usize) -> bool {
comments.iter().any(|comment| {
comment.text.contains("SAFETY:")
&& (comment.end_line + 1 == unsafe_start_line
|| comment.start_line == unsafe_start_line
|| comment.start_line == unsafe_start_line + 1)
})
}
fn is_risky_cast_target(ty: &Type) -> bool {
RISKY_CAST_TARGETS.contains(&type_name(ty).as_str())
}
fn is_clamped_immediately(expr: &Expr) -> bool {
let mut expr = expr;
while let Expr::Paren(inner) = expr {
expr = &inner.expr;
}
matches!(
expr,
Expr::MethodCall(call) if CLAMP_GUARD_METHODS.contains(&call.method.to_string().as_str())
)
}
fn classify_index_kind(index: &Expr) -> &'static str {
match index {
Expr::Range(_) => "range_slice",
Expr::Lit(ExprLit {
lit: Lit::Str(_), ..
}) => "string_key_indexing",
_ => "indexing",
}
}
fn unsafe_surface_finding(file: &Path, span: proc_macro2::Span, item_path: &str) -> Finding {
Finding::at_span(
UNSAFE_SURFACE_RULE,
file,
span,
item_path,
Severity::Warn,
EvidenceClass::DerivedFact,
Some(serde_json::json!({
"reason": "no `SAFETY:` comment found adjacent to this unsafe block",
})),
)
}
fn integer_cast_risk_finding(
file: &Path,
span: proc_macro2::Span,
item_path: &str,
target_type: &str,
) -> Finding {
Finding::at_span(
INTEGER_CAST_RISK_RULE,
file,
span,
item_path,
Severity::Warn,
EvidenceClass::Heuristic,
Some(serde_json::json!({
"target_type": target_type,
"reason": "a possible truncation candidate based on the cast's target type; the \
source expression's real type is not resolved at the Fast Tier, so this is a \
syntax-only proxy, not a truncation proof",
})),
)
}
fn panic_in_lib_finding(
file: &Path,
span: proc_macro2::Span,
item_path: &str,
kind: &str,
) -> Finding {
Finding::at_span(
PANIC_IN_LIB_RULE,
file,
span,
item_path,
Severity::Warn,
EvidenceClass::DerivedFact,
Some(serde_json::json!({
"kind": kind,
"reason": "a panicking construct reachable from a `pub` path; not a claim that it \
will panic at runtime",
})),
)
}
struct UnsafeVisitor<'a> {
file: &'a Path,
item_path: &'a str,
comments: &'a [CommentSpan],
findings: Vec<Finding>,
}
impl<'ast> Visit<'ast> for UnsafeVisitor<'_> {
fn visit_expr_unsafe(&mut self, node: &'ast ExprUnsafe) {
let start_line = node.span().start().line;
if !has_adjacent_safety_comment(self.comments, start_line) {
self.findings.push(unsafe_surface_finding(
self.file,
node.span(),
self.item_path,
));
}
visit::visit_expr_unsafe(self, node);
}
fn visit_item_fn(&mut self, _node: &'ast ItemFn) {}
}
#[derive(Default)]
struct UnsafeBlockSizeVisitor {
block_line_counts: Vec<u32>,
}
impl<'ast> Visit<'ast> for UnsafeBlockSizeVisitor {
fn visit_expr_unsafe(&mut self, node: &'ast ExprUnsafe) {
let span = node.span();
let line_count = (span.end().line - span.start().line + 1) as u32;
self.block_line_counts.push(line_count);
}
}
fn unsafe_density_finding_for_file(path: &Path, ast: &syn::File, source: &str) -> Option<Finding> {
let mut visitor = UnsafeBlockSizeVisitor::default();
visitor.visit_file(ast);
if visitor.block_line_counts.is_empty() {
return None;
}
let unsafe_lines_total: u32 = visitor.block_line_counts.iter().sum();
let max_unsafe_block_size = visitor.block_line_counts.iter().copied().max()?;
let file_lines_of_code = source.lines().count();
if file_lines_of_code == 0 {
return None;
}
let unsafe_density = f64::from(unsafe_lines_total) / file_lines_of_code as f64;
if unsafe_density <= UNSAFE_DENSITY_THRESHOLD
&& max_unsafe_block_size <= MAX_UNSAFE_BLOCK_SIZE_THRESHOLD
{
return None;
}
Some(Finding {
id: format!("{UNSAFE_DENSITY_RULE}:{}", path.display()).into(),
rule: UNSAFE_DENSITY_RULE.into(),
severity: Severity::Warn,
location: Location {
file: path.to_path_buf(),
line: OneBasedLine::FIRST,
item_path: path.display().to_string(),
},
evidence_class: EvidenceClass::DerivedFact,
origin: Origin::Code,
evidence: Some(serde_json::json!({
"file": path.display().to_string(),
"unsafe_density": unsafe_density,
"unsafe_lines_total": unsafe_lines_total,
"file_lines_of_code": file_lines_of_code,
"max_unsafe_block_size": max_unsafe_block_size,
"unsafe_block_count": visitor.block_line_counts.len(),
})),
limitations: None,
caused_by: Vec::new(),
causes: Vec::new(),
})
}
struct CastVisitor<'a> {
file: &'a Path,
item_path: &'a str,
findings: Vec<Finding>,
}
impl<'ast> Visit<'ast> for CastVisitor<'_> {
fn visit_expr_cast(&mut self, node: &'ast ExprCast) {
if is_risky_cast_target(&node.ty) && !is_clamped_immediately(&node.expr) {
self.findings.push(integer_cast_risk_finding(
self.file,
node.span(),
self.item_path,
&type_name(&node.ty),
));
}
visit::visit_expr_cast(self, node);
}
fn visit_item_fn(&mut self, _node: &'ast ItemFn) {}
}
struct PanicVisitor<'a> {
file: &'a Path,
item_path: &'a str,
findings: Vec<Finding>,
}
impl PanicVisitor<'_> {
fn push_panic_finding(&mut self, span: proc_macro2::Span, kind: &str) {
self.findings
.push(panic_in_lib_finding(self.file, span, self.item_path, kind));
}
}
impl<'ast> Visit<'ast> for PanicVisitor<'_> {
fn visit_expr_method_call(&mut self, node: &'ast ExprMethodCall) {
let kind = match node.method.to_string().as_str() {
"unwrap" => Some("unwrap"),
"expect" => Some("expect"),
_ => None,
};
if let Some(kind) = kind {
self.push_panic_finding(node.span(), kind);
}
visit::visit_expr_method_call(self, node);
}
fn visit_expr_macro(&mut self, node: &'ast ExprMacro) {
if node.mac.path.is_ident("panic") {
self.push_panic_finding(node.span(), "panic_macro");
}
visit::visit_expr_macro(self, node);
}
fn visit_expr_index(&mut self, node: &'ast ExprIndex) {
self.push_panic_finding(node.span(), classify_index_kind(&node.index));
visit::visit_expr_index(self, node);
}
fn visit_item_fn(&mut self, _node: &'ast ItemFn) {}
}
fn matches_known_secret_pattern(value: &str) -> Option<&'static str> {
if value.len() == 20
&& value.starts_with("AKIA")
&& value[4..]
.chars()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())
{
return Some("aws_access_key_id");
}
const GITHUB_TOKEN_PREFIXES: &[&str] = &["ghp_", "gho_", "ghu_", "ghs_", "ghr_"];
if value.len() == 40
&& GITHUB_TOKEN_PREFIXES
.iter()
.any(|prefix| value.starts_with(prefix))
&& value[4..].chars().all(|c| c.is_ascii_alphanumeric())
{
return Some("github_token");
}
const SLACK_TOKEN_PREFIXES: &[&str] = &["xoxb-", "xoxp-", "xoxa-", "xoxr-", "xoxs-"];
if value.len() >= 20
&& SLACK_TOKEN_PREFIXES
.iter()
.any(|prefix| value.starts_with(prefix))
{
return Some("slack_token");
}
if value.len() == 39
&& value.starts_with("AIza")
&& value[4..]
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
return Some("google_api_key");
}
if value.contains("-----BEGIN") && value.contains("PRIVATE KEY-----") {
return Some("pem_private_key");
}
None
}
fn shannon_entropy(value: &str) -> f64 {
let mut counts: HashMap<char, u32> = HashMap::new();
let mut total = 0u32;
for c in value.chars() {
*counts.entry(c).or_insert(0) += 1;
total += 1;
}
if total == 0 {
return 0.0;
}
counts.values().fold(0.0, |acc, &count| {
let p = f64::from(count) / f64::from(total);
acc - p * p.log2()
})
}
fn normalized_words(name: &str) -> String {
let mut result = String::from("_");
let mut prev_lower_or_digit = false;
for c in name.chars() {
if c == '_' || c == '-' {
if !result.ends_with('_') {
result.push('_');
}
prev_lower_or_digit = false;
continue;
}
if c.is_uppercase() && prev_lower_or_digit {
result.push('_');
}
result.extend(c.to_lowercase());
prev_lower_or_digit = c.is_lowercase() || c.is_numeric();
}
if !result.ends_with('_') {
result.push('_');
}
result
}
fn is_suspicious_name(name: &str) -> bool {
let normalized = normalized_words(name);
SUSPICIOUS_NAME_MARKERS
.iter()
.any(|marker| normalized.contains(&format!("_{marker}_")))
}
fn attrs_have_cfg_test(attrs: &[Attribute]) -> bool {
attrs.iter().any(|attr| {
if !attr.path().is_ident("cfg") {
return false;
}
let syn::Meta::List(list) = &attr.meta else {
return false;
};
list.tokens
.clone()
.into_iter()
.any(|token| matches!(&token, proc_macro2::TokenTree::Ident(ident) if ident == "test"))
})
}
fn is_test_scoped(cfg_test_depth: usize, attrs: &[Attribute]) -> bool {
cfg_test_depth > 0 || attrs_have_cfg_test(attrs) || has_test_attr(attrs)
}
fn hardcoded_secret_finding(
file: &Path,
span: proc_macro2::Span,
item_path: &str,
kind: &str,
pattern: Option<&str>,
length: usize,
) -> Finding {
let mut evidence = serde_json::json!({
"kind": kind,
"length": length,
});
if let Some(pattern) = pattern {
evidence["pattern"] = serde_json::Value::String(pattern.to_string());
}
Finding::at_span(
HARDCODED_SECRET_RULE,
file,
span,
item_path,
Severity::Warn,
EvidenceClass::Heuristic,
Some(evidence),
)
}
struct SecretVisitor<'a> {
file: &'a Path,
path: Vec<String>,
cfg_test_depth: usize,
binding_name: Option<String>,
findings: Vec<Finding>,
}
impl<'a> SecretVisitor<'a> {
fn current_path(&self) -> String {
qualified_item_path(self.file, &self.path)
}
fn item_path_for(&self) -> String {
match &self.binding_name {
Some(name) if self.path.is_empty() => name.clone(),
Some(name) => format!("{}::{name}", self.path.join("::")),
None => self.current_path(),
}
}
fn visit_fn_scoped(
&mut self,
attrs: &[Attribute],
ident: &syn::Ident,
visit: impl FnOnce(&mut Self),
) {
if is_test_scoped(self.cfg_test_depth, attrs) {
return;
}
self.path.push(ident.to_string());
visit(self);
self.path.pop();
}
fn visit_binding_scoped(
&mut self,
attrs: &[Attribute],
ident: &syn::Ident,
visit: impl FnOnce(&mut Self),
) {
if is_test_scoped(self.cfg_test_depth, attrs) {
return;
}
let previous = self.binding_name.take();
self.binding_name = Some(ident.to_string());
visit(self);
self.binding_name = previous;
}
}
fn local_binding_name(pat: &Pat) -> Option<String> {
match pat {
Pat::Ident(pat_ident) => Some(pat_ident.ident.to_string()),
Pat::Type(pat_type) => local_binding_name(&pat_type.pat),
_ => None,
}
}
impl<'ast> Visit<'ast> for SecretVisitor<'_> {
fn visit_attribute(&mut self, _node: &'ast Attribute) {}
fn visit_item_mod(&mut self, node: &'ast ItemMod) {
let gated = attrs_have_cfg_test(&node.attrs);
if gated {
self.cfg_test_depth += 1;
}
if node.content.is_some() {
self.path.push(node.ident.to_string());
visit::visit_item_mod(self, node);
self.path.pop();
} else {
visit::visit_item_mod(self, node);
}
if gated {
self.cfg_test_depth -= 1;
}
}
fn visit_item_impl(&mut self, node: &'ast ItemImpl) {
if is_test_scoped(self.cfg_test_depth, &node.attrs) {
return;
}
visit::visit_item_impl(self, node);
}
fn visit_item_fn(&mut self, node: &'ast ItemFn) {
self.visit_fn_scoped(&node.attrs, &node.sig.ident, |w| {
visit::visit_item_fn(w, node)
});
}
fn visit_impl_item_fn(&mut self, node: &'ast ImplItemFn) {
self.visit_fn_scoped(&node.attrs, &node.sig.ident, |w| {
visit::visit_impl_item_fn(w, node)
});
}
fn visit_trait_item_fn(&mut self, node: &'ast TraitItemFn) {
self.visit_fn_scoped(&node.attrs, &node.sig.ident, |w| {
visit::visit_trait_item_fn(w, node)
});
}
fn visit_item_const(&mut self, node: &'ast ItemConst) {
self.visit_binding_scoped(&node.attrs, &node.ident, |w| {
visit::visit_item_const(w, node)
});
}
fn visit_item_static(&mut self, node: &'ast ItemStatic) {
self.visit_binding_scoped(&node.attrs, &node.ident, |w| {
visit::visit_item_static(w, node)
});
}
fn visit_local(&mut self, node: &'ast Local) {
let previous = self.binding_name.take();
self.binding_name = local_binding_name(&node.pat);
visit::visit_local(self, node);
self.binding_name = previous;
}
fn visit_expr_lit(&mut self, node: &'ast ExprLit) {
if self.cfg_test_depth == 0
&& let Lit::Str(lit_str) = &node.lit
{
let value = lit_str.value();
if let Some(pattern) = matches_known_secret_pattern(&value) {
self.findings.push(hardcoded_secret_finding(
self.file,
node.span(),
&self.item_path_for(),
"known_pattern",
Some(pattern),
value.len(),
));
} else if self.binding_name.as_deref().is_some_and(is_suspicious_name)
&& value.len() >= MIN_SECRET_LENGTH
&& shannon_entropy(&value) >= MIN_SECRET_ENTROPY
{
self.findings.push(hardcoded_secret_finding(
self.file,
node.span(),
&self.item_path_for(),
"high_entropy_assignment",
None,
value.len(),
));
}
}
visit::visit_expr_lit(self, node);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_util::TempDir;
fn findings_for(source: &str, name: &str) -> Vec<Finding> {
let dir = TempDir::new(name);
let file = dir.join("lib.rs");
std::fs::write(&file, source).unwrap();
analyze_file(&file).unwrap()
}
fn rule_findings<'a>(findings: &'a [Finding], rule: &str) -> Vec<&'a Finding> {
findings.iter().filter(|f| f.rule == rule).collect()
}
#[test]
fn unsafe_block_without_safety_comment_is_flagged() {
let findings = findings_for(
"fn f() {\n unsafe {\n std::hint::unreachable_unchecked();\n }\n}\n",
"security-unsafe-no-comment",
);
let hits = rule_findings(&findings, UNSAFE_SURFACE_RULE);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].severity, Severity::Warn);
assert_eq!(hits[0].evidence_class, EvidenceClass::DerivedFact);
assert!(hits[0].is_gating());
}
#[test]
fn unsafe_block_with_preceding_safety_comment_is_not_flagged() {
let findings = findings_for(
"fn f() {\n // SAFETY: caller guarantees the pointer is valid\n unsafe {\n std::hint::unreachable_unchecked();\n }\n}\n",
"security-unsafe-preceding-comment",
);
assert!(rule_findings(&findings, UNSAFE_SURFACE_RULE).is_empty());
}
#[test]
fn unsafe_block_with_inner_safety_comment_is_not_flagged() {
let findings = findings_for(
"fn f() {\n unsafe {\n // SAFETY: caller guarantees the pointer is valid\n std::hint::unreachable_unchecked();\n }\n}\n",
"security-unsafe-inner-comment",
);
assert!(rule_findings(&findings, UNSAFE_SURFACE_RULE).is_empty());
}
#[test]
fn unsafe_fn_declaration_alone_is_not_flagged() {
let findings = findings_for(
"unsafe fn f() {\n std::hint::unreachable_unchecked();\n}\n",
"security-unsafe-fn-decl",
);
assert!(rule_findings(&findings, UNSAFE_SURFACE_RULE).is_empty());
}
#[test]
fn nested_local_fn_unsafe_block_is_not_double_counted() {
let findings = findings_for(
"fn outer() {\n fn inner() {\n unsafe {\n std::hint::unreachable_unchecked();\n }\n }\n inner();\n}\n",
"security-unsafe-nested-fn",
);
assert_eq!(rule_findings(&findings, UNSAFE_SURFACE_RULE).len(), 1);
}
#[test]
fn unsafe_surface_registry_example_still_triggers_the_rule() {
let example = crate::rule_registry::lookup(UNSAFE_SURFACE_RULE)
.expect("unsafe-surface has a registry entry")
.example
.expect("unsafe-surface has a curated example")
.before;
let findings = findings_for(example, "unsafe-surface-registry-example");
assert_eq!(rule_findings(&findings, UNSAFE_SURFACE_RULE).len(), 1);
}
#[test]
fn a_single_large_unsafe_block_fires_via_the_size_arm() {
let mut source = String::from("fn padding() {\n");
for i in 0..300 {
source.push_str(&format!(" let _ = {i};\n"));
}
source.push_str("}\n\nfn big_unsafe() {\n unsafe {\n");
for _ in 0..29 {
source.push_str(" std::hint::unreachable_unchecked();\n");
}
source.push_str(" }\n}\n");
let findings = findings_for(&source, "security-unsafe-density-large-block");
let hits = rule_findings(&findings, UNSAFE_DENSITY_RULE);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].evidence_class, EvidenceClass::DerivedFact);
assert!(hits[0].is_gating());
let evidence = hits[0].evidence.as_ref().unwrap();
assert_eq!(evidence["max_unsafe_block_size"], 31);
assert!(evidence["unsafe_density"].as_f64().unwrap() <= UNSAFE_DENSITY_THRESHOLD);
}
#[test]
fn several_small_unsafe_blocks_fire_via_the_density_arm() {
let mut source = String::new();
for i in 0..5 {
source.push_str(&format!(
"fn f{i}() {{\n unsafe {{\n std::hint::unreachable_unchecked();\n }}\n}}\n\n"
));
}
let findings = findings_for(&source, "security-unsafe-density-many-small-blocks");
let hits = rule_findings(&findings, UNSAFE_DENSITY_RULE);
assert_eq!(hits.len(), 1);
let evidence = hits[0].evidence.as_ref().unwrap();
assert_eq!(evidence["unsafe_block_count"], 5);
assert!(evidence["unsafe_density"].as_f64().unwrap() > UNSAFE_DENSITY_THRESHOLD);
assert!(
evidence["max_unsafe_block_size"].as_u64().unwrap()
<= u64::from(MAX_UNSAFE_BLOCK_SIZE_THRESHOLD)
);
}
#[test]
fn a_small_isolated_unsafe_block_is_not_flagged() {
let mut source = String::from("fn padding() {\n");
for i in 0..60 {
source.push_str(&format!(" let _ = {i};\n"));
}
source.push_str(
"}\n\nfn f() {\n unsafe {\n std::hint::unreachable_unchecked();\n }\n}\n",
);
let findings = findings_for(&source, "security-unsafe-density-small-isolated");
assert!(rule_findings(&findings, UNSAFE_DENSITY_RULE).is_empty());
}
#[test]
fn a_file_with_no_unsafe_blocks_is_not_flagged() {
let findings = findings_for(
"fn f(x: i32) -> i32 {\n x + 1\n}\n",
"security-unsafe-density-none",
);
assert!(rule_findings(&findings, UNSAFE_DENSITY_RULE).is_empty());
}
#[test]
fn unsafe_density_registry_example_still_triggers_the_rule() {
let example = crate::rule_registry::lookup(UNSAFE_DENSITY_RULE)
.expect("unsafe-density has a registry entry")
.example
.expect("unsafe-density has a curated example")
.before;
let findings = findings_for(example, "unsafe-density-registry-example");
assert_eq!(rule_findings(&findings, UNSAFE_DENSITY_RULE).len(), 1);
}
#[test]
fn cast_to_narrow_int_type_is_flagged() {
let findings = findings_for(
"fn f(x: i64) -> i32 {\n x as i32\n}\n",
"security-cast-narrow",
);
let hits = rule_findings(&findings, INTEGER_CAST_RISK_RULE);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].severity, Severity::Warn);
assert_eq!(hits[0].evidence_class, EvidenceClass::Heuristic);
assert!(!hits[0].is_gating());
assert_eq!(hits[0].evidence.as_ref().unwrap()["target_type"], "i32");
}
#[test]
fn cast_to_wide_int_type_is_not_flagged() {
let findings = findings_for(
"fn f(x: i32) -> i64 {\n x as i64\n}\n",
"security-cast-wide",
);
assert!(rule_findings(&findings, INTEGER_CAST_RISK_RULE).is_empty());
}
#[test]
fn cast_to_a_type_outside_the_risky_list_is_not_flagged() {
let findings = findings_for(
"fn f(x: i32) -> f64 {\n x as f64\n}\n",
"security-cast-other",
);
assert!(rule_findings(&findings, INTEGER_CAST_RISK_RULE).is_empty());
}
#[test]
fn cast_of_a_clamped_value_is_not_flagged() {
let findings = findings_for(
"fn f(x: i64) -> i32 {\n x.clamp(0, 100) as i32\n}\n",
"security-cast-clamped",
);
assert!(rule_findings(&findings, INTEGER_CAST_RISK_RULE).is_empty());
}
#[test]
fn cast_of_a_saturating_sub_result_is_not_flagged() {
let findings = findings_for(
"fn f(a: usize, b: usize) -> u32 {\n a.saturating_sub(b) as u32\n}\n",
"security-cast-saturating-sub",
);
assert!(rule_findings(&findings, INTEGER_CAST_RISK_RULE).is_empty());
}
#[test]
fn cast_of_a_parenthesized_clamped_value_is_not_flagged() {
let findings = findings_for(
"fn f(x: i64) -> i32 {\n (x.max(0)) as i32\n}\n",
"security-cast-paren-clamped",
);
assert!(rule_findings(&findings, INTEGER_CAST_RISK_RULE).is_empty());
}
#[test]
fn cast_of_a_sum_of_clamped_values_is_still_flagged() {
let findings = findings_for(
"fn f(a: i64, b: i64) -> i32 {\n (a.max(0) + b.max(0)) as i32\n}\n",
"security-cast-sum-of-clamped",
);
assert_eq!(rule_findings(&findings, INTEGER_CAST_RISK_RULE).len(), 1);
}
#[test]
fn integer_cast_risk_registry_example_still_triggers_the_rule() {
let example = crate::rule_registry::lookup(INTEGER_CAST_RISK_RULE)
.expect("integer-cast-risk has a registry entry")
.example
.expect("integer-cast-risk has a curated example")
.before;
let findings = findings_for(example, "integer-cast-risk-registry-example");
assert_eq!(rule_findings(&findings, INTEGER_CAST_RISK_RULE).len(), 1);
}
#[test]
fn is_risky_cast_target_matches_exactly_the_documented_list() {
for name in RISKY_CAST_TARGETS {
let ty: Type = syn::parse_str(name).unwrap();
assert!(is_risky_cast_target(&ty), "{name} should be risky");
}
for name in ["u64", "i64", "u128", "i128", "bool", "f32", "f64"] {
let ty: Type = syn::parse_str(name).unwrap();
assert!(!is_risky_cast_target(&ty), "{name} should not be risky");
}
}
#[test]
fn unwrap_in_a_pub_fn_is_flagged() {
let findings = findings_for(
"pub fn f(x: Option<i32>) -> i32 {\n x.unwrap()\n}\n",
"security-panic-unwrap-pub",
);
let hits = rule_findings(&findings, PANIC_IN_LIB_RULE);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].severity, Severity::Warn);
assert_eq!(hits[0].evidence_class, EvidenceClass::DerivedFact);
assert!(hits[0].is_gating());
assert_eq!(hits[0].evidence.as_ref().unwrap()["kind"], "unwrap");
}
#[test]
fn panic_in_lib_registry_example_still_triggers_the_rule() {
let example = crate::rule_registry::lookup(PANIC_IN_LIB_RULE)
.expect("panic-in-lib has a registry entry")
.example
.expect("panic-in-lib has a curated example")
.before;
let findings = findings_for(example, "security-panic-in-lib-registry-example");
assert_eq!(rule_findings(&findings, PANIC_IN_LIB_RULE).len(), 1);
}
#[test]
fn expect_in_a_pub_fn_is_flagged() {
let findings = findings_for(
"pub fn f(x: Option<i32>) -> i32 {\n x.expect(\"missing\")\n}\n",
"security-panic-expect-pub",
);
let hits = rule_findings(&findings, PANIC_IN_LIB_RULE);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].evidence.as_ref().unwrap()["kind"], "expect");
}
#[test]
fn panic_macro_in_a_pub_fn_is_flagged() {
let findings = findings_for(
"pub fn f() {\n panic!(\"unreachable\")\n}\n",
"security-panic-macro-pub",
);
let hits = rule_findings(&findings, PANIC_IN_LIB_RULE);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].evidence.as_ref().unwrap()["kind"], "panic_macro");
}
#[test]
fn indexing_in_a_pub_fn_is_flagged() {
let findings = findings_for(
"pub fn f(xs: &[i32]) -> i32 {\n xs[0]\n}\n",
"security-panic-indexing-pub",
);
let hits = rule_findings(&findings, PANIC_IN_LIB_RULE);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].evidence.as_ref().unwrap()["kind"], "indexing");
}
#[test]
fn range_indexing_in_a_pub_fn_is_flagged_as_range_slice() {
let findings = findings_for(
"pub fn f(s: &str, end: usize) -> &str {\n &s[1..end]\n}\n",
"security-panic-indexing-range",
);
let hits = rule_findings(&findings, PANIC_IN_LIB_RULE);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].evidence.as_ref().unwrap()["kind"], "range_slice");
}
#[test]
fn string_key_indexing_in_a_pub_fn_is_flagged_as_string_key_indexing() {
let findings = findings_for(
"pub fn f(v: &serde_json::Value) -> &serde_json::Value {\n &v[\"key\"]\n}\n",
"security-panic-indexing-string-key",
);
let hits = rule_findings(&findings, PANIC_IN_LIB_RULE);
assert_eq!(hits.len(), 1);
assert_eq!(
hits[0].evidence.as_ref().unwrap()["kind"],
"string_key_indexing"
);
}
#[test]
fn unwrap_in_a_private_fn_is_not_flagged() {
let findings = findings_for(
"fn f(x: Option<i32>) -> i32 {\n x.unwrap()\n}\n",
"security-panic-unwrap-private",
);
assert!(rule_findings(&findings, PANIC_IN_LIB_RULE).is_empty());
}
#[test]
fn unwrap_in_a_pub_test_fn_is_not_flagged() {
let findings = findings_for(
"#[test]\npub fn f() {\n Some(1).unwrap();\n}\n",
"security-panic-unwrap-pub-test",
);
assert!(rule_findings(&findings, PANIC_IN_LIB_RULE).is_empty());
}
#[test]
fn unwrap_in_a_pub_trait_default_method_is_not_flagged() {
let findings = findings_for(
"pub trait T {\n fn f(x: Option<i32>) -> i32 {\n x.unwrap()\n }\n}\n",
"security-panic-unwrap-trait-default",
);
assert!(rule_findings(&findings, PANIC_IN_LIB_RULE).is_empty());
}
#[test]
fn unwrap_on_ok_method_named_call_in_a_pub_fn_is_not_flagged() {
let findings = findings_for(
"pub fn f(x: Result<i32, ()>) -> Option<i32> {\n x.ok()\n}\n",
"security-panic-ok-pub",
);
assert!(rule_findings(&findings, PANIC_IN_LIB_RULE).is_empty());
}
#[test]
fn nested_local_fn_pub_unwrap_is_not_double_counted() {
let findings = findings_for(
"pub fn outer() {\n pub fn inner(x: Option<i32>) -> i32 {\n x.unwrap()\n }\n inner(Some(1));\n}\n",
"security-panic-nested-fn",
);
assert_eq!(rule_findings(&findings, PANIC_IN_LIB_RULE).len(), 1);
}
#[test]
fn aws_access_key_id_pattern_is_flagged_as_a_bare_function_argument() {
let findings = findings_for(
"pub fn f() {\n validate(\"AKIAIOSFODNN7EXAMPLE\");\n}\n",
"security-secret-aws-key",
);
let hits = rule_findings(&findings, HARDCODED_SECRET_RULE);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].severity, Severity::Warn);
assert_eq!(hits[0].evidence_class, EvidenceClass::Heuristic);
assert!(!hits[0].is_gating());
let evidence = hits[0].evidence.as_ref().unwrap();
assert_eq!(evidence["kind"], "known_pattern");
assert_eq!(evidence["pattern"], "aws_access_key_id");
assert!(
evidence.get("value").is_none(),
"must not leak the literal's text"
);
}
#[test]
fn github_token_pattern_is_flagged() {
let findings = findings_for(
"const TOKEN: &str = \"ghp_1234567890abcdefghijklmnopqrstuvwxyz\";\n",
"security-secret-github-token",
);
let hits = rule_findings(&findings, HARDCODED_SECRET_RULE);
assert_eq!(hits.len(), 1);
assert_eq!(
hits[0].evidence.as_ref().unwrap()["pattern"],
"github_token"
);
}
#[test]
fn slack_token_pattern_is_flagged() {
let findings = findings_for(
"const TOKEN: &str = \"xoxb-123456789012345\";\n",
"security-secret-slack-token",
);
let hits = rule_findings(&findings, HARDCODED_SECRET_RULE);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].evidence.as_ref().unwrap()["pattern"], "slack_token");
}
#[test]
fn google_api_key_pattern_is_flagged() {
let source = format!(
"const KEY: &str = \"{}{}\";\n",
"AIzaSyD1234567890abcdefg", "hijklmnopqrstuv"
);
let findings = findings_for(&source, "security-secret-google-key");
let hits = rule_findings(&findings, HARDCODED_SECRET_RULE);
assert_eq!(hits.len(), 1);
assert_eq!(
hits[0].evidence.as_ref().unwrap()["pattern"],
"google_api_key"
);
}
#[test]
fn pem_private_key_header_is_flagged() {
let findings = findings_for(
"const KEY: &str = \"-----BEGIN RSA PRIVATE KEY-----\";\n",
"security-secret-pem",
);
let hits = rule_findings(&findings, HARDCODED_SECRET_RULE);
assert_eq!(hits.len(), 1);
assert_eq!(
hits[0].evidence.as_ref().unwrap()["pattern"],
"pem_private_key"
);
}
#[test]
fn high_entropy_literal_with_a_suspicious_module_level_const_name_is_flagged() {
let findings = findings_for(
"const API_SECRET: &str = \"Kx7$mQ2#Lp9@Rn4^Wz6&Tb3!\";\n",
"security-secret-entropy-const",
);
let hits = rule_findings(&findings, HARDCODED_SECRET_RULE);
assert_eq!(hits.len(), 1);
let evidence = hits[0].evidence.as_ref().unwrap();
assert_eq!(evidence["kind"], "high_entropy_assignment");
assert!(evidence.get("pattern").is_none());
}
#[test]
fn hardcoded_secret_registry_example_still_triggers_the_rule() {
let example = crate::rule_registry::lookup(HARDCODED_SECRET_RULE)
.expect("hardcoded-secret has a registry entry")
.example
.expect("hardcoded-secret has a curated example")
.before;
let findings = findings_for(example, "security-secret-registry-example");
assert_eq!(rule_findings(&findings, HARDCODED_SECRET_RULE).len(), 1);
}
#[test]
fn high_entropy_literal_with_a_suspicious_let_name_inside_a_fn_is_flagged() {
let findings = findings_for(
"fn f() {\n let auth_token = \"Kx7$mQ2#Lp9@Rn4^Wz6&Tb3!\";\n use_token(auth_token);\n}\n",
"security-secret-entropy-let",
);
assert_eq!(rule_findings(&findings, HARDCODED_SECRET_RULE).len(), 1);
}
#[test]
fn high_entropy_literal_without_a_suspicious_name_is_not_flagged() {
let findings = findings_for(
"const DATA: &str = \"Kx7$mQ2#Lp9@Rn4^Wz6&Tb3!\";\n",
"security-secret-entropy-non-suspicious-name",
);
assert!(rule_findings(&findings, HARDCODED_SECRET_RULE).is_empty());
}
#[test]
fn low_entropy_literal_with_a_suspicious_name_is_not_flagged() {
let findings = findings_for(
"const PASSWORD: &str = \"aaaaaaaaaaaaaaaa\";\n",
"security-secret-low-entropy",
);
assert!(rule_findings(&findings, HARDCODED_SECRET_RULE).is_empty());
}
#[test]
fn short_high_entropy_literal_with_a_suspicious_name_is_not_flagged() {
let findings = findings_for(
"const SECRET: &str = \"Kx7$mQ2#\";\n",
"security-secret-too-short",
);
assert!(rule_findings(&findings, HARDCODED_SECRET_RULE).is_empty());
}
#[test]
fn known_pattern_inside_a_test_attributed_fn_is_not_flagged() {
let findings = findings_for(
"#[test]\nfn t() {\n validate(\"AKIAIOSFODNN7EXAMPLE\");\n}\n",
"security-secret-test-fn",
);
assert!(rule_findings(&findings, HARDCODED_SECRET_RULE).is_empty());
}
#[test]
fn entropy_literal_inside_a_cfg_test_mod_is_not_flagged() {
let findings = findings_for(
"#[cfg(test)]\nmod tests {\n const API_SECRET: &str = \"Kx7$mQ2#Lp9@Rn4^Wz6&Tb3!\";\n}\n",
"security-secret-cfg-test-mod",
);
assert!(rule_findings(&findings, HARDCODED_SECRET_RULE).is_empty());
}
#[test]
fn shannon_entropy_is_zero_for_an_empty_string() {
assert_eq!(shannon_entropy(""), 0.0);
}
#[test]
fn shannon_entropy_is_zero_for_a_single_repeated_character() {
assert_eq!(shannon_entropy("aaaaaaaa"), 0.0);
}
#[test]
fn shannon_entropy_matches_log2_of_the_alphabet_size_when_every_character_is_distinct() {
let entropy = shannon_entropy("abcdefgh");
assert!((entropy - 8.0_f64.log2()).abs() < 1e-9, "got {entropy}");
}
#[test]
fn is_suspicious_name_matches_common_secret_markers_case_insensitively() {
for name in ["API_SECRET", "authToken", "client_secret", "PASSWORD"] {
assert!(is_suspicious_name(name), "{name} should be suspicious");
}
for name in ["data", "message", "counter"] {
assert!(!is_suspicious_name(name), "{name} should not be suspicious");
}
}
#[test]
fn is_suspicious_name_does_not_match_a_marker_as_a_mere_substring() {
for name in ["DEFAULT_MIN_TOKENS", "TOKENIZER", "PASSWORDLESS"] {
assert!(!is_suspicious_name(name), "{name} should not be suspicious");
}
}
#[test]
fn high_entropy_doc_comment_before_a_const_is_not_attributed_to_that_const() {
let findings = findings_for(
"/// Kx7$mQ2#Lp9@Rn4^Wz6&Tb3! see the design doc for details.\nconst API_SECRET: &str = \"short\";\n",
"security-secret-doc-comment-not-leaked",
);
assert!(rule_findings(&findings, HARDCODED_SECRET_RULE).is_empty());
}
}