From 9f8a858c754bd50721f9c0bd7b32375eb3d08551 Mon Sep 17 00:00:00 2001 From: AtoMicKraK1n Date: Wed, 29 Jul 2026 18:08:58 +0530 Subject: [PATCH 1/6] refactor: improves rules detection and updates deps --- Cargo.lock | 341 +++++++++++------- Cargo.toml | 8 +- crates/sentio-cli/src/telemetry.rs | 18 +- .../src/rules/anchor/unchecked_arithmetic.rs | 137 +++++-- .../sentio-core/tests/fixtures/sw005/safe.rs | 7 + 5 files changed, 346 insertions(+), 165 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6048c06..149652a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -76,6 +76,12 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + [[package]] name = "cc" version = "1.2.65" @@ -123,7 +129,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -138,6 +144,35 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -147,25 +182,31 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "dirs" -version = "5.0.1" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ "dirs-sys", ] [[package]] name = "dirs-sys" -version = "0.4.1" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -176,7 +217,16 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", ] [[package]] @@ -268,6 +318,22 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + [[package]] name = "icu_collections" version = "2.2.0" @@ -425,6 +491,12 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "log" version = "0.4.33" @@ -447,6 +519,12 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "once_cell" version = "1.21.4" @@ -486,6 +564,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "proc-macro2" version = "1.0.106" @@ -512,9 +596,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "redox_users" -version = "0.4.6" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", @@ -606,7 +690,7 @@ dependencies = [ "quote", "serde", "serde_json", - "syn", + "syn 3.0.3", "toml", "walkdir", ] @@ -638,7 +722,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -656,11 +740,11 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "0.6.9" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -716,6 +800,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -724,27 +819,57 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] name = "thiserror" -version = "1.0.69" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.69" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", +] + +[[package]] +name = "time" +version = "0.3.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", ] [[package]] @@ -759,44 +884,42 @@ dependencies = [ [[package]] name = "toml" -version = "0.8.23" +version = "1.1.4+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" dependencies = [ - "serde", + "indexmap", + "serde_core", "serde_spanned", "toml_datetime", - "toml_edit", + "toml_parser", + "toml_writer", + "winnow", ] [[package]] name = "toml_datetime" -version = "0.6.11" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ - "serde", + "serde_core", ] [[package]] -name = "toml_edit" -version = "0.22.27" +name = "toml_parser" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ - "indexmap", - "serde", - "serde_spanned", - "toml_datetime", - "toml_write", "winnow", ] [[package]] -name = "toml_write" -version = "0.1.2" +name = "toml_writer" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "unicode-ident" @@ -812,20 +935,34 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "ureq" -version = "2.12.1" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" dependencies = [ "base64", + "cookie_store", "flate2", "log", - "once_cell", + "percent-encoding", "rustls", "rustls-pki-types", "serde", "serde_json", - "url", - "webpki-roots 0.26.11", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64", + "http", + "httparse", + "log", ] [[package]] @@ -840,6 +977,12 @@ dependencies = [ "serde", ] +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -863,6 +1006,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "walkdir" version = "2.5.0" @@ -911,7 +1060,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -924,15 +1073,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "webpki-roots" -version = "0.26.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" -dependencies = [ - "webpki-roots 1.0.8", -] - [[package]] name = "webpki-roots" version = "1.0.8" @@ -957,22 +1097,13 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -984,67 +1115,34 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - [[package]] name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", "windows_i686_gnullvm", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -1057,48 +1155,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -1107,12 +1181,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" -version = "0.7.15" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" [[package]] name = "writeable" @@ -1139,7 +1210,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -1160,7 +1231,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -1200,7 +1271,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 1e82354..663e7ec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,13 +19,13 @@ readme = "README.md" [workspace.dependencies] anyhow = "1.0" clap = { version = "4.5", features = ["derive"] } -dirs = "5.0" +dirs = "6.0.0" proc-macro2 = { version = "1.0", features = ["span-locations"] } quote = "1.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -syn = { version = "2.0", features = ["full", "visit"] } -toml = "0.8" -ureq = { version = "2.10", features = ["json"] } +syn = { version = "3.0.3", features = ["full", "visit"] } +toml = "1.1" +ureq = { version = "3.3.0", features = ["json"] } uuid = { version = "1.10", features = ["v4"] } walkdir = "2.5" diff --git a/crates/sentio-cli/src/telemetry.rs b/crates/sentio-cli/src/telemetry.rs index 98d5dcd..f672f30 100644 --- a/crates/sentio-cli/src/telemetry.rs +++ b/crates/sentio-cli/src/telemetry.rs @@ -8,6 +8,8 @@ //! install into a single "unique machine" count rather than inflating on //! every run. Set `SENTIO_NO_TELEMETRY=1` to disable the ping entirely. +use std::time::Duration; + const NO_TELEMETRY_ENV: &str = "SENTIO_NO_TELEMETRY"; /// Endpoint that receives version-check pings. @@ -30,16 +32,26 @@ pub fn check_version(installed: &str) -> VersionCheck { return VersionCheck { latest: None }; }; - let mut request = ureq::get(endpoint).query("version", installed); + // ureq 3.x: timeouts live on config (Agent or per-request), not RequestBuilder. + let mut request = ureq::get(endpoint) + .config() + .timeout_global(Some(Duration::from_secs(2))) + .build() + .query("version", installed); + if let Some(id) = telemetry_id() { request = request.query("id", &id); } let latest = request - .timeout(std::time::Duration::from_secs(2)) .call() .ok() - .and_then(|response| response.into_json::().ok()) + .and_then(|mut response| { + response + .body_mut() + .read_json::() + .ok() + }) .and_then(|body| { body.get("latest") .and_then(|v| v.as_str()) diff --git a/crates/sentio-core/src/rules/anchor/unchecked_arithmetic.rs b/crates/sentio-core/src/rules/anchor/unchecked_arithmetic.rs index c9fa787..270832a 100644 --- a/crates/sentio-core/src/rules/anchor/unchecked_arithmetic.rs +++ b/crates/sentio-core/src/rules/anchor/unchecked_arithmetic.rs @@ -4,7 +4,7 @@ use crate::syntax::ParsedFile; use quote::ToTokens; use syn::spanned::Spanned; use syn::visit::{self, Visit}; -use syn::{BinOp, Expr, ExprBinary, ExprCast, ExprParen, ExprUnary, Type}; +use syn::{BinOp, Expr, ExprBinary, ExprCast, ExprParen, ExprUnary, Lit, Type}; #[derive(Debug, Default)] pub struct UncheckedArithmeticRule; @@ -15,8 +15,10 @@ impl Rule for UncheckedArithmeticRule { id: "SW005", title: "Unchecked arithmetic", severity: RuleSeverity::High, - description: "Detects arithmetic operations (+, -, *) on account data that can \ - silently overflow or underflow in release builds, where Rust wraps by default.", + description: "Detects unchecked +, -, * on account data with a non-trivial (variable \ + or non-unit) delta that can silently overflow/underflow in release builds. \ + Focuses on economically relevant steps (e.g. user-controlled `amount`), not \ + unit counter bumps like `count += 1` which are not practical overflow attacks.", fix_guidance: "Use checked_add(), checked_sub(), or checked_mul() and propagate \ the error with ?, or use saturating_add()/saturating_sub() when wrapping is intentional.", }; @@ -42,8 +44,9 @@ impl Rule for UncheckedArithmeticRule { column, }, help: Some( - "Replace `x += y` with `x = x.checked_add(y).ok_or(ErrorCode::Overflow)?`, \ - or use `saturating_add` if overflow should saturate rather than error." + "Replace `x += y` with `x = x.checked_add(y).ok_or(ErrorCode::Overflow)?` \ + when `y` is variable or non-unit. Unit steps like `count += 1` are lower risk; \ + still prefer checked math for money/supply fields." .to_string(), ), }) @@ -58,17 +61,20 @@ struct ArithmeticCollector { impl<'ast> Visit<'ast> for ArithmeticCollector { fn visit_expr_binary(&mut self, node: &'ast ExprBinary) { match &node.op { - // Compound assignments: +=, -=, *= - // Only flag when the target has a field access — loop counters like `i += 1` are skipped. + // Compound assignments: +=, -=, *= on account fields. + // Skip unit steps (`+= 1`, `-= 1`, `*= 1`) — not practical overflow paths. BinOp::AddAssign(_) | BinOp::SubAssign(_) | BinOp::MulAssign(_) - if expr_has_field_access(&node.left) && !expr_is_widened_to_128(&node.left) => + if expr_has_field_access(&node.left) + && !expr_is_widened_to_128(&node.left) + && !is_trivial_compound_step(&node.op, &node.right) => { let op = op_symbol(&node.op); let left = node.left.to_token_stream().to_string(); let loc = node.left.span().start(); self.findings.push(( format!( - "unchecked `{op}` on `{}`; can overflow or underflow in release builds", + "unchecked `{op}` on `{}` with non-unit/variable delta; \ + can overflow or underflow in release builds", left.split_whitespace().collect::>().join(" ") ), loc.line, @@ -76,17 +82,16 @@ impl<'ast> Visit<'ast> for ArithmeticCollector { )); } // Pure arithmetic: +, -, * - // Flag only when account-field operands are not cast to u128/i128 first. - // Widening to 128-bit before math is the standard Solana/Anchor overflow pattern - // (e.g. `supply as u128 + MINIMUM as u128` inside checked_div). + // Flag field-involving ops unless widened to u128/i128, or field ± 1 unit step. BinOp::Add(_) | BinOp::Sub(_) | BinOp::Mul(_) - if should_flag_binary_arithmetic(&node.left, &node.right) => + if should_flag_binary_arithmetic(&node.op, &node.left, &node.right) => { let op = op_symbol(&node.op); let loc = node.left.span().start(); self.findings.push(( format!( - "unchecked `{op}` involving account field; can overflow or underflow in release builds" + "unchecked `{op}` involving account field with non-unit/variable delta; \ + can overflow or underflow in release builds" ), loc.line, loc.column + 1, @@ -99,20 +104,64 @@ impl<'ast> Visit<'ast> for ArithmeticCollector { } } -/// Flag when at least one operand touches account field data AND that field-side is not -/// widened to 128-bit. Local-only arithmetic stays quiet. -fn should_flag_binary_arithmetic(left: &Expr, right: &Expr) -> bool { +/// `+= 1`, `-= 1`, `*= 1` — counter bumps, not attacker-chosen magnitude. +fn is_trivial_compound_step(op: &BinOp, rhs: &Expr) -> bool { + match op { + BinOp::AddAssign(_) | BinOp::SubAssign(_) => is_unit_integer_literal(rhs), + BinOp::MulAssign(_) => integer_literal_value(rhs) == Some(1), + _ => false, + } +} + +/// Flag when account-field data is involved without u128 widen, except field ± 1. +fn should_flag_binary_arithmetic(op: &BinOp, left: &Expr, right: &Expr) -> bool { let left_field = expr_has_field_access(left); let right_field = expr_has_field_access(right); if !left_field && !right_field { return false; } - // Every field-involving operand must be widened; otherwise flag. + + // field + 1 / 1 + field / field - 1 — unit step, skip for + and - + if matches!(op, BinOp::Add(_) | BinOp::Sub(_)) && is_field_unit_step(left, right) { + return false; + } + let left_risky = left_field && !expr_is_widened_to_128(left); let right_risky = right_field && !expr_is_widened_to_128(right); left_risky || right_risky } +/// True when one side is a field path and the other is literal `1` (add/sub only). +fn is_field_unit_step(left: &Expr, right: &Expr) -> bool { + let left_field = expr_has_field_access(left); + let right_field = expr_has_field_access(right); + (left_field && !right_field && is_unit_integer_literal(right)) + || (right_field && !left_field && is_unit_integer_literal(left)) +} + +fn is_unit_integer_literal(expr: &Expr) -> bool { + integer_literal_value(expr) == Some(1) +} + +fn integer_literal_value(expr: &Expr) -> Option { + match peel_expr(expr) { + Expr::Lit(expr_lit) => match &expr_lit.lit { + Lit::Int(int_lit) => int_lit.base10_parse::().ok(), + _ => None, + }, + _ => None, + } +} + +fn peel_expr(expr: &Expr) -> &Expr { + match expr { + Expr::Paren(ExprParen { expr, .. }) => peel_expr(expr), + Expr::Group(g) => peel_expr(&g.expr), + Expr::Reference(r) => peel_expr(&r.expr), + other => other, + } +} + fn expr_has_field_access(expr: &Expr) -> bool { match expr { Expr::Field(_) => true, @@ -122,21 +171,18 @@ fn expr_has_field_access(expr: &Expr) -> bool { Expr::Reference(r) => expr_has_field_access(&r.expr), Expr::Try(t) => expr_has_field_access(&t.expr), Expr::MethodCall(m) => { - // `pool.amount.checked_add(x)` — field is on the receiver path expr_has_field_access(&m.receiver) || m.args.iter().any(expr_has_field_access) } Expr::Call(c) => expr_has_field_access(&c.func) || c.args.iter().any(expr_has_field_access), Expr::Binary(b) => expr_has_field_access(&b.left) || expr_has_field_access(&b.right), Expr::Path(_) | Expr::Lit(_) => false, _ => { - // Fallback for unusual shapes: token string with a real field-like dot. let s = expr.to_token_stream().to_string(); token_string_has_field_access(&s) } } } -/// True when the expression (after parens/refs) is `… as u128` or `… as i128`. fn expr_is_widened_to_128(expr: &Expr) -> bool { match expr { Expr::Paren(ExprParen { expr, .. }) => expr_is_widened_to_128(expr), @@ -160,7 +206,6 @@ fn type_is_128_bit(ty: &Type) -> bool { fn token_string_has_field_access(expr: &str) -> bool { let trimmed = expr.trim(); - // Exclude float literals like "1.0" or "3.14_f64". if trimmed.chars().all(|c| { c.is_ascii_digit() || c == '.' @@ -229,6 +274,39 @@ mod tests { assert!(findings[0].message.contains("+")); } + #[test] + fn does_not_flag_unit_counter_increment() { + // Auditor thesis: += 1 is not an economically practical overflow attack. + let file = parse_file( + r#" + use anchor_lang::prelude::*; + pub fn mint_one(ctx: Context) -> Result<()> { + ctx.accounts.profile.nft_count += 1; + Ok(()) + } + "#, + ); + assert!( + run(&file).is_empty(), + "unit step += 1 must not be SW005: {:?}", + run(&file) + ); + } + + #[test] + fn does_not_flag_unit_counter_decrement() { + let file = parse_file( + r#" + use anchor_lang::prelude::*; + pub fn burn_one(ctx: Context) -> Result<()> { + ctx.accounts.profile.nft_count -= 1; + Ok(()) + } + "#, + ); + assert!(run(&file).is_empty()); + } + #[test] fn flags_sub_assign_and_mul_on_account_field() { let file = parse_file( @@ -296,7 +374,6 @@ mod tests { #[test] fn does_not_flag_u128_widened_account_field_add() { - // Foundation token-swap style: widen then add constant inside checked_div. let file = parse_file( r#" use anchor_lang::prelude::*; @@ -375,4 +452,18 @@ mod tests { "raw u64 fee math should still flag: {findings:?}" ); } + + #[test] + fn still_flags_add_assign_with_literal_other_than_one() { + let file = parse_file( + r#" + use anchor_lang::prelude::*; + pub fn handler(ctx: Context) -> Result<()> { + ctx.accounts.vault.balance += 100; + Ok(()) + } + "#, + ); + assert_eq!(run(&file).len(), 1); + } } diff --git a/crates/sentio-core/tests/fixtures/sw005/safe.rs b/crates/sentio-core/tests/fixtures/sw005/safe.rs index 34b2cb0..8bb4211 100644 --- a/crates/sentio-core/tests/fixtures/sw005/safe.rs +++ b/crates/sentio-core/tests/fixtures/sw005/safe.rs @@ -36,6 +36,12 @@ pub fn handler_loop(_ctx: Context) -> Result<()> { Ok(()) } +/// Safe: unit counter bump — not an economically practical overflow path. +pub fn handler_unit_counter(ctx: Context) -> Result<()> { + ctx.accounts.vault.nft_count += 1; + Ok(()) +} + /// Safe: account fields cast to u128 before arithmetic (standard overflow pattern). pub fn handler_u128_widen(ctx: Context, amount: u64) -> Result<()> { let _ = (amount as u128) @@ -49,6 +55,7 @@ pub fn handler_u128_widen(ctx: Context, amount: u64) -> Result<()> { #[account] pub struct Vault { pub balance: u64, + pub nft_count: u64, } #[error_code] From fc8696da976b527ffa7c84a3291f03f150053345 Mon Sep 17 00:00:00 2001 From: AtoMicKraK1n Date: Wed, 29 Jul 2026 18:10:03 +0530 Subject: [PATCH 2/6] fix: all checks should pass --- crates/sentio-cli/src/telemetry.rs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/crates/sentio-cli/src/telemetry.rs b/crates/sentio-cli/src/telemetry.rs index f672f30..ed3fd5d 100644 --- a/crates/sentio-cli/src/telemetry.rs +++ b/crates/sentio-cli/src/telemetry.rs @@ -46,12 +46,7 @@ pub fn check_version(installed: &str) -> VersionCheck { let latest = request .call() .ok() - .and_then(|mut response| { - response - .body_mut() - .read_json::() - .ok() - }) + .and_then(|mut response| response.body_mut().read_json::().ok()) .and_then(|body| { body.get("latest") .and_then(|v| v.as_str()) From e9aed55258e6b4f416adcebff45d21f0a91b85c4 Mon Sep 17 00:00:00 2001 From: AtoMicKraK1n Date: Sun, 2 Aug 2026 19:21:32 +0530 Subject: [PATCH 3/6] feat(SW003): confused-deputy when signer hits unvalidated CPI program --- README.md | 2 +- .../sentio-core/src/instruction_analysis.rs | 41 ++- .../src/rules/anchor/arbitrary_cpi.rs | 240 ++++++++++++++---- .../sentio-core/tests/fixtures/sw003/risky.rs | 27 ++ crates/sentio-core/tests/rules_sw003.rs | 17 +- 5 files changed, 267 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index f8b4575..15fa05c 100644 --- a/README.md +++ b/README.md @@ -228,7 +228,7 @@ By rule: | ----- | ---------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | SW001 | Missing signer check | Critical | `AccountInfo`/`UncheckedAccount` named as authority with no `#[account(signer)]` and no `is_signer` guard | | SW002 | Missing owner check | Critical | `AccountInfo`/`UncheckedAccount` with no `owner` or `address` constraint and no owner guard in handler | -| SW003 | Arbitrary CPI target | Critical | Raw `invoke`/`invoke_signed` calls with no preceding program key validation | +| SW003 | Arbitrary CPI target | Critical | Unvalidated CPI program (`invoke`/`invoke_signed`); stronger message when a `Signer` is also passed (confused deputy / malicious royalty program) | | SW005 | Unchecked arithmetic | High | `+`, `-`, `*`, `+=`, `-=`, `*=` on account fields with no checked math; can overflow in release builds | | SW006 | Type cosplay | Critical | `try_from_slice` without a discriminator check; a malicious account type can be deserialized as another | | SW008 | Missing post-CPI reload | High | Account written after a CPI that may have mutated it, without an intervening `reload()` | diff --git a/crates/sentio-core/src/instruction_analysis.rs b/crates/sentio-core/src/instruction_analysis.rs index 0eab231..cec9138 100644 --- a/crates/sentio-core/src/instruction_analysis.rs +++ b/crates/sentio-core/src/instruction_analysis.rs @@ -215,15 +215,37 @@ impl FunctionBodyCollector { extract_account_name_from_str(&val) }) .collect(), + syn::Expr::Array(a) => a + .elems + .iter() + .flat_map(|e| self.extract_account_names_from_expr(e)) + .collect(), + syn::Expr::Repeat(r) => self.extract_account_names_from_expr(&r.expr), syn::Expr::Call(call) => { let func = normalize_tokens(&call.func.to_token_stream().to_string()); if func.contains("CpiContext::new") { - if let Some(accounts_arg) = call.args.iter().nth(1) { - return self.extract_account_names_from_expr(accounts_arg); - } + // Collect program + accounts args (both matter for CPI analysis). + return call + .args + .iter() + .flat_map(|arg| self.extract_account_names_from_expr(arg)) + .collect(); } + // `foo.to_account_info()` as a bare call is rare; method form handled below. vec![] } + syn::Expr::MethodCall(m) => { + let method = m.method.to_string(); + if method == "to_account_info" || method == "clone" || method == "into" { + let recv = normalize_tokens(&m.receiver.to_token_stream().to_string()); + if let Some(name) = extract_account_name_from_str(&recv) { + return vec![name]; + } + // Nested: accounts.buyer.to_account_info() + return self.extract_account_names_from_expr(&m.receiver); + } + self.extract_account_names_from_expr(&m.receiver) + } syn::Expr::Path(p) => { let var = p .path @@ -234,6 +256,8 @@ impl FunctionBodyCollector { self.let_bindings.get(&var).cloned().unwrap_or_default() } syn::Expr::Reference(r) => self.extract_account_names_from_expr(&r.expr), + syn::Expr::Paren(p) => self.extract_account_names_from_expr(&p.expr), + syn::Expr::Try(t) => self.extract_account_names_from_expr(&t.expr), _ => vec![], } } @@ -294,12 +318,13 @@ impl<'ast> Visit<'ast> for FunctionBodyCollector { fn visit_expr_call(&mut self, node: &'ast syn::ExprCall) { let callee = normalize_tokens(&node.func.to_token_stream().to_string()); let cpi_account_names = if classify_call_kind(&callee) == CallKind::Cpi { - let mut found = vec![]; + // Merge names from all args (invoke metas array + CpiContext builders). + let mut found = Vec::new(); for arg in &node.args { - let names = self.extract_account_names_from_expr(arg); - if !names.is_empty() { - found = names; - break; + for name in self.extract_account_names_from_expr(arg) { + if !found.iter().any(|n| n == &name) { + found.push(name); + } } } found diff --git a/crates/sentio-core/src/rules/anchor/arbitrary_cpi.rs b/crates/sentio-core/src/rules/anchor/arbitrary_cpi.rs index 9dd345f..830f1a5 100644 --- a/crates/sentio-core/src/rules/anchor/arbitrary_cpi.rs +++ b/crates/sentio-core/src/rules/anchor/arbitrary_cpi.rs @@ -1,7 +1,9 @@ +use crate::anchor_accounts::{collect_anchor_accounts_index, AnchorFieldTypeKind}; use crate::finding::SourceLocation; use crate::instruction_analysis::{collect_instruction_index, CallKind}; use crate::rules::{Rule, RuleContext, RuleMatch, RuleMetadata, RuleSeverity}; use crate::syntax::ParsedFile; +use std::collections::HashSet; #[derive(Debug, Default)] pub struct ArbitraryCpiRule; @@ -12,19 +14,25 @@ impl Rule for ArbitraryCpiRule { id: "SW003", title: "Arbitrary CPI target", severity: RuleSeverity::Critical, - description: "Detects CPI calls where no key or program ID check precedes the invocation, allowing an attacker to supply a malicious program as the CPI target.", - fix_guidance: "Verify the target program key before invoking (e.g. require!(cpi_program.key() == expected::ID, ...)) or use Program<'info, T> so Anchor validates the program ID automatically.", + description: "Detects CPI calls (invoke/invoke_signed) without prior program ID \ + validation. An attacker-supplied program can receive the transaction's signer \ + privileges (confused deputy): e.g. a marketplace CPI to a fake \"royalty\" \ + program that drains the buyer. Always validate program IDs or use \ + Program<'info, T> / an allowlist — never trust user-provided program addresses.", + fix_guidance: "require!(program.key() == expected::ID, ...) or Program<'info, T> \ + before CPI. Prefer allowlists for optional external programs (royalties, hooks). \ + Never pass a Signer into a CPI whose program account is unvalidated.", }; &METADATA } fn match_file(&self, file: &ParsedFile, _ctx: &RuleContext<'_>) -> Vec { let index = collect_instruction_index(&file.syntax); + let accounts = collect_anchor_accounts_index(&file.syntax); + let signer_fields = collect_signer_field_names(&accounts); let mut findings = Vec::new(); for function in &index.functions { - // Only flag raw invoke/invoke_signed — Anchor CpiContext calls are validated - // at the account struct level via Program<'info, T> (covered by SW020). let cpi_calls: Vec<_> = function .calls .iter() @@ -36,31 +44,57 @@ impl Rule for ArbitraryCpiRule { } for cpi_call in cpi_calls { - // Check if any key-referencing guard appears before this CPI call. - let guarded = function - .guards + if has_program_validation_before(function, cpi_call.order) { + continue; + } + + let delegated_signers: Vec<&String> = cpi_call + .cpi_account_names .iter() - .any(|g| g.references_key && g.order < cpi_call.order); - - if !guarded { - findings.push(RuleMatch { - rule_id: "SW003", - severity: RuleSeverity::Critical, - message: format!( - "CPI call `{}` in `{}` has no preceding program key validation.", + .filter(|name| signer_fields.iter().any(|s| s.eq_ignore_ascii_case(name))) + .collect(); + + let (message, help) = if !delegated_signers.is_empty() { + let names = delegated_signers + .iter() + .map(|s| s.as_str()) + .collect::>() + .join(", "); + ( + format!( + "CPI `{}` in `{}` has no program ID check and passes signer \ + privilege(s) (`{names}`) into the callee — classic confused-deputy: \ + a malicious program can act with those signers (e.g. extra transfers).", cpi_call.callee, function.name ), - location: SourceLocation { - path: file.path.display().to_string(), - line: cpi_call.span.start_line, - column: cpi_call.span.start_column, - }, - help: Some( - "Add require!(program.key() == expected::ID, ...) before the CPI, or use Program<'info, T> to enforce program ID validation at the account level." - .to_string(), + "Validate the CPI program ID (require! / Program<'info, T> / allowlist) \ + before invoke. Do not forward buyer/authority Signers to untrusted programs." + .to_string(), + ) + } else { + ( + format!( + "CPI call `{}` in `{}` has no preceding program key validation; \ + an attacker can supply a malicious CPI target.", + cpi_call.callee, function.name ), - }); - } + "Add require!(program.key() == expected::ID, ...) before the CPI, use \ + Program<'info, T>, or an allowlist for external programs (royalties, hooks)." + .to_string(), + ) + }; + + findings.push(RuleMatch { + rule_id: "SW003", + severity: RuleSeverity::Critical, + message, + location: SourceLocation { + path: file.path.display().to_string(), + line: cpi_call.span.start_line, + column: cpi_call.span.start_column, + }, + help: Some(help), + }); } } @@ -78,6 +112,50 @@ fn is_raw_invoke(callee: &str) -> bool { || n.ends_with("::invoke_unchecked") } +fn has_program_validation_before( + function: &crate::instruction_analysis::InstructionFunction, + cpi_order: usize, +) -> bool { + function.guards.iter().any(|g| { + g.order < cpi_order + && (g.references_key || guard_looks_like_program_allowlist(&g.expression)) + }) +} + +/// Broader than bare `.key()` — allowlist / program_id / ::ID comparisons in require!/if. +fn guard_looks_like_program_allowlist(expression: &str) -> bool { + let compact: String = expression + .chars() + .filter(|c| !c.is_whitespace()) + .collect::() + .to_ascii_lowercase(); + compact.contains("program_id") + || compact.contains("::id") + || compact.contains("allowlist") + || compact.contains("allowed_program") + || compact.contains("approved_program") + || (compact.contains("program") && compact.contains("key()") && compact.contains("==")) +} + +fn collect_signer_field_names( + accounts: &crate::anchor_accounts::AnchorAccountsIndex, +) -> HashSet { + let mut names = HashSet::new(); + for item in &accounts.structs { + for field in &item.fields { + let Some(name) = field.ast.name.clone() else { + continue; + }; + let is_signer_type = field.type_info.kind == AnchorFieldTypeKind::Signer; + let has_signer_constraint = field.constraints.is_signer; + if is_signer_type || has_signer_constraint { + names.insert(name); + } + } + } + names +} + #[cfg(test)] mod tests { use super::*; @@ -93,6 +171,15 @@ mod tests { } } + fn run(file: &ParsedFile) -> Vec { + ArbitraryCpiRule.match_file( + file, + &RuleContext { + files: std::slice::from_ref(file), + }, + ) + } + #[test] fn flags_cpi_without_key_check() { let file = parse_file( @@ -109,14 +196,7 @@ mod tests { } "#, ); - - let rule = ArbitraryCpiRule; - let findings = rule.match_file( - &file, - &RuleContext { - files: std::slice::from_ref(&file), - }, - ); + let findings = run(&file); assert_eq!(findings.len(), 1); assert_eq!(findings[0].rule_id, "SW003"); } @@ -141,15 +221,7 @@ mod tests { } "#, ); - - let rule = ArbitraryCpiRule; - let findings = rule.match_file( - &file, - &RuleContext { - files: std::slice::from_ref(&file), - }, - ); - assert!(findings.is_empty()); + assert!(run(&file).is_empty()); } #[test] @@ -164,14 +236,84 @@ mod tests { } "#, ); + assert!(run(&file).is_empty()); + } - let rule = ArbitraryCpiRule; - let findings = rule.match_file( - &file, - &RuleContext { - files: std::slice::from_ref(&file), - }, + #[test] + fn flags_confused_deputy_signer_passed_to_unvalidated_program() { + // Marketplace-style: CPI to user-supplied royalty program with buyer as signer. + let file = parse_file( + r#" + use anchor_lang::prelude::*; + use solana_program::program::invoke; + + #[derive(Accounts)] + pub struct Buy<'info> { + pub buyer: Signer<'info>, + /// CHECK: supposed royalty program — unvalidated + pub royalty_program: AccountInfo<'info>, + #[account(mut)] + pub buyer_token: AccountInfo<'info>, + } + + pub fn buy(ctx: Context) -> Result<()> { + let ix = solana_program::instruction::Instruction { + program_id: *ctx.accounts.royalty_program.key, + accounts: vec![], + data: vec![], + }; + invoke( + &ix, + &[ + ctx.accounts.buyer.to_account_info(), + ctx.accounts.buyer_token.to_account_info(), + ctx.accounts.royalty_program.to_account_info(), + ], + )?; + Ok(()) + } + "#, + ); + let findings = run(&file); + assert_eq!(findings.len(), 1); + assert!( + findings[0].message.to_lowercase().contains("signer") + || findings[0].message.to_lowercase().contains("confused"), + "expected confused-deputy messaging: {}", + findings[0].message + ); + } + + #[test] + fn does_not_flag_when_program_validated_even_with_signer_in_metas() { + let file = parse_file( + r#" + use anchor_lang::prelude::*; + use solana_program::program::invoke; + + #[derive(Accounts)] + pub struct Buy<'info> { + pub buyer: Signer<'info>, + pub royalty_program: AccountInfo<'info>, + } + + pub fn buy(ctx: Context) -> Result<()> { + require_keys_eq!(*ctx.accounts.royalty_program.key, royalty::ID); + invoke( + &ix, + &[ + ctx.accounts.buyer.to_account_info(), + ctx.accounts.royalty_program.to_account_info(), + ], + )?; + Ok(()) + } + "#, + ); + assert!( + run(&file).is_empty(), + "validated program ID must clear SW003 even with signer in metas: {:?}", + run(&file) ); - assert!(findings.is_empty()); } } diff --git a/crates/sentio-core/tests/fixtures/sw003/risky.rs b/crates/sentio-core/tests/fixtures/sw003/risky.rs index dc1400b..c0de15f 100644 --- a/crates/sentio-core/tests/fixtures/sw003/risky.rs +++ b/crates/sentio-core/tests/fixtures/sw003/risky.rs @@ -16,3 +16,30 @@ pub fn handler(ctx: Context, data: Vec) -> Result<()> { invoke(&ix, &[ctx.accounts.target_program.clone()])?; Ok(()) } + +/// Confused deputy: unvalidated "royalty" program + buyer signer in CPI metas. +#[derive(Accounts)] +pub struct BuyWithRoyalty<'info> { + pub buyer: Signer<'info>, + /// CHECK: attacker-controlled program + pub royalty_program: AccountInfo<'info>, + #[account(mut)] + pub buyer_ata: AccountInfo<'info>, +} + +pub fn buy_with_fake_royalty(ctx: Context) -> Result<()> { + let ix = solana_program::instruction::Instruction { + program_id: *ctx.accounts.royalty_program.key, + accounts: vec![], + data: vec![], + }; + invoke( + &ix, + &[ + ctx.accounts.buyer.to_account_info(), + ctx.accounts.buyer_ata.to_account_info(), + ctx.accounts.royalty_program.to_account_info(), + ], + )?; + Ok(()) +} diff --git a/crates/sentio-core/tests/rules_sw003.rs b/crates/sentio-core/tests/rules_sw003.rs index 20f0814..e9f8366 100644 --- a/crates/sentio-core/tests/rules_sw003.rs +++ b/crates/sentio-core/tests/rules_sw003.rs @@ -4,8 +4,21 @@ mod common; fn sw003_flags_risky_fixture() { let result = common::scan_fixture("sw003/risky.rs", "SW003"); - assert_eq!(result.findings.len(), 1); - assert_eq!(result.findings[0].rule_id, "SW003"); + assert!( + result.findings.len() >= 2, + "expected unvalidated CPI + confused-deputy style cases: {:?}", + result.findings + ); + assert!(result.findings.iter().all(|f| f.rule_id == "SW003")); + assert!( + result + .findings + .iter() + .any(|f| f.message.to_lowercase().contains("signer") + || f.message.to_lowercase().contains("confused")), + "expected confused-deputy messaging: {:?}", + result.findings + ); } #[test] From afc0ae5ff7fa7e1df68624ddfbbbc2c708e60b73 Mon Sep 17 00:00:00 2001 From: Prakhar Sharma Date: Sun, 2 Aug 2026 19:25:27 +0530 Subject: [PATCH 4/6] Remove downloads badge for sentio-cli Removed downloads badge for sentio-cli from README. --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 15fa05c..08e4b6f 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,6 @@

sentio-cli version - crates.io downloads sentio-core version license

From dec6baad5a97e76d309663a6e7008a22854f5d20 Mon Sep 17 00:00:00 2001 From: Prakhar Sharma Date: Sun, 2 Aug 2026 19:29:22 +0530 Subject: [PATCH 5/6] Update license to MIT Changed license from 'MIT OR Apache-2.0' to 'MIT'. --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 663e7ec..49c3f0f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ resolver = "2" [workspace.package] version = "0.3.1" edition = "2021" -license = "MIT OR Apache-2.0" +license = "MIT" repository = "https://github.com/sentio-security/sentio-rs" authors = ["sentio-security"] description = "AST-based security scanner for Solana/Anchor programs" From 8e754289286b064d637a3f24a6d8c125c29b1ffc Mon Sep 17 00:00:00 2001 From: AtoMicKraK1n Date: Sun, 2 Aug 2026 21:38:49 +0530 Subject: [PATCH 6/6] chore: reclassify rule severities per audit rubric --- README.md | 50 ++++++++++--------- .../rules/anchor/cpi_remaining_accounts.rs | 4 +- .../src/rules/anchor/init_if_needed_usage.rs | 4 +- .../src/rules/anchor/missing_realloc_zero.rs | 4 +- .../rules/anchor/missing_token_owner_check.rs | 4 +- .../rules/anchor/pda_bump_not_canonical.rs | 4 +- 6 files changed, 36 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 08e4b6f..8cb63ba 100644 --- a/README.md +++ b/README.md @@ -223,30 +223,32 @@ By rule: ## Rules -| ID | Title | Severity | What it catches | -| ----- | ---------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| SW001 | Missing signer check | Critical | `AccountInfo`/`UncheckedAccount` named as authority with no `#[account(signer)]` and no `is_signer` guard | -| SW002 | Missing owner check | Critical | `AccountInfo`/`UncheckedAccount` with no `owner` or `address` constraint and no owner guard in handler | -| SW003 | Arbitrary CPI target | Critical | Unvalidated CPI program (`invoke`/`invoke_signed`); stronger message when a `Signer` is also passed (confused deputy / malicious royalty program) | -| SW005 | Unchecked arithmetic | High | `+`, `-`, `*`, `+=`, `-=`, `*=` on account fields with no checked math; can overflow in release builds | -| SW006 | Type cosplay | Critical | `try_from_slice` without a discriminator check; a malicious account type can be deserialized as another | -| SW008 | Missing post-CPI reload | High | Account written after a CPI that may have mutated it, without an intervening `reload()` | -| SW009 | Missing token mint check | High | Mutable `TokenAccount` with no `token::mint` constraint and no `associated_token`, allowing wrong-mint deposits | -| SW010 | Missing token owner check | High | Mutable `TokenAccount` with no `token::authority` or authority `has_one`, allowing unauthorized withdrawals | -| SW011 | AccountInfo as data account | Medium | `AccountInfo` used where a typed `Account<'info, T>` is needed (init/has_one/seeds constraints present) | -| SW012 | Missing seeds + bump on PDA | High | PDA accounts with `seeds` but no `bump`, skipping bump verification | -| SW013 | PDA seed unvalidated account | High | PDA seeds reference an `AccountInfo`/`UncheckedAccount` sibling with no `owner`, `address`, or `signer` constraint | -| SW014 | PDA bump not canonical | Medium | `bump = ` uses a caller-supplied bump instead of Anchor's canonical derivation | -| SW016 | init_if_needed usage | Medium | `init_if_needed` accounts that can be silently re-initialized, resetting state | -| SW018 | Missing realloc::zero | Medium | `realloc` without `realloc::zero = true`, leaving stale data in reallocated memory | -| SW020 | AccountInfo as CPI program | Medium | `AccountInfo` used as a CPI program account instead of typed `Program<'info, T>` | -| SW021 | PDA seed collision risk | High | Adjacent variable-length seeds (e.g. `name.as_bytes()` next to `symbol.as_bytes()`) with no fixed-length seed between them, allowing different inputs to derive the same PDA | -| SW022 | Missing close constraint | High | Manual lamport draining to close accounts without `#[account(close = ...)]`; account data not zeroed, leaving it open to reinitialization with stale data | -| SW023 | Unvalidated remaining_accounts in CPI | High | `ctx.remaining_accounts` forwarded into a CPI; unconstrained accounts retain outer-transaction signer privileges inside the call, enabling privilege escalation | -| SW024 | Division by zero | High | Division or modulo where the divisor is a variable or account field with no prior zero-check; a zero divisor panics and fails the transaction | -| SW025 | unwrap() / expect() in handler | Medium | `.unwrap()` or `.expect()` in instruction code panics on None/Err, failing the transaction with a generic error and exposing a DoS vector on user-controlled inputs | -| SW026 | create_program_address usage | High | `create_program_address` accepts a caller-supplied bump and does not enforce canonical derivation; use `find_program_address` or Anchor's `seeds + bump` constraint instead | -| SW027 | Missing event on state change | Low | Instruction handler writes to account state but emits no `emit!()` event, leaving off-chain indexers and audit trails blind to the state transition | +Severities follow an audit rubric: **Critical** = direct value loss / compromise with minimal preconditions; **High** = value loss or corruption with one clear precondition; **Medium** = needs chaining; **Low** = hygiene. + +| ID | Title | Severity | +| --- | --- | --- | +| SW001 | Missing signer check | Critical | +| SW002 | Missing owner check | Critical | +| SW003 | Arbitrary CPI target | Critical | +| SW005 | Unchecked arithmetic | High | +| SW006 | Type cosplay — missing discriminator check | Critical | +| SW008 | Missing post-CPI account reload | High | +| SW009 | Missing token account mint check | High | +| SW010 | Missing token account owner check | Critical | +| SW011 | AccountInfo used as data account | High | +| SW012 | Missing seeds + bump on PDA | High | +| SW013 | PDA seed references unvalidated account | High | +| SW014 | PDA bump may not be canonical | High | +| SW016 | init_if_needed usage (manual review) | High | +| SW018 | Missing realloc::zero = true | Low | +| SW020 | AccountInfo used as CPI target program | Critical | +| SW021 | PDA seed collision risk | High | +| SW022 | Manual account closure without close constraint | High | +| SW023 | Unvalidated remaining_accounts forwarded to CPI | Critical | +| SW024 | Division by zero | High | +| SW025 | unwrap() / expect() in instruction handler | Medium | +| SW026 | create_program_address used instead of find_program_address | High | +| SW027 | Missing event emission on state change | Low | ### Inline Suppressions diff --git a/crates/sentio-core/src/rules/anchor/cpi_remaining_accounts.rs b/crates/sentio-core/src/rules/anchor/cpi_remaining_accounts.rs index 99e981d..47b202d 100644 --- a/crates/sentio-core/src/rules/anchor/cpi_remaining_accounts.rs +++ b/crates/sentio-core/src/rules/anchor/cpi_remaining_accounts.rs @@ -11,7 +11,7 @@ impl Rule for CpiRemainingAccountsRule { static METADATA: RuleMetadata = RuleMetadata { id: "SW023", title: "Unvalidated remaining_accounts forwarded to CPI", - severity: RuleSeverity::High, + severity: RuleSeverity::Critical, description: "Detects instruction handlers that forward ctx.remaining_accounts into a \ CPI call. Accounts in remaining_accounts are not declared in the Accounts \ struct so they carry no type, owner, or signer constraints. Any account \ @@ -57,7 +57,7 @@ impl Rule for CpiRemainingAccountsRule { if let Some(cpi_call) = cpi_calls.first() { findings.push(RuleMatch { rule_id: "SW023", - severity: RuleSeverity::High, + severity: RuleSeverity::Critical, message: format!( "Function `{}` forwards `remaining_accounts` into a CPI; unvalidated \ accounts retain outer-transaction signer privileges inside the call.", diff --git a/crates/sentio-core/src/rules/anchor/init_if_needed_usage.rs b/crates/sentio-core/src/rules/anchor/init_if_needed_usage.rs index 94f46d7..ac56164 100644 --- a/crates/sentio-core/src/rules/anchor/init_if_needed_usage.rs +++ b/crates/sentio-core/src/rules/anchor/init_if_needed_usage.rs @@ -11,7 +11,7 @@ impl Rule for InitIfNeededUsageRule { static METADATA: RuleMetadata = RuleMetadata { id: "SW016", title: "init_if_needed usage (manual review)", - severity: RuleSeverity::Medium, + severity: RuleSeverity::High, description: "Flags Anchor account fields using init_if_needed because the pattern can permit unintended re-initialization or state reset.", fix_guidance: @@ -42,7 +42,7 @@ impl Rule for InitIfNeededUsageRule { let field_name = field.ast.name.clone().unwrap_or_default(); findings.push(RuleMatch { rule_id: "SW016", - severity: RuleSeverity::Medium, + severity: RuleSeverity::High, message: format!( "Account `{field_name}` uses `init_if_needed`; review for re-initialization or state-reset risk." ), diff --git a/crates/sentio-core/src/rules/anchor/missing_realloc_zero.rs b/crates/sentio-core/src/rules/anchor/missing_realloc_zero.rs index f0a4c77..4f90d28 100644 --- a/crates/sentio-core/src/rules/anchor/missing_realloc_zero.rs +++ b/crates/sentio-core/src/rules/anchor/missing_realloc_zero.rs @@ -11,7 +11,7 @@ impl Rule for MissingReallocZeroRule { static METADATA: RuleMetadata = RuleMetadata { id: "SW018", title: "Missing realloc::zero = true", - severity: RuleSeverity::Medium, + severity: RuleSeverity::Low, description: "Detects realloc usage without realloc::zero = true. Without zeroing, reallocated memory may contain stale data readable by the program or attackers.", fix_guidance: "Add realloc::zero = true to your #[account(realloc = ..., realloc::zero = true, realloc_authority = ...)] constraint.", }; @@ -27,7 +27,7 @@ impl Rule for MissingReallocZeroRule { if field.constraints.realloc && !field.constraints.realloc_zero { findings.push(RuleMatch { rule_id: "SW018", - severity: RuleSeverity::Medium, + severity: RuleSeverity::Low, message: format!( "Account `{}` uses `realloc` without `realloc::zero = true`; reallocated memory may contain stale data.", field.ast.name.clone().unwrap_or_default() diff --git a/crates/sentio-core/src/rules/anchor/missing_token_owner_check.rs b/crates/sentio-core/src/rules/anchor/missing_token_owner_check.rs index 68b70f5..28fb897 100644 --- a/crates/sentio-core/src/rules/anchor/missing_token_owner_check.rs +++ b/crates/sentio-core/src/rules/anchor/missing_token_owner_check.rs @@ -13,7 +13,7 @@ impl Rule for MissingTokenOwnerCheckRule { static METADATA: RuleMetadata = RuleMetadata { id: "SW010", title: "Missing token account owner check", - severity: RuleSeverity::High, + severity: RuleSeverity::Critical, description: "Detects mutable token account fields that have no token::authority or \ associated_token::authority constraint, allowing an attacker to substitute a token \ account they control as the signer's account.", @@ -46,7 +46,7 @@ impl Rule for MissingTokenOwnerCheckRule { let name = field.ast.name.clone().unwrap_or_default(); findings.push(RuleMatch { rule_id: "SW010", - severity: RuleSeverity::High, + severity: RuleSeverity::Critical, message: format!( "Mutable token account `{name}` has no `token::authority` constraint; \ an attacker can pass a token account they own as the signer's account" diff --git a/crates/sentio-core/src/rules/anchor/pda_bump_not_canonical.rs b/crates/sentio-core/src/rules/anchor/pda_bump_not_canonical.rs index 722f3c4..a91df1d 100644 --- a/crates/sentio-core/src/rules/anchor/pda_bump_not_canonical.rs +++ b/crates/sentio-core/src/rules/anchor/pda_bump_not_canonical.rs @@ -11,7 +11,7 @@ impl Rule for PdaBumpNotCanonicalRule { static METADATA: RuleMetadata = RuleMetadata { id: "SW014", title: "PDA bump may not be canonical", - severity: RuleSeverity::Medium, + severity: RuleSeverity::High, description: "Detects PDA accounts where the bump constraint is set to an explicit \ bare identifier rather than a stored field (e.g. account.bump). A user-supplied \ or re-derived bump may not be the canonical bump, opening a second-preimage \ @@ -59,7 +59,7 @@ impl Rule for PdaBumpNotCanonicalRule { let field_name = field.ast.name.clone().unwrap_or_default(); findings.push(RuleMatch { rule_id: "SW014", - severity: RuleSeverity::Medium, + severity: RuleSeverity::High, message: format!( "PDA `{field_name}` uses `bump = {compact}` — verify `{compact}` is \ the canonical bump stored on-chain rather than a user-supplied value"