diff --git a/Cargo.lock b/Cargo.lock index 03885eb..2f718e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,9 +26,9 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "agave-feature-set" -version = "3.1.9" +version = "3.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd05b6a1f0867ccce373385f007b9683a116228bad9c3a0965316209617788a8" +checksum = "4a36f13a213d45f45f8ff87ea9fc6b0a792a7997c76b7c5d6d4a2ebe741d19d0" dependencies = [ "ahash", "solana-epoch-schedule", @@ -40,9 +40,9 @@ dependencies = [ [[package]] name = "agave-syscalls" -version = "3.1.9" +version = "3.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ae5f415f57e60b9dc1c17a314d595b32308f2470c2969beacaec9d0461d089d" +checksum = "108ce050e29fa68a49928213a74f12fc314f8f6964cceeec908be700f1ae5a80" dependencies = [ "bincode", "libsecp256k1", @@ -125,7 +125,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" dependencies = [ "anstyle", - "anstyle-parse", + "anstyle-parse 0.2.7", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse 1.0.0", "anstyle-query", "anstyle-wincon", "colorchoice", @@ -135,9 +150,9 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" @@ -148,6 +163,15 @@ dependencies = [ "utf8parse", ] +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + [[package]] name = "anstyle-query" version = "1.1.5" @@ -521,19 +545,20 @@ dependencies = [ [[package]] name = "borsh" -version = "1.6.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f" +checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" dependencies = [ "borsh-derive", + "bytes", "cfg_aliases", ] [[package]] name = "borsh-derive" -version = "1.6.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0686c856aa6aac0c4498f936d7d6a02df690f614c03e4d906d1018062b5c5e2c" +checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59" dependencies = [ "once_cell", "proc-macro-crate", @@ -593,11 +618,17 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + [[package]] name = "cc" -version = "1.2.56" +version = "1.2.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" dependencies = [ "find-msvc-tools", "shlex", @@ -630,9 +661,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.60" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" dependencies = [ "clap_builder", "clap_derive", @@ -640,11 +671,11 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.60" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ - "anstream", + "anstream 1.0.0", "anstyle", "clap_lex", "strsim", @@ -661,9 +692,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.55" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" dependencies = [ "heck", "proc-macro2", @@ -673,9 +704,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cli" @@ -690,6 +721,7 @@ dependencies = [ "dirs", "ed25519-dalek", "indicatif", + "quasar-audit", "quasar-idl", "quasar-profile", "rand 0.8.5", @@ -701,9 +733,9 @@ dependencies = [ [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "combine" @@ -1133,7 +1165,7 @@ version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d" dependencies = [ - "anstream", + "anstream 0.6.21", "anstyle", "env_filter", "jiff", @@ -1893,9 +1925,9 @@ dependencies = [ [[package]] name = "num_enum" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" dependencies = [ "num_enum_derive", "rustversion", @@ -1903,9 +1935,9 @@ dependencies = [ [[package]] name = "num_enum_derive" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -1941,9 +1973,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "once_cell_polyfill" @@ -2014,7 +2046,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c06810dac15a4ef83d3dabdb4f2f22fb39c9adff669cd2781da4f716510a647c" dependencies = [ "solana-account-view 1.0.0", - "solana-address 2.3.0", + "solana-address 2.4.0", "solana-define-syscall 4.0.1", "solana-instruction-view 1.0.0", "solana-program-error", @@ -2027,7 +2059,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24044a0815753862b558e179e78f03f7344cb755de48617a09d7d23b50883b6c" dependencies = [ "pinocchio", - "solana-address 2.3.0", + "solana-address 2.4.0", ] [[package]] @@ -2038,7 +2070,7 @@ dependencies = [ "pinocchio", "pinocchio-system", "solana-account", - "solana-address 2.3.0", + "solana-address 2.4.0", "solana-instruction", ] @@ -2066,9 +2098,9 @@ checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "portable-atomic-util" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5" +checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3" dependencies = [ "portable-atomic", ] @@ -2098,7 +2130,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.4+spec-1.1.0", + "toml_edit 0.25.5+spec-1.1.0", ] [[package]] @@ -2121,6 +2153,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "quasar-audit" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quasar-idl", + "syn 2.0.117", +] + [[package]] name = "quasar-derive" version = "0.1.0" @@ -2141,7 +2182,7 @@ dependencies = [ "quasar-lang", "quasar-spl", "solana-account", - "solana-address 2.3.0", + "solana-address 2.4.0", "solana-instruction", "solana-program-pack", "spl-token-interface", @@ -2166,7 +2207,7 @@ dependencies = [ "quasar-derive", "quasar-pod", "solana-account-view 2.0.0", - "solana-address 2.3.0", + "solana-address 2.4.0", "solana-define-syscall 5.0.0", "solana-instruction", "solana-instruction-view 2.0.0", @@ -2182,7 +2223,7 @@ dependencies = [ "mollusk-svm", "quasar-lang", "solana-account", - "solana-address 2.3.0", + "solana-address 2.4.0", "solana-instruction", ] @@ -2216,7 +2257,7 @@ name = "quasar-spl" version = "0.1.0" dependencies = [ "quasar-lang", - "solana-address 2.3.0", + "solana-address 2.4.0", "solana-program-error", ] @@ -2263,7 +2304,7 @@ dependencies = [ "quasar-test-sysvar", "quasar-test-token-cpi", "solana-account", - "solana-address 2.3.0", + "solana-address 2.4.0", "solana-instruction", "solana-program-pack", "spl-token-interface", @@ -2724,7 +2765,7 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc3397241392f5756925029acaa8515dc70fcbe3d8059d4885d7d6533baf64fd" dependencies = [ - "solana-address 2.3.0", + "solana-address 2.4.0", "solana-program-error", "solana-program-memory", ] @@ -2735,7 +2776,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f37ca34c37f92ee341b73d5ce7c8ef5bb38e9a87955b4bd343c63fa18b149215" dependencies = [ - "solana-address 2.3.0", + "solana-address 2.4.0", "solana-program-error", ] @@ -2745,7 +2786,7 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc141b940560430425ebaadb7645496c45f6a10fad9911d719bd03eab7f4d422" dependencies = [ - "solana-address 2.3.0", + "solana-address 2.4.0", "solana-program-error", ] @@ -2755,14 +2796,14 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2ecac8e1b7f74c2baa9e774c42817e3e75b20787134b76cc4d45e8a604488f5" dependencies = [ - "solana-address 2.3.0", + "solana-address 2.4.0", ] [[package]] name = "solana-address" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "500b83d41bda401b84ebff6033e2e7bc828870ea444805112d15fc0a3e470b9c" +checksum = "7f67735365edc7fb19ed74ec950597107c8ee9cbfebac57b8868b3e78fb6df16" dependencies = [ "borsh", "bytemuck", @@ -2840,9 +2881,9 @@ dependencies = [ [[package]] name = "solana-bpf-loader-program" -version = "3.1.9" +version = "3.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cc6de8801030c776e7f2875621f19f806330bb8c0e273a97d5c1112663c0d63" +checksum = "36128e7525889b05f13a4dbfb86f1ff3aef2063ab63b899591d348226e20def8" dependencies = [ "agave-syscalls", "bincode", @@ -2882,9 +2923,9 @@ dependencies = [ [[package]] name = "solana-compute-budget" -version = "3.1.9" +version = "3.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e87b9f9e3014226c130c630b8a982212f8f562763e0245955cc3698512b08cc" +checksum = "c346408840a596e128b1b5214fd72a2c1cf8484cc96965e10333f6a18fcb897b" dependencies = [ "solana-fee-structure", "solana-program-runtime", @@ -2906,9 +2947,9 @@ dependencies = [ [[package]] name = "solana-curve25519" -version = "3.1.9" +version = "3.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "720e1d774f0404957bf112365b2720ca9af9b0d1bdf0a653b22cfb52dbaa9c9e" +checksum = "f3d7e1177e6006823b91e0a930d94992ed74f8a6327d54ee50a9457ff72e625a" dependencies = [ "bytemuck", "bytemuck_derive", @@ -3040,7 +3081,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60147e4d0a4620013df40bf30a86dd299203ff12fcb8b593cd51014fce0875d8" dependencies = [ "solana-account-view 1.0.0", - "solana-address 2.3.0", + "solana-address 2.4.0", "solana-define-syscall 4.0.1", "solana-program-error", ] @@ -3052,7 +3093,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c35d02cd27575b1fd05938d3624e476b0173208139053b51aa48dd9bec149f1" dependencies = [ "solana-account-view 2.0.0", - "solana-address 2.3.0", + "solana-address 2.4.0", "solana-define-syscall 5.0.0", "solana-program-error", ] @@ -3148,7 +3189,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0448b1fd891c5f46491e5dc7d9986385ba3c852c340db2911dd29faa01d2b08d" dependencies = [ "lazy_static", - "solana-address 2.3.0", + "solana-address 2.4.0", "solana-hash 4.2.0", "solana-instruction", "solana-sanitize", @@ -3202,9 +3243,9 @@ dependencies = [ [[package]] name = "solana-poseidon" -version = "3.1.9" +version = "3.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08f78b376ada87092ac1ce2aeda1b90e16a3c07b881497bce9fec4fa7762ce19" +checksum = "25a2c8f53a80947785b58e55bda1b619bac03f266c9055b7b0208775f0522057" dependencies = [ "ark-bn254 0.4.0", "ark-bn254 0.5.0", @@ -3273,9 +3314,9 @@ dependencies = [ [[package]] name = "solana-program-option" -version = "3.0.1" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "362279f6e8020e4cf11313233789bf619420ad8835ebc91963ee5cec91bb05da" +checksum = "7a88006a9b8594088cec9027ab77caaaa258a2aaa2083d3f086c44b42e50aeab" [[package]] name = "solana-program-pack" @@ -3288,9 +3329,9 @@ dependencies = [ [[package]] name = "solana-program-runtime" -version = "3.1.9" +version = "3.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adad04a3aa8e5597c2d8e4933be8c50126f9911ed7bf97eb17a2516d93a19266" +checksum = "b9cefa43d3f60a2dd25e0fcc3a5a46f50de021c42981029b1e8f375e1d954853" dependencies = [ "base64 0.22.1", "bincode", @@ -3346,7 +3387,7 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b06bd918d60111ee1f97de817113e2040ca0cedb740099ee8d646233f6b906c" dependencies = [ - "solana-address 2.3.0", + "solana-address 2.4.0", ] [[package]] @@ -3391,7 +3432,7 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "def234c1956ff616d46c9dd953f251fa7096ddbaa6d52b165218de97882b7280" dependencies = [ - "solana-address 2.3.0", + "solana-address 2.4.0", ] [[package]] @@ -3506,9 +3547,9 @@ dependencies = [ [[package]] name = "solana-svm-callback" -version = "3.1.9" +version = "3.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce7750f8e00d1116629b47ab0a9dba78b332d4e6583ee4e15df1b791dc834099" +checksum = "b9114cc1391e57d0d6902c7c347964cb8e41e3c141ebb4be3ec5440be1c994fa" dependencies = [ "solana-account", "solana-clock", @@ -3518,30 +3559,30 @@ dependencies = [ [[package]] name = "solana-svm-feature-set" -version = "3.1.9" +version = "3.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41b991162ad8f2fa5e35f15c7d2c90bf8ad3fdfc269cc1eaac199749d60420e4" +checksum = "62a20c4fc8d409780c4592c17ac3e01b6f3dc949e6ffd3acbda6d2a21e67b53a" [[package]] name = "solana-svm-log-collector" -version = "3.1.9" +version = "3.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d92fe6bc5e6376b28cba96cbb31c3158605a51d4b66bb5ed1a25233ef4c276" +checksum = "f92f14f80cf719d5bc2c14bbee50075920637d1f9afe2fce990e5394fa579293" dependencies = [ "log", ] [[package]] name = "solana-svm-measure" -version = "3.1.9" +version = "3.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a1dd1d72be70c87927454988359177107925e3c328226358ed82f1b023061aa" +checksum = "f4c68915dfa801dd03b3f7c3e0a510fe233754463e77f5afe6a30a51ac9fa326" [[package]] name = "solana-svm-timings" -version = "3.1.9" +version = "3.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12493e27f0fdcd34dcb1e7c0d41c88571dcec3ed704aaec405557bad7287f834" +checksum = "89a98d45197b57ae84bbf97647840adb153e444ad147638e742929d83343f48e" dependencies = [ "eager", "enum-iterator", @@ -3550,9 +3591,9 @@ dependencies = [ [[package]] name = "solana-svm-transaction" -version = "3.1.9" +version = "3.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bae5b541edec0d134ff094d9f9394475ac242df424ecb98665c83849bebb0dde" +checksum = "1a6074e3b4bdd7ade15db51a4f309eb9e22239d33b01f4ea5ecf6aff8f81d0ba" dependencies = [ "solana-hash 3.1.0", "solana-message", @@ -3564,9 +3605,9 @@ dependencies = [ [[package]] name = "solana-svm-type-overrides" -version = "3.1.9" +version = "3.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8c9f8e9939fd6ae4d9b1072de19500380f3dc7b990439b304b504219016cc3d" +checksum = "51b2ea7c2f849cd6d190e2607c2af779d3dad2792784cc13c0e9342e429c87a1" dependencies = [ "rand 0.8.5", ] @@ -3588,9 +3629,9 @@ dependencies = [ [[package]] name = "solana-system-program" -version = "3.1.9" +version = "3.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad3cdd09f7d9e13f41c7e107ef96445a2249085be5da217b1e7ef1deb126b543" +checksum = "14d88343aac5ad97d240eca299c8dfcde833e861b6a40bff6fda3ac215efc211" dependencies = [ "bincode", "log", @@ -3650,7 +3691,7 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "17358d1e9a13e5b9c2264d301102126cf11a47fd394cdf3dec174fe7bc96e1de" dependencies = [ - "solana-address 2.3.0", + "solana-address 2.4.0", "solana-sdk-ids", ] @@ -3660,7 +3701,7 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96697cff5075a028265324255efed226099f6d761ca67342b230d09f72cc48d2" dependencies = [ - "solana-address 2.3.0", + "solana-address 2.4.0", "solana-hash 4.2.0", "solana-instruction", "solana-instruction-error", @@ -3673,9 +3714,9 @@ dependencies = [ [[package]] name = "solana-transaction-context" -version = "3.1.9" +version = "3.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5378750bacbb3e3c19203588a240e725712e2e92336aa345ece0efb67258215" +checksum = "be7c191d89fb883fef0b4bb4225121f7ad14eb5664d5dc9707b4af661e21924c" dependencies = [ "bincode", "qualifier_attr", @@ -3793,9 +3834,9 @@ checksum = "591ef38edfb78ca4771ee32cf494cb8771944bee237a9b91fc9c1424ac4b777b" [[package]] name = "tempfile" -version = "3.26.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", "getrandom 0.4.2", @@ -3855,9 +3896,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] @@ -3882,17 +3923,17 @@ dependencies = [ [[package]] name = "toml" -version = "1.0.6+spec-1.1.0" +version = "1.0.7+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "399b1124a3c9e16766831c6bba21e50192572cdd98706ea114f9502509686ffc" +checksum = "dd28d57d8a6f6e458bc0b8784f8fdcc4b99a437936056fa122cb234f18656a96" dependencies = [ "indexmap", "serde_core", "serde_spanned 1.0.4", - "toml_datetime 1.0.0+spec-1.1.0", + "toml_datetime 1.0.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow", + "winnow 1.0.0", ] [[package]] @@ -3906,9 +3947,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.0.0+spec-1.1.0" +version = "1.0.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" +checksum = "9b320e741db58cac564e26c607d3cc1fdc4a88fd36c879568c07856ed83ff3e9" dependencies = [ "serde_core", ] @@ -3924,28 +3965,28 @@ dependencies = [ "serde_spanned 0.6.9", "toml_datetime 0.6.11", "toml_write", - "winnow", + "winnow 0.7.15", ] [[package]] name = "toml_edit" -version = "0.25.4+spec-1.1.0" +version = "0.25.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" +checksum = "8ca1a40644a28bce036923f6a431df0b34236949d111cc07cb6dca830c9ef2e1" dependencies = [ "indexmap", - "toml_datetime 1.0.0+spec-1.1.0", + "toml_datetime 1.0.1+spec-1.1.0", "toml_parser", - "winnow", + "winnow 1.0.0", ] [[package]] name = "toml_parser" -version = "1.0.9+spec-1.1.0" +version = "1.0.10+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +checksum = "7df25b4befd31c4816df190124375d5a20c6b6921e2cad937316de3fccd63420" dependencies = [ - "winnow", + "winnow 1.0.0", ] [[package]] @@ -3956,9 +3997,9 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "toml_writer" -version = "1.0.6+spec-1.1.0" +version = "1.0.7+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" +checksum = "f17aaa1c6e3dc22b1da4b6bba97d066e354c7945cac2f7852d4e4e7ca7a6b56d" [[package]] name = "trybuild" @@ -3972,7 +4013,7 @@ dependencies = [ "serde_json", "target-triple", "termcolor", - "toml 1.0.6+spec-1.1.0", + "toml 1.0.7+spec-1.1.0", ] [[package]] @@ -4037,7 +4078,7 @@ dependencies = [ "mollusk-svm", "quasar-lang", "solana-account", - "solana-address 2.3.0", + "solana-address 2.4.0", "solana-instruction", ] @@ -4211,9 +4252,9 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "wincode" -version = "0.4.7" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b591a322f17191f9a62acbc15df6a0699f63475d28a3113c2ed0774f62a5522" +checksum = "dc91ddd8c932a38bbec58ed536d9e93ce9cd01b6af9b6de3c501132cf98ddec6" dependencies = [ "pastey", "proc-macro2", @@ -4384,6 +4425,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -4474,18 +4524,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.42" +version = "0.8.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" +checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.42" +version = "0.8.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" +checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 0b4e261..e0bbba8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["lang", "derive", "pod", "spl", "idl", "profile", "examples/*", "tests/programs/*", "tests/suite", "cli"] +members = ["lang", "derive", "pod", "spl", "idl", "audit", "profile", "examples/*", "tests/programs/*", "tests/suite", "cli"] exclude = ["examples/anchor-vault"] [workspace.package] diff --git a/audit/Cargo.toml b/audit/Cargo.toml new file mode 100644 index 0000000..ce0ce36 --- /dev/null +++ b/audit/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "quasar-audit" +description = "Security auditor for Quasar Solana programs — Sealevel attack detection" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true + +[lib] +name = "quasar_audit" +path = "src/lib.rs" + +[dependencies] +quasar-idl = { path = "../idl" } +proc-macro2 = { version = "1", features = ["span-locations"] } +syn = { version = "2", features = ["full", "parsing", "visit"] } diff --git a/audit/src/checks.rs b/audit/src/checks.rs new file mode 100644 index 0000000..16b3a40 --- /dev/null +++ b/audit/src/checks.rs @@ -0,0 +1,481 @@ +use { + crate::{ + extract_snippet, + helpers::{impl_block_contains_call, impl_block_contains_zero_drain}, + parsers::{AuditAccountsStruct, AuditField}, + Finding, Rule, Severity, + }, + module_resolver::ResolvedFile, + quasar_idl::parser::{self, module_resolver}, + std::collections::{HashMap, HashSet}, +}; + +const UNCHECKED_TYPES: &[&str] = &["UncheckedAccount", "AccountInfo", "AccountView"]; + +fn field_finding( + severity: Severity, + rule: Rule, + ix: &parser::program::RawInstruction, + accounts: &AuditAccountsStruct, + field: &AuditField, + source: &str, + message: String, +) -> Finding { + Finding { + severity, + rule, + location: format!("{} -> {}", ix.name, field.name), + source_file: accounts.file_path.clone(), + source_line: field.line, + snippet: extract_snippet(source, field.line), + message, + } +} + +pub fn check_discriminator_collisions(parsed: &parser::ParsedProgram, findings: &mut Vec) { + for msg in parser::find_discriminator_collisions(parsed) { + findings.push(Finding { + severity: Severity::Critical, + rule: Rule::DiscriminatorCollision, + location: "global".to_string(), + source_file: String::new(), + source_line: 0, + snippet: String::new(), + message: msg.trim().to_string(), + }); + } +} + +pub fn check_missing_signer( + ix: &parser::program::RawInstruction, + accounts: &AuditAccountsStruct, + source: &str, + findings: &mut Vec, +) { + let has_any_signer = accounts.fields.iter().any(|f| f.signer); + let has_writable = accounts.fields.iter().any(|f| f.writable); + + if has_writable && !has_any_signer { + let first_writable = accounts.fields.iter().find(|f| f.writable); + let line = first_writable.map_or(0, |f| f.line); + findings.push(Finding { + severity: Severity::Critical, + rule: Rule::MissingSigner, + location: ix.name.clone(), + source_file: accounts.file_path.clone(), + source_line: line, + snippet: extract_snippet(source, line), + message: "instruction modifies accounts but has no signer — anyone can invoke it" + .to_string(), + }); + } +} + +pub fn check_untyped_accounts( + ix: &parser::program::RawInstruction, + accounts: &AuditAccountsStruct, + source: &str, + findings: &mut Vec, +) { + for field in &accounts.fields { + if !UNCHECKED_TYPES.iter().any(|t| field.type_name == *t) { + continue; + } + + if field.has_owner || field.has_constraint || field.has_address { + continue; + } + + let signer_names: Vec<&str> = accounts + .fields + .iter() + .filter(|f| f.signer) + .map(|f| f.name.as_str()) + .collect(); + if field.pda_seed_count > 0 + && field + .pda_seed_refs + .iter() + .any(|r| signer_names.contains(&r.as_str())) + { + continue; + } + + let validated_by_sibling = accounts.fields.iter().any(|sibling| { + sibling.name != field.name && sibling.has_one.iter().any(|h| h == &field.name) + }); + if validated_by_sibling { + continue; + } + + findings.push(field_finding( + Severity::Warning, + Rule::UntypedAccount, + ix, + accounts, + field, + source, + format!( + "`{}` uses unchecked type `{}` — no owner or discriminator validation", + field.name, field.type_name + ), + )); + } +} + +pub fn check_duplicate_mutable( + ix: &parser::program::RawInstruction, + accounts: &AuditAccountsStruct, + source: &str, + findings: &mut Vec, +) { + let writable_typed: Vec<&AuditField> = accounts + .fields + .iter() + .filter(|f| f.writable && f.type_name == "Account") + .collect(); + + let mut by_type: HashMap<&str, Vec<&AuditField>> = HashMap::new(); + for field in &writable_typed { + let key = if field.type_inner.is_empty() { + "unknown" + } else { + &field.type_inner + }; + by_type.entry(key).or_default().push(field); + } + + for (type_name, fields) in &by_type { + if fields.len() < 2 { + continue; + } + + let has_uniqueness_check = fields + .iter() + .any(|f| f.has_constraint || f.has_token_constraint); + + let all_pda = fields.iter().all(|f| f.pda_seed_count > 0); + + if !has_uniqueness_check && !all_pda { + let names: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect(); + let first = fields[0]; + findings.push(field_finding( + Severity::Warning, + Rule::DuplicateMutable, + ix, + accounts, + first, + source, + format!( + "multiple writable `Account<{}>` fields ({}) without a constraint checking \ + key uniqueness", + type_name, + names.join(", "), + ), + )); + } + } +} + +pub fn check_pda_specificity( + ix: &parser::program::RawInstruction, + accounts: &AuditAccountsStruct, + source: &str, + findings: &mut Vec, +) { + let signer_names: Vec<&str> = accounts + .fields + .iter() + .filter(|f| f.signer) + .map(|f| f.name.as_str()) + .collect(); + + for field in &accounts.fields { + if field.pda_seed_count == 0 { + continue; + } + + let seeds_ref_signer = field + .pda_seed_refs + .iter() + .any(|r| signer_names.contains(&r.as_str())); + + if seeds_ref_signer { + continue; + } + + let (severity, detail) = if field.pda_has_account_ref { + ( + Severity::Info, + format!( + "`{}` PDA seeds reference account(s) ({}) but none are signers — the PDA may \ + be shared across users if those accounts are not user-specific. Consider \ + adding a signer-derived seed.", + field.name, + field.pda_seed_refs.join(", "), + ), + ) + } else { + ( + Severity::Warning, + format!( + "`{}` PDA seeds contain only constants — the same PDA is shared across all \ + users. Consider adding a user-specific seed to prevent PDA sharing attacks.", + field.name, + ), + ) + }; + + findings.push(field_finding( + severity, + Rule::PdaSharing, + ix, + accounts, + field, + source, + detail, + )); + } +} + +pub fn check_arbitrary_cpi( + ix: &parser::program::RawInstruction, + accounts: &AuditAccountsStruct, + source: &str, + findings: &mut Vec, +) { + for field in &accounts.fields { + let name_lower = field.name.to_lowercase(); + let looks_like_program = name_lower.contains("program"); + let is_unchecked = UNCHECKED_TYPES.iter().any(|t| field.type_name == *t); + + if looks_like_program + && is_unchecked + && field.type_name != "Program" + && field.type_name != "SystemProgram" + && !field.has_address + && !field.has_constraint + && !field.has_owner + { + findings.push(field_finding( + Severity::Critical, + Rule::ArbitraryCpi, + ix, + accounts, + field, + source, + format!( + "`{}` looks like a program account but uses `{}` instead of typed \ + `Program` — attacker can substitute a malicious program", + field.name, field.type_name, + ), + )); + } + } +} + +pub fn check_init_if_needed( + ix: &parser::program::RawInstruction, + accounts: &AuditAccountsStruct, + program_has_drain_path: bool, + source: &str, + findings: &mut Vec, +) { + for field in &accounts.fields { + if !field.has_init_if_needed + || field.has_constraint + || field.has_token_constraint + || !field.has_one.is_empty() + { + continue; + } + + let (severity, qualifier) = if program_has_drain_path { + ( + Severity::Critical, + "the program contains a lamport-drain path that could make it reinitializable", + ) + } else { + ( + Severity::Info, + "no drain path detected, but verify no future instruction introduces one", + ) + }; + + findings.push(field_finding( + severity, + Rule::InitIfNeeded, + ix, + accounts, + field, + source, + format!( + "`{}` uses `init_if_needed` without additional constraints — {} . Consider adding \ + a `has_one` or `constraint` check.", + field.name, qualifier, + ), + )); + } +} + +pub fn check_data_matching( + ix: &parser::program::RawInstruction, + accounts: &AuditAccountsStruct, + source: &str, + findings: &mut Vec, +) { + let has_signer = accounts.fields.iter().any(|f| f.signer); + if !has_signer { + return; + } + + let signer_names: Vec<&str> = accounts + .fields + .iter() + .filter(|f| f.signer) + .map(|f| f.name.as_str()) + .collect(); + + for field in &accounts.fields { + if !field.writable || field.signer || field.type_name != "Account" || field.has_address { + continue; + } + + if field.pda_seed_count > 0 && field.pda_has_account_ref { + continue; + } + + let has_one_reaches_signer = field + .has_one + .iter() + .any(|h| reaches_signer(h, accounts, &signer_names)); + + let token_auth_reaches_signer = !field.token_authority_ref.is_empty() + && reaches_signer(&field.token_authority_ref, accounts, &signer_names); + + let sibling_has_one_validates = accounts.fields.iter().any(|sibling| { + sibling.name != field.name + && sibling.has_one.iter().any(|h| h == &field.name) + && (sibling.has_constraint + || sibling.pda_seed_count > 0 + || sibling + .has_one + .iter() + .any(|h| reaches_signer(h, accounts, &signer_names))) + }); + + if has_one_reaches_signer + || token_auth_reaches_signer + || sibling_has_one_validates + || field.has_constraint + || field.has_token_constraint + { + continue; + } + + findings.push(field_finding( + Severity::Warning, + Rule::DataMatching, + ix, + accounts, + field, + source, + format!( + "`{}` is a writable `Account<{}>` with no `has_one`, `constraint`, or PDA seeds \ + binding it to the signer — an attacker could pass someone else's account if the \ + stored data isn't validated against the signer", + field.name, + if field.type_inner.is_empty() { + "?" + } else { + &field.type_inner + }, + ), + )); + } +} + +fn reaches_signer(name: &str, accounts: &AuditAccountsStruct, signer_names: &[&str]) -> bool { + let mut visited = HashSet::new(); + let mut stack = vec![name]; + + while let Some(current) = stack.pop() { + if signer_names.contains(¤t) { + return true; + } + if !visited.insert(current) { + continue; + } + + if let Some(field) = accounts.fields.iter().find(|f| f.name == current) { + for seed_ref in &field.pda_seed_refs { + stack.push(seed_ref.as_str()); + } + + for target in &field.has_one { + stack.push(target.as_str()); + } + } + } + + false +} + +pub fn check_revival_attack( + ix: &parser::program::RawInstruction, + accounts: &AuditAccountsStruct, + files: &[ResolvedFile], + source: &str, + findings: &mut Vec, +) { + let writable_without_close: Vec<&AuditField> = accounts + .fields + .iter() + .filter(|f| f.writable && !f.has_close) + .collect(); + + if writable_without_close.is_empty() { + return; + } + + let has_drain_to_zero = files + .iter() + .any(|rf| impl_block_contains_zero_drain(&rf.file, &accounts.name)); + + if !has_drain_to_zero { + return; + } + + let uses_close_method = files + .iter() + .any(|rf| impl_block_contains_call(&rf.file, &accounts.name, "close")); + + if uses_close_method { + return; + } + + for field in &writable_without_close { + let is_closeable = + field.type_name == "Account" || UNCHECKED_TYPES.contains(&field.type_name.as_str()); + + if !is_closeable { + continue; + } + + findings.push(field_finding( + Severity::Warning, + Rule::RevivalAttack, + ix, + accounts, + field, + source, + format!( + "`{}` is writable and the instruction uses `set_lamports` (manual lamport \ + transfer) without the `close` attribute — if lamports are drained to zero the \ + account can be revived within the same transaction because the discriminator is \ + not zeroed. Use `#[account(close = destination)]` instead.", + field.name, + ), + )); + } +} diff --git a/audit/src/helpers.rs b/audit/src/helpers.rs new file mode 100644 index 0000000..a4772b6 --- /dev/null +++ b/audit/src/helpers.rs @@ -0,0 +1,159 @@ +use syn::Item; + +pub fn file_contains_drain_pattern(file: &syn::File) -> bool { + use syn::visit::Visit; + + const DRAIN_NAMES: &[&str] = &["set_lamports", "sub_lamports", "assign"]; + + struct DrainFinder { + found: bool, + } + + impl<'ast> Visit<'ast> for DrainFinder { + fn visit_expr_method_call(&mut self, node: &'ast syn::ExprMethodCall) { + if self.found { + return; + } + if DRAIN_NAMES.iter().any(|n| node.method == n) { + self.found = true; + return; + } + syn::visit::visit_expr_method_call(self, node); + } + + fn visit_expr_call(&mut self, node: &'ast syn::ExprCall) { + if self.found { + return; + } + if let syn::Expr::Path(p) = &*node.func { + if let Some(seg) = p.path.segments.last() { + if DRAIN_NAMES.iter().any(|n| seg.ident == n) { + self.found = true; + return; + } + } + } + syn::visit::visit_expr_call(self, node); + } + } + + let mut finder = DrainFinder { found: false }; + finder.visit_file(file); + finder.found +} + +pub fn impl_blocks_for<'a>( + file: &'a syn::File, + struct_name: &str, +) -> impl Iterator { + let struct_name = struct_name.to_string(); + file.items.iter().filter_map(move |item| match item { + Item::Impl(impl_block) => { + let matches = matches!( + impl_block.self_ty.as_ref(), + syn::Type::Path(tp) if tp.path.segments.iter().any(|s| s.ident == struct_name) + ); + matches.then_some(impl_block) + } + _ => None, + }) +} + +pub fn impl_block_contains_zero_drain(file: &syn::File, struct_name: &str) -> bool { + use syn::visit::Visit; + + struct ZeroDrainFinder { + found: bool, + } + + fn has_zero_literal(args: &syn::punctuated::Punctuated) -> bool { + args.iter().any(|arg| { + if let syn::Expr::Lit(lit) = arg { + if let syn::Lit::Int(int) = &lit.lit { + return int.base10_digits() == "0"; + } + } + false + }) + } + + impl<'ast> Visit<'ast> for ZeroDrainFinder { + fn visit_expr_method_call(&mut self, node: &'ast syn::ExprMethodCall) { + if self.found { + return; + } + if node.method == "set_lamports" && has_zero_literal(&node.args) { + self.found = true; + return; + } + syn::visit::visit_expr_method_call(self, node); + } + + fn visit_expr_call(&mut self, node: &'ast syn::ExprCall) { + if self.found { + return; + } + if let syn::Expr::Path(p) = &*node.func { + if let Some(seg) = p.path.segments.last() { + if seg.ident == "set_lamports" && has_zero_literal(&node.args) { + self.found = true; + return; + } + } + } + syn::visit::visit_expr_call(self, node); + } + } + + impl_blocks_for(file, struct_name).any(|impl_block| { + let mut finder = ZeroDrainFinder { found: false }; + finder.visit_item_impl(impl_block); + finder.found + }) +} + +pub fn impl_block_contains_call(file: &syn::File, struct_name: &str, fn_name: &str) -> bool { + use syn::visit::Visit; + + struct CallFinder<'a> { + target: &'a str, + found: bool, + } + + impl<'a, 'ast> Visit<'ast> for CallFinder<'a> { + fn visit_expr_call(&mut self, node: &'ast syn::ExprCall) { + if self.found { + return; + } + if let syn::Expr::Path(p) = &*node.func { + if let Some(seg) = p.path.segments.last() { + if seg.ident == self.target { + self.found = true; + return; + } + } + } + syn::visit::visit_expr_call(self, node); + } + + fn visit_expr_method_call(&mut self, node: &'ast syn::ExprMethodCall) { + if self.found { + return; + } + if node.method == self.target { + self.found = true; + return; + } + syn::visit::visit_expr_method_call(self, node); + } + } + + impl_blocks_for(file, struct_name).any(|impl_block| { + let mut finder = CallFinder { + target: fn_name, + found: false, + }; + finder.visit_item_impl(impl_block); + finder.found + }) +} diff --git a/audit/src/lib.rs b/audit/src/lib.rs new file mode 100644 index 0000000..84982d7 --- /dev/null +++ b/audit/src/lib.rs @@ -0,0 +1,173 @@ +mod checks; +mod helpers; +mod parsers; + +use { + core::fmt::{Display, Formatter, Result}, + quasar_idl::parser::{self, module_resolver}, + std::{collections::HashMap, path::Path}, +}; + +pub fn audit_program(crate_root: &Path) -> Vec { + let files = module_resolver::resolve_crate(crate_root); + let parsed = parser::parse_program_from_files(crate_root, &files); + + let sources: HashMap = files + .iter() + .map(|rf| (rf.path.display().to_string(), rf.source.as_str())) + .collect(); + + let mut accounts_structs = Vec::new(); + for file in &files { + let path = file.path.display().to_string(); + accounts_structs.extend(parsers::extract_audit_accounts(&file.file, &path)); + } + + let mut findings = Vec::new(); + + checks::check_discriminator_collisions(&parsed, &mut findings); + let program_has_drain_path = files + .iter() + .any(|rf| helpers::file_contains_drain_pattern(&rf.file)); + + for ix in &parsed.instructions { + let accounts = accounts_structs + .iter() + .find(|a| a.name == ix.accounts_type_name); + + if let Some(accounts) = accounts { + let src = sources + .get(&accounts.file_path) + .copied() + .unwrap_or_default(); + checks::check_missing_signer(ix, accounts, src, &mut findings); + checks::check_untyped_accounts(ix, accounts, src, &mut findings); + checks::check_data_matching(ix, accounts, src, &mut findings); + checks::check_duplicate_mutable(ix, accounts, src, &mut findings); + checks::check_pda_specificity(ix, accounts, src, &mut findings); + checks::check_arbitrary_cpi(ix, accounts, src, &mut findings); + checks::check_init_if_needed(ix, accounts, program_has_drain_path, src, &mut findings); + checks::check_revival_attack(ix, accounts, &files, src, &mut findings); + } + } + + findings +} + +pub struct Finding { + pub severity: Severity, + pub rule: Rule, + pub location: String, + pub source_file: String, + pub source_line: usize, + pub snippet: String, + pub message: String, +} + +pub(crate) fn extract_snippet(source: &str, line: usize) -> String { + if line == 0 { + return String::new(); + } + let lines: Vec<&str> = source.lines().collect(); + let start = line.saturating_sub(2); + let end = (line + 1).min(lines.len()); + lines[start..end] + .iter() + .enumerate() + .map(|(i, l)| { + let num = start + i + 1; + let marker = if num == line { ">" } else { " " }; + format!("{marker} {num:>4} | {l}") + }) + .collect::>() + .join("\n") +} + +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Severity { + Critical, + Warning, + Info, +} + +impl Display for Severity { + fn fmt(&self, f: &mut Formatter<'_>) -> Result { + match self { + Severity::Critical => write!(f, "CRITICAL"), + Severity::Warning => write!(f, "WARNING"), + Severity::Info => write!(f, "INFO"), + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Rule { + DiscriminatorCollision, + MissingSigner, + UntypedAccount, + DuplicateMutable, + PdaSharing, + ArbitraryCpi, + InitIfNeeded, + DataMatching, + RevivalAttack, +} + +impl Rule { + pub fn learn_url(self) -> &'static str { + match self { + Rule::DiscriminatorCollision => concat!( + "https://learn.blueshift.gg/en/courses/program-security", + "/type-cosplay" + ), + Rule::MissingSigner => concat!( + "https://learn.blueshift.gg/en/courses/program-security", + "/signer-checks" + ), + Rule::UntypedAccount => concat!( + "https://learn.blueshift.gg/en/courses/program-security", + "/owner-checks" + ), + Rule::DuplicateMutable => concat!( + "https://learn.blueshift.gg/en/courses/program-security", + "/duplicate-mutable-accounts" + ), + Rule::PdaSharing => concat!( + "https://learn.blueshift.gg/en/courses/program-security", + "/pda-sharing" + ), + Rule::ArbitraryCpi => concat!( + "https://learn.blueshift.gg/en/courses/program-security", + "/arbitrary-cpi" + ), + Rule::InitIfNeeded => concat!( + "https://learn.blueshift.gg/en/courses/program-security", + "/reinitialization-attacks" + ), + Rule::DataMatching => concat!( + "https://learn.blueshift.gg/en/courses/program-security", + "/data-matching" + ), + Rule::RevivalAttack => concat!( + "https://learn.blueshift.gg/en/courses/program-security", + "/revival-attacks" + ), + } + } +} + +impl Display for Rule { + fn fmt(&self, f: &mut Formatter<'_>) -> Result { + match self { + Rule::DiscriminatorCollision => write!(f, "discriminator-collision"), + Rule::MissingSigner => write!(f, "missing-signer"), + Rule::UntypedAccount => write!(f, "untyped-account"), + Rule::DuplicateMutable => write!(f, "duplicate-mutable"), + Rule::PdaSharing => write!(f, "pda-sharing"), + Rule::ArbitraryCpi => write!(f, "arbitrary-cpi"), + Rule::InitIfNeeded => write!(f, "init-if-needed"), + Rule::DataMatching => write!(f, "data-matching"), + Rule::RevivalAttack => write!(f, "revival-attack"), + } + } +} diff --git a/audit/src/parsers.rs b/audit/src/parsers.rs new file mode 100644 index 0000000..e684c24 --- /dev/null +++ b/audit/src/parsers.rs @@ -0,0 +1,272 @@ +use { + quasar_idl::parser::helpers, + syn::{spanned::Spanned, Fields, Item}, +}; + +pub(crate) struct AuditAccountsStruct { + pub name: String, + pub file_path: String, + pub fields: Vec, +} + +pub(crate) struct AuditField { + pub name: String, + pub line: usize, + pub type_name: String, + pub type_inner: String, + pub writable: bool, + pub signer: bool, + #[allow(dead_code)] + pub has_init: bool, + pub has_init_if_needed: bool, + pub has_close: bool, + pub has_one: Vec, + pub has_constraint: bool, + pub has_address: bool, + pub has_owner: bool, + pub has_token_constraint: bool, + pub token_authority_ref: String, + pub pda_seed_count: usize, + pub pda_has_account_ref: bool, + pub pda_seed_refs: Vec, +} + +pub fn extract_audit_accounts(file: &syn::File, file_path: &str) -> Vec { + let mut result = Vec::new(); + for item in &file.items { + if let Item::Struct(item_struct) = item { + if !has_derive_accounts(&item_struct.attrs) { + continue; + } + + let name = item_struct.ident.to_string(); + let sibling_names: Vec = match &item_struct.fields { + Fields::Named(named) => named + .named + .iter() + .filter_map(|f| f.ident.as_ref().map(|i| i.to_string())) + .collect(), + _ => vec![], + }; + + let fields = match &item_struct.fields { + Fields::Named(named) => named + .named + .iter() + .map(|f| parse_audit_field(f, &sibling_names)) + .collect(), + _ => continue, + }; + + result.push(AuditAccountsStruct { + name, + file_path: file_path.to_string(), + fields, + }); + } + } + result +} + +fn has_derive_accounts(attrs: &[syn::Attribute]) -> bool { + attrs.iter().any(|attr| { + attr.path().is_ident("derive") + && attr + .meta + .require_list() + .ok() + .is_some_and(|l| l.tokens.to_string().contains("Accounts")) + }) +} + +fn parse_audit_field(field: &syn::Field, sibling_names: &[String]) -> AuditField { + let line = field.span().start().line; + let name = field + .ident + .as_ref() + .expect("named field must have ident") + .to_string(); + let type_name = helpers::type_base_name(&field.ty).unwrap_or_default(); + let type_inner = helpers::type_inner_name(&field.ty).unwrap_or_default(); + let signer = helpers::is_signer_type(&field.ty); + + let mut writable = helpers::is_mut_ref(&field.ty); + let mut has_init = false; + let mut has_init_if_needed = false; + let mut has_close = false; + let mut has_one = Vec::new(); + let mut has_constraint = false; + let mut has_address = false; + let mut has_owner = false; + let mut has_token_constraint = false; + let mut token_authority_ref = String::new(); + let mut pda_seed_count = 0; + let mut pda_has_account_ref = false; + let mut pda_seed_refs = Vec::new(); + + for attr in &field.attrs { + if !attr.path().is_ident("account") { + continue; + } + let tokens_str = match attr.meta.require_list() { + Ok(list) => list.tokens.to_string(), + Err(_) => continue, + }; + + for d in split_directives(&tokens_str) { + if d == "mut" { + writable = true; + } else if d == "init" { + has_init = true; + writable = true; + } else if d == "init_if_needed" { + has_init_if_needed = true; + writable = true; + } else if d.starts_with("close") { + has_close = true; + writable = true; + } else if d.starts_with("has_one") { + if let Some(val) = d + .strip_prefix("has_one") + .and_then(|s| s.trim().strip_prefix('=')) + { + has_one.push(val.trim().to_string()); + } + } else if d.starts_with("constraint") { + has_constraint = true; + } else if d.starts_with("address") { + has_address = true; + } else if d.starts_with("owner") { + has_owner = true; + } else if d.starts_with("seeds") { + let (count, has_ref, refs) = count_seeds(d, sibling_names); + pda_seed_count = count; + pda_has_account_ref = has_ref; + pda_seed_refs = refs; + } else if d.starts_with("token :: mint") || d.starts_with("token::mint") { + has_token_constraint = true; + } else if d.starts_with("token :: authority") || d.starts_with("token::authority") { + has_token_constraint = true; + if let Some(val) = d.split('=').nth(1) { + let val = val.trim(); + if sibling_names.iter().any(|n| n == val) { + token_authority_ref = val.to_string(); + } + } + } + } + } + + if type_name == "SystemProgram" || type_name == "Sysvar" { + has_address = true; + } + + AuditField { + name, + line, + type_name, + type_inner, + writable, + signer, + has_init, + has_init_if_needed, + has_close, + has_one, + has_constraint, + has_address, + has_owner, + has_token_constraint, + token_authority_ref, + pda_seed_count, + pda_has_account_ref, + pda_seed_refs, + } +} + +fn split_directives(s: &str) -> Vec<&str> { + let mut parts = Vec::new(); + let mut start = 0; + let mut depth = 0u32; + let mut in_string = false; + + for (i, c) in s.char_indices() { + match c { + '"' => in_string = !in_string, + '[' | '(' if !in_string => depth += 1, + ']' | ')' if !in_string => depth = depth.saturating_sub(1), + ',' if depth == 0 && !in_string => { + let trimmed = s[start..i].trim(); + if !trimmed.is_empty() { + parts.push(trimmed); + } + start = i + 1; + } + _ => {} + } + } + + let trimmed = s[start..].trim(); + if !trimmed.is_empty() { + parts.push(trimmed); + } + + parts +} + +fn count_seeds(seeds_directive: &str, sibling_names: &[String]) -> (usize, bool, Vec) { + let eq_pos = match seeds_directive.find('=') { + Some(idx) => idx, + None => return (0, false, vec![]), + }; + let after_eq = seeds_directive[eq_pos + 1..].trim(); + + let start = match after_eq.find('[') { + Some(idx) => idx, + None => return (0, false, vec![]), + }; + let mut depth = 0; + let mut end = None; + for (i, c) in after_eq[start..].chars().enumerate() { + match c { + '[' => depth += 1, + ']' => { + depth -= 1; + if depth == 0 { + end = Some(start + i); + break; + } + } + _ => {} + } + } + let end = match end { + Some(idx) => idx, + None => return (0, false, vec![]), + }; + + let inner = &after_eq[start + 1..end]; + let seeds: Vec<&str> = inner + .split(',') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .collect(); + + let count = seeds.len(); + let refs: Vec = seeds + .iter() + .filter_map(|s| { + let s = s.trim(); + if !s.starts_with("b\"") + && s.chars().all(|c| c.is_alphanumeric() || c == '_') + && sibling_names.iter().any(|n| n == s) + { + Some(s.to_string()) + } else { + None + } + }) + .collect(); + let has_ref = !refs.is_empty(); + + (count, has_ref, refs) +} diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 079a84e..2c47006 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -13,6 +13,7 @@ path = "src/main.rs" [dependencies] clap = { version = "4.0", features = ["derive"] } dialoguer = "0.11" +quasar-audit = { path = "../audit" } quasar-idl = { path = "../idl" } quasar-profile = { path = "../profile" } serde = { version = "1", features = ["derive"] } diff --git a/cli/src/audit.rs b/cli/src/audit.rs new file mode 100644 index 0000000..dc1b599 --- /dev/null +++ b/cli/src/audit.rs @@ -0,0 +1,97 @@ +use crate::{ + error::{CliError, CliResult}, + style, AuditCommand, +}; + +pub fn run(cmd: AuditCommand) -> CliResult { + let crate_path = &cmd.crate_path; + + if !crate_path.exists() { + return Err(CliError::PathDoesNotExist(crate_path.display().to_string())); + } + + let mut findings = quasar_audit::audit_program(crate_path); + + if findings.is_empty() { + println!(); + println!(" {}", style::success("No issues found")); + println!(); + return Ok(()); + } + + findings.sort_by_key(|f| f.severity); + + println!(); + println!( + " {} {}", + style::bold("Audit results:"), + style::dim(&format!("{} finding(s)", findings.len())), + ); + println!(); + + let mut criticals = 0; + let mut warnings = 0; + let mut infos = 0; + + for finding in &findings { + match finding.severity { + quasar_audit::Severity::Critical => criticals += 1, + quasar_audit::Severity::Warning => warnings += 1, + quasar_audit::Severity::Info => infos += 1, + } + + let severity_str = match finding.severity { + quasar_audit::Severity::Critical => style::fail("CRITICAL"), + quasar_audit::Severity::Warning => style::warn("WARNING"), + quasar_audit::Severity::Info => style::dim("INFO"), + }; + + println!( + " {} {} {}", + severity_str, + style::bold(&format!("[{}]", finding.rule)), + style::dim(&finding.location), + ); + + if finding.source_line > 0 { + println!( + " {} {}:{}", + style::dim("at"), + style::dim(&finding.source_file), + style::dim(&finding.source_line.to_string()), + ); + } + + println!(" {}", finding.message); + + if !finding.snippet.is_empty() { + println!(); + for line in finding.snippet.lines() { + println!(" {}", style::dim(line)); + } + println!(); + } + + println!( + " {} {}", + style::dim("Learn more:"), + style::dim(finding.rule.learn_url()), + ); + println!(); + } + + let mut parts = Vec::new(); + if criticals > 0 { + parts.push(style::fail(&format!("{} critical", criticals))); + } + if warnings > 0 { + parts.push(style::warn(&format!("{} warning(s)", warnings))); + } + if infos > 0 { + parts.push(style::dim(&format!("{} info", infos))); + } + println!(" {}", parts.join(" ")); + println!(); + + Ok(()) +} diff --git a/cli/src/error.rs b/cli/src/error.rs index 295cd0a..e53a84c 100644 --- a/cli/src/error.rs +++ b/cli/src/error.rs @@ -4,8 +4,8 @@ pub type CliResult = Result<(), CliError>; #[derive(Debug, Error)] pub enum CliError { - #[error("Error: path does not exist")] - PathDoesNotExist, + #[error("Error: path does not exist: {0}")] + PathDoesNotExist(String), #[error("Io error")] IoError(#[from] std::io::Error), #[error("Toml parse error")] diff --git a/cli/src/lib.rs b/cli/src/lib.rs index 8fae2cb..8a53dcc 100644 --- a/cli/src/lib.rs +++ b/cli/src/lib.rs @@ -3,6 +3,7 @@ use { std::path::PathBuf, }; +pub mod audit; pub mod build; pub mod cfg; pub mod clean; @@ -32,6 +33,8 @@ pub struct Cli { #[derive(Subcommand, Debug)] pub enum Command { + /// Run security audit on a program crate + Audit(AuditCommand), /// Scaffold a new Quasar project Init(InitCommand), /// Generate boilerplate (instructions, state, etc.) @@ -60,6 +63,13 @@ pub enum Command { // Command args // --------------------------------------------------------------------------- +#[derive(Args, Debug)] +pub struct AuditCommand { + /// Path to the program crate directory + #[arg(value_name = "PATH")] + pub crate_path: PathBuf, +} + #[derive(Args, Debug, Default)] pub struct InitCommand { /// Project name — skips the interactive name prompt @@ -239,6 +249,7 @@ pub struct CompletionsCommand { pub fn run(cli: Cli) -> CliResult { match cli.command { + Command::Audit(cmd) => audit::run(cmd), Command::Init(cmd) => init::run( cmd.name, cmd.yes, @@ -315,6 +326,7 @@ pub fn print_help() { ); println!(); println!(" {}", style::bold("Commands:")); + print_cmd("audit ", "Run security audit on a program"); print_cmd("init [name] [-y] [--no-git]", "Scaffold a new project"); print_cmd("new instruction ", "Generate a new instruction"); print_cmd( diff --git a/idl/src/parser/mod.rs b/idl/src/parser/mod.rs index 8df3ba4..4e3bc45 100644 --- a/idl/src/parser/mod.rs +++ b/idl/src/parser/mod.rs @@ -34,10 +34,19 @@ pub struct ParsedProgram { /// Parse an entire quasar program crate and produce a `ParsedProgram`. pub fn parse_program(crate_root: &Path) -> ParsedProgram { - // 1. Resolve all source files let files = module_resolver::resolve_crate(crate_root); + parse_program_from_files(crate_root, &files) +} - // 2. Find lib.rs (first resolved file that has declare_id! or #[program]) +/// Parse pre-resolved source files into a `ParsedProgram`. +/// +/// This is the lower-level entry point used when the caller already has the +/// resolved file list (e.g. the audit crate, which also inspects the raw ASTs). +pub fn parse_program_from_files( + crate_root: &Path, + files: &[module_resolver::ResolvedFile], +) -> ParsedProgram { + // 1. Find lib.rs (first resolved file that has declare_id! or #[program]) let lib_file = files .iter() .find(|f| f.path.ends_with("lib.rs")) @@ -53,25 +62,25 @@ pub fn parse_program(crate_root: &Path) -> ParsedProgram { // 5. Collect all #[derive(Accounts)] structs across all files let mut accounts_structs = Vec::new(); - for file in &files { + for file in files { accounts_structs.extend(accounts::extract_accounts_structs(&file.file)); } // 6. Collect all #[account(discriminator = N)] state structs let mut state_accounts = Vec::new(); - for file in &files { + for file in files { state_accounts.extend(state::extract_state_accounts(&file.file)); } // 7. Collect all #[event(discriminator = N)] structs let mut all_events = Vec::new(); - for file in &files { + for file in files { all_events.extend(events::extract_events(&file.file)); } // 8. Collect all #[error_code] enums let mut all_errors = Vec::new(); - for file in &files { + for file in files { all_errors.extend(errors::extract_errors(&file.file)); } diff --git a/idl/src/parser/module_resolver.rs b/idl/src/parser/module_resolver.rs index ae8c5c6..b9ad725 100644 --- a/idl/src/parser/module_resolver.rs +++ b/idl/src/parser/module_resolver.rs @@ -5,6 +5,7 @@ use std::path::{Path, PathBuf}; /// A resolved source file with its parsed AST. pub struct ResolvedFile { pub path: PathBuf, + pub source: String, pub file: syn::File, } @@ -84,6 +85,7 @@ fn resolve_file(path: &Path, files: &mut Vec) { files.push(ResolvedFile { path: path.to_path_buf(), + source, file, }); }