From ec9087969816be575a5825ddd12ca9c41eb0e1b1 Mon Sep 17 00:00:00 2001 From: Juan Sebastian Valencia Londono <¨valencialondonojuansebastian@gmail.com¨> Date: Fri, 24 Jul 2026 10:30:17 -0500 Subject: [PATCH 1/5] feat(core): add event cast narrowing detector --- tooling/sanctifier-core/src/finding_codes.rs | 7 + .../src/rules/event_data_cast.rs | 390 ++++++++++++++++++ tooling/sanctifier-core/src/rules/mod.rs | 2 + .../tests/detector_snapshots.rs | 10 + .../fixtures/detectors/event_data_cast.rs | 48 +++ .../detector_snapshots__event_data_cast.snap | 25 ++ 6 files changed, 482 insertions(+) create mode 100644 tooling/sanctifier-core/src/rules/event_data_cast.rs create mode 100644 tooling/sanctifier-core/tests/fixtures/detectors/event_data_cast.rs create mode 100644 tooling/sanctifier-core/tests/snapshots/detector_snapshots__event_data_cast.snap diff --git a/tooling/sanctifier-core/src/finding_codes.rs b/tooling/sanctifier-core/src/finding_codes.rs index ff708f46..9a64b010 100644 --- a/tooling/sanctifier-core/src/finding_codes.rs +++ b/tooling/sanctifier-core/src/finding_codes.rs @@ -19,6 +19,7 @@ pub const ERROR_CODE_COLLISION: &str = "S016"; pub const FEE_ROUNDING: &str = "S017"; pub const ARG_DOS: &str = "SANCT_ARG_DOS"; pub const SANCT_UNWRAP: &str = "SANCT_UNWRAP"; +pub const SANCT_EVENT_DATA_CAST: &str = "SANCT_EVENT_DATA_CAST"; #[derive(Debug, Clone, Serialize)] pub struct FindingCode { @@ -128,6 +129,12 @@ pub fn all_finding_codes() -> Vec { description: "Contract entrypoint uses unwrap, expect, or a risky unwrap_or_default fallback", }, + FindingCode { + code: SANCT_EVENT_DATA_CAST, + category: "events", + description: + "Narrowing integer cast in event emission data silently truncates values indexers receive", + }, ] } diff --git a/tooling/sanctifier-core/src/rules/event_data_cast.rs b/tooling/sanctifier-core/src/rules/event_data_cast.rs new file mode 100644 index 00000000..2ef8d38f --- /dev/null +++ b/tooling/sanctifier-core/src/rules/event_data_cast.rs @@ -0,0 +1,390 @@ +use crate::rules::{Rule, RuleViolation, Severity}; +use std::collections::HashMap; +use syn::spanned::Spanned; +use syn::visit::Visit; +use syn::{parse_str, File}; + +const FINDING_CODE: &str = "SANCT_EVENT_DATA_CAST"; + +pub struct EventDataCastRule; + +impl EventDataCastRule { + pub fn new() -> Self { + Self + } +} + +impl Default for EventDataCastRule { + fn default() -> Self { + Self::new() + } +} + +impl Rule for EventDataCastRule { + fn name(&self) -> &str { + "event_data_cast" + } + + fn description(&self) -> &str { + "Detects narrowing integer casts in event emission data that silently truncate values indexers see" + } + + fn check(&self, source: &str) -> Vec { + let file = match parse_str::(source) { + Ok(f) => f, + Err(_) => return vec![], + }; + + let mut visitor = EventDataCastVisitor { + violations: Vec::new(), + current_fn: None, + var_types: HashMap::new(), + }; + visitor.visit_file(&file); + visitor.violations + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} + +// ── Integer type helpers ───────────────────────────────────────────── + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct IntType { + signed: bool, + bits: u16, +} + +fn int_type_from_type(ty: &syn::Type) -> Option { + match ty { + syn::Type::Path(type_path) if type_path.path.segments.len() == 1 => type_path + .path + .segments + .first() + .and_then(|segment| int_type_from_str(&segment.ident.to_string())), + _ => None, + } +} + +fn int_type_from_expr(expr: &syn::Expr, var_types: &HashMap) -> Option { + match expr { + syn::Expr::Path(expr_path) if expr_path.path.segments.len() == 1 => expr_path + .path + .segments + .first() + .and_then(|segment| var_types.get(&segment.ident.to_string()).copied()), + syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Int(lit), + .. + }) => int_type_from_str(lit.suffix()), + syn::Expr::Paren(paren) => int_type_from_expr(&paren.expr, var_types), + syn::Expr::Group(group) => int_type_from_expr(&group.expr, var_types), + _ => None, + } +} + +fn int_type_from_str(name: &str) -> Option { + let (signed, bits) = match name { + "i8" => (true, 8), + "i16" => (true, 16), + "i32" => (true, 32), + "i64" => (true, 64), + "i128" => (true, 128), + "isize" => (true, usize::BITS as u16), + "u8" => (false, 8), + "u16" => (false, 16), + "u32" => (false, 32), + "u64" => (false, 64), + "u128" => (false, 128), + "usize" => (false, usize::BITS as u16), + _ => return None, + }; + Some(IntType { signed, bits }) +} + +fn is_lossy_cast(source: IntType, target: IntType) -> bool { + target.bits < source.bits || target.signed != source.signed +} + +fn int_type_label(ty: IntType) -> String { + let prefix = if ty.signed { "i" } else { "u" }; + format!("{prefix}{}", ty.bits) +} + +fn collect_signature_int_types(sig: &syn::Signature) -> HashMap { + sig.inputs + .iter() + .filter_map(|arg| match arg { + syn::FnArg::Typed(pat_ty) => pat_ident(&pat_ty.pat).zip(int_type_from_type(&pat_ty.ty)), + syn::FnArg::Receiver(_) => None, + }) + .collect() +} + +fn local_int_binding(pat: &syn::Pat) -> Option<(String, IntType)> { + match pat { + syn::Pat::Type(pat_ty) => pat_ident(&pat_ty.pat).zip(int_type_from_type(&pat_ty.ty)), + _ => None, + } +} + +fn pat_ident(pat: &syn::Pat) -> Option { + match pat { + syn::Pat::Ident(ident) => Some(ident.ident.to_string()), + _ => None, + } +} + +// ── Visitor ────────────────────────────────────────────────────────── + +struct EventDataCastVisitor { + violations: Vec, + current_fn: Option, + var_types: HashMap, +} + +impl<'ast> Visit<'ast> for EventDataCastVisitor { + fn visit_impl_item_fn(&mut self, node: &'ast syn::ImplItemFn) { + let prev = self.current_fn.take(); + let prev_types = std::mem::take(&mut self.var_types); + self.current_fn = Some(node.sig.ident.to_string()); + self.var_types = collect_signature_int_types(&node.sig); + syn::visit::visit_impl_item_fn(self, node); + self.current_fn = prev; + self.var_types = prev_types; + } + + fn visit_item_fn(&mut self, node: &'ast syn::ItemFn) { + let prev = self.current_fn.take(); + let prev_types = std::mem::take(&mut self.var_types); + self.current_fn = Some(node.sig.ident.to_string()); + self.var_types = collect_signature_int_types(&node.sig); + syn::visit::visit_item_fn(self, node); + self.current_fn = prev; + self.var_types = prev_types; + } + + fn visit_local(&mut self, node: &'ast syn::Local) { + if let Some((ident, int_type)) = local_int_binding(&node.pat) { + self.var_types.insert(ident, int_type); + } + syn::visit::visit_local(self, node); + } + + fn visit_expr_method_call(&mut self, node: &'ast syn::ExprMethodCall) { + if node.method == "publish" { + if let syn::Expr::MethodCall(inner) = &*node.receiver { + if inner.method == "events" { + if let syn::Expr::Path(path) = &*inner.receiver { + if path.path.is_ident("env") { + if let Some(fn_name) = &self.current_fn { + for arg in &node.args { + scan_for_lossy_casts( + arg, + fn_name, + &self.var_types, + &mut self.violations, + ); + } + } + } + } + } + } + } + syn::visit::visit_expr_method_call(self, node); + } +} + +// ── Recursive cast scanner ─────────────────────────────────────────── + +fn scan_for_lossy_casts( + expr: &syn::Expr, + fn_name: &str, + var_types: &HashMap, + violations: &mut Vec, +) { + match expr { + syn::Expr::Cast(cast) => { + if let (Some(source), Some(target)) = ( + int_type_from_expr(&cast.expr, var_types), + int_type_from_type(&cast.ty), + ) { + if is_lossy_cast(source, target) { + let line = cast.span().start().line; + violations.push( + RuleViolation::new( + FINDING_CODE, + Severity::Warning, + format!( + "{}: narrowing cast `{} as {}` in event data — \ + indexers receive truncated value", + FINDING_CODE, + int_type_label(source), + int_type_label(target), + ), + format!("{}:{}", fn_name, line), + ) + .with_suggestion( + "Emit the full-width value in the event to prevent \ + data loss for indexers" + .to_string(), + ), + ); + } + } + } + syn::Expr::Tuple(tuple) => { + for elem in &tuple.elems { + scan_for_lossy_casts(elem, fn_name, var_types, violations); + } + } + syn::Expr::Paren(paren) => { + scan_for_lossy_casts(&paren.expr, fn_name, var_types, violations); + } + syn::Expr::MethodCall(m) => { + for arg in &m.args { + scan_for_lossy_casts(arg, fn_name, var_types, violations); + } + } + syn::Expr::Call(c) => { + for arg in &c.args { + scan_for_lossy_casts(arg, fn_name, var_types, violations); + } + } + syn::Expr::Binary(b) => { + scan_for_lossy_casts(&b.left, fn_name, var_types, violations); + scan_for_lossy_casts(&b.right, fn_name, var_types, violations); + } + syn::Expr::Unary(u) => { + scan_for_lossy_casts(&u.expr, fn_name, var_types, violations); + } + _ => {} + } +} + +// ── Unit tests ─────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_flags_narrowing_cast_in_event_data() { + let rule = EventDataCastRule::new(); + let source = r#" + use soroban_sdk::{contractimpl, symbol_short, Env}; + + #[contractimpl] + impl Contract { + pub fn deposit(env: Env, amount: i128) { + env.events().publish( + (symbol_short!("DEPOSIT"),), + (amount as u32,), + ); + } + } + "#; + let violations = rule.check(source); + assert_eq!(violations.len(), 1); + assert_eq!(violations[0].rule_name, FINDING_CODE); + assert!(violations[0].message.contains("narrowing cast")); + assert!(violations[0].location.contains("deposit")); + } + + #[test] + fn test_ignores_widening_cast() { + let rule = EventDataCastRule::new(); + let source = r#" + use soroban_sdk::{contractimpl, symbol_short, Env}; + + #[contractimpl] + impl Contract { + pub fn deposit(env: Env, amount: u32) { + env.events().publish( + (symbol_short!("DEPOSIT"),), + (amount as u64,), + ); + } + } + "#; + let violations = rule.check(source); + assert_eq!(violations.len(), 0); + } + + #[test] + fn test_ignores_cast_outside_event_context() { + let rule = EventDataCastRule::new(); + let source = r#" + impl Contract { + pub fn truncate(_env: (), amount: i128) -> u32 { + amount as u32 + } + } + "#; + let violations = rule.check(source); + assert_eq!(violations.len(), 0); + } + + #[test] + fn test_flags_multiple_casts_in_event() { + let rule = EventDataCastRule::new(); + let source = r#" + use soroban_sdk::{contractimpl, symbol_short, Env}; + + #[contractimpl] + impl Contract { + pub fn swap(env: Env, amount_in: i128, amount_out: i64) { + env.events().publish( + (symbol_short!("SWAP"),), + (amount_in as u64, amount_out as u32), + ); + } + } + "#; + let violations = rule.check(source); + assert_eq!(violations.len(), 2); + } + + #[test] + fn test_flags_signedness_change() { + let rule = EventDataCastRule::new(); + let source = r#" + use soroban_sdk::{contractimpl, symbol_short, Env}; + + #[contractimpl] + impl Contract { + pub fn wrap(env: Env, amount: i64) { + env.events().publish( + (symbol_short!("WRAP"),), + (amount as u64,), + ); + } + } + "#; + let violations = rule.check(source); + assert_eq!(violations.len(), 1); + } + + #[test] + fn test_ignores_no_cast_event() { + let rule = EventDataCastRule::new(); + let source = r#" + use soroban_sdk::{contractimpl, symbol_short, Env}; + + #[contractimpl] + impl Contract { + pub fn deposit(env: Env, amount: i128) { + env.events().publish( + (symbol_short!("DEPOSIT"),), + (amount,), + ); + } + } + "#; + let violations = rule.check(source); + assert_eq!(violations.len(), 0); + } +} diff --git a/tooling/sanctifier-core/src/rules/mod.rs b/tooling/sanctifier-core/src/rules/mod.rs index 5283fea9..08c82d78 100644 --- a/tooling/sanctifier-core/src/rules/mod.rs +++ b/tooling/sanctifier-core/src/rules/mod.rs @@ -3,6 +3,7 @@ pub mod arithmetic_overflow; pub mod auth_gap; pub mod edge_amount; pub mod error_code_collision; +pub mod event_data_cast; pub mod fee_rounding; pub mod hardcoded_addr; pub mod ledger_size; @@ -131,6 +132,7 @@ impl RuleRegistry { registry.register(missing_ttl::MissingTtlRule::new()); registry.register(arg_dos::ArgDosRule::new()); registry.register(sanct_unwrap::SanctUnwrapRule::new()); + registry.register(event_data_cast::EventDataCastRule::new()); registry } } diff --git a/tooling/sanctifier-core/tests/detector_snapshots.rs b/tooling/sanctifier-core/tests/detector_snapshots.rs index d121fbac..e04fe124 100644 --- a/tooling/sanctifier-core/tests/detector_snapshots.rs +++ b/tooling/sanctifier-core/tests/detector_snapshots.rs @@ -15,6 +15,7 @@ use sanctifier_core::rules::{ arg_dos::ArgDosRule, arithmetic_overflow::ArithmeticOverflowRule, auth_gap::AuthGapRule, edge_amount::EdgeAmountRule, error_code_collision::ErrorCodeCollisionRule, + event_data_cast::EventDataCastRule, fee_rounding::FeeRoundingRule, hardcoded_addr::HardcodedAddrRule, ledger_size::LedgerSizeRule, missing_ttl::MissingTtlRule, panic_detection::PanicDetectionRule, sanct_unwrap::SanctUnwrapRule, unhandled_result::UnhandledResultRule, @@ -147,6 +148,15 @@ fn snapshot_sanct_unwrap() { ); } +#[test] +fn snapshot_event_data_cast() { + assert_detector_snapshot( + "event_data_cast", + &EventDataCastRule::new(), + include_str!("fixtures/detectors/event_data_cast.rs"), + ); +} + #[test] fn arg_dos_detector_flags_only_uncapped_argument_iteration() { let findings = RuleRegistry::with_default_rules() diff --git a/tooling/sanctifier-core/tests/fixtures/detectors/event_data_cast.rs b/tooling/sanctifier-core/tests/fixtures/detectors/event_data_cast.rs new file mode 100644 index 00000000..f7b7f7f0 --- /dev/null +++ b/tooling/sanctifier-core/tests/fixtures/detectors/event_data_cast.rs @@ -0,0 +1,48 @@ +#![no_std] +use soroban_sdk::{contract, contractimpl, symbol_short, Env, Symbol}; + +const TOPIC: Symbol = symbol_short!("TOPIC"); + +// FIXTURE: event_data_cast detector +// Narrowing integer casts inside event emission data silently truncate +// values that indexers consume. This detector flags casts where the +// target type is narrower (fewer bits) or has different signedness. + +#[contract] +pub struct CastContract; + +#[contractimpl] +impl CastContract { + // VIOLATION: i128 → u32 loses 96 bits and signedness. + pub fn deposit(env: Env, amount: i128) { + env.events().publish((TOPIC,), (amount as u32,)); + } + + // VIOLATION: i64 → u64 keeps width but loses sign information. + pub fn wrap_signed(env: Env, amount: i64) { + env.events().publish((TOPIC,), (amount as u64,)); + } + + // VIOLATION: two narrowing casts in one event. + pub fn swap(env: Env, amount_in: i128, amount_out: i64) { + env.events().publish( + (TOPIC,), + (amount_in as u64, amount_out as u32), + ); + } + + // SAFE: widening cast (u32 → u64). No information loss. + pub fn widen(env: Env, amount: u32) { + env.events().publish((TOPIC,), (amount as u64,)); + } + + // SAFE: no cast at all — value emitted with full width. + pub fn raw(env: Env, amount: i128) { + env.events().publish((TOPIC,), (amount,)); + } + + // SAFE: cast outside event context. Not in scope for this detector. + pub fn truncate_elsewhere(_env: Env, amount: i128) -> u32 { + amount as u32 + } +} diff --git a/tooling/sanctifier-core/tests/snapshots/detector_snapshots__event_data_cast.snap b/tooling/sanctifier-core/tests/snapshots/detector_snapshots__event_data_cast.snap new file mode 100644 index 00000000..e06e31e2 --- /dev/null +++ b/tooling/sanctifier-core/tests/snapshots/detector_snapshots__event_data_cast.snap @@ -0,0 +1,25 @@ +--- +source: tooling/sanctifier-core/tests/detector_snapshots.rs +assertion_line: 31 +expression: findings +--- +- rule_name: SANCT_EVENT_DATA_CAST + severity: Warning + message: "SANCT_EVENT_DATA_CAST: narrowing cast `i128 as u32` in event data — indexers receive truncated value" + location: "deposit:18" + suggestion: Emit the full-width value in the event to prevent data loss for indexers +- rule_name: SANCT_EVENT_DATA_CAST + severity: Warning + message: "SANCT_EVENT_DATA_CAST: narrowing cast `i64 as u64` in event data — indexers receive truncated value" + location: "wrap_signed:23" + suggestion: Emit the full-width value in the event to prevent data loss for indexers +- rule_name: SANCT_EVENT_DATA_CAST + severity: Warning + message: "SANCT_EVENT_DATA_CAST: narrowing cast `i128 as u64` in event data — indexers receive truncated value" + location: "swap:30" + suggestion: Emit the full-width value in the event to prevent data loss for indexers +- rule_name: SANCT_EVENT_DATA_CAST + severity: Warning + message: "SANCT_EVENT_DATA_CAST: narrowing cast `i64 as u32` in event data — indexers receive truncated value" + location: "swap:30" + suggestion: Emit the full-width value in the event to prevent data loss for indexers From 59da4bcc021a79cdaf527c6259c6cf169fec78a4 Mon Sep 17 00:00:00 2001 From: Juan Sebastian Valencia Londono <¨valencialondonojuansebastian@gmail.com¨> Date: Fri, 24 Jul 2026 17:14:48 -0500 Subject: [PATCH 2/5] fix(core): fix errors with workflow --- .../sanctifier-core/tests/detector_snapshots.rs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/tooling/sanctifier-core/tests/detector_snapshots.rs b/tooling/sanctifier-core/tests/detector_snapshots.rs index a786323f..75a8df8a 100644 --- a/tooling/sanctifier-core/tests/detector_snapshots.rs +++ b/tooling/sanctifier-core/tests/detector_snapshots.rs @@ -18,13 +18,12 @@ use sanctifier_core::rules::{ arithmetic_overflow::ArithmeticOverflowRule, auth_gap::AuthGapRule, balance_equality::BalanceEqualityRule, division_by_zero::DivisionByZeroRule, edge_amount::EdgeAmountRule, error_code_collision::ErrorCodeCollisionRule, - event_data_cast::EventDataCastRule, - fee_rounding::FeeRoundingRule, hardcoded_addr::HardcodedAddrRule, ledger_size::LedgerSizeRule, - missing_ttl::MissingTtlRule, panic_detection::PanicDetectionRule, - sanct_unwrap::SanctUnwrapRule, shift_overflow::ShiftOverflowRule, - state_write_in_view::StateWriteInViewRule, unbounded_storage::UnboundedStorageRule, - unhandled_result::UnhandledResultRule, unused_variable::UnusedVariableRule, - view_panic::ViewPanicRule, Rule, RuleRegistry, + event_data_cast::EventDataCastRule, fee_rounding::FeeRoundingRule, + hardcoded_addr::HardcodedAddrRule, ledger_size::LedgerSizeRule, missing_ttl::MissingTtlRule, + panic_detection::PanicDetectionRule, sanct_unwrap::SanctUnwrapRule, + shift_overflow::ShiftOverflowRule, state_write_in_view::StateWriteInViewRule, + unbounded_storage::UnboundedStorageRule, unhandled_result::UnhandledResultRule, + unused_variable::UnusedVariableRule, view_panic::ViewPanicRule, Rule, RuleRegistry, }; /// Run a detector against its fixture and snapshot the resulting findings. @@ -177,6 +176,9 @@ fn snapshot_event_data_cast() { "event_data_cast", &EventDataCastRule::new(), include_str!("fixtures/detectors/event_data_cast.rs"), + ); +} + fn snapshot_unbounded_storage() { assert_detector_snapshot( "unbounded_storage", From ecfdff0723bce3231c524ead5befef154f6001f9 Mon Sep 17 00:00:00 2001 From: Juan Sebastian Valencia Londono <¨valencialondonojuansebastian@gmail.com¨> Date: Mon, 27 Jul 2026 16:10:44 -0500 Subject: [PATCH 3/5] fix(core): add missing closing brace in SANCT_EVENT_DATA_CAST finding code entry --- tooling/sanctifier-core/src/finding_codes.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tooling/sanctifier-core/src/finding_codes.rs b/tooling/sanctifier-core/src/finding_codes.rs index 393315ff..89d31e46 100644 --- a/tooling/sanctifier-core/src/finding_codes.rs +++ b/tooling/sanctifier-core/src/finding_codes.rs @@ -177,6 +177,8 @@ pub fn all_finding_codes() -> Vec { category: "events", description: "Narrowing integer cast in event emission data silently truncates values indexers receive", + }, + FindingCode { code: INIT_HARDCODED_ADMIN, category: "authentication", description: From 43097437378fb6989be22b6d50a20d14969c8410 Mon Sep 17 00:00:00 2001 From: Juan Sebastian Valencia Londono <¨valencialondonojuansebastian@gmail.com¨> Date: Mon, 27 Jul 2026 16:11:35 -0500 Subject: [PATCH 4/5] docs(detector): add event_data_cast page and register in catalog, error codes, and differential corpus --- docs/detectors/README.md | 1 + docs/detectors/event_data_cast.md | 65 +++++++++++++++++++ docs/error-codes.md | 1 + .../fixtures/corpus/differential-corpus.json | 2 + 4 files changed, 69 insertions(+) create mode 100644 docs/detectors/event_data_cast.md diff --git a/docs/detectors/README.md b/docs/detectors/README.md index 94fa4c04..4ece01a4 100644 --- a/docs/detectors/README.md +++ b/docs/detectors/README.md @@ -31,6 +31,7 @@ detector page and to the relevant [Glossary](../glossary.md) term. | [`balance_equality`](balance_equality.md) | [`SANCT_BALANCE_EQ`](../error-codes.md) | logic | Info | Balance gated with `==`/`!=` where `>=`/`<=` was intended | | [`unused_variable`](unused_variable.md) | [`S015`](../error-codes.md) | code_hygiene | Info | Unused local bindings (dead code) | | [`error_code_collision`](error_code_collision.md) | [`S016`](../error-codes.md) | code_hygiene | Medium | Duplicate/inconsistent `#[contracterror]` discriminants | +| [`event_data_cast`](event_data_cast.md) | [`SANCT_EVENT_DATA_CAST`](../error-codes.md) | events | Warning | Narrowing integer cast in event emission silently truncates values indexers receive | | [`fee_rounding`](fee_rounding.md) | [`S017`](../error-codes.md) | arithmetic | High | Integer-division fees that round to zero for micro-amounts | | [`unsigned_underflow`](unsigned_underflow.md) | [`S019`](../error-codes.md) | arithmetic | High | Unchecked `-` / `-=` on an unsigned integer that wraps past zero | | [`ledger_seconds`](ledger_seconds.md) | [`S021`](../error-codes.md) | time_logic | Medium | Ledger sequence number mixed with a seconds-magnitude literal | diff --git a/docs/detectors/event_data_cast.md b/docs/detectors/event_data_cast.md new file mode 100644 index 00000000..59dec3fe --- /dev/null +++ b/docs/detectors/event_data_cast.md @@ -0,0 +1,65 @@ +# `event_data_cast` — Lossy integer cast in event emission + +| | | +| --- | --- | +| **Finding code** | [`SANCT_EVENT_DATA_CAST`](../error-codes.md) | +| **Category** | events | +| **Severity** | Warning | +| **Source rule** | [`rules/event_data_cast.rs`](../../tooling/sanctifier-core/src/rules/event_data_cast.rs) | +| **Glossary** | [Events](../glossary.md#events) · [Type narrowing](../glossary.md#type-narrowing) | + +## What it catches + +An integer `as`-cast inside `env.events().publish(…)` where the **target type is +narrower** (fewer bits) or has **different signedness** from the source. This +silently truncates the value that indexers and off-chain consumers receive, +leading to incorrect balances, amounts, or state in downstream analytics. + +## Vulnerable example + +```rust +#[contractimpl] +impl Token { + pub fn deposit(env: Env, amount: i128) { + env.events() + .publish((TOPIC,), (amount as u32,)); + // i128 → u32 loses 96 bits AND signedness + } +} +``` + +## The fix + +Emit the full-width value and let off-chain consumers decide how to interpret it, +or cast through a checked conversion that panics on truncation: + +```rust +#[contractimpl] +impl Token { + pub fn deposit(env: Env, amount: i128) { + // Full-width: no information loss. + env.events().publish((TOPIC,), (amount,)); + } +} +``` + +## How Sanctifier detects it + +The rule walks the AST of every function and, when it encounters an +`env.events().publish(…)` call, recursively inspects each event-data argument +for `as`-casts. A cast is flagged when: + +- `target.bits() < source.bits()` (narrowing), **or** +- `target.signed() != source.signed()` (signedness change). + +Widening casts (`u32 as u64`) and casts outside event context are ignored. + +**Limitations:** the detector tracks types through `let` bindings but not across +function boundaries; an intermediate variable whose type was inferred from a +narrowing cast in a helper function will not be flagged. + +## References + +- Soroban — [Events](https://soroban.stellar.org/docs/getting-started/events) +- [CWE-681: Incorrect Conversion between Numeric Types](https://cwe.mitre.org/data/definitions/681.html) +- Related: [`shift_overflow`](shift_overflow.md) diff --git a/docs/error-codes.md b/docs/error-codes.md index e081f196..4dc8c383 100644 --- a/docs/error-codes.md +++ b/docs/error-codes.md @@ -22,6 +22,7 @@ the fix, and references. | `S016` | code_hygiene | Duplicate/inconsistent `#[contracterror]` discriminants | [`error_code_collision`](detectors/error_code_collision.md) | | `S017` | arithmetic | Fee/interest integer division that rounds to zero for micro-amounts | [`fee_rounding`](detectors/fee_rounding.md) | | `SANCT_ARG_DOS` | denial_of_service | `Vec`/`Map` argument iterated without a length cap | [`arg_dos`](detectors/arg_dos.md) | +| `SANCT_EVENT_DATA_CAST` | events | Narrowing integer cast in event emission data silently truncates values indexers receive | [`event_data_cast`](detectors/event_data_cast.md) | | `SANCT_UNWRAP` | panic_handling | `unwrap` / `expect` / risky `unwrap_or_default` inside `#[contractimpl]` entrypoints; replace with typed errors or explicit domain defaults | [`sanct_unwrap`](detectors/sanct_unwrap.md) | | `SANCT_VISIBILITY` | authentication | Helper-shaped state mutator exposed through `#[contractimpl]` without authorization | [`sanct_visibility`](detectors/sanct_visibility.md) | | `SANCT_UNBOUNDED_STORAGE` | denial_of_service | Persistent/instance collection grows via append/insert with no removal or length cap | [`unbounded_storage`](detectors/unbounded_storage.md) | diff --git a/tooling/sanctifier-core/tests/fixtures/corpus/differential-corpus.json b/tooling/sanctifier-core/tests/fixtures/corpus/differential-corpus.json index 191b8513..bf4ca36f 100644 --- a/tooling/sanctifier-core/tests/fixtures/corpus/differential-corpus.json +++ b/tooling/sanctifier-core/tests/fixtures/corpus/differential-corpus.json @@ -20,6 +20,8 @@ "SANCT_BALANCE_EQ": "SANCT_BALANCE_EQ", "unused_variable": "S015", "error_code_collision": "S016", + "event_data_cast": "SANCT_EVENT_DATA_CAST", + "SANCT_EVENT_DATA_CAST": "SANCT_EVENT_DATA_CAST", "fee_rounding": "S017", "unsigned_underflow": "S019", "ledger_seconds": "S021", From bb849186575e2c43d7774b266d059700fb63bb1a Mon Sep 17 00:00:00 2001 From: Juan Sebastian Valencia Londono <¨valencialondonojuansebastian@gmail.com¨> Date: Wed, 29 Jul 2026 20:54:14 -0500 Subject: [PATCH 5/5] fix(core): register event_data_cast detector in mod.rs and snapshot test The merge with upstream/main accidentally dropped the module declaration and registry.register call for EventDataCastRule, causing CI to fail on the no_orphan_detector_pages test. Also add the missing golden snapshot test function that had an unreferenced snapshot file. --- tooling/sanctifier-core/src/rules/mod.rs | 2 ++ .../sanctifier-core/tests/detector_snapshots.rs | 16 +++++++++++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/tooling/sanctifier-core/src/rules/mod.rs b/tooling/sanctifier-core/src/rules/mod.rs index 510b7873..4c604073 100644 --- a/tooling/sanctifier-core/src/rules/mod.rs +++ b/tooling/sanctifier-core/src/rules/mod.rs @@ -6,6 +6,7 @@ pub mod balance_equality; pub mod division_by_zero; pub mod edge_amount; pub mod error_code_collision; +pub mod event_data_cast; pub mod excessive_clone; pub mod fee_rounding; pub mod hardcoded_addr; @@ -178,6 +179,7 @@ impl RuleRegistry { registry.register(unsigned_underflow::UnsignedUnderflowRule::new()); registry.register(ledger_seconds::LedgerSecondsRule::new()); registry.register(tier_boundary_off_by_one::TierBoundaryOffByOneRule::new()); + registry.register(event_data_cast::EventDataCastRule::new()); registry } } diff --git a/tooling/sanctifier-core/tests/detector_snapshots.rs b/tooling/sanctifier-core/tests/detector_snapshots.rs index 62823d4a..7211c65f 100644 --- a/tooling/sanctifier-core/tests/detector_snapshots.rs +++ b/tooling/sanctifier-core/tests/detector_snapshots.rs @@ -18,9 +18,10 @@ use sanctifier_core::rules::{ arithmetic_overflow::ArithmeticOverflowRule, auth_gap::AuthGapRule, balance_equality::BalanceEqualityRule, division_by_zero::DivisionByZeroRule, edge_amount::EdgeAmountRule, error_code_collision::ErrorCodeCollisionRule, - excessive_clone::ExcessiveCloneRule, fee_rounding::FeeRoundingRule, - hardcoded_addr::HardcodedAddrRule, init_hardcoded_admin::InitHardcodedAdminRule, - ledger_seconds::LedgerSecondsRule, ledger_size::LedgerSizeRule, missing_ttl::MissingTtlRule, + event_data_cast::EventDataCastRule, excessive_clone::ExcessiveCloneRule, + fee_rounding::FeeRoundingRule, hardcoded_addr::HardcodedAddrRule, + init_hardcoded_admin::InitHardcodedAdminRule, ledger_seconds::LedgerSecondsRule, + ledger_size::LedgerSizeRule, missing_ttl::MissingTtlRule, panic_detection::PanicDetectionRule, sanct_unwrap::SanctUnwrapRule, shift_overflow::ShiftOverflowRule, state_write_in_view::StateWriteInViewRule, tier_boundary_off_by_one::TierBoundaryOffByOneRule, unbounded_storage::UnboundedStorageRule, @@ -271,6 +272,15 @@ fn snapshot_unsigned_underflow() { ); } +#[test] +fn snapshot_event_data_cast() { + assert_detector_snapshot( + "event_data_cast", + &EventDataCastRule::new(), + include_str!("fixtures/detectors/event_data_cast.rs"), + ); +} + #[test] fn unbounded_storage_detector_flags_only_uncapped_persistent_growth() { let findings = RuleRegistry::with_default_rules().run_by_name(