Skip to content
Open
1 change: 1 addition & 0 deletions docs/detectors/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
65 changes: 65 additions & 0 deletions docs/detectors/event_data_cast.md
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions docs/error-codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
7 changes: 7 additions & 0 deletions tooling/sanctifier-core/src/finding_codes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ pub const EXCESSIVE_CLONE: &str = "S020";
pub const ARG_DOS: &str = "SANCT_ARG_DOS";
pub const BALANCE_EQUALITY: &str = "SANCT_BALANCE_EQ";
pub const SANCT_UNWRAP: &str = "SANCT_UNWRAP";
pub const SANCT_EVENT_DATA_CAST: &str = "SANCT_EVENT_DATA_CAST";
pub const INIT_HARDCODED_ADMIN: &str = "SANCT_INIT_HARDCODED_ADMIN";
pub const SANCT_VISIBILITY: &str = "SANCT_VISIBILITY";
pub const UNBOUNDED_STORAGE: &str = "SANCT_UNBOUNDED_STORAGE";
Expand Down Expand Up @@ -178,6 +179,12 @@ pub fn all_finding_codes() -> Vec<FindingCode> {
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",
},
FindingCode {
code: INIT_HARDCODED_ADMIN,
category: "authentication",
Expand Down
Loading
Loading