use std::collections::{HashMap, HashSet};
use sdkt_wasm::ContractSpec;
use syn::visit::{self, Visit};
use syn::{Expr, ExprCall, ExprMethodCall, FnArg, Item, Local, Pat};
use crate::error::AuditError;
use crate::types::{AuditReport, Severity};
pub struct AuditContext<'a> {
pub spec: Option<&'a ContractSpec>,
}
pub trait AuditRule {
fn id(&self) -> &'static str;
fn severity(&self) -> Severity;
fn description(&self) -> &'static str;
fn check(&self, scans: &[FnScan], ctx: &AuditContext, report: &mut AuditReport);
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct FnScan {
pub fn_name: String,
pub require_auth: usize,
pub invoke_contract: usize,
pub bound: HashSet<String>,
pub usage: HashMap<String, usize>,
}
impl FnScan {
fn new(fn_name: String) -> Self {
Self {
fn_name,
require_auth: 0,
invoke_contract: 0,
bound: HashSet::new(),
usage: HashMap::new(),
}
}
}
fn is_auth_fn(name: &str) -> bool {
matches!(
name,
"require_auth" | "require_auth_for_args" | "require_auth_for_caller"
)
}
pub(crate) fn is_privileged(name: &str) -> bool {
let n = unqualified(name).to_lowercase();
n == "initialize"
|| n.starts_with("initialize_")
|| n == "init"
|| n.starts_with("init_")
|| [
"admin",
"mint",
"burn",
"pause",
"unpause",
"upgrade",
"withdraw",
"configure",
"freeze",
"ban",
"set_admin",
"owner_set",
"transfer_ownership",
"change_owner",
"set_auth",
]
.iter()
.any(|p| n.contains(p))
}
pub(crate) fn is_initialize(name: &str) -> bool {
let n = unqualified(name).to_lowercase();
n == "initialize" || n.starts_with("initialize_") || n == "init" || n.starts_with("init_")
}
fn unqualified(name: &str) -> &str {
name.rsplit("::").next().unwrap_or(name)
}
struct FnVisitor<'a> {
scan: &'a mut FnScan,
}
impl<'ast, 'a> Visit<'ast> for FnVisitor<'a> {
fn visit_local(&mut self, node: &'ast Local) {
if let Pat::Ident(pat) = &node.pat {
self.scan.bound.insert(pat.ident.to_string());
}
visit::visit_local(self, node);
}
fn visit_expr_call(&mut self, node: &'ast ExprCall) {
if let Expr::Path(p) = &*node.func {
if let Some(seg) = p.path.segments.last() {
let name = seg.ident.to_string();
if is_auth_fn(&name) {
self.scan.require_auth += 1;
}
if name == "invoke_contract" {
self.scan.invoke_contract += 1;
}
}
}
for arg in &node.args {
self.count_ident(arg);
}
visit::visit_expr_call(self, node);
}
fn visit_expr_method_call(&mut self, node: &'ast ExprMethodCall) {
let method = node.method.to_string();
if is_auth_fn(&method) {
self.scan.require_auth += 1;
}
if method == "invoke_contract" {
self.scan.invoke_contract += 1;
}
self.count_ident(&node.receiver);
for arg in &node.args {
self.count_ident(arg);
}
visit::visit_expr_method_call(self, node);
}
}
impl<'a> FnVisitor<'a> {
fn count_ident(&mut self, expr: &Expr) {
if let Expr::Path(p) = expr {
if let Some(ident) = p.path.get_ident() {
let s = ident.to_string();
if self.scan.bound.contains(&s) {
*self.scan.usage.entry(s).or_insert(0) += 1;
}
}
}
}
}
pub fn scan_all_functions(ast: &syn::File) -> Vec<FnScan> {
let mut out = Vec::new();
for item in &ast.items {
match item {
Item::Fn(f) => {
let scan = fn_scan(&f.sig, &f.block);
out.push(scan);
}
Item::Impl(imp) => {
let type_name = match &*imp.self_ty {
syn::Type::Path(p) => p
.path
.segments
.last()
.map(|s| s.ident.to_string())
.unwrap_or_default(),
_ => String::new(),
};
for inner in &imp.items {
if let syn::ImplItem::Fn(m) = inner {
let qual = if type_name.is_empty() {
m.sig.ident.to_string()
} else {
format!("{}::{}", type_name, m.sig.ident)
};
let mut scan = fn_scan(&m.sig, &m.block);
scan.fn_name = qual;
out.push(scan);
}
}
}
_ => {}
}
}
out
}
fn fn_scan(sig: &syn::Signature, block: &syn::Block) -> FnScan {
let mut scan = FnScan::new(sig.ident.to_string());
for input in &sig.inputs {
if let FnArg::Typed(pt) = input {
if let Pat::Ident(pat) = &*pt.pat {
scan.bound.insert(pat.ident.to_string());
}
}
}
let mut visitor = FnVisitor { scan: &mut scan };
visitor.visit_block(block);
scan
}
pub fn scan_all_functions_str(src: &str) -> Option<Vec<FnScan>> {
let ast = syn::parse_file(src).ok()?;
Some(scan_all_functions(&ast))
}
pub fn all_rules() -> Vec<Box<dyn AuditRule>> {
vec![
Box::new(crate::rules::Auth001),
Box::new(crate::rules::Auth002),
Box::new(crate::rules::Auth003),
Box::new(crate::rules::Move001),
]
}
fn run_rules(
ast: &syn::File,
ctx: &AuditContext,
disabled: &[&str],
) -> Result<AuditReport, AuditError> {
let scans = scan_all_functions(ast);
let mut report = AuditReport::default();
crate::registry::run_registered(&scans, ctx, disabled, &mut report);
Ok(report)
}
pub fn audit_source(src: &str) -> Result<AuditReport, AuditError> {
audit_source_with(src, &[])
}
pub fn audit_source_with(src: &str, disabled: &[&str]) -> Result<AuditReport, AuditError> {
let ast = syn::parse_file(src).map_err(AuditError::Parse)?;
let ctx = AuditContext { spec: None };
run_rules(&ast, &ctx, disabled)
}
pub fn audit_source_with_spec(
src: &str,
spec: &ContractSpec,
disabled: &[&str],
) -> Result<AuditReport, AuditError> {
let ast = syn::parse_file(src).map_err(AuditError::Parse)?;
let ctx = AuditContext { spec: Some(spec) };
run_rules(&ast, &ctx, disabled)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::rules::{Auth001, Auth002, Auth003, Move001};
fn report_for(src: &str) -> AuditReport {
audit_source(src).unwrap()
}
fn has(rep: &AuditReport, id: &str) -> bool {
rep.findings.iter().any(|f| f.rule_id == id)
}
#[test]
fn auth001_fires_on_privileged_without_auth() {
let src = "pub fn mint_token(to: Address) { /* no auth */ }";
let rep = report_for(src);
assert!(has(&rep, "AUTH-001"));
}
#[test]
fn auth001_silent_when_auth_present() {
let src = "pub fn mint_token(to: Address) { require_auth(); }";
let rep = report_for(src);
assert!(!has(&rep, "AUTH-001"));
}
#[test]
fn auth001_disabled_is_silent() {
let src = "pub fn mint_token(to: Address) { }";
let rep = audit_source_with(src, &["AUTH-001"]).unwrap();
assert!(!has(&rep, "AUTH-001"));
}
#[test]
fn auth001_negative_on_normal_fn() {
let src = "pub fn balance_of(who: Address) -> u32 { 0 }";
let rep = report_for(src);
assert!(!has(&rep, "AUTH-001"));
}
#[test]
fn auth002_fires_on_invoke_without_auth() {
let src = "pub fn relay() { env.invoke_contract(&addr, sym, vec![]); }";
let rep = report_for(src);
assert!(has(&rep, "AUTH-002"));
}
#[test]
fn auth002_silent_when_auth_present() {
let src = "pub fn relay() { require_auth(); env.invoke_contract(&addr, sym, vec![]); }";
let rep = report_for(src);
assert!(!has(&rep, "AUTH-002"));
}
#[test]
fn auth003_fires_on_unguarded_initialize() {
let src = "pub fn initialize(admin: Address) { /* no auth */ }";
let rep = report_for(src);
assert!(has(&rep, "AUTH-003"));
}
#[test]
fn auth003_silent_when_initialize_has_auth() {
let src = "pub fn initialize(admin: Address) { require_auth(); }";
let rep = report_for(src);
assert!(!has(&rep, "AUTH-003"));
}
#[test]
fn move001_fires_on_double_arg_use() {
let src = "pub fn foo(a: Address) { bar(a); baz(a); }";
let rep = report_for(src);
assert!(has(&rep, "MOVE-001"));
}
#[test]
fn move001_silent_on_single_use() {
let src = "pub fn foo(a: Address) { bar(a); }";
let rep = report_for(src);
assert!(!has(&rep, "MOVE-001"));
}
#[test]
fn clean_source_reports_no_findings() {
let src = "pub fn hello(name: String) -> String { require_auth(); hello_helper(name) }";
let rep = report_for(src);
assert!(rep.is_clean());
assert_eq!(rep.summary.total, 0);
}
#[test]
fn parse_error_is_reported() {
let res = audit_source("this is not valid rust @@@");
assert!(matches!(res, Err(AuditError::Parse(_))));
}
#[test]
fn rules_implement_trait() {
fn assert_rule(_r: &dyn AuditRule) {}
assert_rule(&Auth001);
assert_rule(&Auth002);
assert_rule(&Auth003);
assert_rule(&Move001);
}
}