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..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"
@@ -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/README.md b/README.md
index f8b4575..8cb63ba 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,6 @@
-
@@ -224,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 | Raw `invoke`/`invoke_signed` calls with no preceding program key validation |
-| 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-cli/src/telemetry.rs b/crates/sentio-cli/src/telemetry.rs
index 98d5dcd..ed3fd5d 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,21 @@ 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/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/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"
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/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/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]
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]