From fdf34a290bf0c2ebd0b32855cd68a5437cfcae5a Mon Sep 17 00:00:00 2001 From: Dirk Brink Date: Thu, 6 Aug 2026 14:34:45 -0600 Subject: [PATCH] Add initial set of CodeQL rules and enable in CI --- .github/CODEOWNERS | 1 + .github/workflows/codeql.yml | 105 ++ codeql/.gitignore | 4 + codeql/Makefile | 73 ++ codeql/README.md | 107 ++ codeql/codeql-pack.lock.yml | 24 + ...algorand-publication-field-length-check.md | 55 + .../rules/already-locked-receiver-mutex.md | 114 +++ .../docs/rules/canonical-chain-id-parsing.md | 62 ++ .../rules/canonical-vaa-address-parsing.md | 60 ++ codeql/docs/rules/canonical-vaa-id-parsing.md | 70 ++ .../evm-finality-release-and-reorg-checks.md | 93 ++ ...e-successful-receipt-before-observation.md | 60 ++ .../docs/rules/evm-verify-and-publish-gate.md | 55 + ...message-publication-canonical-timestamp.md | 61 ++ .../message-publication-safe-serialization.md | 64 ++ ...ized-receipt-outcome-before-publication.md | 88 ++ .../rules/run-with-scissors-error-return.md | 56 + ...ana-commitment-match-before-publication.md | 113 ++ .../solana-message-account-validation.md | 55 + ...ana-require-successful-transaction-meta.md | 53 + .../rules/xrpl-derived-generated-emitter.md | 60 ++ codeql/docs/rules/xrpl-first-memo-only.md | 57 ++ .../xrpl-require-validated-transaction.md | 52 + codeql/qlpack.yml | 34 + ...algorand-publication-field-length-check.ql | 376 +++++++ codeql/src/already-locked-receiver-mutex.ql | 294 ++++++ codeql/src/canonical-chain-id-parsing.ql | 276 +++++ codeql/src/canonical-vaa-address-parsing.ql | 429 ++++++++ codeql/src/canonical-vaa-id-parsing.ql | 116 +++ .../delegate-consensus-canonical-digest.ql | 186 ++++ .../delegated-guardian-config-validation.ql | 78 ++ .../evm-ccl-signed-message-immutability.ql | 223 ++++ .../evm-finality-release-and-reorg-checks.ql | 433 ++++++++ ...e-successful-receipt-before-observation.ql | 403 ++++++++ codeql/src/evm-verify-and-publish-gate.ql | 137 +++ codeql/src/governance-vaa-typed-payload.ql | 148 +++ .../guardian-signer-exact-digest-length.ql | 124 +++ ...message-publication-canonical-timestamp.ql | 237 +++++ .../message-publication-safe-serialization.ql | 140 +++ ...ized-receipt-outcome-before-publication.ql | 215 ++++ codeql/src/run-with-scissors-error-return.ql | 201 ++++ codeql/src/solana-alt-owner-before-decode.ql | 160 +++ ...ana-commitment-match-before-publication.ql | 355 +++++++ .../src/solana-message-account-validation.ql | 222 ++++ ...ana-require-successful-transaction-meta.ql | 278 +++++ .../untrusted-vaa-use-before-verification.ql | 134 +++ codeql/src/xrpl-derived-generated-emitter.ql | 342 +++++++ codeql/src/xrpl-first-memo-only.ql | 179 ++++ .../src/xrpl-require-validated-transaction.ql | 186 ++++ codeql/suites/wormhole-go.qls | 25 + ...nd-publication-field-length-check.expected | 17 + ...orand-publication-field-length-check.qlref | 1 + .../go.mod | 3 + .../node/pkg/watchers/algorand/generated.go | 12 + .../node/pkg/watchers/algorand/negative.go | 79 ++ .../node/pkg/watchers/algorand/positive.go | 170 +++ .../node/pkg/watchers/algorand/stubs.go | 34 + .../pkg/watchers/algorand/watcher_test.go | 10 + .../node/pkg/watchers/evm/near_miss.go | 15 + .../already-locked-receiver-mutex.expected | 30 + .../already-locked-receiver-mutex.qlref | 1 + .../test/already-locked-receiver-mutex/go.mod | 3 + .../node/cmd/ccq/negative.go | 13 + .../node/cmd/ccq/pending_request_stubs.go | 10 + .../node/cmd/ccq/positive.go | 11 + .../node/pkg/accountant/accountant_stubs.go | 38 + .../node/pkg/accountant/negative.go | 55 + .../node/pkg/accountant/positive.go | 126 +++ .../node/pkg/accountant/positive_test.go | 5 + .../node/pkg/governor/governor_stubs.go | 11 + .../node/pkg/governor/negative.go | 20 + .../node/pkg/governor/positive.go | 23 + .../canonical-chain-id-parsing.expected | 9 + .../canonical-chain-id-parsing.qlref | 1 + codeql/test/canonical-chain-id-parsing/go.mod | 3 + .../node/pkg/adminrpc/negative.go | 94 ++ .../node/pkg/adminrpc/positive.go | 62 ++ .../node/pkg/proto/adminrpc/requests.pb.go | 32 + .../node/pkg/txverifier/utils.go | 20 + .../node/pkg/watchers/ibc/watcher.go | 105 ++ .../sdk/vaa/structs.go | 29 + .../canonical-vaa-address-parsing.expected | 12 + .../canonical-vaa-address-parsing.qlref | 1 + .../test/canonical-vaa-address-parsing/go.mod | 3 + .../node/pkg/addresscases/negative.go | 72 ++ .../node/pkg/addresscases/positive.go | 59 ++ .../node/pkg/common/common.go | 7 + .../node/pkg/db/db.go | 15 + .../node/pkg/notary/admincommands.go | 13 + .../node/pkg/publicrpc/publicrpcserver.go | 51 + .../node/pkg/watchers/cosmwasm/watcher.go | 52 + .../node/pkg/watchers/evm/utils.go | 11 + .../node/pkg/watchers/sui/watcher.go | 15 + .../node/pkg/watchers/xrpl/watcher.go | 9 + .../sdk/vaa/vaa.go | 22 + .../canonical-vaa-id-parsing.expected | 6 + .../canonical-vaa-id-parsing.qlref | 1 + codeql/test/canonical-vaa-id-parsing/go.mod | 3 + .../pkg/adminrpc/canonical_parser_alias.go | 17 + .../node/pkg/adminrpc/generated.pb.go | 12 + .../node/pkg/adminrpc/manual_parse_test.go | 12 + .../node/pkg/adminrpc/negative.go | 66 ++ .../node/pkg/adminrpc/positive.go | 89 ++ .../node/pkg/db/db.go | 17 + .../node/pkg/vaa/vaa.go | 19 + ...legate-consensus-canonical-digest.expected | 16 + .../delegate-consensus-canonical-digest.qlref | 1 + .../go.mod | 3 + .../node/pkg/common/chainlock.go | 36 + .../node/pkg/crypto/crypto.go | 7 + .../node/pkg/notary/notary.go | 11 + .../node/pkg/processor/observation.go | 221 ++++ .../node/pkg/processor/observation_test.go | 12 + .../node/pkg/proto/proto.go | 3 + .../ethereum/errors.go | 5 + ...finality-release-and-reorg-checks.expected | 67 ++ ...vm-finality-release-and-reorg-checks.qlref | 1 + .../gethtypes/types.go | 13 + .../go.mod | 3 + .../node/pkg/common/message.go | 6 + .../node/pkg/watchers/evm/negative.go | 140 +++ .../node/pkg/watchers/evm/positive.go | 965 ++++++++++++++++++ .../node/pkg/watchers/evm/stubs.go | 58 ++ .../rpc/errors.go | 5 + ...essful-receipt-before-observation.expected | 23 + ...uccessful-receipt-before-observation.qlref | 1 + .../gethtypes/types.go | 16 + .../go.mod | 3 + .../node/pkg/common/message.go | 5 + .../node/pkg/watchers/evm/evm_test.go | 13 + .../node/pkg/watchers/evm/negative.go | 163 +++ .../node/pkg/watchers/evm/positive.go | 307 ++++++ .../node/pkg/watchers/evm/stubs.go | 57 ++ .../evm/unsafe/positive_unsafe_helper.go | 56 + .../node/pkg/watchers/solana/non_evm.go | 18 + .../evm-verify-and-publish-gate.expected | 5 + .../evm-verify-and-publish-gate.qlref | 1 + .../test/evm-verify-and-publish-gate/go.mod | 3 + .../node/pkg/common/message_publication.go | 5 + .../node/pkg/watchers/evm/evm_test.go | 10 + .../node/pkg/watchers/evm/negative.go | 74 ++ .../node/pkg/watchers/evm/positive.go | 35 + .../node/pkg/watchers/evm/stubs.go | 28 + .../node/pkg/watchers/solana/non_evm.go | 11 + .../go.mod | 3 + ...e-publication-canonical-timestamp.expected | 7 + ...sage-publication-canonical-timestamp.qlref | 1 + .../node/pkg/common/message.go | 7 + .../pkg/watchers/evm/fakecommon/message.go | 7 + .../node/pkg/watchers/evm/negative.go | 61 ++ .../node/pkg/watchers/evm/positive.go | 65 ++ .../node/pkg/watchers/evm/positive_test.go | 13 + .../watchers/evm/unsupported_boundaries.go | 20 + .../sdk/vaa/structs.go | 14 + .../go.mod | 3 + ...ge-publication-safe-serialization.expected | 12 + ...ssage-publication-safe-serialization.qlref | 1 + .../node/pkg/common/chainlock.go | 32 + .../node/pkg/common/chainlock_test.go | 7 + .../node/pkg/db/governor.go | 59 ++ .../node/pkg/notary/notary.go | 61 ++ .../tools/non_node.go | 8 + .../go.mod | 3 + ...eceipt-outcome-before-publication.expected | 15 + ...d-receipt-outcome-before-publication.qlref | 1 + .../node/pkg/watchers/near/near_miss.go | 27 + .../node/pkg/watchers/near/negative.go | 53 + .../node/pkg/watchers/near/positive.go | 181 ++++ .../node/pkg/watchers/near/stubs.go | 37 + .../go.mod | 3 + .../node/pkg/common/scissors.go | 14 + .../watchers/scissors/certusone_positive.go | 16 + ...ors-error-return-certusone-module.expected | 1 + ...issors-error-return-certusone-module.qlref | 1 + .../run-with-scissors-error-return/go.mod | 3 + .../node/pkg/common/scissors.go | 23 + .../node/pkg/watchers/scissors/negative.go | 98 ++ .../node/pkg/watchers/scissors/positive.go | 70 ++ .../node/pkg/watchers/scissors/unsupported.go | 40 + .../run-with-scissors-error-return.expected | 6 + .../run-with-scissors-error-return.qlref | 1 + .../go.mod | 3 + .../node/pkg/watchers/solana/near_miss.go | 25 + .../node/pkg/watchers/solana/negative.go | 115 +++ .../node/pkg/watchers/solana/positive.go | 237 +++++ .../node/pkg/watchers/solana/stubs.go | 89 ++ ...mmitment-match-before-publication.expected | 20 + ...-commitment-match-before-publication.qlref | 1 + .../solana-message-account-validation/go.mod | 3 + .../node/pkg/watchers/solana/generated.pb.go | 6 + .../node/pkg/watchers/solana/near_miss.go | 50 + .../node/pkg/watchers/solana/negative.go | 67 ++ .../node/pkg/watchers/solana/positive.go | 84 ++ .../node/pkg/watchers/solana/solana_test.go | 5 + ...solana-message-account-validation.expected | 9 + .../solana-message-account-validation.qlref | 1 + .../go.mod | 3 + .../node/pkg/watchers/solana/negative.go | 27 + .../node/pkg/watchers/solana/positive.go | 79 ++ .../pkg/watchers/solana/solana_meta_test.go | 8 + .../node/pkg/watchers/solana/stubs.go | 33 + ...quire-successful-transaction-meta.expected | 12 + ...-require-successful-transaction-meta.qlref | 1 + .../ethcrypto/crypto.go | 7 + .../xrpl-derived-generated-emitter/go.mod | 3 + .../pkg/watchers/xrpl/badlayout/positive.go | 43 + .../node/pkg/watchers/xrpl/boundary.go | 21 + .../node/pkg/watchers/xrpl/generated.go | 14 + .../node/pkg/watchers/xrpl/near_miss.go | 30 + .../node/pkg/watchers/xrpl/negative.go | 32 + .../pkg/watchers/xrpl/nttignored/positive.go | 48 + .../pkg/watchers/xrpl/nttwrong/near_miss.go | 51 + .../pkg/watchers/xrpl/overwrite/positive.go | 49 + .../node/pkg/watchers/xrpl/positive.go | 83 ++ .../watchers/xrpl/production_regression.go | 37 + .../node/pkg/watchers/xrpl/regression_test.go | 12 + .../node/pkg/watchers/xrpl/stubs.go | 59 ++ .../xrpl-derived-generated-emitter.expected | 12 + .../xrpl-derived-generated-emitter.qlref | 1 + codeql/test/xrpl-first-memo-only/go.mod | 3 + .../node/pkg/watchers/xrpl/near_miss.go | 107 ++ .../node/pkg/watchers/xrpl/negative.go | 34 + .../node/pkg/watchers/xrpl/positive.go | 104 ++ .../xrpl-first-memo-only.expected | 10 + .../xrpl-first-memo-only.qlref | 1 + .../xrpl-require-validated-transaction/go.mod | 3 + .../node/pkg/watchers/xrpl/near_miss.go | 31 + .../node/pkg/watchers/xrpl/negative.go | 50 + .../node/pkg/watchers/xrpl/parser_stubs.go | 33 + .../node/pkg/watchers/xrpl/positive.go | 112 ++ ...rpl-require-validated-transaction.expected | 18 + .../xrpl-require-validated-transaction.qlref | 1 + cspell-custom-words.txt | 23 +- 234 files changed, 14389 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/codeql.yml create mode 100644 codeql/.gitignore create mode 100644 codeql/Makefile create mode 100644 codeql/README.md create mode 100644 codeql/codeql-pack.lock.yml create mode 100644 codeql/docs/rules/algorand-publication-field-length-check.md create mode 100644 codeql/docs/rules/already-locked-receiver-mutex.md create mode 100644 codeql/docs/rules/canonical-chain-id-parsing.md create mode 100644 codeql/docs/rules/canonical-vaa-address-parsing.md create mode 100644 codeql/docs/rules/canonical-vaa-id-parsing.md create mode 100644 codeql/docs/rules/evm-finality-release-and-reorg-checks.md create mode 100644 codeql/docs/rules/evm-require-successful-receipt-before-observation.md create mode 100644 codeql/docs/rules/evm-verify-and-publish-gate.md create mode 100644 codeql/docs/rules/message-publication-canonical-timestamp.md create mode 100644 codeql/docs/rules/message-publication-safe-serialization.md create mode 100644 codeql/docs/rules/near-finalized-receipt-outcome-before-publication.md create mode 100644 codeql/docs/rules/run-with-scissors-error-return.md create mode 100644 codeql/docs/rules/solana-commitment-match-before-publication.md create mode 100644 codeql/docs/rules/solana-message-account-validation.md create mode 100644 codeql/docs/rules/solana-require-successful-transaction-meta.md create mode 100644 codeql/docs/rules/xrpl-derived-generated-emitter.md create mode 100644 codeql/docs/rules/xrpl-first-memo-only.md create mode 100644 codeql/docs/rules/xrpl-require-validated-transaction.md create mode 100644 codeql/qlpack.yml create mode 100644 codeql/src/algorand-publication-field-length-check.ql create mode 100644 codeql/src/already-locked-receiver-mutex.ql create mode 100644 codeql/src/canonical-chain-id-parsing.ql create mode 100644 codeql/src/canonical-vaa-address-parsing.ql create mode 100644 codeql/src/canonical-vaa-id-parsing.ql create mode 100644 codeql/src/delegate-consensus-canonical-digest.ql create mode 100644 codeql/src/delegated-guardian-config-validation.ql create mode 100644 codeql/src/evm-ccl-signed-message-immutability.ql create mode 100644 codeql/src/evm-finality-release-and-reorg-checks.ql create mode 100644 codeql/src/evm-require-successful-receipt-before-observation.ql create mode 100644 codeql/src/evm-verify-and-publish-gate.ql create mode 100644 codeql/src/governance-vaa-typed-payload.ql create mode 100644 codeql/src/guardian-signer-exact-digest-length.ql create mode 100644 codeql/src/message-publication-canonical-timestamp.ql create mode 100644 codeql/src/message-publication-safe-serialization.ql create mode 100644 codeql/src/near-finalized-receipt-outcome-before-publication.ql create mode 100644 codeql/src/run-with-scissors-error-return.ql create mode 100644 codeql/src/solana-alt-owner-before-decode.ql create mode 100644 codeql/src/solana-commitment-match-before-publication.ql create mode 100644 codeql/src/solana-message-account-validation.ql create mode 100644 codeql/src/solana-require-successful-transaction-meta.ql create mode 100644 codeql/src/untrusted-vaa-use-before-verification.ql create mode 100644 codeql/src/xrpl-derived-generated-emitter.ql create mode 100644 codeql/src/xrpl-first-memo-only.ql create mode 100644 codeql/src/xrpl-require-validated-transaction.ql create mode 100644 codeql/suites/wormhole-go.qls create mode 100644 codeql/test/algorand-publication-field-length-check/algorand-publication-field-length-check.expected create mode 100644 codeql/test/algorand-publication-field-length-check/algorand-publication-field-length-check.qlref create mode 100644 codeql/test/algorand-publication-field-length-check/go.mod create mode 100644 codeql/test/algorand-publication-field-length-check/node/pkg/watchers/algorand/generated.go create mode 100644 codeql/test/algorand-publication-field-length-check/node/pkg/watchers/algorand/negative.go create mode 100644 codeql/test/algorand-publication-field-length-check/node/pkg/watchers/algorand/positive.go create mode 100644 codeql/test/algorand-publication-field-length-check/node/pkg/watchers/algorand/stubs.go create mode 100644 codeql/test/algorand-publication-field-length-check/node/pkg/watchers/algorand/watcher_test.go create mode 100644 codeql/test/algorand-publication-field-length-check/node/pkg/watchers/evm/near_miss.go create mode 100644 codeql/test/already-locked-receiver-mutex/already-locked-receiver-mutex.expected create mode 100644 codeql/test/already-locked-receiver-mutex/already-locked-receiver-mutex.qlref create mode 100644 codeql/test/already-locked-receiver-mutex/go.mod create mode 100644 codeql/test/already-locked-receiver-mutex/node/cmd/ccq/negative.go create mode 100644 codeql/test/already-locked-receiver-mutex/node/cmd/ccq/pending_request_stubs.go create mode 100644 codeql/test/already-locked-receiver-mutex/node/cmd/ccq/positive.go create mode 100644 codeql/test/already-locked-receiver-mutex/node/pkg/accountant/accountant_stubs.go create mode 100644 codeql/test/already-locked-receiver-mutex/node/pkg/accountant/negative.go create mode 100644 codeql/test/already-locked-receiver-mutex/node/pkg/accountant/positive.go create mode 100644 codeql/test/already-locked-receiver-mutex/node/pkg/accountant/positive_test.go create mode 100644 codeql/test/already-locked-receiver-mutex/node/pkg/governor/governor_stubs.go create mode 100644 codeql/test/already-locked-receiver-mutex/node/pkg/governor/negative.go create mode 100644 codeql/test/already-locked-receiver-mutex/node/pkg/governor/positive.go create mode 100644 codeql/test/canonical-chain-id-parsing/canonical-chain-id-parsing.expected create mode 100644 codeql/test/canonical-chain-id-parsing/canonical-chain-id-parsing.qlref create mode 100644 codeql/test/canonical-chain-id-parsing/go.mod create mode 100644 codeql/test/canonical-chain-id-parsing/node/pkg/adminrpc/negative.go create mode 100644 codeql/test/canonical-chain-id-parsing/node/pkg/adminrpc/positive.go create mode 100644 codeql/test/canonical-chain-id-parsing/node/pkg/proto/adminrpc/requests.pb.go create mode 100644 codeql/test/canonical-chain-id-parsing/node/pkg/txverifier/utils.go create mode 100644 codeql/test/canonical-chain-id-parsing/node/pkg/watchers/ibc/watcher.go create mode 100644 codeql/test/canonical-chain-id-parsing/sdk/vaa/structs.go create mode 100644 codeql/test/canonical-vaa-address-parsing/canonical-vaa-address-parsing.expected create mode 100644 codeql/test/canonical-vaa-address-parsing/canonical-vaa-address-parsing.qlref create mode 100644 codeql/test/canonical-vaa-address-parsing/go.mod create mode 100644 codeql/test/canonical-vaa-address-parsing/node/pkg/addresscases/negative.go create mode 100644 codeql/test/canonical-vaa-address-parsing/node/pkg/addresscases/positive.go create mode 100644 codeql/test/canonical-vaa-address-parsing/node/pkg/common/common.go create mode 100644 codeql/test/canonical-vaa-address-parsing/node/pkg/db/db.go create mode 100644 codeql/test/canonical-vaa-address-parsing/node/pkg/notary/admincommands.go create mode 100644 codeql/test/canonical-vaa-address-parsing/node/pkg/publicrpc/publicrpcserver.go create mode 100644 codeql/test/canonical-vaa-address-parsing/node/pkg/watchers/cosmwasm/watcher.go create mode 100644 codeql/test/canonical-vaa-address-parsing/node/pkg/watchers/evm/utils.go create mode 100644 codeql/test/canonical-vaa-address-parsing/node/pkg/watchers/sui/watcher.go create mode 100644 codeql/test/canonical-vaa-address-parsing/node/pkg/watchers/xrpl/watcher.go create mode 100644 codeql/test/canonical-vaa-address-parsing/sdk/vaa/vaa.go create mode 100644 codeql/test/canonical-vaa-id-parsing/canonical-vaa-id-parsing.expected create mode 100644 codeql/test/canonical-vaa-id-parsing/canonical-vaa-id-parsing.qlref create mode 100644 codeql/test/canonical-vaa-id-parsing/go.mod create mode 100644 codeql/test/canonical-vaa-id-parsing/node/pkg/adminrpc/canonical_parser_alias.go create mode 100644 codeql/test/canonical-vaa-id-parsing/node/pkg/adminrpc/generated.pb.go create mode 100644 codeql/test/canonical-vaa-id-parsing/node/pkg/adminrpc/manual_parse_test.go create mode 100644 codeql/test/canonical-vaa-id-parsing/node/pkg/adminrpc/negative.go create mode 100644 codeql/test/canonical-vaa-id-parsing/node/pkg/adminrpc/positive.go create mode 100644 codeql/test/canonical-vaa-id-parsing/node/pkg/db/db.go create mode 100644 codeql/test/canonical-vaa-id-parsing/node/pkg/vaa/vaa.go create mode 100644 codeql/test/delegate-consensus-canonical-digest/delegate-consensus-canonical-digest.expected create mode 100644 codeql/test/delegate-consensus-canonical-digest/delegate-consensus-canonical-digest.qlref create mode 100644 codeql/test/delegate-consensus-canonical-digest/go.mod create mode 100644 codeql/test/delegate-consensus-canonical-digest/node/pkg/common/chainlock.go create mode 100644 codeql/test/delegate-consensus-canonical-digest/node/pkg/crypto/crypto.go create mode 100644 codeql/test/delegate-consensus-canonical-digest/node/pkg/notary/notary.go create mode 100644 codeql/test/delegate-consensus-canonical-digest/node/pkg/processor/observation.go create mode 100644 codeql/test/delegate-consensus-canonical-digest/node/pkg/processor/observation_test.go create mode 100644 codeql/test/delegate-consensus-canonical-digest/node/pkg/proto/proto.go create mode 100644 codeql/test/evm-finality-release-and-reorg-checks/ethereum/errors.go create mode 100644 codeql/test/evm-finality-release-and-reorg-checks/evm-finality-release-and-reorg-checks.expected create mode 100644 codeql/test/evm-finality-release-and-reorg-checks/evm-finality-release-and-reorg-checks.qlref create mode 100644 codeql/test/evm-finality-release-and-reorg-checks/gethtypes/types.go create mode 100644 codeql/test/evm-finality-release-and-reorg-checks/go.mod create mode 100644 codeql/test/evm-finality-release-and-reorg-checks/node/pkg/common/message.go create mode 100644 codeql/test/evm-finality-release-and-reorg-checks/node/pkg/watchers/evm/negative.go create mode 100644 codeql/test/evm-finality-release-and-reorg-checks/node/pkg/watchers/evm/positive.go create mode 100644 codeql/test/evm-finality-release-and-reorg-checks/node/pkg/watchers/evm/stubs.go create mode 100644 codeql/test/evm-finality-release-and-reorg-checks/rpc/errors.go create mode 100644 codeql/test/evm-require-successful-receipt-before-observation/evm-require-successful-receipt-before-observation.expected create mode 100644 codeql/test/evm-require-successful-receipt-before-observation/evm-require-successful-receipt-before-observation.qlref create mode 100644 codeql/test/evm-require-successful-receipt-before-observation/gethtypes/types.go create mode 100644 codeql/test/evm-require-successful-receipt-before-observation/go.mod create mode 100644 codeql/test/evm-require-successful-receipt-before-observation/node/pkg/common/message.go create mode 100644 codeql/test/evm-require-successful-receipt-before-observation/node/pkg/watchers/evm/evm_test.go create mode 100644 codeql/test/evm-require-successful-receipt-before-observation/node/pkg/watchers/evm/negative.go create mode 100644 codeql/test/evm-require-successful-receipt-before-observation/node/pkg/watchers/evm/positive.go create mode 100644 codeql/test/evm-require-successful-receipt-before-observation/node/pkg/watchers/evm/stubs.go create mode 100644 codeql/test/evm-require-successful-receipt-before-observation/node/pkg/watchers/evm/unsafe/positive_unsafe_helper.go create mode 100644 codeql/test/evm-require-successful-receipt-before-observation/node/pkg/watchers/solana/non_evm.go create mode 100644 codeql/test/evm-verify-and-publish-gate/evm-verify-and-publish-gate.expected create mode 100644 codeql/test/evm-verify-and-publish-gate/evm-verify-and-publish-gate.qlref create mode 100644 codeql/test/evm-verify-and-publish-gate/go.mod create mode 100644 codeql/test/evm-verify-and-publish-gate/node/pkg/common/message_publication.go create mode 100644 codeql/test/evm-verify-and-publish-gate/node/pkg/watchers/evm/evm_test.go create mode 100644 codeql/test/evm-verify-and-publish-gate/node/pkg/watchers/evm/negative.go create mode 100644 codeql/test/evm-verify-and-publish-gate/node/pkg/watchers/evm/positive.go create mode 100644 codeql/test/evm-verify-and-publish-gate/node/pkg/watchers/evm/stubs.go create mode 100644 codeql/test/evm-verify-and-publish-gate/node/pkg/watchers/solana/non_evm.go create mode 100644 codeql/test/message-publication-canonical-timestamp/go.mod create mode 100644 codeql/test/message-publication-canonical-timestamp/message-publication-canonical-timestamp.expected create mode 100644 codeql/test/message-publication-canonical-timestamp/message-publication-canonical-timestamp.qlref create mode 100644 codeql/test/message-publication-canonical-timestamp/node/pkg/common/message.go create mode 100644 codeql/test/message-publication-canonical-timestamp/node/pkg/watchers/evm/fakecommon/message.go create mode 100644 codeql/test/message-publication-canonical-timestamp/node/pkg/watchers/evm/negative.go create mode 100644 codeql/test/message-publication-canonical-timestamp/node/pkg/watchers/evm/positive.go create mode 100644 codeql/test/message-publication-canonical-timestamp/node/pkg/watchers/evm/positive_test.go create mode 100644 codeql/test/message-publication-canonical-timestamp/node/pkg/watchers/evm/unsupported_boundaries.go create mode 100644 codeql/test/message-publication-canonical-timestamp/sdk/vaa/structs.go create mode 100644 codeql/test/message-publication-safe-serialization/go.mod create mode 100644 codeql/test/message-publication-safe-serialization/message-publication-safe-serialization.expected create mode 100644 codeql/test/message-publication-safe-serialization/message-publication-safe-serialization.qlref create mode 100644 codeql/test/message-publication-safe-serialization/node/pkg/common/chainlock.go create mode 100644 codeql/test/message-publication-safe-serialization/node/pkg/common/chainlock_test.go create mode 100644 codeql/test/message-publication-safe-serialization/node/pkg/db/governor.go create mode 100644 codeql/test/message-publication-safe-serialization/node/pkg/notary/notary.go create mode 100644 codeql/test/message-publication-safe-serialization/tools/non_node.go create mode 100644 codeql/test/near-finalized-receipt-outcome-before-publication/go.mod create mode 100644 codeql/test/near-finalized-receipt-outcome-before-publication/near-finalized-receipt-outcome-before-publication.expected create mode 100644 codeql/test/near-finalized-receipt-outcome-before-publication/near-finalized-receipt-outcome-before-publication.qlref create mode 100644 codeql/test/near-finalized-receipt-outcome-before-publication/node/pkg/watchers/near/near_miss.go create mode 100644 codeql/test/near-finalized-receipt-outcome-before-publication/node/pkg/watchers/near/negative.go create mode 100644 codeql/test/near-finalized-receipt-outcome-before-publication/node/pkg/watchers/near/positive.go create mode 100644 codeql/test/near-finalized-receipt-outcome-before-publication/node/pkg/watchers/near/stubs.go create mode 100644 codeql/test/run-with-scissors-error-return-certusone-module/go.mod create mode 100644 codeql/test/run-with-scissors-error-return-certusone-module/node/pkg/common/scissors.go create mode 100644 codeql/test/run-with-scissors-error-return-certusone-module/node/pkg/watchers/scissors/certusone_positive.go create mode 100644 codeql/test/run-with-scissors-error-return-certusone-module/run-with-scissors-error-return-certusone-module.expected create mode 100644 codeql/test/run-with-scissors-error-return-certusone-module/run-with-scissors-error-return-certusone-module.qlref create mode 100644 codeql/test/run-with-scissors-error-return/go.mod create mode 100644 codeql/test/run-with-scissors-error-return/node/pkg/common/scissors.go create mode 100644 codeql/test/run-with-scissors-error-return/node/pkg/watchers/scissors/negative.go create mode 100644 codeql/test/run-with-scissors-error-return/node/pkg/watchers/scissors/positive.go create mode 100644 codeql/test/run-with-scissors-error-return/node/pkg/watchers/scissors/unsupported.go create mode 100644 codeql/test/run-with-scissors-error-return/run-with-scissors-error-return.expected create mode 100644 codeql/test/run-with-scissors-error-return/run-with-scissors-error-return.qlref create mode 100644 codeql/test/solana-commitment-match-before-publication/go.mod create mode 100644 codeql/test/solana-commitment-match-before-publication/node/pkg/watchers/solana/near_miss.go create mode 100644 codeql/test/solana-commitment-match-before-publication/node/pkg/watchers/solana/negative.go create mode 100644 codeql/test/solana-commitment-match-before-publication/node/pkg/watchers/solana/positive.go create mode 100644 codeql/test/solana-commitment-match-before-publication/node/pkg/watchers/solana/stubs.go create mode 100644 codeql/test/solana-commitment-match-before-publication/solana-commitment-match-before-publication.expected create mode 100644 codeql/test/solana-commitment-match-before-publication/solana-commitment-match-before-publication.qlref create mode 100644 codeql/test/solana-message-account-validation/go.mod create mode 100644 codeql/test/solana-message-account-validation/node/pkg/watchers/solana/generated.pb.go create mode 100644 codeql/test/solana-message-account-validation/node/pkg/watchers/solana/near_miss.go create mode 100644 codeql/test/solana-message-account-validation/node/pkg/watchers/solana/negative.go create mode 100644 codeql/test/solana-message-account-validation/node/pkg/watchers/solana/positive.go create mode 100644 codeql/test/solana-message-account-validation/node/pkg/watchers/solana/solana_test.go create mode 100644 codeql/test/solana-message-account-validation/solana-message-account-validation.expected create mode 100644 codeql/test/solana-message-account-validation/solana-message-account-validation.qlref create mode 100644 codeql/test/solana-require-successful-transaction-meta/go.mod create mode 100644 codeql/test/solana-require-successful-transaction-meta/node/pkg/watchers/solana/negative.go create mode 100644 codeql/test/solana-require-successful-transaction-meta/node/pkg/watchers/solana/positive.go create mode 100644 codeql/test/solana-require-successful-transaction-meta/node/pkg/watchers/solana/solana_meta_test.go create mode 100644 codeql/test/solana-require-successful-transaction-meta/node/pkg/watchers/solana/stubs.go create mode 100644 codeql/test/solana-require-successful-transaction-meta/solana-require-successful-transaction-meta.expected create mode 100644 codeql/test/solana-require-successful-transaction-meta/solana-require-successful-transaction-meta.qlref create mode 100644 codeql/test/xrpl-derived-generated-emitter/ethcrypto/crypto.go create mode 100644 codeql/test/xrpl-derived-generated-emitter/go.mod create mode 100644 codeql/test/xrpl-derived-generated-emitter/node/pkg/watchers/xrpl/badlayout/positive.go create mode 100644 codeql/test/xrpl-derived-generated-emitter/node/pkg/watchers/xrpl/boundary.go create mode 100644 codeql/test/xrpl-derived-generated-emitter/node/pkg/watchers/xrpl/generated.go create mode 100644 codeql/test/xrpl-derived-generated-emitter/node/pkg/watchers/xrpl/near_miss.go create mode 100644 codeql/test/xrpl-derived-generated-emitter/node/pkg/watchers/xrpl/negative.go create mode 100644 codeql/test/xrpl-derived-generated-emitter/node/pkg/watchers/xrpl/nttignored/positive.go create mode 100644 codeql/test/xrpl-derived-generated-emitter/node/pkg/watchers/xrpl/nttwrong/near_miss.go create mode 100644 codeql/test/xrpl-derived-generated-emitter/node/pkg/watchers/xrpl/overwrite/positive.go create mode 100644 codeql/test/xrpl-derived-generated-emitter/node/pkg/watchers/xrpl/positive.go create mode 100644 codeql/test/xrpl-derived-generated-emitter/node/pkg/watchers/xrpl/production_regression.go create mode 100644 codeql/test/xrpl-derived-generated-emitter/node/pkg/watchers/xrpl/regression_test.go create mode 100644 codeql/test/xrpl-derived-generated-emitter/node/pkg/watchers/xrpl/stubs.go create mode 100644 codeql/test/xrpl-derived-generated-emitter/xrpl-derived-generated-emitter.expected create mode 100644 codeql/test/xrpl-derived-generated-emitter/xrpl-derived-generated-emitter.qlref create mode 100644 codeql/test/xrpl-first-memo-only/go.mod create mode 100644 codeql/test/xrpl-first-memo-only/node/pkg/watchers/xrpl/near_miss.go create mode 100644 codeql/test/xrpl-first-memo-only/node/pkg/watchers/xrpl/negative.go create mode 100644 codeql/test/xrpl-first-memo-only/node/pkg/watchers/xrpl/positive.go create mode 100644 codeql/test/xrpl-first-memo-only/xrpl-first-memo-only.expected create mode 100644 codeql/test/xrpl-first-memo-only/xrpl-first-memo-only.qlref create mode 100644 codeql/test/xrpl-require-validated-transaction/go.mod create mode 100644 codeql/test/xrpl-require-validated-transaction/node/pkg/watchers/xrpl/near_miss.go create mode 100644 codeql/test/xrpl-require-validated-transaction/node/pkg/watchers/xrpl/negative.go create mode 100644 codeql/test/xrpl-require-validated-transaction/node/pkg/watchers/xrpl/parser_stubs.go create mode 100644 codeql/test/xrpl-require-validated-transaction/node/pkg/watchers/xrpl/positive.go create mode 100644 codeql/test/xrpl-require-validated-transaction/xrpl-require-validated-transaction.expected create mode 100644 codeql/test/xrpl-require-validated-transaction/xrpl-require-validated-transaction.qlref diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 25924f8f1c..4885355c5f 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -40,6 +40,7 @@ /wormchain/devnet/txverifier @djb15 @johnsaigle @mdulin2 @pleasew8t /wormchain/ts-sdk/ @evan-gray @kev1n-peters @panoel /linters/ @djb15 @johnsaigle @mdulin2 @pleasew8t @bemic +/codeql/ @djb15 @johnsaigle @mdulin2 @pleasew8t @bemic # Protobuf for node diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000000..e1256ede78 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,105 @@ +name: CodeQL + +on: + push: + branches: [ main ] + paths: + - "node/**" + - "sdk/**/*.go" + - "sdk/**/go.mod" + - "sdk/**/go.sum" + - "codeql/**" + - ".github/workflows/codeql.yml" + pull_request: + branches: [ main ] + paths: + - "node/**" + - "sdk/**/*.go" + - "sdk/**/go.mod" + - "sdk/**/go.sum" + - "codeql/**" + - ".github/workflows/codeql.yml" + workflow_dispatch: + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +jobs: + # Compile the custom query pack and run its unit test fixtures. + query-tests: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version: "1.25.10" + - name: Install CodeQL CLI + env: + GH_TOKEN: ${{ github.token }} + run: | + gh extension install github/gh-codeql + gh codeql version + - name: Install query pack dependencies + env: + GH_TOKEN: ${{ github.token }} + working-directory: codeql + run: gh codeql pack install + - name: Compile queries + env: + GH_TOKEN: ${{ github.token }} + working-directory: codeql + run: gh codeql query compile src + - name: Run query tests + env: + GH_TOKEN: ${{ github.token }} + working-directory: codeql + run: gh codeql test run test + + # Analyze the node and sdk Go modules with the custom query suite and + # upload the results to GitHub code scanning. + analyze: + strategy: + fail-fast: false + matrix: + module: [node, sdk] + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version: "1.25.10" + - name: Initialize CodeQL + uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 + with: + languages: go + build-mode: manual + config: | + disable-default-queries: true + paths: + - ${{ matrix.module }} + paths-ignore: + - "**/*_test.go" + queries: + - uses: ./codeql/suites/wormhole-go.qls + # CodeQL extracts Go code while it is compiled, so build only the module + # under analysis. -a forces a full rebuild so every file is extracted. + - name: Build Go module + env: + MODULE: ${{ matrix.module }} + run: cd "$MODULE" && go build -a ./... + - name: Analyze + uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 + with: + category: "wormhole-codeql/${{ matrix.module }}" diff --git a/codeql/.gitignore b/codeql/.gitignore new file mode 100644 index 0000000000..bd52295d7b --- /dev/null +++ b/codeql/.gitignore @@ -0,0 +1,4 @@ +# CodeQL output +*.sarif +# CodeQL databases +*-db/ diff --git a/codeql/Makefile b/codeql/Makefile new file mode 100644 index 0000000000..7d6ee723aa --- /dev/null +++ b/codeql/Makefile @@ -0,0 +1,73 @@ +# The pack lives inside the Wormhole monorepo, so the source root defaults to the parent directory. +WORMHOLE_REPO ?= .. +NODE_DB ?= ./wormhole-node-go-db +SDK_DB ?= ./wormhole-sdk-go-db +SUITE ?= suites/wormhole-go.qls +NODE_OUTPUT ?= wormhole-node-go-lints.sarif +SDK_OUTPUT ?= wormhole-sdk-go-lints.sarif + +.PHONY: all +all: analyze + +.PHONY: create-db create-node-db create-sdk-db +create-db: create-node-db create-sdk-db + +create-node-db: + codeql database create $(NODE_DB) \ + --language=go \ + --source-root=$(WORMHOLE_REPO) \ + --command='/bin/sh -c "cd node && go build -a ./..."' + +create-sdk-db: + codeql database create $(SDK_DB) \ + --language=go \ + --source-root=$(WORMHOLE_REPO) \ + --command='/bin/sh -c "cd sdk && go build -a ./..."' + +.PHONY: create-db-overwrite create-node-db-overwrite create-sdk-db-overwrite +create-db-overwrite: create-node-db-overwrite create-sdk-db-overwrite + +create-node-db-overwrite: + codeql database create $(NODE_DB) \ + --language=go \ + --source-root=$(WORMHOLE_REPO) \ + --command='/bin/sh -c "cd node && go build -a ./..."' \ + --overwrite + +create-sdk-db-overwrite: + codeql database create $(SDK_DB) \ + --language=go \ + --source-root=$(WORMHOLE_REPO) \ + --command='/bin/sh -c "cd sdk && go build -a ./..."' \ + --overwrite + +.PHONY: analyze analyze-node analyze-sdk +analyze: analyze-node analyze-sdk + +analyze-node: + codeql database analyze $(NODE_DB) \ + $(SUITE) \ + --format=sarif-latest \ + --output=$(NODE_OUTPUT) + +analyze-sdk: + codeql database analyze $(SDK_DB) \ + $(SUITE) \ + --format=sarif-latest \ + --output=$(SDK_OUTPUT) + +.PHONY: scan +scan: create-db-overwrite + $(MAKE) analyze + +.PHONY: compile +compile: + codeql query compile src + +.PHONY: test +test: + codeql test run test + +.PHONY: clean +clean: + rm -rf $(NODE_DB) $(SDK_DB) $(NODE_OUTPUT) $(SDK_OUTPUT) diff --git a/codeql/README.md b/codeql/README.md new file mode 100644 index 0000000000..22f441ba41 --- /dev/null +++ b/codeql/README.md @@ -0,0 +1,107 @@ +# Wormhole Go CodeQL Lints + +This directory is a CodeQL query pack with Wormhole-specific Go lint rules. Run the commands below from this directory (`codeql/`). The pack lock file (`codeql-pack.lock.yml`) pins the Go query dependencies; run `codeql pack install` once to download them. + +CI compiles the queries, runs the query unit tests, and analyzes the `node` and `sdk` Go modules with this pack (see `.github/workflows/codeql.yml`). Findings are uploaded to GitHub code scanning and appear as annotations on pull requests. + +## Rules + +- [`wormhole/go/already-locked-receiver-mutex`](docs/rules/already-locked-receiver-mutex.md): require documented `AlreadyLocked` helpers to be called while holding the exact receiver mutex. +- [`wormhole/go/algorand-publication-field-length-check`](docs/rules/algorand-publication-field-length-check.md): require exact length checks before decoding Algorand publication nonce and sequence fields. +- [`wormhole/go/canonical-chain-id-parsing`](docs/rules/canonical-chain-id-parsing.md): use Wormhole SDK chain-ID conversion helpers at modeled external boundaries. +- [`wormhole/go/canonical-vaa-address-parsing`](docs/rules/canonical-vaa-address-parsing.md): use Wormhole SDK address parsers for external address values. +- [`wormhole/go/canonical-vaa-id-parsing`](docs/rules/canonical-vaa-id-parsing.md): parse complete VAA IDs with the canonical parser instead of reconstructing components manually. +- `wormhole/go/delegate-consensus-canonical-digest`: key delegate observation quorum buckets by the reconstructed `MessagePublication` VAA signing digest, not by serialized observations or composite keys. +- `wormhole/go/delegated-guardian-config-validation`: strictly parse guardian addresses, reject duplicate canonical keys, and enforce non-empty threshold/quorum before governance serialization. +- [`wormhole/go/evm-finality-release-and-reorg-checks`](docs/rules/evm-finality-release-and-reorg-checks.md): require finality, receipt refetch, and reorg-provenance checks before releasing pending EVM observations. +- [`wormhole/go/evm-require-successful-receipt-before-observation`](docs/rules/evm-require-successful-receipt-before-observation.md): require a local successful-receipt proof before EVM log observation or publication. +- [`wormhole/go/evm-verify-and-publish-gate`](docs/rules/evm-verify-and-publish-gate.md): route EVM watcher publication through `verifyAndPublish`. +- `wormhole/go/evm-ccl-signed-message-immutability`: preserve signed `MessagePublication` fields after observation and update only release metadata such as `effectiveCL` or `additionalBlocks`. +- `wormhole/go/governance-vaa-typed-payload`: production governance VAA construction must pass `CreateGovernanceVAA` a payload from a checked SDK typed governance serializer or `EmptyPayloadVaa`. +- `wormhole/go/guardian-signer-exact-digest-length`: `GuardianSigner.Sign` implementations must reject non-32-byte digest input before signing. +- [`wormhole/go/message-publication-canonical-timestamp`](docs/rules/message-publication-canonical-timestamp.md): use `vaa.TimeFromUnix` for modeled chain-derived publication timestamps. +- [`wormhole/go/message-publication-safe-serialization`](docs/rules/message-publication-safe-serialization.md): avoid deprecated publication serialization helpers that omit security-relevant fields. +- [`wormhole/go/near-finalized-receipt-outcome-before-publication`](docs/rules/near-finalized-receipt-outcome-before-publication.md): require same-outcome NEAR finality and finalized-header provenance before processing receipt logs for publication. +- [`wormhole/go/run-with-scissors-error-return`](docs/rules/run-with-scissors-error-return.md): return runnable errors instead of writing directly to the same `errC`. +- `wormhole/go/solana-alt-owner-before-decode`: prove an RPC-fetched address lookup table account exists and is owned by the ALT program before decoding its bytes. +- [`wormhole/go/solana-commitment-match-before-publication`](docs/rules/solana-commitment-match-before-publication.md): require decoded Solana message commitment to match the exact watcher before scheduling or publication. +- [`wormhole/go/solana-message-account-validation`](docs/rules/solana-message-account-validation.md): require validated constructor provenance for Solana message account data. +- [`wormhole/go/solana-require-successful-transaction-meta`](docs/rules/solana-require-successful-transaction-meta.md): require successful Solana transaction metadata before parsing or processing observations. +- `wormhole/go/untrusted-vaa-use-before-verification`: parsed signed VAAs from untrusted boundaries must be verified with the complete guardian set before storage or external delivery; `vaa.Unmarshal` only checks wire format. +- [`wormhole/go/xrpl-derived-generated-emitter`](docs/rules/xrpl-derived-generated-emitter.md): derive collision-resistant emitters for XRPL-generated messages. +- [`wormhole/go/xrpl-first-memo-only`](docs/rules/xrpl-first-memo-only.md): inspect only the first XRPL memo for Wormhole Core and NTT messages. +- [`wormhole/go/xrpl-require-validated-transaction`](docs/rules/xrpl-require-validated-transaction.md): require a validated-ledger proof before parsing an XRPL transaction. + +## Compile + +Compile every query in the pack: + +```sh +codeql query compile src +``` + +Compile the registered suite: + +```sh +codeql query compile suites/wormhole-go.qls +``` + +## Test + +Run every rule fixture: + +```sh +codeql test run test +``` + +Run one rule fixture: + +```sh +codeql test run test/xrpl-first-memo-only +``` + +## Analyze Wormhole + +Create separate Go databases for `node` and `sdk`. CodeQL extracts Go code while the database is created, so use a single source root with subdirectory-scoped build commands rather than creating one database from the whole repository. This avoids unrelated SDK subdirectories such as `sdk/js`, `sdk/js-proto-node`, `sdk/js-proto-web`, `sdk/js-wasm`, and `sdk/rust`. + +Create the `node` database: + +```sh +codeql database create wormhole-node-go-db \ + --language=go \ + --source-root=.. \ + --command='cd node && go build -a ./...' \ + --overwrite +``` + +Create the Go SDK database: + +```sh +codeql database create wormhole-sdk-go-db \ + --language=go \ + --source-root=.. \ + --command='cd sdk && go build -a ./...' \ + --overwrite +``` + +Analyze both databases with the registered suite: + +```sh +codeql database analyze wormhole-node-go-db \ + suites/wormhole-go.qls \ + --format=sarif-latest \ + --output=wormhole-node-go-lints.sarif + +codeql database analyze wormhole-sdk-go-db \ + suites/wormhole-go.qls \ + --format=sarif-latest \ + --output=wormhole-sdk-go-lints.sarif +``` + +If you already have finalized databases, skip the `database create` commands and run `database analyze` against those database paths. + +Alternatively, use the `Makefile`, which defaults the source root to the enclosing repository: + +```sh +make scan +``` diff --git a/codeql/codeql-pack.lock.yml b/codeql/codeql-pack.lock.yml new file mode 100644 index 0000000000..d8c46decc9 --- /dev/null +++ b/codeql/codeql-pack.lock.yml @@ -0,0 +1,24 @@ +--- +lockVersion: 1.0.0 +dependencies: + codeql/concepts: + version: 0.0.26 + codeql/controlflow: + version: 2.0.36 + codeql/dataflow: + version: 2.1.8 + codeql/go-all: + version: 7.2.0 + codeql/mad: + version: 1.0.52 + codeql/ssa: + version: 2.0.28 + codeql/threat-models: + version: 1.0.52 + codeql/tutorial: + version: 1.0.52 + codeql/typetracking: + version: 2.0.36 + codeql/util: + version: 2.0.39 +compiled: false diff --git a/codeql/docs/rules/algorand-publication-field-length-check.md b/codeql/docs/rules/algorand-publication-field-length-check.md new file mode 100644 index 0000000000..fe304f5b5d --- /dev/null +++ b/codeql/docs/rules/algorand-publication-field-length-check.md @@ -0,0 +1,55 @@ +# Algorand Publication Field Length Check + +Prove Algorand `publishMessage` nonce and sequence byte fields are exactly 8 bytes before decoding them with `binary.BigEndian.Uint64`. + +## Why This Matters + +Algorand watcher code converts chain data into canonical Wormhole `MessagePublication` values. The publication nonce comes from `ApplicationArgs[2]`, and the sequence comes from the first log entry. `binary.BigEndian.Uint64` requires an 8-byte input; malformed field lengths can panic the watcher before the observation is skipped. Container bounds, app ID, method-name checks, and contract-side `Itob` expectations do not prove the exact byte width of the field being decoded. + +## Examples + +### Violation + +```go +func build(at ApplicationTransaction, ed EvalDelta) MessagePublication { + nonce := binary.BigEndian.Uint64(at.ApplicationArgs[2]) + sequence := binary.BigEndian.Uint64([]byte(ed.Logs[0])) + return MessagePublication{Nonce: uint32(nonce), Sequence: sequence} +} +``` + +### Fix + +```go +func build(at ApplicationTransaction, ed EvalDelta) (MessagePublication, bool) { + if len(at.ApplicationArgs[2]) != 8 || len([]byte(ed.Logs[0])) != 8 { + return MessagePublication{}, false + } + nonce := binary.BigEndian.Uint64(at.ApplicationArgs[2]) + sequence := binary.BigEndian.Uint64([]byte(ed.Logs[0])) + return MessagePublication{Nonce: uint32(nonce), Sequence: sequence}, true +} +``` + +## What The Rule Checks + +The rule reports production Go under `node/pkg/watchers/algorand/` and `pkg/watchers/algorand/` when an Algorand publication function decodes `at.ApplicationArgs[2]` or `[]byte(ed.Logs[0])` with `binary.BigEndian.Uint64` without a dominating exact `len(value) == 8` proof for the same value. + +It recognizes direct `binary.BigEndian.Uint64(...)` calls, local aliases of `binary.BigEndian`, local aliases of the nonce or sequence bytes, stale guards invalidated by reassignment of the alias or exact indexed source, and thin local helper calls whose parameter reaches an internal `Uint64`. A checked helper is safe only when the helper enforces exact length and every relevant helper result published into `MessagePublication.Nonce` or `MessagePublication.Sequence` is dominated by rejection of the helper error. The rule ignores tests, generated files, non-Algorand watchers, non-publication decodes, container-bounds-only code, and typed/fixed-width values already produced by checked parsers. + +## Limitations + +The model is intentionally bounded to the two Wormhole Algorand publication fields and thin local helpers in watcher code. Deep interprocedural propagation, non-local abstractions, equivalent checked parsers with different shapes, and publication construction hidden behind complex containers may be missed. The query proves dominance syntactically with current guard forms; unusual but safe control flow may need additional fixtures before being accepted. + +## Learn More + +- [Rule contract](../../../.codeql-lint-builder/rules/algorand-publication-field-length-check.md): records the exact field definitions, guard contract, helper treatment, bypass risks, tests, and calibration evidence. +- [Second-gate return report](../../../.codeql-lint-builder/runs/algorand-publication-field-length-check/09-second-gate-return-2026-07-15.md): records the final checked-helper publication-use fix and zero-result recalibration. +- [Rule query](../../src/algorand-publication-field-length-check.ql): defines exact field sources, `Uint64` sinks, dominance, stale-guard invalidation, and thin-helper modeling. +- [Rule fixtures](../../test/algorand-publication-field-length-check/): encode unguarded direct decodes, non-exact checks, stale guards, `BigEndian` aliases, helper bypasses, checked-helper error handling, and exclusions. + +The rule artifact cites Wormhole Algorand watcher source, malformed-length regression tests, contract code, and hardening commit `5ce968ff1638d353f9bfe8c94461f9583eaeeedf`, but this checkout does not contain the Wormhole repository, so this page cannot provide verified version-pinned links to them. + +## Maintainer Notes + +The CodeQL ID is `wormhole/go/algorand-publication-field-length-check`. Update query and fixtures together if Algorand watcher paths, nonce/sequence source expressions, publication construction, helper return conventions, or accepted exact-length guard shapes change. diff --git a/codeql/docs/rules/already-locked-receiver-mutex.md b/codeql/docs/rules/already-locked-receiver-mutex.md new file mode 100644 index 0000000000..1aa20f574e --- /dev/null +++ b/codeql/docs/rules/already-locked-receiver-mutex.md @@ -0,0 +1,114 @@ +# AlreadyLocked Receiver Mutex + +Call documented Wormhole `AlreadyLocked` helpers only while holding the required write lock on the same receiver instance. + +## Why This Matters + +Methods named `AlreadyLocked` encode a caller-side locking precondition. They are intentionally written without acquiring their own mutex because callers are expected to already hold the receiver's write lock while mutating shared Accountant, Governor, or CCQ state. Calling one of these helpers without the exact receiver lock can race concurrent state updates; calling it after an intervening non-deferred `Unlock` can also violate the precondition even when an earlier lock appears in the same function. + +## Examples + +### Violation + +```go +func publish(a *Accountant, transfer *Transfer) error { + return a.publishTransferAlreadyLocked(transfer) +} +``` + +```go +func publishAfterUnlock(a *Accountant, transfer *Transfer) error { + a.pendingTransfersLock.Lock() + a.pendingTransfersLock.Unlock() + return a.publishTransferAlreadyLocked(transfer) +} +``` + +```go +func publishWrongReceiver(a, other *Accountant, transfer *Transfer) error { + a.pendingTransfersLock.Lock() + defer a.pendingTransfersLock.Unlock() + return other.publishTransferAlreadyLocked(transfer) +} +``` + +### Fix + +```go +func publish(a *Accountant, transfer *Transfer) error { + a.pendingTransfersLock.Lock() + defer a.pendingTransfersLock.Unlock() + return a.publishTransferAlreadyLocked(transfer) +} +``` + +```go +func publishAfterRelock(a *Accountant, transfer *Transfer) error { + a.pendingTransfersLock.Lock() + a.pendingTransfersLock.Unlock() + a.pendingTransfersLock.Lock() + defer a.pendingTransfersLock.Unlock() + return a.publishTransferAlreadyLocked(transfer) +} +``` + +## What The Rule Checks + +The rule reports production Go calls under `node/pkg/accountant/`, `node/pkg/governor/`, and `node/cmd/ccq/` when a documented `AlreadyLocked` helper is called without a proven same-function write-lock precondition on the same receiver. Direct `go` and `defer` helper invocations are always reported because execution is not guaranteed to occur while the syntactic lock remains held. + +Exact method-to-mutex mappings are: + +| Package | Receiver type | AlreadyLocked method | Required receiver mutex | +| --- | --- | --- | --- | +| `accountant` | `*Accountant` | `publishTransferAlreadyLocked` | `pendingTransfersLock` | +| `accountant` | `*Accountant` | `addPendingTransferAlreadyLocked` | `pendingTransfersLock` | +| `accountant` | `*Accountant` | `deletePendingTransferAlreadyLocked` | `pendingTransfersLock` | +| `governor` | `*ChainGovernor` | `parseMsgAlreadyLocked` | `mutex` | +| `governor` | `*ChainGovernor` | `loadFromDBAlreadyLocked` | `mutex` | +| `ccq` | `*PendingResponses` | `updateMetricsAlreadyLocked` | `mu` | + +A call is considered protected when an ordinary synchronous same-function call to `receiver..Lock()` occurs before the `AlreadyLocked` call and the lock's basic block dominates the helper call. Deferred and goroutine-launched lock calls do not count. The receiver must be the same unmodified receiver value: locking `a.pendingTransfersLock` does not protect `other.publishTransferAlreadyLocked(...)`, and rebinding `a` or a locked alias before the helper invalidates the proof. + +The rule treats non-deferred unlocks as lock-precondition breakers. A later ordinary synchronous same-branch relock can re-establish the precondition only when no textually intervening `break`, `continue`, or `goto` can bypass it. This is a conservative structured-control approximation rather than a complete path-sensitive lock-state analysis. + +Nested `AlreadyLocked` calls inherit the enclosing helper's precondition in a bounded way. A call from inside one of the documented `AlreadyLocked` methods is allowed when it is made on the enclosing method receiver, or on a one-hop local alias assigned from that still-unmodified receiver. The inheritance is rejected if the receiver parameter is reassigned before the nested call, or if the alias is reassigned after being initialized from the receiver. + +The rule recognizes ordered same-function receiver aliases and local value equivalence for direct mutex accesses such as: + +```go +func publish(a *Accountant, transfer *Transfer) error { + acct := a + acct.pendingTransfersLock.Lock() + defer acct.pendingTransfersLock.Unlock() + return a.publishTransferAlreadyLocked(transfer) +} +``` + +It also handles reassignment-sensitive cases conservatively for nested precondition inheritance: + +```go +func (a *Accountant) publishTransferAlreadyLocked(transfer *Transfer) error { + acct := a + acct = otherAccountant() + return acct.addPendingTransferAlreadyLocked(transfer) // not covered by inherited precondition +} +``` + +## Exclusions + +The rule ignores tests, generated files, files outside `node/pkg/accountant/`, `node/pkg/governor/`, and `node/cmd/ccq/`, unrelated method names, unrelated receiver types, read locks, deferred unlocks that run after the helper call, and locks on different receiver instances or different mutex fields. + +## Limitations + +This is a bounded receiver-lock rule, not a full interprocedural lock-state proof. It does not model lock acquisition in callers, wrappers, closures, goroutines, function values, interfaces, reflection, unsafe pointer manipulation, container-stored receivers, or deep alias chains. It requires a same-function dominating `Lock` unless the call is a bounded nested call inside a documented `AlreadyLocked` method. It also does not prove that the protected helper itself avoids unlocking internally; it only checks call-site preconditions for the documented helper set. + +## Learn More + +- [Rule contract](../../../.codeql-lint-builder/rules/already-locked-receiver-mutex.md): records the exact method-to-mutex policy and lifecycle evidence. +- [Rule query](../../src/already-locked-receiver-mutex.ql): defines the production scope, exact method-to-mutex mappings, same-receiver lock matching, unlock/relock handling, and bounded nested precondition inheritance. +- [Go `sync.Mutex` documentation](https://pkg.go.dev/sync#Mutex): describes lock and unlock semantics for Go mutexes. +- [Wormhole Accountant package](https://github.com/wormhole-foundation/wormhole/tree/main/node/pkg/accountant), [Governor package](https://github.com/wormhole-foundation/wormhole/tree/main/node/pkg/governor), and [CCQ command package](https://github.com/wormhole-foundation/wormhole/tree/main/node/cmd/ccq): source areas covered by this rule. + +## Maintainer Notes + +The CodeQL ID is `wormhole/go/already-locked-receiver-mutex`. Keep the method-to-mutex table synchronized with `isAlreadyLockedMethod` and `isAlreadyLockedCall` in the query. If new `AlreadyLocked` helpers are added, add explicit method-to-mutex mappings and fixtures before broadening by naming convention alone. If alias or interprocedural support is expanded, preserve the same-receiver requirement and add reassignment, unlock-without-relock, and nested-helper regression cases. diff --git a/codeql/docs/rules/canonical-chain-id-parsing.md b/codeql/docs/rules/canonical-chain-id-parsing.md new file mode 100644 index 0000000000..15e0e166d1 --- /dev/null +++ b/codeql/docs/rules/canonical-chain-id-parsing.md @@ -0,0 +1,62 @@ +# Canonical Chain ID Parsing + +Boundary-derived Wormhole chain IDs must be converted with the SDK chain-ID helpers instead of local range checks and direct `vaa.ChainID(...)` casts. + +## Why This Matters + +Wormhole chain IDs are `uint16` wire values, but many uses also require SDK-registered-chain semantics. Local conversions blur those two decisions and duplicate validation in admin RPC, public RPC, governance, watcher, IBC, manager, Governor, Accountant, and txverifier paths. The SDK helpers make the intended boundary explicit: wire-valid, registered numeric, or registered string. + +## Examples + +### Violation + +```go +func submit(req *proto.GovernanceRequest) BodyContractUpgrade { + if req.ChainId > math.MaxUint16 { + return BodyContractUpgrade{} + } + return BodyContractUpgrade{ + TargetChainID: vaa.ChainID(req.ChainId), + } +} +``` + +### Fix + +```go +func submit(req *proto.GovernanceRequest) (BodyContractUpgrade, error) { + chain, err := vaa.ChainIDFromNumber[uint32](req.ChainId) + if err != nil { + return BodyContractUpgrade{}, err + } + return BodyContractUpgrade{TargetChainID: chain}, nil +} +``` + +Use `vaa.KnownChainIDFromNumber` instead when the value selects a local watcher, reobserver, chain-specific policy, or supported-chain map. Use `vaa.StringToKnownChainID` for string configuration or text input that must name an SDK-known chain. + +## What The Rule Checks + +The rule reports direct conversions to SDK `vaa.ChainID` when a modeled boundary source flows into a chain-ID use context without first passing through `vaa.ChainIDFromNumber`, `vaa.KnownChainIDFromNumber`, or `vaa.StringToKnownChainID`. Reported contexts include struct fields, call arguments, indexes, comparisons, and assignments. + +The exact checked scope is production Go under `node/`, `pkg/`, or `cmd/` path shapes used by Wormhole CodeQL databases, excluding tests, protobuf/gRPC generated files, generated ABI bindings, and CodeQL generated files. Modeled sources are intentionally bounded: generated Wormhole protobuf field/getter reads whose base comes from a parameter or channel receive, IBC `WasmAttributes.GetAsUint("message.chain_id", 16)`, IBC `ChannelChains` JSON entries at index `1`, and txverifier parameter-backed range values. The query also recognizes both `github.com/wormhole-foundation/wormhole/sdk/vaa` and `github.com/certusone/wormhole/sdk/vaa` import paths. + +Fix an alert by choosing the helper that matches the surrounding semantics. Do not blindly replace every alert with known-chain validation: governance payloads, historical VAA lookup, peer/version-skew data, and other wire-compatible contexts may need `ChainIDFromNumber` rather than `KnownChainIDFromNumber`. + +## Limitations + +The query does not prove arbitrary external provenance. Its IBC and JSON coverage is a shape-based model tied to concrete production patterns and same-file near-miss fixtures. It does not cover broad string parsing, generic HTTP/config/database sources, interprocedural wrapper helpers, full serialized `chain/address/sequence` parsing owned by `canonical-vaa-id-parsing`, or chain-native EVM `chainId` before it is mapped to a Wormhole chain. Already-typed `vaa.ChainID` values, SDK constants, non-chain-ID numeric fields, SDK wire serialization internals, tests, and generated code are out of scope. + +## Learn More + +- [Rule contract](../../../.codeql-lint-builder/rules/canonical-chain-id-parsing.md): records helper semantics, source model, calibration evidence, and the wire-valid versus registered-chain distinction. +- [Second-gate acceptance report](../../../.codeql-lint-builder/runs/canonical-chain-id-parsing/09-second-gate-return-2026-07-15.md): documents the final bounded IBC/JSON model and validation results. +- [Rule query](../../src/canonical-chain-id-parsing.ql): defines production scope, boundary sources, SDK-helper recognition, and direct-cast sinks. +- [Rule fixtures](../../test/canonical-chain-id-parsing/): encode protobuf, IBC, JSON, txverifier, helper, internal-value, and near-miss cases. +- [Calibration SARIF](../../../.codeql-lint-builder/runs/canonical-chain-id-parsing/07-calibrate-2026-07-14.sarif): stores representative production findings used during calibration. + +The rule artifact cites Wormhole source revisions and helper-introduction commits, but this checkout does not contain the Wormhole repository source, so this page links only to local artifacts and run evidence. + +## Maintainer Notes + +The CodeQL ID is `wormhole/go/canonical-chain-id-parsing`. Keep the source model evidence-based; do not broaden by variable names alone. When adding a new boundary source, add production evidence plus negative fixtures for same-file lookalikes. Maintainers reviewing findings should classify the intended remediation as wire-valid or registered-chain before proposing a fix. diff --git a/codeql/docs/rules/canonical-vaa-address-parsing.md b/codeql/docs/rules/canonical-vaa-address-parsing.md new file mode 100644 index 0000000000..0abb0c11aa --- /dev/null +++ b/codeql/docs/rules/canonical-vaa-address-parsing.md @@ -0,0 +1,60 @@ +# Canonical VAA Address Parsing + +External Wormhole address data must be normalized with `vaa.StringToAddress` or `vaa.BytesToAddress` before it is used as a `vaa.Address` protocol identity. + +## Why This Matters + +`vaa.Address` is Wormhole's 32-byte identity form for emitters, VAA IDs, publications, Governor/Accountant state, delegated observations, relayer keys, and lookup/storage tuples. Manual casts, `hex.DecodeString` plus `copy`, or package-local address parsers can disagree with SDK behavior for left-padding, optional `0x`, overlength rejection, and ASCII-versus-hex handling. + +## Examples + +### Violation + +```go +func publication(emitter string) common.MessagePublication { + decoded, _ := hex.DecodeString(emitter) + addr := vaa.Address{} + copy(addr[:], decoded) + return common.MessagePublication{EmitterAddress: addr} +} +``` + +### Fix + +```go +func publication(emitter string) (common.MessagePublication, error) { + addr, err := vaa.StringToAddress(emitter) + if err != nil { + return common.MessagePublication{}, err + } + return common.MessagePublication{EmitterAddress: addr}, nil +} +``` + +For raw bytes, use `vaa.BytesToAddress` and handle the error before constructing the identity sink. + +## What The Rule Checks + +The rule reports non-canonical `vaa.Address` construction that reaches Wormhole identity sinks. It covers direct `vaa.Address(...)` conversions from byte-like data, `copy` into a `vaa.Address`, and unsafe helper calls whose result flows to identity fields or returns. Identity sinks include `MessagePublication`, `VAA`, `VAAID`, token bridge keys, `EmitterAddress`, `emitterAddr`, `targetAddress`, and `vaa.Address` returns used by those paths. + +The exact checked scope is production Go under `node/` or `pkg/`, excluding tests and CodeQL generated files. The query recognizes canonical `vaa.StringToAddress` and `vaa.BytesToAddress` results, safe thin wrappers that delegate to those calls, already-typed `VAA` / `MessagePublication` / `VAAID` propagation, and explicit reviewed adapter exceptions. Current explicit exceptions include EVM `PadAddress(common.Address)`, Aptos `uint64` sender encoding, NEAR exact-32 emitter digest copy, XRPL account helpers, Sui typed array conversion, compile-time known-tokenbridge synthetic notary construction, and the narrow public RPC `GetSignedVAA` `MessageId.EmitterAddress` exact-32 lookup path. + +Fix an alert by routing string input through `vaa.StringToAddress`, byte input through `vaa.BytesToAddress`, or a whole serialized VAA ID through the canonical whole-ID parser owned by `canonical-vaa-id-parsing`. A string helper that decodes locally and then calls `BytesToAddress` is still intentionally reported because it changes string parsing policy. + +## Limitations + +The query is conservative about source provenance. It reports the current CosmWasm local `StringToAddress` copy because the accepted query cannot soundly prove the authenticated core-event producer path. It keeps a full-VAA-ID address-component result as overlap evidence in calibration, but root-cause ownership for complete `chain/address/sequence` parsing belongs to `canonical-vaa-id-parsing`. Hashes, transaction IDs, `common.Hash`, chain-ID parsing, tests, generated files, and already-typed internal address propagation are out of scope. + +## Learn More + +- [Rule contract](../../../.codeql-lint-builder/rules/canonical-vaa-address-parsing.md): records the address normalization standard, exceptions, test matrix, calibration results, and ownership boundaries. +- [Final acceptance-blocker report](../../../.codeql-lint-builder/runs/canonical-vaa-address-parsing/10-final-acceptance-blockers-2026-07-15.md): documents the final public RPC and CosmWasm decisions plus retained production findings. +- [Rule query](../../src/canonical-vaa-address-parsing.ql): implements unsafe conversions, copy sinks, helper summaries, canonical sanitizer recognition, and explicit exceptions. +- [Rule fixtures](../../test/canonical-vaa-address-parsing/): encode direct conversions, manual copies, unsafe wrappers, canonical wrappers, typed propagation, whole-ID ownership, hash exclusions, and chain-adapter exceptions. +- [Calibration SARIF](../../../calibration/canonical-vaa-address-parsing/results.sarif): stores the latest production calibration results referenced by the acceptance report. + +The rule artifact cites Wormhole source revisions and examples, but this checkout does not contain the Wormhole repository source, so this page links only to local artifacts, query code, fixtures, and calibration outputs. + +## Maintainer Notes + +The CodeQL ID is `wormhole/go/canonical-vaa-address-parsing`. Add new chain-specific adapter exceptions only after documenting source type, width, and semantics in fixtures and the rule artifact. Keep duplicate-report boundaries with `canonical-vaa-id-parsing` explicit: complete serialized VAA IDs are whole-ID parser issues; standalone address components are address-normalization issues. diff --git a/codeql/docs/rules/canonical-vaa-id-parsing.md b/codeql/docs/rules/canonical-vaa-id-parsing.md new file mode 100644 index 0000000000..dc3402174a --- /dev/null +++ b/codeql/docs/rules/canonical-vaa-id-parsing.md @@ -0,0 +1,70 @@ +# Canonical VAA ID Parsing + +Serialized Wormhole VAA IDs in the form `chain/address/sequence` must be parsed with the canonical VAA-ID parser before constructing storage or lookup identities. + +## Why This Matters + +VAA IDs are used to decide whether a signed VAA already exists and to drive repair, reobservation, admin, and storage flows. Manual splitting can disagree with the canonical parser on tuple shape, chain width, sequence width, and especially emitter-address decoding. The preserved regression is an admin RPC path that split a VAA key and built `vaa.Address([]byte(parts[1]))`, treating hex text as ASCII bytes and therefore looking up a different VAA ID than the one stored canonically. + +## Examples + +### Violation + +```go +func hasVAA(db database, vaaKey string) (bool, error) { + parts := strings.Split(vaaKey, "/") + chain, err := strconv.ParseUint(parts[0], 10, 16) + if err != nil { + return false, err + } + seq, err := strconv.ParseUint(parts[2], 10, 64) + if err != nil { + return false, err + } + + id := guardianDB.VAAID{ + EmitterChain: vaa.ChainID(chain), + EmitterAddress: vaa.Address([]byte(parts[1])), + Sequence: seq, + } + return db.HasVAA(id) +} +``` + +### Fix + +```go +func hasVAA(db database, vaaKey string) (bool, error) { + id, err := guardianDB.VaaIDFromString(vaaKey) + if err != nil { + return false, err + } + return db.HasVAA(*id) +} +``` + +## What The Rule Checks + +The rule reports production Go under `node/` when a slash split of a serialized VAA/message ID flows into a `VAAID` `EmitterAddress` field instead of routing the complete ID string through the canonical parser. It covers `VaaIDFromString` in `node/pkg/db` / `pkg/db` and future `VAAIDFromString` in `sdk/vaa` as canonical parsers, excludes their bodies, and recognizes both direct `vaa.Address([]byte(parts[1]))` reconstruction and component-level `vaa.StringToAddress(parts[1])` followed by manual tuple assembly. + +Fix an alert by parsing the complete serialized ID with `db.VaaIDFromString` on the pinned DB-backed revision, or `vaa.VAAIDFromString` on SDK-migrated revisions, and by handling parse errors before using the identity. Component-level parsing is not a substitute for the whole-ID parser. + +The exact checked scope is production Go files under `node/`, excluding `*_test.go`, `*.pb.go`, and CodeQL generated files. Current fixtures cover the admin RPC ASCII-vs-hex regression, renamed local aliases, helper bypasses, ignored canonical parser results followed by manual reconstruction, canonical parser use, typed-field construction, string production, txverifier near misses, CLI near misses, tests, and generated files. + +## Limitations + +This query is intentionally narrow. It does not try to prove every semantic VAA-ID string by name; it requires local split-component flow into `VAAID.EmitterAddress`. Deep interprocedural parsing, reflection, or opaque helper libraries can be missed. Parsing a package-local non-storage tuple, producing a VAA-ID string from typed fields, splitting CLI arguments into request fields, or parsing a standalone address belongs outside this rule. Standalone address normalization is owned by `canonical-vaa-address-parsing`; full `chain/address/sequence` reconstruction is owned here. + +## Learn More + +- [Rule contract](../../../.codeql-lint-builder/rules/canonical-vaa-id-parsing.md): records the standard, architecture boundary, test matrix, calibration evidence, and ownership decisions. +- [Acceptance review](../../../.codeql-lint-builder/runs/canonical-vaa-id-parsing/08-review-and-learn-acceptance-2026-07-14.md): records the final accepted state after tightening parser identity and duplicate-report boundaries. +- [Rule query](../../src/canonical-vaa-id-parsing.ql): implements the production scope, canonical-parser recognition, split-flow model, and exclusions. +- [Rule fixtures](../../test/canonical-vaa-id-parsing/): encode positives, canonical fixes, near misses, generated/test exclusions, and parser-alias regressions. +- [Calibration results](../../../.codeql-lint-builder/results/canonical-vaa-id-parsing/workflow07-20260714.csv): records the representative production result reviewed during calibration. + +The rule artifact cites Wormhole source revisions and migration commits, but this checkout does not contain the Wormhole repository source, so this page links only to local rule artifacts, query code, fixtures, and run reports. + +## Maintainer Notes + +The CodeQL ID is `wormhole/go/canonical-vaa-id-parsing`. Preserve this rule's precedence over address parsing for complete serialized VAA IDs. Update the query and fixtures together if the canonical parser moves, if `VAAID` type names or package paths change, or if production introduces a reviewed parser wrapper that delegates completely to the canonical parser. diff --git a/codeql/docs/rules/evm-finality-release-and-reorg-checks.md b/codeql/docs/rules/evm-finality-release-and-reorg-checks.md new file mode 100644 index 0000000000..379b0f005f --- /dev/null +++ b/codeql/docs/rules/evm-finality-release-and-reorg-checks.md @@ -0,0 +1,93 @@ +# EVM Finality Release And Reorg Checks + +Release an EVM watcher pending message from `w.pending` only after local, fail-closed proofs establish the expected finality threshold and reject receipt reorg drift before `(*Watcher).verifyAndPublish`. + +## Why This Matters + +Pending EVM messages are provisional until the watcher reaches the message's effective consistency level and block-height delay. Even after the delay, the originally observed receipt can be orphaned or replaced by a reorg. The release path must therefore refetch the receipt and prove that the refetched receipt still describes the same transaction in the same block before publishing the pending message. + +## Examples + +### Violation + +```go +for _, pending := range w.pending { + receipt, _ := connector.TransactionReceipt(ctx, common.BytesToHash(pending.message.TxID)) + return w.verifyAndPublish(pending.message, ctx, common.BytesToHash(pending.message.TxID), receipt) +} +``` + +This releases a pending message without proving it came from the same watcher pending map, without checking the effective consistency level or height threshold, and without fail-closed handling for refetch errors, nil receipts, transaction-hash drift, or block-hash drift. + +### Fix + +```go +blockNumberU := ev.Number.Uint64() +thisConsistencyLevel := vaa.ConsistencyLevelFinalized +thisConsistencyLevel = vaa.ConsistencyLevelSafe + +for key, pending := range w.pending { + if !consistencyLevelMatches(thisConsistencyLevel, pending.effectiveCL) { + continue + } + if blockNumberU < pending.height+pending.additionalBlocks { + continue + } + + txHash := common.BytesToHash(pending.message.TxID) + receipt, err := connector.TransactionReceipt(ctx, txHash) + if errors.Is(err, ethereum.NotFound) { + continue + } + if err != nil { + continue + } + if receipt == nil { + continue + } + if receipt.TxHash != txHash { + continue + } + if receipt.BlockHash != key.BlockHash { + continue + } + + return w.verifyAndPublish(pending.message, ctx, txHash, receipt) +} +``` + +## What The Rule Checks + +The V1 rule reports production EVM watcher calls that release `pending.message` through `verifyAndPublish` unless the call is in the currently modeled pending-release shape: a `range` over the same watcher's `w.pending` map with a pending value and key available at the sink. + +For each modeled release, all required proofs must be visible in the same function and must be CFG-dominating, fail-closed guards (`continue` or `return`) before the `verifyAndPublish` call: + +- the pending entry comes from the same watcher's `w.pending` range; +- the current effective consistency level matches `pending.effectiveCL`; +- the current block height has reached `pending.height + pending.additionalBlocks`; +- the receipt is refetched with the pending message transaction hash; +- not-found/orphaned receipt errors are rejected; +- generic non-nil receipt-fetch errors are rejected; +- nil receipts are rejected; +- the refetched receipt's `TxHash` matches the pending message transaction hash; and +- the refetched receipt's `BlockHash` matches the pending range key's block hash. + +Receipt execution success is intentionally delegated to the sibling rule `evm-require-successful-receipt-before-observation`; this rule checks finality-release and reorg-consistency preconditions only. + +## Limitations + +The V1 source model is intentionally bounded to current pending-release shapes in `node/pkg/watchers/evm/`: direct `range` loops over `w.pending`, `pending.message`, `pending.effectiveCL`, `pending.height`, `pending.additionalBlocks`, range keys with `BlockHash`, `thisConsistencyLevel`, `blockNumberU`, `TransactionReceipt`, `errors.Is(..., ErrNoResult|NotFound)`, nil/error checks, and direct transaction/block hash comparisons. + +The rule does not yet model reobservation/v2 paths, helper-propagated proofs, arbitrary wrapper functions, alternate field names, interprocedural invariants, or broader receipt provenance. Add those only with fixtures that preserve the fail-closed, CFG-dominating proof requirement. It also does not prove receipt success; keep that responsibility in the sibling receipt-success rule. + +## Learn More + +- [Rule contract](../../../.codeql-lint-builder/rules/evm-finality-release-and-reorg-checks.md): records the bounded V1 policy, source evidence, and lifecycle decisions. +- [Rule query](../../src/evm-finality-release-and-reorg-checks.ql): defines the V1 source shapes, pending-release sink, required proof predicates, and diagnostic messages. +- [Rule fixtures](../../test/evm-finality-release-and-reorg-checks/): encode missing-proof cases, accepted guard shapes, and bounded-source behavior. +- [Sibling receipt-success rule](./evm-require-successful-receipt-before-observation.md): checks that a receipt succeeded before observation or publication. +- [EVM verify-and-publish gate](./evm-verify-and-publish-gate.md): documents the approved publication gate for EVM watcher messages. + +## Maintainer Notes + +The CodeQL ID is `wormhole/go/evm-finality-release-and-reorg-checks`. Update the query, fixtures, and this page together when pending storage, pending release loops, consistency-level calculation, height calculation, receipt refetching, or reorg checks change. Do not broaden V1 by trusting helper names or returned tuple shapes alone; model helper propagation separately and require the same CFG-dominating fail-closed proofs at the publication sink. diff --git a/codeql/docs/rules/evm-require-successful-receipt-before-observation.md b/codeql/docs/rules/evm-require-successful-receipt-before-observation.md new file mode 100644 index 0000000000..83a4e12b6d --- /dev/null +++ b/codeql/docs/rules/evm-require-successful-receipt-before-observation.md @@ -0,0 +1,60 @@ +# EVM Successful Receipt Before Observation + +Require proof that an EVM transaction receipt succeeded before parsing its publication logs or passing it to `(*Watcher).verifyAndPublish`. The proof may be local or inherited from the checked successful return of the modeled `MessageEventsForTransaction` helper. + +## Why This Matters + +Transaction inclusion, block finality, a non-nil receipt, and a matching transaction hash do not prove successful EVM execution. Observation code should reject failed receipts before deriving or publishing Wormhole messages. + +## Examples + +### Violation + +```go +receipt, err := connector.TransactionReceipt(ctx, txHash) +if err != nil || receipt == nil { + return err +} +return w.verifyAndPublish(msg, ctx, txHash, receipt) +``` + +### Fix + +```go +receipt, err := connector.TransactionReceipt(ctx, txHash) +if err != nil || receipt == nil { + return err +} +if receipt.Status != gethTypes.ReceiptStatusSuccessful { + return fmt.Errorf("transaction failed") +} +return w.verifyAndPublish(msg, ctx, txHash, receipt) +``` + +## What The Rule Checks + +The rule reports production EVM watcher calls to `verifyAndPublish` whose receipt argument lacks a dominating success-status proof. It also reports direct, indexed, aliased, and supported one-hop-helper parsing of `receipt.Logs` before the same receipt is proven successful. + +Tests, generated files, non-EVM watcher code, and uses dominated by a same-receipt `Status == ReceiptStatusSuccessful` proof are excluded. A checked successful `MessageEventsForTransaction` tuple also proves its returned receipt successful and binds its returned messages to that receipt. Reassigning the receipt or message slice, mixing tuple values from different calls, or failing to reject the paired error invalidates the inherited proof. + +## Helper Summary + +The query models the canonical `MessageEventsForTransaction` implementation in `node/pkg/watchers/evm/by_transaction.go`. Every nil-error return must remain dominated by a successful-status proof for the returned receipt before the summary activates. + +At the caller, the returned receipt, message slice, and checked error must come from the same tuple. The published message must be read from that slice. Unchecked or overwritten errors, mixed helper calls, reassigned receipts or slices, reassigned range values, and mutated slice elements remain findings. Arbitrary wrappers remain unsupported unless they receive an equally precise semantic summary. + +Existing-database calibration reports zero findings in 16.7 seconds, removing the former `reobserve.go` false positives without requiring redundant local receipt-status checks. + +Receipt-log provenance is deliberately shallow and covers the supported range, index, local alias, and one-hop parse-helper shapes encoded by the fixtures. + +## Learn More + +- [Rule contract](../../../.codeql-lint-builder/rules/evm-require-successful-receipt-before-observation.md) +- [Acceptance review](../../../.codeql-lint-builder/runs/evm-require-successful-receipt-before-observation/10-final-review-and-acceptance-2026-07-15.md) +- [Tuple-postcondition bugfix](../../../.codeql-lint-builder/runs/evm-require-successful-receipt-before-observation/11-tuple-postcondition-bugfix-2026-07-17.md) +- [Rule query](../../src/evm-require-successful-receipt-before-observation.ql) +- [Rule fixtures](../../test/evm-require-successful-receipt-before-observation/) + +## Maintainer Notes + +The CodeQL ID is `wormhole/go/evm-require-successful-receipt-before-observation`. Update the query, fixtures, contract, and this page together when receipt acquisition, log parsing, or `verifyAndPublish` call paths change. The helper summary is intentionally restricted to the canonical implementation path and proves its successful-return behavior; matching only a function name or tuple shape is insufficient. diff --git a/codeql/docs/rules/evm-verify-and-publish-gate.md b/codeql/docs/rules/evm-verify-and-publish-gate.md new file mode 100644 index 0000000000..cc24b2c43d --- /dev/null +++ b/codeql/docs/rules/evm-verify-and-publish-gate.md @@ -0,0 +1,55 @@ +# EVM Verify-And-Publish Gate + +Publish EVM watcher `MessagePublication` values only through `(*Watcher).verifyAndPublish`. + +## Why This Matters + +The EVM watcher’s `msgC` channel is the handoff from chain observation code to the shared processor. `verifyAndPublish` is the security gate that rejects nil messages, applies transfer-verifier logic when configured, updates `verificationState`, and then performs the channel send. A direct send to `w.msgC` can publish an EVM token-bridge message without the receipt-backed transfer-verifier state update that downstream code relies on. + +## Examples + +### Violation + +```go +func publish(w *Watcher, msg *common.MessagePublication) { + w.msgC <- msg +} +``` + +```go +func publish(w *Watcher, msg *common.MessagePublication) { + out := w.msgC + out <- msg +} +``` + +### Fix + +```go +func publish(w *Watcher, msg *common.MessagePublication, receipt *Receipt) error { + return w.verifyAndPublish(msg, context.Background(), msg.TxID, receipt) +} +``` + +## What The Rule Checks + +The rule reports production Go sends under `node/pkg/watchers/evm/` when the send target is the EVM watcher's `msgC` publication channel and the send is not inside `func (w *Watcher) verifyAndPublish(...)`. It recognizes direct field sends, ordered same-function local alias chains, and directly called thin helpers whose channel parameter is passed `w.msgC` or a supported alias. + +The rule ignores tests, generated files, non-EVM watcher channels, channel wiring in constructors, channel reads, unrelated same-typed channels, and helpers that only construct or return `*common.MessagePublication` without publishing. + +## Limitations + +The accepted model is intentionally bounded. It does not follow channel identity through closures, wrapper structs, function values, method expressions, interfaces, helper factories, reflection, unsafe pointers, package globals, or heterogeneous containers. Production calibration found no such bypass path. The rule also trusts the entire `verifyAndPublish` method body; it does not prove that the method internally verifies before its approved send. + +## Learn More + +- [Rule contract](../../../.codeql-lint-builder/rules/evm-verify-and-publish-gate.md): policy, source evidence, test matrix, calibration, and accepted unsupported boundaries. +- [Acceptance review](../../../.codeql-lint-builder/runs/evm-verify-and-publish-gate/08-review-and-learn-acceptance-2026-07-14.md): final gate checklist and residual risk. +- [Rule query](../../src/evm-verify-and-publish-gate.ql): scope, channel identity, alias, helper, and approved-body predicates. +- [Rule fixtures](../../test/evm-verify-and-publish-gate/): direct, alias, helper, precision, and unsupported-boundary cases. + +The rule artifact cites Wormhole source and hardening commit `983dd07551557530a337dcbff5bd579564e57426`, but this checkout does not contain the Wormhole repository, so this page cannot provide verified version-pinned links to those files. + +## Maintainer Notes + +The CodeQL ID is `wormhole/go/evm-verify-and-publish-gate`. Update the query, fixtures, and this page together if the EVM watcher publication channel, `Watcher` package path, `MessagePublication` type, or approved publication gate changes. Do not broaden the exception to a second helper by name alone; first decide whether the architecture now has multiple approved gates. diff --git a/codeql/docs/rules/message-publication-canonical-timestamp.md b/codeql/docs/rules/message-publication-canonical-timestamp.md new file mode 100644 index 0000000000..ed00f034ce --- /dev/null +++ b/codeql/docs/rules/message-publication-canonical-timestamp.md @@ -0,0 +1,61 @@ +# MessagePublication Canonical Timestamp + +Convert chain-derived Unix-second timestamps with `vaa.TimeFromUnix` before assigning them to `common.MessagePublication.Timestamp`. + +## Why This Matters + +`MessagePublication.Timestamp` is part of the VAA signing and wire-format path, where timestamps serialize at `uint32` precision. Direct `time.Unix(...)` conversion, often after `int64(...)` or `uint32(...)` casts, bypasses the SDK-owned range check that rejects negative and above-`math.MaxUint32` values. `vaa.TimeFromUnix` centralizes that protocol bound and forces callers to fail closed on invalid chain timestamps. + +## Examples + +### Violation + +```go +func build(blockTime uint64) common.MessagePublication { + return common.MessagePublication{ + Timestamp: time.Unix(int64(blockTime), 0), + } +} +``` + +```go +func build(blockTime uint64) common.MessagePublication { + timestamp, _ := vaa.TimeFromUnix(blockTime) + return common.MessagePublication{Timestamp: timestamp} +} +``` + +### Fix + +```go +func build(blockTime uint64) (common.MessagePublication, error) { + timestamp, err := vaa.TimeFromUnix(blockTime) + if err != nil { + return common.MessagePublication{}, err + } + return common.MessagePublication{Timestamp: timestamp}, nil +} +``` + +## What The Rule Checks + +The rule reports production Go under `node/` when a keyed composite literal for `common.MessagePublication` assigns `Timestamp:` from unsafe Unix-second conversion provenance. Covered positives include direct `time.Unix`, local temporaries, a thin helper returning only `time.Time`, and `vaa.TimeFromUnix` results whose paired error is ignored, overwritten, not checked, or checked without preventing publication. + +It accepts `vaa.TimeFromUnix` when the returned timestamp reaches the field and the paired error is rejected before publication, including `err != nil` fail-closed branches and `err == nil` guarded publication. It ignores tests, generated files, non-publication timestamps, local wall-clock round trips such as `time.Unix(time.Now().Unix(), 0)`, typed `time.Time` inputs from parsers, deserialization/rehydration, and unrelated same-named `MessagePublication` types outside `node/pkg/common`. + +## Limitations + +This rule currently checks `Timestamp:` keyed composite literals only. Post-construction field assignments such as `msg.Timestamp = time.Unix(...)` are an honest unsupported future scope; the known IBC-style field-mutation candidate should be modeled separately before this contract expands. The rule also avoids broad semantic source classification for every RPC/event field and does not handle deep interfaces, reflection, function values, or generic helper factories. + +## Learn More + +- [Rule contract](../../../.codeql-lint-builder/rules/message-publication-canonical-timestamp.md): policy, exact narrowed scope, pinned positives, test matrix, and calibration. +- [Final review/fix report](../../../.codeql-lint-builder/runs/message-publication-canonical-timestamp/08-return-fix-2026-07-15.md): final scope narrowing, type-identity fix, and test results. +- [Rule query](../../src/message-publication-canonical-timestamp.ql): composite-literal sink, `time.Unix`, `vaa.TimeFromUnix`, and error-guard predicates. +- [Rule fixtures](../../test/message-publication-canonical-timestamp/): direct conversions, unchecked helper errors, fail-closed guards, wall-clock exclusions, unrelated-type exclusion, and unsupported boundaries. + +The rule artifact cites Wormhole source plus timestamp hardening commits `5e0920b281e7e57ea53857f7c8e23e3134505149` and `f4f745660`, but this checkout does not contain the Wormhole repository, so this page cannot provide verified version-pinned links to those files. + +## Maintainer Notes + +The CodeQL ID is `wormhole/go/message-publication-canonical-timestamp`. Preserve the resolved type check for `node/pkg/common.MessagePublication`; selector-name-only matching caused a known false-positive risk. If field-assignment support is added, add dedicated probes, fixtures, and recalibration rather than silently changing this composite-literal-only rule. diff --git a/codeql/docs/rules/message-publication-safe-serialization.md b/codeql/docs/rules/message-publication-safe-serialization.md new file mode 100644 index 0000000000..4530c229f5 --- /dev/null +++ b/codeql/docs/rules/message-publication-safe-serialization.md @@ -0,0 +1,64 @@ +# MessagePublication Safe Serialization + +Use current `MessagePublication` binary serialization APIs for production data: `MarshalBinary` and `UnmarshalBinary`. + +## Why This Matters + +The deprecated `(*MessagePublication).Marshal` and `common.UnmarshalMessagePublication` helpers omit `Unreliable` and `verificationState`. Those fields affect reobservation behavior and transfer-verifier / txverifier decisions, including rejected or anomalous messages. Using the old format for current Governor, Notary, pending-message, or transport data can erase security state during persistence or recovery. + +## Examples + +### Violation + +```go +func write(p *PendingTransfer) ([]byte, error) { + return p.Msg.Marshal() +} +``` + +```go +func read(buf []byte) (*common.MessagePublication, error) { + return common.UnmarshalMessagePublication(buf) +} +``` + +### Fix + +```go +func write(p *PendingTransfer) ([]byte, error) { + return p.Msg.MarshalBinary() +} +``` + +```go +func read(buf []byte) (*common.MessagePublication, error) { + msg := &common.MessagePublication{} + if err := msg.UnmarshalBinary(buf); err != nil { + return nil, err + } + return msg, nil +} +``` + +## What The Rule Checks + +The rule reports production Go under `node/` that calls or captures the deprecated `MessagePublication` helpers. It resolves targets rather than relying on import spelling, so aliases, type aliases, embedded/promoted `Marshal` methods, parenthesized calls, bound method values, and selector captures are covered. + +The only accepted deprecated read is the bounded old Governor migration path: `node/pkg/db/governor.go`, inside `UnmarshalPendingTransfer`, under the true branch of the declared `isOld` parameter. Deprecated writes are never excepted. The rule ignores tests, generated files, non-`node/` code, helper definitions in `chainlock.go`, JSON/VAA/protobuf serialization, unrelated `Marshal` methods, and code that routes a message without serializing it. + +## Limitations + +The model does not prove arbitrary indirect invocations through reflection, `interface{}`, generic higher-order plumbing, or wrappers that erase the static `MessagePublication` target. Future migration exceptions should be added only when statically tied to an old-version discriminator and read-only for old bytes. + +## Learn More + +- [Rule contract](../../../.codeql-lint-builder/rules/message-publication-safe-serialization.md): policy, Governor exception, source evidence, fixtures, and calibration. +- [Acceptance review](../../../.codeql-lint-builder/runs/message-publication-safe-serialization/08-review-and-learn-acceptance-2026-07-15.md): final review notes for the migration exception and fixture coverage. +- [Rule query](../../src/message-publication-safe-serialization.ql): target-resolution, selector-capture, and old Governor branch predicates. +- [Rule fixtures](../../test/message-publication-safe-serialization/): deprecated/current calls, aliases, embedded types, captures, tests, and non-node scope cases. + +The rule artifact cites Wormhole source and hardening commit `a708838a9db46c02503ce38e01442852c2d88578`, but this checkout does not contain the Wormhole repository, so this page cannot provide verified version-pinned links to those files. + +## Maintainer Notes + +The CodeQL ID is `wormhole/go/message-publication-safe-serialization`. Keep the Governor exception bound to the resolved `isOld` parameter and true-branch AST shape; do not weaken it to identifier spelling. Update fixtures whenever new compatibility branches, wrapper serializers, or `MessagePublication` package paths are introduced. diff --git a/codeql/docs/rules/near-finalized-receipt-outcome-before-publication.md b/codeql/docs/rules/near-finalized-receipt-outcome-before-publication.md new file mode 100644 index 0000000000..5a278d2b36 --- /dev/null +++ b/codeql/docs/rules/near-finalized-receipt-outcome-before-publication.md @@ -0,0 +1,88 @@ +# NEAR Finalized Receipt Outcome Before Publication + +Require NEAR watcher receipt-outcome logs to be published only after proving that the same `receipt_outcome.block_hash` is finalized. + +## Why This Matters + +NEAR receipt outcomes can expose Wormhole logs before the receipt outcome's block is finalized. A watcher that publishes logs from an unfinalized receipt outcome can observe data that is later invalidated by consensus reorganization or finality lag. The local publication path should therefore bind the parsed logs, the finality proof, and the header passed to publication to the same receipt outcome. + +## Examples + +### Violation + +```go +func observe(e *Watcher, logger *Logger, ctx context.Context, job *Job, receiptOutcome Result) error { + outcome := receiptOutcome.Get("outcome") + logs := outcome.Get("logs") + for _, log := range logs.Array() { + return e.processWormholeLog(logger, ctx, job, Header{}, "", log) + } + return nil +} +``` + +```go +func observe(e *Watcher, other *Watcher, logger *Logger, ctx context.Context, job *Job, receiptOutcome Result) error { + outcomeBlockHash := receiptOutcome.Get("block_hash") + blockHeader, isFinalized := other.finalizer.isFinalized(logger, ctx, outcomeBlockHash.String()) + if !isFinalized { + return errNotFinalized() + } + + outcome := receiptOutcome.Get("outcome") + logs := outcome.Get("logs") + for _, log := range logs.Array() { + return e.processWormholeLog(logger, ctx, job, blockHeader, "", log) + } + return nil +} +``` + +### Fix + +```go +func observe(e *Watcher, logger *Logger, ctx context.Context, job *Job, receiptOutcome Result) error { + outcome := receiptOutcome.Get("outcome") + outcomeBlockHash := receiptOutcome.Get("block_hash") + logs := outcome.Get("logs") + + blockHeader, isFinalized := e.finalizer.isFinalized(logger, ctx, outcomeBlockHash.String()) + if !isFinalized { + return errNotFinalized() + } + + for _, log := range logs.Array() { + return e.processWormholeLog(logger, ctx, job, blockHeader, "", log) + } + return nil +} +``` + +## What The Rule Checks + +The rule reports production calls to `(*Watcher).processWormholeLog` in `node/pkg/watchers/near/*.go` when the published log is the direct range-loop value from `logs.Array()` and `logs` was derived from `receiptOutcome.Get("outcome").Get("logs")` in the same function. + +The implementation accepts that publication only when all of these local correlations hold: + +- the finality proof is a `(*Finalizer).isFinalized` call reached through the same watcher receiver as the `processWormholeLog` sink, namely `watcher.finalizer.isFinalized(...)` for the same `watcher.processWormholeLog(...)` receiver; +- the proof's block hash argument is the `String()` value of `receiptOutcome.Get("block_hash")` for the same receipt outcome that supplied the iterated logs; +- the boolean result returned by that proof dominates the sink on the true/finalized branch, including fail-closed `if !isFinalized { return ... }` and guarded `if isFinalized { publish(...) }` shapes; and +- the block header passed to `processWormholeLog` is the header result returned by that same `isFinalized` call and has not been reassigned before publication. + +Tests, generated files, non-NEAR watcher files, unrelated `isFinalized` methods, different watcher receivers, wrong receipt outcomes, proofs performed after publication, inverted guards, reassigned receipt outcomes, and reassigned or separately fetched headers are not accepted as proofs for this sink. + +## Limitations + +This is an intentionally bounded, intra-function rule. It does not propagate receipt-outcome log provenance or finality proofs through arbitrary helpers, wrappers, method calls, interfaces, closures, goroutines, or stored state. The modeled sink is the production `Watcher.processWormholeLog` call reached while directly iterating logs derived from `receiptOutcome.Get("outcome").Get("logs")`; direct alternate publication sinks are out of scope. + +The loop model is also direct: the published argument must be the range value from `logs.Array()` in the same loop body. If future production code publishes logs after helper parsing, slice aliasing beyond the current local assignments, callback dispatch, or another publication API, add fixtures and recalibrate the query before expanding this contract. + +## Learn More + +- [Rule contract](../../../.codeql-lint-builder/rules/near-finalized-receipt-outcome-before-publication.md) +- [Rule query](../../src/near-finalized-receipt-outcome-before-publication.ql) +- [Rule fixtures](../../test/near-finalized-receipt-outcome-before-publication/) + +## Maintainer Notes + +The CodeQL ID is `wormhole/go/near-finalized-receipt-outcome-before-publication`. Keep the same-receiver, same-receipt-outcome, true-branch dominance, and same-proof-header requirements together when changing this rule; weakening any one of them can turn unrelated finality checks into false negatives. Update the query, fixtures, rule contract, and this page together when NEAR watcher publication code changes. diff --git a/codeql/docs/rules/run-with-scissors-error-return.md b/codeql/docs/rules/run-with-scissors-error-return.md new file mode 100644 index 0000000000..ed0665213c --- /dev/null +++ b/codeql/docs/rules/run-with-scissors-error-return.md @@ -0,0 +1,56 @@ +# RunWithScissors Error Return + +Return fatal runnable errors from `common.RunWithScissors` runnables instead of sending directly to the same wrapper error channel. + +## Why This Matters + +`RunWithScissors` centralizes watcher goroutine lifecycle handling: panic recovery, metrics, and nonblocking forwarding of returned errors to `errC`. A runnable that sends directly to the same `errC` bypasses that nonblocking wrapper path and can hang shutdown or cleanup when the receiver has stopped. If it also returns the same error, the failure may be delivered twice. + +## Examples + +### Violation + +```go +common.RunWithScissors(ctx, errC, "poller", func() error { + if err := poll(); err != nil { + errC <- fmt.Errorf("poll failed: %w", err) + return nil + } + return nil +}) +``` + +### Fix + +```go +common.RunWithScissors(ctx, errC, "poller", func() error { + if err := poll(); err != nil { + return fmt.Errorf("poll failed: %w", err) + } + return nil +}) +``` + +## What The Rule Checks + +The rule reports production Go sends under `node/` that execute in a runnable passed directly to `common.RunWithScissors(ctx, errC, name, runnable)` and target the same error channel as argument 1. It supports both `github.com/wormhole-foundation/wormhole/node/pkg/common` and `github.com/certusone/wormhole/node/pkg/common` package paths. + +Supported runnable forms are inline function literals, local function values with a reaching pre-call function-literal assignment that is not overwritten, method values, receiver-method bodies that send to the same receiver field, and one synchronous helper hop where the helper receives the same channel parameter or sends through the same receiver field. The rule ignores tests, generated files, wrapper-owned forwarding inside `RunWithScissors`/`StartRunnable`/`startRunnable`, sibling reads from `errC`, unrelated channels, non-`RunWithScissors` goroutines, and helper calls launched under `go`. + +## Limitations + +This calibrated rule starts only from direct `common.RunWithScissors` calls. Thin lifecycle wrappers around `RunWithScissors` are intentionally deferred/unsupported, even if they appear to forward `(ctx, errC, name, runnable)` unchanged. Async helpers and goroutine boundaries are also deferred: a helper called with `go report(errC, err)` from the runnable is out of scope. Local channel aliases such as `ch := errC; ch <- err`, container-stored channels, recursive helper chains, interface dispatch, and deep interprocedural channel propagation are tolerated false negatives rather than name-matched approximations. + +## Learn More + +- [Rule contract](../../../.codeql-lint-builder/rules/run-with-scissors-error-return.md): records the lifecycle standard, direct-call-only scope, unsupported boundaries, test matrix, and calibration evidence. +- [Acceptance / return-fix report](../../../.codeql-lint-builder/runs/run-with-scissors-error-return/08-review-and-learn-return-fixes-2026-07-15.md): records async-helper exclusion, local function-value reaching-definition fixes, direct-call-only scope, and final calibration. +- [Rule query](../../src/run-with-scissors-error-return.ql): defines package paths, runnable resolution, same-channel correlation, helper modeling, and report location. +- [Rule fixtures](../../test/run-with-scissors-error-return/): encode supported positives, returned-error negatives, sibling reads, unrelated channels, unsupported aliases, async helpers, and thin-wrapper boundaries. +- [Certusone module fixtures](../../test/run-with-scissors-error-return-certusone-module/): ensure the historical module path is recognized. + +The rule artifact cites Wormhole source, tests, watcher docs, and hardening commit `f04918d1b483b772727855d1711392f7575798da`, but this checkout does not contain the Wormhole repository, so this page cannot provide verified version-pinned links to them. + +## Maintainer Notes + +The CodeQL ID is `wormhole/go/run-with-scissors-error-return`. Keep the direct-call-only contract, async helper exclusion, and thin-wrapper deferral synchronized across query, fixtures, and docs. If thin wrappers or aliases become required, add explicit fixtures before broadening same-channel modeling. diff --git a/codeql/docs/rules/solana-commitment-match-before-publication.md b/codeql/docs/rules/solana-commitment-match-before-publication.md new file mode 100644 index 0000000000..6df3034f2f --- /dev/null +++ b/codeql/docs/rules/solana-commitment-match-before-publication.md @@ -0,0 +1,113 @@ +# Solana Commitment Match Before Publication + +Check a decoded Solana message commitment against the watcher's configured commitment before publishing the message or scheduling an instruction-account retry. + +## Why This Matters + +The Solana watcher decodes a requested consistency level from account and shim message data, converts it into a watcher commitment, and then decides whether the observation should be published. Publishing to `msgC`, or scheduling `retryFetchMessageAccount`, before proving that decoded commitment is acceptable can let a lower-commitment observation flow through a watcher that is configured for a stricter commitment. + +## Examples + +### Violation + +```go +func process(s *SolanaWatcher, proposal *MessagePublicationAccount, isReobservation bool) { + commitment, err := accountConsistencyLevelToCommitment(proposal.ConsistencyLevel) + if err != nil { + return + } + + s.msgC <- &MessagePublication{} +} +``` + +Instruction retry scheduling has the same requirement: + +```go +func schedule(s *SolanaWatcher, ctx Context, rpcClient *RPCClient, acc PublicKey, sig Signature, data *ShimPostMessageData, isReobservation bool) { + commitment, err := data.ConsistencyLevel.Commitment() + if err != nil { + return + } + _ = commitment + + RunWithScissors(ctx, nil, "retryFetchMessageAccount", func(ctx Context) error { + s.retryFetchMessageAccount(ctx, rpcClient, acc, 0, 0, isReobservation, sig) + return nil + }) +} +``` + +### Fix + +```go +func process(s *SolanaWatcher, proposal *MessagePublicationAccount, isReobservation bool) { + commitment, err := accountConsistencyLevelToCommitment(proposal.ConsistencyLevel) + if err != nil { + return + } + if !s.checkCommitment(commitment, isReobservation) { + return + } + + s.msgC <- &MessagePublication{} +} +``` + +For instruction retry scheduling, guard the converted instruction commitment before the direct retry call or before the `RunWithScissors` scheduling call: + +```go +func schedule(s *SolanaWatcher, ctx Context, rpcClient *RPCClient, acc PublicKey, sig Signature, data *ShimPostMessageData, isReobservation bool) { + commitment, err := data.ConsistencyLevel.Commitment() + if err != nil { + return + } + if !s.checkCommitment(commitment, isReobservation) { + return + } + + RunWithScissors(ctx, nil, "retryFetchMessageAccount", func(ctx Context) error { + s.retryFetchMessageAccount(ctx, rpcClient, acc, 0, 0, isReobservation, sig) + return nil + }) +} +``` + +## What The Rule Checks + +The rule reports production Go code under `node/pkg/watchers/solana/` when a decoded commitment reaches a publication or instruction-retry sink without both required proofs: + +- the conversion error from the same decoded commitment conversion is rejected after the conversion; and +- the same `SolanaWatcher` receiver that publishes or schedules the observation has proven `checkCommitment(commitment, isReobservation)` true before the sink. + +The account and shim publication sink is a send to a watcher's `msgC` field, such as `s.msgC <- observation`. Account commitments are conversions from `accountConsistencyLevelToCommitment(proposal.ConsistencyLevel)`. Shim commitments are conversions from `postMessage.ConsistencyLevel.Commitment()`. + +The instruction-retry sink is either a direct call to `s.retryFetchMessageAccount(...)` or a direct `RunWithScissors(ctx, errC, "retryFetchMessageAccount", func(...) { s.retryFetchMessageAccount(...) })` scheduling call. For those paths, the relevant decoded commitment is the instruction `PostMessageData.ConsistencyLevel.Commitment()` conversion. + +Accepted `checkCommitment` proof shapes are intentionally narrow: + +- `if !s.checkCommitment(commitment, isReobservation) { return }` before the sink; +- `if s.checkCommitment(commitment, isReobservation) { ... sink ... }`; and +- `if !s.checkCommitment(commitment, isReobservation) { ... } else { ... sink ... }`. + +The proof must call the `checkCommitment` method on a `SolanaWatcher`, pass the converted commitment as the first argument, and use the same watcher receiver as the later `msgC` send or `retryFetchMessageAccount` receiver. A helper with the same method name, a different watcher instance, an ignored check result, or a check after the sink does not satisfy the rule. + +The conversion error guard must occur after the conversion and before the sink. A stale guard from an earlier assignment, an ignored conversion error, or a reassignment of the error variable before the sink invalidates the conversion proof. Reassigning the commitment after `checkCommitment` also invalidates the commitment proof for later publication. + +The rule deliberately ignores tests, generated files, non-Solana-watcher files, unrelated `RunWithScissors` jobs, close-event paths that delegate through already modeled account processing, and manual comparisons such as `commitment != CommitmentConfirmed` that do not use the watcher's canonical `checkCommitment` policy. + +## Limitations + +The model is local and syntax-bounded. It does not prove arbitrary interprocedural wrappers, local function variables passed to `RunWithScissors`, non-inline retry scheduling, deep receiver aliasing, container-stored commitments, or custom helper predicates equivalent to `checkCommitment`. Branch proof recognition is limited to the supported shapes above and requires a direct `return` in the failing branch. Receiver matching is exact enough to avoid accepting checks on another watcher, but it may miss complex aliases that are semantically equivalent. + +The query ties instruction scheduling to direct `retryFetchMessageAccount` calls and direct `RunWithScissors` calls named `"retryFetchMessageAccount"`. Thin lifecycle wrappers, asynchronous helper boundaries, and renamed scheduling jobs are out of scope unless the query and fixtures are extended together. + +## Learn More + +- [Rule contract](../../../.codeql-lint-builder/rules/solana-commitment-match-before-publication.md): records the intended project policy, source references, test matrix, and calibration notes when present in the lint-builder artifacts. +- [Rule query](../../src/solana-commitment-match-before-publication.ql): defines decoded commitment conversion, conversion-error proof, receiver-matched `checkCommitment` proof, publication sinks, and instruction retry scheduling sinks. +- [Rule fixtures](../../test/solana-commitment-match-before-publication/): encode account and shim `msgC` sends, direct and scheduled instruction retries, accepted branch shapes, receiver mismatches, stale guards, reassignment invalidation, and near-miss carve-outs. + +## Maintainer Notes + +The CodeQL ID is `wormhole/go/solana-commitment-match-before-publication`. Keep this page, the query, and the fixtures synchronized if `checkCommitment`, `accountConsistencyLevelToCommitment`, `PostMessageData.ConsistencyLevel.Commitment`, `msgC`, `retryFetchMessageAccount`, or `RunWithScissors` scheduling conventions change. Add fixtures before broadening receiver aliasing, branch-shape recognition, or wrapper support so the rule remains high precision. diff --git a/codeql/docs/rules/solana-message-account-validation.md b/codeql/docs/rules/solana-message-account-validation.md new file mode 100644 index 0000000000..751bafb1ed --- /dev/null +++ b/codeql/docs/rules/solana-message-account-validation.md @@ -0,0 +1,55 @@ +# Solana Message Account Validation + +Create Solana watcher message account data with `NewMessageAccountData` and reject its error before parsing or processing the value. + +## Why This Matters + +A Solana account stores program state as bytes. In the Wormhole Solana watcher, `NewMessageAccountData` is the validation boundary that checks those bytes before they become `MessageAccountData`. Constructing that type directly, or using the constructor result after an error, lets unvalidated data reach parsing or processing code without the constructor's discriminator and length checks. + +## Examples + +### Violation + +```go +func process(raw []byte) { + data := MessageAccountData{Data: raw} + ParseMessagePublicationAccount(data) +} +``` + +### Fix + +```go +func process(raw []byte) error { + data, err := NewMessageAccountData(raw) + if err != nil { + return err + } + ParseMessagePublicationAccount(data) + return nil +} +``` + +## What The Rule Checks + +The rule reports calls to `ParseMessagePublicationAccount` and `processMessageAccount` in production files under `node/pkg/watchers/solana/` when the relevant argument cannot be traced to a successful `NewMessageAccountData` call. It accepts local aliases, one pointer round trip, and checked factory wrappers up to the modeled depth. Fix an alert by routing the raw bytes through the constructor and ensuring its error is rejected on every path to the reported call. + +The rule deliberately ignores tests, generated files, similarly named functions outside the Solana watcher, and calls inside the parser or processor implementations themselves. + +## Limitations + +The model is intentionally local and recognizes at most two levels of safe factory wrapping. More complex interprocedural flows, container storage, or equivalent validators with another name may be reported. An accepted error guard must start on a later source line than the constructor assignment; this matches gofmt-normalized watcher code but can conservatively report a semicolon-separated valid guard. Conversely, the rule proves use of the designated constructor and its error guard; it does not independently verify the constructor's implementation. + +## Learn More + +- [Solana accounts](https://solana.com/docs/core/accounts): mutable Solana program state is stored in account data; this documentation is maintained online and is not version-pinned. +- [Rule contract](../../../.codeql-lint-builder/rules/solana-message-account-validation.md): records the project policy, architecture intent, source references, test matrix, and calibration evidence. +- [Acceptance review](../../../.codeql-lint-builder/runs/solana-message-account-validation/08-review-and-learn-acceptance-2026-07-13.md): records the final stale-guard, source-order, performance, and acceptance gate. +- [Rule query](../../src/solana-message-account-validation.ql): defines the enforced constructor provenance, error guard, scope, and report location. +- [Rule fixtures](../../test/solana-message-account-validation/): encode accepted constructor flows, violations, and boundary cases. + +The rule artifact cites Wormhole source and hardening commit `e889d725f`, but this checkout does not contain the Wormhole repository, so this page cannot provide verified version-pinned links to them. + +## Maintainer Notes + +The CodeQL ID is `wormhole/go/solana-message-account-validation`. The query depends on local data flow, global value numbering, and dominating error guards. Update the query and all three fixture categories together if parser entry points, the constructor name, watcher paths, or the allowed factory depth changes. diff --git a/codeql/docs/rules/solana-require-successful-transaction-meta.md b/codeql/docs/rules/solana-require-successful-transaction-meta.md new file mode 100644 index 0000000000..ffdb610fd1 --- /dev/null +++ b/codeql/docs/rules/solana-require-successful-transaction-meta.md @@ -0,0 +1,53 @@ +# Solana Successful Transaction Metadata + +Validate that Solana RPC transaction metadata is present and successful before parsing transactions, reading metadata that drives parsing, or calling `processTransaction`. + +## Why This Matters + +The Solana watcher observes finalized transaction records. Finality alone does not prove that a transaction executed successfully or that useful metadata exists. Treating failed or metadata-less transactions as observations can make the watcher parse logs or transaction contents that should have been rejected at the RPC metadata boundary. + +## Examples + +### Violation + +```go +func observe(txRpc *rpc.GetTransactionResult) error { + for _, log := range txRpc.Meta.LogMessages { + _ = log + } + return processTransaction(txRpc, txRpc.Meta) +} +``` + +### Fix + +```go +func observe(txRpc *rpc.GetTransactionResult) error { + if err := validateTransactionMeta(txRpc.Meta); err != nil { + return err + } + return processTransaction(txRpc, txRpc.Meta) +} +``` + +An equivalent direct guard is also accepted when it proves the same metadata value is non-nil and has `Err == nil` before the sink. + +## What The Rule Checks + +The rule reports production Solana watcher sinks under `node/pkg/watchers/solana/`: calls to `processTransaction` with transaction metadata, transaction extraction from responses tied to a metadata value, and reads of metadata fields such as `LogMessages` before parsing decisions. It requires a dominating successful-meta proof for the same metadata value. + +The rule deliberately ignores tests, generated files, account-subscription/account-ID paths without `*rpc.TransactionMeta`, and `Err` field reads that participate in validation. + +## Limitations + +The model is intra-procedural. It recognizes `validateTransactionMeta(meta)` only when the returned error is rejected before the sink, and direct `meta != nil && meta.Err == nil` guards only for the same metadata version. It conservatively reports when the metadata or validator error is reassigned before use, when validation happens after the sink, or when an ignored/overwritten validator error breaks the proof. + +## Learn More + +- [Rule contract](../../../.codeql-lint-builder/rules/solana-require-successful-transaction-meta.md): records the project policy, architecture intent, source references, test matrix, calibration evidence, and acceptance transition. +- [Rule query](../../src/solana-require-successful-transaction-meta.ql): defines the Solana transaction-metadata proof model and sink set. +- [Rule fixtures](../../test/solana-require-successful-transaction-meta/): encode violations, accepted guards, reassignment invalidation, and out-of-scope boundaries. + +## Maintainer Notes + +The CodeQL ID is `wormhole/go/solana-require-successful-transaction-meta`. Update the query, fixtures, and this page together if Solana watcher transaction-entry paths, `validateTransactionMeta`, or `processTransaction` signatures change. diff --git a/codeql/docs/rules/xrpl-derived-generated-emitter.md b/codeql/docs/rules/xrpl-derived-generated-emitter.md new file mode 100644 index 0000000000..d0d1e6e3f0 --- /dev/null +++ b/codeql/docs/rules/xrpl-derived-generated-emitter.md @@ -0,0 +1,60 @@ +# XRPL Derived Generated Emitter + +Use the family-specific domain-separated emitter when the XRPL watcher synthesizes XTCF, XACK, or NTT `MessagePublication` values. + +## Why This Matters + +XRPL watcher-generated messages do not come from an on-chain Core bridge emitter. Their `EmitterAddress` becomes part of the VAA identity consumed by downstream protocols, so generated XRPL message families need their own namespaces. XTCF and XACK must use the generated managed-account emitter with the nonzero `"XRPL"` prefix. NTT must use `keccak256("ntt" + source manager + source token)`. Raw account emitters are reserved for generic XRPL Core payments and can collide with, or be confused for, generated-message domains. + +## Examples + +### Violation + +```go +func parseTicketCreateTransaction(account string) *MessagePublication { + return &MessagePublication{ + EmitterChain: vaa.ChainIDXRPL, + EmitterAddress: addressToEmitter(account), // raw Core-style emitter + Payload: buildXTCFPayload(), + } +} +``` + +### Fix + +```go +func parseTicketCreateTransaction(account string) *MessagePublication { + return &MessagePublication{ + EmitterChain: vaa.ChainIDXRPL, + EmitterAddress: calculateGeneratedEmitterAddress(account), + Payload: buildXTCFPayload(), + } +} +``` + +For NTT, use the approved NTT helper that hashes the exact `"ntt"`, source-manager, and source-token sequence. + +## What The Rule Checks + +The rule reports production `MessagePublication` composite literals under `node/pkg/watchers/xrpl/` whose `EmitterChain` is `ChainIDXRPL`, whose enclosing function matches the current XTCF, XACK, or NTT parser shapes, and whose `EmitterAddress` cannot be proven to use the approved family derivation. + +It recognizes XTCF/XACK functions by `xtcfPrefix` or `xackPrefix` use and NTT by `parseNttTransaction` calling `buildNTTPayload`. For XTCF/XACK, the approved helper must seed from `addressToEmitter` and overlay `generatedEmitterPrefix` into bytes `[0:4]` without other writes/copies to the returned emitter. For NTT, the approved helper must return bytes derived from `Keccak256` over a buffer containing `"ntt"`, `sourceNTTManager`, and `sourceToken` in the modeled slice positions. + +The rule ignores tests, generated files, non-XRPL watchers, non-XRPL publications, payload-only helpers, routing code, and the generic XRPL Core raw-emitter path. + +## Limitations + +Family classification is tied to the current XRPL parser shapes and helper names. The query does not prove arbitrary byte-level equivalence, interface dispatch, reflection, global mutable emitters, deeply indirect helper factories, or future generated XRPL families. It conservatively rejects multiple assignments to the local emitter value and generated-emitter helpers that perform extra returned-emitter writes. A future compliant helper with a different shape may need query and fixture updates. + +## Learn More + +- [Rule contract](../../../.codeql-lint-builder/rules/xrpl-derived-generated-emitter.md): records the XRPL emitter standard, historical fixes, model, tests, and calibration evidence. +- [Acceptance review](../../../.codeql-lint-builder/runs/xrpl-derived-generated-emitter/08-review-and-learn-acceptance-2026-07-14.md): records the final helper-binding, assignment-order, overwrite, and acceptance gate. +- [Rule query](../../src/xrpl-derived-generated-emitter.ql): defines family recognition, approved helper internals, report locations, and scope. +- [Rule fixtures](../../test/xrpl-derived-generated-emitter/): encode raw emitters, wrong NTT derivations, bad layouts, overwrites, generated/test exclusions, and near misses. + +The rule artifact cites Wormhole source, README sections, regression tests, and historical fix commits, but this checkout does not contain the Wormhole repository, so this page cannot provide verified version-pinned links to those files. + +## Maintainer Notes + +The CodeQL ID is `wormhole/go/xrpl-derived-generated-emitter`. Update the query and fixtures together if XRPL parser function names, payload-prefix signals, generated-emitter helper layout, NTT derivation helper internals, `MessagePublication` construction style, or watcher paths change. diff --git a/codeql/docs/rules/xrpl-first-memo-only.md b/codeql/docs/rules/xrpl-first-memo-only.md new file mode 100644 index 0000000000..66884c89f7 --- /dev/null +++ b/codeql/docs/rules/xrpl-first-memo-only.md @@ -0,0 +1,57 @@ +# XRPL First Memo Only + +Wormhole Core and NTT parsing in the XRPL watcher must inspect only `Memos[0]`, never scan or select a later memo. + +## Why This Matters + +An XRP Ledger transaction can carry an ordered array of arbitrary memos. Wormhole assigns canonical meaning to the first memo for Core and Native Token Transfer (NTT) messages. If the watcher scans the array, a malformed or unrelated first memo can be bypassed by a later memo that looks like a Wormhole message, changing which payload the watcher treats as canonical. + +## Examples + +### Violation + +```go +func parse(tx Transaction) bool { + for _, memo := range tx.Memos { + if memo.MemoFormat == coreMemoFormat { + return true + } + } + return false +} +``` + +### Fix + +```go +func parse(tx Transaction) bool { + if len(tx.Memos) == 0 { + return false + } + return tx.Memos[0].MemoFormat == coreMemoFormat +} +``` + +## What The Rule Checks + +The rule reports range loops, nonzero or dynamic indexing, and scanning-helper calls that consume an XRPL transaction's `Memos` collection while recognizing `coreMemoFormat` or `nttMemoFormat`. It covers typed `Transaction.Memos` values and the `"Memos"` entry of `FlatTransaction` values in production files under `node/pkg/watchers/xrpl/`. + +Fix an alert by checking that the memo collection is nonempty and inspecting index zero directly. A malformed first memo must cause rejection rather than a search for a later fallback. The rule ignores tests, generated files, unrelated memo formats, unrelated types with a `Memos` field, local memo arrays, and iteration over the selected first memo's data bytes. + +## Limitations + +Recognition is tied to the current watcher path, transaction type names, and the identifiers `coreMemoFormat` and `nttMemoFormat`. Collection flow through unmodeled helpers or renamed protocol constants can evade the rule, while a helper that scans for other purposes may be reported when called with one of the recognized formats. + +## Learn More + +- [XRPL transaction Memos field](https://xrpl.org/docs/references/protocol/transactions/common-fields#memos-field): specifies that `Memos` is an array carrying arbitrary messaging data; this documentation is maintained online and is not version-pinned. +- [Rule contract](../../../.codeql-lint-builder/rules/xrpl-first-memo-only.md): records the Wormhole first-memo policy, source references, test matrix, and calibration evidence. +- [Acceptance review](../../../.codeql-lint-builder/runs/xrpl-first-memo-only/08-review-and-learn-acceptance-2026-07-13.md): records the final wrapper-model gate and acceptance decision. +- [Rule query](../../src/xrpl-first-memo-only.ql): defines the recognized formats, collection sources, scanning behavior, and exclusions. +- [Rule fixtures](../../test/xrpl-first-memo-only/): encode direct scans, helper scans, first-index access, and near misses. + +The rule artifact cites Wormhole source, regression tests, and fix commit `0d2738c68`, but this checkout does not contain the Wormhole repository, so this page cannot provide verified version-pinned links to them. + +## Maintainer Notes + +The CodeQL ID is `wormhole/go/xrpl-first-memo-only`. The query uses local data flow and a transitive local call relation to connect format recognizers to memo access. Update the query and fixtures together when XRPL transaction representations, memo-format constants, helper structure, or watcher paths change. diff --git a/codeql/docs/rules/xrpl-require-validated-transaction.md b/codeql/docs/rules/xrpl-require-validated-transaction.md new file mode 100644 index 0000000000..484dd7ee7c --- /dev/null +++ b/codeql/docs/rules/xrpl-require-validated-transaction.md @@ -0,0 +1,52 @@ +# Require A Validated XRPL Transaction + +Prove that an XRPL transaction's `Validated` field is true before passing that same transaction to a Wormhole parser entry point. + +## Why This Matters + +An XRPL API response may describe a transaction before its result is final. The Wormhole parser can turn a transaction into an observation, so parsing a response that is not from a validated ledger can make provisional transaction data eligible for downstream processing. A check after parsing, a check of another transaction, or a check invalidated by reassignment does not establish the required precondition. + +## Examples + +### Violation + +```go +func parse(parser *Parser, tx TxResponse) { + parser.ParseTxResponse(&txResponseV2{TxResponse: tx}) +} +``` + +### Fix + +```go +func parse(parser *Parser, tx TxResponse) { + if !tx.Validated { + return + } + parser.ParseTxResponse(&txResponseV2{TxResponse: tx}) +} +``` + +## What The Rule Checks + +The rule reports calls to `Parser.ParseTransactionStream` and `Parser.ParseTxResponse` in production files under `node/pkg/watchers/xrpl/` unless a dominating branch proves `Validated == true` for the parsed transaction. It recognizes a direct field check, a local boolean alias, stream transactions, and the `txResponseV2` wrapper used by `ParseTxResponse`. It rejects proofs invalidated by replacing the transaction, wrapper, wrapped transaction, or validated field before the call. + +Fix an alert by returning or continuing when `Validated` is false before invoking the parser. The rule ignores tests, generated files, same-named methods on other receiver types, parser methods outside the XRPL watcher, and checks of unrelated transaction values. + +## Limitations + +The proof must be visible in the same function and match the modeled parser and wrapper shapes. Validation performed in a helper, encoded through a different wrapper, or represented by another API may be reported even if semantically safe. Mutation invalidation uses source position and can conservatively reject a mutation on a branch that cannot reach the parser call. Taking the transaction's address also conservatively invalidates the proof, even if a pointer is rebound before use. The rule checks ledger validation only; it does not prove transaction success or any other parser precondition. + +## Learn More + +- [XRPL transaction finality](https://xrpl.org/docs/concepts/transactions/finality-of-results): explains validated ledgers and when transaction outcomes are final; this documentation is maintained online and is not version-pinned. +- [Rule contract](../../../.codeql-lint-builder/rules/xrpl-require-validated-transaction.md): records the project policy, architecture intent, source references, test matrix, and calibration evidence. +- [Acceptance review](../../../.codeql-lint-builder/runs/xrpl-require-validated-transaction/08-review-and-learn-acceptance-2026-07-13.md): records the final mutation, pointer, precision, and acceptance gate. +- [Rule query](../../src/xrpl-require-validated-transaction.ql): defines parser entry points, accepted proof shapes, mutation invalidation, and scope. +- [Rule fixtures](../../test/xrpl-require-validated-transaction/): encode dominating checks, invalid checks, mutation cases, wrappers, and near misses. + +The rule artifact cites Wormhole source for the caller contract and finality policy, but this checkout does not contain the Wormhole repository, so this page cannot provide verified version-pinned links to it. + +## Maintainer Notes + +The CodeQL ID is `wormhole/go/xrpl-require-validated-transaction`. The query uses global value numbering and control-flow dominance to connect the parsed value to its validation proof. Update the query and fixtures together if parser methods, receiver types, response wrappers, transaction fields, or watcher paths change. diff --git a/codeql/qlpack.yml b/codeql/qlpack.yml new file mode 100644 index 0000000000..639dabc601 --- /dev/null +++ b/codeql/qlpack.yml @@ -0,0 +1,34 @@ +--- +library: false +name: wormhole/wormhole-go-lints +version: 0.0.1 +extractor: go +description: Wormhole-specific Go CodeQL lint rules. +default-suite: + - query: src/already-locked-receiver-mutex.ql + - query: src/algorand-publication-field-length-check.ql + - query: src/canonical-chain-id-parsing.ql + - query: src/canonical-vaa-address-parsing.ql + - query: src/canonical-vaa-id-parsing.ql + - query: src/delegate-consensus-canonical-digest.ql + - query: src/delegated-guardian-config-validation.ql + - query: src/evm-ccl-signed-message-immutability.ql + - query: src/evm-finality-release-and-reorg-checks.ql + - query: src/evm-require-successful-receipt-before-observation.ql + - query: src/evm-verify-and-publish-gate.ql + - query: src/governance-vaa-typed-payload.ql + - query: src/guardian-signer-exact-digest-length.ql + - query: src/message-publication-canonical-timestamp.ql + - query: src/message-publication-safe-serialization.ql + - query: src/near-finalized-receipt-outcome-before-publication.ql + - query: src/run-with-scissors-error-return.ql + - query: src/solana-alt-owner-before-decode.ql + - query: src/solana-commitment-match-before-publication.ql + - query: src/solana-message-account-validation.ql + - query: src/solana-require-successful-transaction-meta.ql + - query: src/untrusted-vaa-use-before-verification.ql + - query: src/xrpl-derived-generated-emitter.ql + - query: src/xrpl-first-memo-only.ql + - query: src/xrpl-require-validated-transaction.ql +dependencies: + codeql/go-all: 7.2.0 diff --git a/codeql/src/algorand-publication-field-length-check.ql b/codeql/src/algorand-publication-field-length-check.ql new file mode 100644 index 0000000000..cd737df33d --- /dev/null +++ b/codeql/src/algorand-publication-field-length-check.ql @@ -0,0 +1,376 @@ +/** + * @name Algorand publication field decoded without exact length check + * @description Algorand publishMessage nonce and sequence bytes must be proven exactly 8 bytes before binary.BigEndian.Uint64 decoding. + * @kind problem + * @problem.severity warning + * @precision high + * @id wormhole/go/algorand-publication-field-length-check + * @tags security + * external/cwe/cwe-20 + */ + +import go +import semmle.go.concepts.GeneratedFile +import semmle.go.controlflow.ControlFlowGraph +import semmle.go.dataflow.GlobalValueNumbering + +predicate isProductionAlgorandWatcherFile(File f) { + ( + f.getRelativePath().matches("node/pkg/watchers/algorand/%.go") + or + f.getRelativePath().matches("pkg/watchers/algorand/%.go") + ) and + not f.getRelativePath().matches("%_test.go") and + not f instanceof GeneratedFile +} + +predicate isAlgorandPublicationFunction(FuncDecl f) { + isProductionAlgorandWatcherFile(f.getFile()) and + exists(AstNode n | + n.getEnclosingFunction() = f and + ( + exists(Ident id | id = n and id.getName() in ["publishMessage", "MessagePublication"]) + or + exists(SelectorExpr sel, Field field | + sel = n and + sel.refersTo(field) and + field.getName() in ["Nonce", "Sequence"] + ) + ) + ) +} + +predicate isApplicationArgsTwo(IndexExpr idx) { + idx.getIndex().getIntValue() = 2 and + exists(SelectorExpr args, Field field | + args = idx.getBase().stripParens() and + args.refersTo(field) and + field.getName() = "ApplicationArgs" + ) +} + +predicate isLogsZero(IndexExpr idx) { + idx.getIndex().getIntValue() = 0 and + exists(SelectorExpr logs, Field field | + logs = idx.getBase().stripParens() and + logs.refersTo(field) and + field.getName() = "Logs" + ) +} + +predicate isSequenceBytesExpr(Expr e) { + exists(ConversionExpr conv, IndexExpr idx | + e.stripParens() = conv and + idx = conv.getOperand().stripParens() and + isLogsZero(idx) + ) +} + +predicate isPublicationFieldStorageExpr(Expr e) { + isApplicationArgsTwo(e.stripParens().(IndexExpr)) + or + isLogsZero(e.stripParens().(IndexExpr)) +} + +predicate isExactPublicationFieldExpr(Expr e) { + isApplicationArgsTwo(e.stripParens().(IndexExpr)) + or + isSequenceBytesExpr(e) +} + +predicate publicationFieldFlowsTo(Expr sinkArg, Expr source) { + isExactPublicationFieldExpr(source) and + ( + DataFlow::localFlow(DataFlow::exprNode(source), DataFlow::exprNode(sinkArg.stripParens())) + or + exists(Assignment assign, Expr lhs | + assign.getRhs(_) = source and + lhs = assign.getLhs(_) and + sameLocalVariable(lhs, sinkArg) and + assign.getLocation().getStartLine() < sinkArg.getLocation().getStartLine() + ) + ) +} + +predicate isBinaryBigEndianUint64Call(CallExpr call) { + call.getTarget().getName() = "Uint64" and + call.getTarget().getPackage().getPath() = "encoding/binary" and + exists(SelectorExpr uint64Sel, Expr ord | + uint64Sel = call.getCalleeExpr().stripParens() and + uint64Sel.getSelector().getName() = "Uint64" and + ord = uint64Sel.getBase().stripParens() and + isBinaryBigEndianOrderExpr(ord, call) + ) +} + +predicate isBinaryBigEndianSelector(Expr e) { + exists(SelectorExpr bigEndianSel | + bigEndianSel = e.stripParens() and + bigEndianSel.getSelector().getName() = "BigEndian" + ) +} + +predicate isBinaryBigEndianOrderExpr(Expr ord, AstNode use) { + isBinaryBigEndianSelector(ord) + or + exists(Assignment assign, Expr lhs, Expr rhs | + rhs = assign.getRhs(_) and + isBinaryBigEndianSelector(rhs) and + lhs = assign.getLhs(_) and + sameLocalVariable(lhs, ord) and + assign.getEnclosingFunction() = use.getEnclosingFunction() and + assign.getLocation().getStartLine() < use.getLocation().getStartLine() + ) +} + +predicate sameLocalValue(Expr a, Expr b) { + globalValueNumber(DataFlow::exprNode(a.stripParens())) = + globalValueNumber(DataFlow::exprNode(b.stripParens())) + or + DataFlow::localFlow(DataFlow::exprNode(a.stripParens()), DataFlow::exprNode(b.stripParens())) + or + DataFlow::localFlow(DataFlow::exprNode(b.stripParens()), DataFlow::exprNode(a.stripParens())) +} + +predicate samePublicationFieldValue(Expr a, Expr b) { + sameLocalValue(a, b) + or + isApplicationArgsTwo(a.stripParens().(IndexExpr)) and isApplicationArgsTwo(b.stripParens().(IndexExpr)) + or + isLogsZero(a.stripParens().(IndexExpr)) and isSequenceBytesExpr(b) + or + isSequenceBytesExpr(a) and isLogsZero(b.stripParens().(IndexExpr)) + or + isSequenceBytesExpr(a) and isSequenceBytesExpr(b) +} + +predicate isLenCallFor(CallExpr lenCall, Expr value) { + lenCall.getCalleeName() = "len" and + lenCall.getNumArgument() = 1 and + samePublicationFieldValue(lenCall.getArgument(0), value) +} + +predicate isEight(Expr e) { e.getIntValue() = 8 } + +predicate sameLocalVariableAssigned(Expr lhs, Expr value) { + sameLocalVariable(lhs, value) +} + +predicate valueReassignedBetween(Expr value, Expr condition, AstNode use) { + exists(Assignment assign, Expr lhs | + lhs = assign.getLhs(_) and + sameLocalVariableAssigned(lhs, value) and + assign.getLocation().getStartLine() > condition.getLocation().getStartLine() and + assign.getLocation().getStartLine() < use.getLocation().getStartLine() + ) + or + exists(Assignment assign, Expr lhs | + lhs = assign.getLhs(_) and + isPublicationFieldStorageExpr(lhs) and + samePublicationFieldValue(lhs, value) and + assign.getLocation().getStartLine() > condition.getLocation().getStartLine() and + assign.getLocation().getStartLine() < use.getLocation().getStartLine() + ) +} + +predicate exactLengthConditionFor(Expr condition, Expr value, boolean truth) { + truth = true and + exists(EqExpr eq, CallExpr lenCall | + condition = eq and + ( + isLenCallFor(lenCall, value) and lenCall = eq.getLeftOperand() and isEight(eq.getRightOperand()) + or + isLenCallFor(lenCall, value) and lenCall = eq.getRightOperand() and isEight(eq.getLeftOperand()) + ) + ) + or + truth = false and + exists(NeqExpr neq, CallExpr lenCall | + condition = neq and + ( + isLenCallFor(lenCall, value) and lenCall = neq.getLeftOperand() and isEight(neq.getRightOperand()) + or + isLenCallFor(lenCall, value) and lenCall = neq.getRightOperand() and isEight(neq.getLeftOperand()) + ) + ) +} + +predicate exactLengthGuardDominates(Expr value, AstNode use) { + exists(ControlFlow::ConditionGuardNode guard, Expr condition, boolean truth | + exactLengthConditionFor(condition, value, truth) and + not valueReassignedBetween(value, condition, use) and + guard.ensures(DataFlow::exprNode(condition), truth) and + ( + use instanceof Expr and + guard.dominates(DataFlow::exprNode(use.(Expr)).getBasicBlock()) + or + exists(ControlFlow::Node useNode | + useNode.isFirstNodeOf(use) and + guard.dominates(useNode.getBasicBlock()) + ) + ) + ) +} + +predicate isDirectUnguardedPublicationDecode(CallExpr call) { + isProductionAlgorandWatcherFile(call.getFile()) and + isAlgorandPublicationFunction(call.getEnclosingFunction()) and + isBinaryBigEndianUint64Call(call) and + exists(Expr fieldSource | + publicationFieldFlowsTo(call.getArgument(0), fieldSource) and + not exactLengthGuardDominates(call.getArgument(0), call) + ) +} + +predicate parameterFlowsToUint64(Parameter parameter, CallExpr uint64Call) { + exists(FuncDecl helper, Ident parameterRead | + helper = parameter.getFunction() and + parameterRead.refersTo(parameter) and + parameterRead.getEnclosingFunction() = helper and + isBinaryBigEndianUint64Call(uint64Call) and + uint64Call.getEnclosingFunction() = helper and + DataFlow::localFlow( + DataFlow::exprNode(parameterRead), DataFlow::exprNode(uint64Call.getArgument(0)) + ) + ) +} + +predicate helperParameterHasExactGuard(Parameter parameter, CallExpr uint64Call) { + exists(FuncDecl helper, Ident parameterRead | + helper = parameter.getFunction() and + parameterRead.refersTo(parameter) and + parameterRead.getEnclosingFunction() = helper and + exactLengthGuardDominates(parameterRead, uint64Call) + ) +} + +predicate assignmentReceivesTupleElement(Assignment assign, CallExpr call, int index, Expr lhs) { + assign.getRhs(0) = call and + lhs = assign.getLhs(index) +} + +predicate exprRefersToNilLocal(Expr e) { e.(Ident).getName() = "nil" } + +predicate sameLocalVariable(Expr a, Expr b) { + exists(Entity target | + a.stripParens().(Ident).refersTo(target) and + b.stripParens().(Ident).refersTo(target) + ) +} + +predicate neqNilExprFor(Expr condition, Expr err) { + exists(NeqExpr neq, Expr nil | + condition = neq and + exprRefersToNilLocal(nil) and + ( + sameLocalVariable(neq.getLeftOperand(), err) and neq.getRightOperand() = nil + or + sameLocalVariable(neq.getRightOperand(), err) and neq.getLeftOperand() = nil + ) + ) +} + +predicate errorRejectedBefore(Expr err, AstNode use) { + exists(ControlFlow::ConditionGuardNode guard, Expr condition | + neqNilExprFor(condition, err) and + condition.getLocation().getStartLine() > err.getLocation().getStartLine() and + guard.ensures(DataFlow::exprNode(condition), false) and + ( + use instanceof Expr and + guard.dominates(DataFlow::exprNode(use.(Expr)).getBasicBlock()) + or + exists(ControlFlow::Node useNode | + useNode.isFirstNodeOf(use) and + guard.dominates(useNode.getBasicBlock()) + ) + ) + ) +} + +predicate pairedHelperErrorExpr(CallExpr call, Expr err) { + exists(Assignment assign | + assign.getEnclosingFunction() = call.getEnclosingFunction() and + assignmentReceivesTupleElement(assign, call, 1, err) + ) +} + +predicate pairedHelperResultExpr(CallExpr call, Expr decoded) { + exists(Assignment assign | + assign.getEnclosingFunction() = call.getEnclosingFunction() and + assignmentReceivesTupleElement(assign, call, 0, decoded) + ) +} + +predicate exprContainsSameLocalValue(Expr outer, Expr value) { + sameLocalValue(outer, value) + or + exists(Expr child | + child = outer.getAChild*() and + sameLocalValue(child, value) + ) + or + exists(Ident id | + id = outer.getAChild*() and + sameLocalVariable(id, value) + ) +} + +predicate publicationUsesHelperResult(CompositeLit publication, Expr decoded) { + exists(int i, KeyValueExpr field, Expr fieldValue | + field = publication.getElement(i) and + field.getKey().(Ident).getName() in ["Nonce", "Sequence"] and + fieldValue = field.getValue() and + exprContainsSameLocalValue(fieldValue, decoded) + ) +} + +predicate helperResultPublicationUse(CallExpr call, CompositeLit publication) { + exists(Expr decoded | + pairedHelperResultExpr(call, decoded) and + publication.getEnclosingFunction() = call.getEnclosingFunction() and + publication.getLocation().getStartLine() > call.getLocation().getStartLine() and + publication.getType().getName() = "MessagePublication" and + publicationUsesHelperResult(publication, decoded) + ) +} + +predicate helperResultPublicationUseNotRejected(CallExpr call) { + exists(Expr err, CompositeLit publication | + pairedHelperErrorExpr(call, err) and + helperResultPublicationUse(call, publication) and + not errorRejectedBefore(err, publication) + ) +} + +predicate isThinHelperPublicationDecode(CallExpr call, Parameter parameter, CallExpr internalUint64) { + exists(FuncDecl helper | + helper = parameter.getFunction() and + isProductionAlgorandWatcherFile(call.getFile()) and + isAlgorandPublicationFunction(call.getEnclosingFunction()) and + call.getTarget().getFuncDecl() = helper and + parameterFlowsToUint64(parameter, internalUint64) and + isProductionAlgorandWatcherFile(helper.getFile()) and + exists(int i, Expr fieldSource | + parameter = helper.getParameter(i) and + publicationFieldFlowsTo(call.getArgument(i), fieldSource) + ) + ) +} + +predicate isUnguardedThinHelperPublicationDecode(CallExpr call) { + exists(Parameter parameter, CallExpr internalUint64 | + isThinHelperPublicationDecode(call, parameter, internalUint64) and + ( + not helperParameterHasExactGuard(parameter, internalUint64) + or + ( + exists(CompositeLit publication | helperResultPublicationUse(call, publication)) and + helperResultPublicationUseNotRejected(call) + ) + ) + ) +} + +from CallExpr call +where isDirectUnguardedPublicationDecode(call) or isUnguardedThinHelperPublicationDecode(call) +select call, "Algorand publishMessage nonce/sequence bytes must be proven exactly 8 bytes before Uint64 decoding; malformed lengths can panic the watcher before the observation is skipped." diff --git a/codeql/src/already-locked-receiver-mutex.ql b/codeql/src/already-locked-receiver-mutex.ql new file mode 100644 index 0000000000..ca5ddb7580 --- /dev/null +++ b/codeql/src/already-locked-receiver-mutex.ql @@ -0,0 +1,294 @@ +/** + * @name AlreadyLocked helper called without holding the required receiver mutex + * @description Documented AlreadyLocked helper methods must be called only while the exact receiver's mutex write lock is held. + * @kind problem + * @problem.severity warning + * @precision medium + * @id wormhole/go/already-locked-receiver-mutex + * @tags security + */ + +import go +import semmle.go.concepts.GeneratedFile +import semmle.go.controlflow.ControlFlowGraph +import semmle.go.dataflow.GlobalValueNumbering + +predicate isRelevantProductionFile(File f) { + ( + f.getRelativePath().matches("node/pkg/accountant/%.go") or + f.getRelativePath().matches("node/pkg/governor/%.go") or + f.getRelativePath().matches("node/cmd/ccq/%.go") + ) and + not f.getRelativePath().matches("%_test.go") and + not f instanceof GeneratedFile +} + +predicate sameLocalVariable(Expr a, Expr b) { + exists(Ident ai, Ident bi, Variable v | + a.stripParens() = ai and + b.stripParens() = bi and + ai.refersTo(v) and + bi.refersTo(v) + ) +} + +predicate sameValue(Expr a, Expr b) { + globalValueNumber(DataFlow::exprNode(a.stripParens())) = + globalValueNumber(DataFlow::exprNode(b.stripParens())) + or + DataFlow::localFlow(DataFlow::exprNode(a.stripParens()), DataFlow::exprNode(b.stripParens())) + or + DataFlow::localFlow(DataFlow::exprNode(b.stripParens()), DataFlow::exprNode(a.stripParens())) + or + sameLocalVariable(a, b) +} + +predicate before(AstNode earlier, AstNode later) { + earlier.getLocation().getStartLine() < later.getLocation().getStartLine() + or + earlier.getLocation().getStartLine() = later.getLocation().getStartLine() and + earlier.getLocation().getStartColumn() < later.getLocation().getStartColumn() +} + +predicate isDeferCall(CallExpr call) { exists(DeferStmt defer | defer.getCall() = call) } + +predicate isAsynchronousCall(CallExpr call) { + isDeferCall(call) or exists(GoStmt go | call = go.getCall() or call.getParent*() = go) +} + +predicate methodOnReceiver(CallExpr call, Expr receiver, string packageName, string typeName, string methodName) { + isRelevantProductionFile(call.getFile()) and + call.getCalleeName() = methodName and + call.getTarget().getPackage().getName() = packageName and + call.getTarget() instanceof Method and + call.getTarget().(Method).getReceiverBaseType().getName() = typeName and + exists(SelectorExpr callee | + typeName in ["Accountant", "ChainGovernor", "PendingResponses"] and + callee = call.getCalleeExpr().(SelectorExpr) and + receiver = callee.getBase() + ) +} + +predicate isAlreadyLockedMethod(Method method, string mutexField) { + method.getPackage().getName() = "accountant" and + method.getReceiverBaseType().getName() = "Accountant" and + method.getName() in ["publishTransferAlreadyLocked", "addPendingTransferAlreadyLocked", "deletePendingTransferAlreadyLocked"] and + mutexField = "pendingTransfersLock" + or + method.getPackage().getName() = "governor" and + method.getReceiverBaseType().getName() = "ChainGovernor" and + method.getName() in ["parseMsgAlreadyLocked", "loadFromDBAlreadyLocked"] and + mutexField = "mutex" + or + method.getPackage().getName() = "ccq" and + method.getReceiverBaseType().getName() = "PendingResponses" and + method.getName() = "updateMetricsAlreadyLocked" and + mutexField = "mu" +} + +predicate isAlreadyLockedCall(CallExpr call, Expr receiver, string mutexField) { + methodOnReceiver(call, receiver, "accountant", "Accountant", "publishTransferAlreadyLocked") and + mutexField = "pendingTransfersLock" + or + methodOnReceiver(call, receiver, "accountant", "Accountant", "addPendingTransferAlreadyLocked") and + mutexField = "pendingTransfersLock" + or + methodOnReceiver(call, receiver, "accountant", "Accountant", "deletePendingTransferAlreadyLocked") and + mutexField = "pendingTransfersLock" + or + methodOnReceiver(call, receiver, "governor", "ChainGovernor", "parseMsgAlreadyLocked") and + mutexField = "mutex" + or + methodOnReceiver(call, receiver, "governor", "ChainGovernor", "loadFromDBAlreadyLocked") and + mutexField = "mutex" + or + methodOnReceiver(call, receiver, "ccq", "PendingResponses", "updateMetricsAlreadyLocked") and + mutexField = "mu" +} + +predicate mutexMethodCallReceiver( + CallExpr call, Expr actualReceiver, string mutexField, string methodName +) { + call.getCalleeName() = methodName and + exists(SelectorExpr callee, SelectorExpr mutexSelector, Field field | + callee = call.getCalleeExpr().(SelectorExpr) and + mutexSelector = callee.getBase().(SelectorExpr) and + mutexSelector.refersTo(field) and + field.getName() = mutexField and + actualReceiver = mutexSelector.getBase() + ) +} + +predicate isMutexMethodCall(CallExpr call, Expr receiver, string mutexField, string methodName) { + exists(Expr actualReceiver | + mutexMethodCallReceiver(call, actualReceiver, mutexField, methodName) and + sameValue(actualReceiver, receiver) + ) +} + +predicate lockDominatesCall(CallExpr lockCall, CallExpr lockedCall) { + exists(ControlFlow::Node lockNode, ControlFlow::Node lockedNode | + lockNode.isFirstNodeOf(lockCall) and + lockedNode.isFirstNodeOf(lockedCall) and + lockNode.getBasicBlock().dominates(lockedNode.getBasicBlock()) + ) +} + +predicate unlockDominatesCallBetween(CallExpr lockCall, CallExpr lockedCall, Expr receiver, string mutexField) { + exists(CallExpr unlockCall | + unlockCall.getEnclosingFunction() = lockedCall.getEnclosingFunction() and + isMutexMethodCall(unlockCall, receiver, mutexField, "Unlock") and + not isDeferCall(unlockCall) and + before(lockCall, unlockCall) and + before(unlockCall, lockedCall) and + lockDominatesCall(unlockCall, lockedCall) + ) +} + +predicate unlockCanReachCall(CallExpr unlockCall, CallExpr lockedCall) { + exists(ControlFlow::Node unlockNode, ControlFlow::Node lockedNode | + unlockNode.isFirstNodeOf(unlockCall) and + lockedNode.isFirstNodeOf(lockedCall) and + lockedNode = unlockNode.getASuccessor*() + ) +} + +predicate directlyContainedInBlock(AstNode node, BlockStmt block) { + block = node.getParent*() and + not exists(BlockStmt inner | + inner != block and + inner = node.getParent*() and + block = inner.getParent*() + ) +} + +predicate branchExitCanBypassRelock(CallExpr unlockCall, CallExpr relockCall, BlockStmt branch) { + exists(Stmt exit | + exit instanceof BreakStmt or exit instanceof ContinueStmt or exit instanceof GotoStmt + | + branch = exit.getParent*() and + before(unlockCall, exit) and + before(exit, relockCall) + ) +} + +predicate hasSameBranchProtectingRelock( + CallExpr unlockCall, CallExpr lockedCall, Expr receiver, string mutexField +) { + exists(CallExpr relockCall, BlockStmt branch | + relockCall.getEnclosingFunction() = lockedCall.getEnclosingFunction() and + isMutexMethodCall(relockCall, receiver, mutexField, "Lock") and + not isAsynchronousCall(relockCall) and + before(unlockCall, relockCall) and + before(relockCall, lockedCall) and + directlyContainedInBlock(unlockCall, branch) and + directlyContainedInBlock(relockCall, branch) and + not branchExitCanBypassRelock(unlockCall, relockCall, branch) + ) +} + +predicate unlockMayExposeCallBetween(CallExpr lockCall, CallExpr lockedCall, Expr receiver, string mutexField) { + exists(CallExpr unlockCall | + unlockCall.getEnclosingFunction() = lockedCall.getEnclosingFunction() and + isMutexMethodCall(unlockCall, receiver, mutexField, "Unlock") and + not isDeferCall(unlockCall) and + before(lockCall, unlockCall) and + unlockCanReachCall(unlockCall, lockedCall) and + not hasSameBranchProtectingRelock(unlockCall, lockedCall, receiver, mutexField) + ) +} + +predicate receiverReassignedBetween( + Expr receiver, Expr lockedReceiver, AstNode earlier, CallExpr later +) { + exists(Ident receiverRead, Variable receiverVariable, Assignment assign, Ident lhs, Expr rhs | + receiver.stripParens() = receiverRead and + receiverRead.refersTo(receiverVariable) and + assign.getEnclosingFunction() = later.getEnclosingFunction() and + assign.getAnLhs().stripParens() = lhs and + lhs.refersTo(receiverVariable) and + rhs = assign.getAnRhs() and + not sameValue(rhs, lockedReceiver) and + before(earlier, assign) and + before(assign, later) + ) +} + +predicate hasRequiredDominatingLock(CallExpr lockedCall, Expr receiver, string mutexField) { + exists(CallExpr lockCall, Expr lockedReceiver | + lockCall.getEnclosingFunction() = lockedCall.getEnclosingFunction() and + mutexMethodCallReceiver(lockCall, lockedReceiver, mutexField, "Lock") and + sameValue(lockedReceiver, receiver) and + not isAsynchronousCall(lockCall) and + before(lockCall, lockedCall) and + lockDominatesCall(lockCall, lockedCall) and + not receiverReassignedBetween(receiver, lockedReceiver, lockCall, lockedCall) and + not unlockDominatesCallBetween(lockCall, lockedCall, receiver, mutexField) and + not unlockMayExposeCallBetween(lockCall, lockedCall, receiver, mutexField) + ) +} + +predicate inheritsRequiredLockPrecondition(CallExpr lockedCall, Expr receiver, string mutexField) { + exists(Method enclosing, ReceiverVariable enclosingReceiver | + lockedCall.getEnclosingFunction() = enclosing.getFuncDecl() and + isAlreadyLockedMethod(enclosing, mutexField) and + enclosingReceiver.isReceiverOf(enclosing.getFuncDecl()) and + hasUnmodifiedReceiverProvenance(receiver, enclosingReceiver, lockedCall) + ) +} + +predicate assignmentToVariableBeforeCall(Assignment assignment, Variable variable, CallExpr call) { + exists(Ident lhs | + assignment.getEnclosingFunction() = call.getEnclosingFunction() and + assignment.getAnLhs().stripParens() = lhs and + lhs.refersTo(variable) and + before(assignment, call) + ) +} + +predicate assignmentToVariableBetween(Assignment assignment, Variable variable, AstNode earlier, CallExpr later) { + exists(Ident lhs | + assignment.getEnclosingFunction() = later.getEnclosingFunction() and + assignment.getAnLhs().stripParens() = lhs and + lhs.refersTo(variable) and + before(earlier, assignment) and + before(assignment, later) + ) +} + +predicate hasUnmodifiedReceiverProvenance(Expr receiver, ReceiverVariable enclosingReceiver, CallExpr lockedCall) { + // Direct calls on the enclosing method receiver inherit the helper precondition only while the + // receiver parameter has not been rebound. A rebinding may point at a different mutex. + exists(Ident receiverRead | + receiver.stripParens() = receiverRead and + receiverRead.refersTo(enclosingReceiver) and + not assignmentToVariableBeforeCall(_, enclosingReceiver, lockedCall) + ) + or + // One-hop aliases inherit the precondition only when they were assigned from the still-unmodified + // receiver and were not overwritten before the nested AlreadyLocked call. + exists(Ident aliasRead, Ident aliasLhs, Variable aliasVariable, Assignment aliasAssignment, Ident receiverRead | + receiver.stripParens() = aliasRead and + aliasRead.refersTo(aliasVariable) and + not aliasVariable = enclosingReceiver and + aliasAssignment.getEnclosingFunction() = lockedCall.getEnclosingFunction() and + aliasAssignment.assigns(aliasLhs, receiverRead) and + aliasLhs.refersTo(aliasVariable) and + receiverRead.refersTo(enclosingReceiver) and + before(aliasAssignment, lockedCall) and + not assignmentToVariableBeforeCall(_, enclosingReceiver, lockedCall) and + not assignmentToVariableBetween(_, aliasVariable, aliasAssignment, lockedCall) + ) +} + +from CallExpr call, Expr receiver, string mutexField +where + isAlreadyLockedCall(call, receiver, mutexField) and + ( + isAsynchronousCall(call) + or + not hasRequiredDominatingLock(call, receiver, mutexField) and + not inheritsRequiredLockPrecondition(call, receiver, mutexField) + ) +select call, + "AlreadyLocked helper must be called only while holding the required write lock on the same receiver." diff --git a/codeql/src/canonical-chain-id-parsing.ql b/codeql/src/canonical-chain-id-parsing.ql new file mode 100644 index 0000000000..bbcb8a1d9b --- /dev/null +++ b/codeql/src/canonical-chain-id-parsing.ql @@ -0,0 +1,276 @@ +/** + * @name Boundary Wormhole chain ID parsed without SDK helper + * @description Production node code should convert boundary-derived Wormhole chain IDs through the SDK chain-ID helpers instead of local casts or parsing. + * @kind problem + * @problem.severity warning + * @precision high + * @id wormhole/go/canonical-chain-id-parsing + * @tags security + * external/cwe/cwe-20 + */ + +import go +import semmle.go.concepts.GeneratedFile + +predicate isProductionNodeFile(File f) { + ( + f.getRelativePath().matches("node/%.go") + or + f.getRelativePath().matches("pkg/%.go") + or + f.getRelativePath().matches("cmd/%.go") + ) and + not f.getRelativePath().matches("%_test.go") and + not f.getRelativePath().matches("%.pb.go") and + not f.getRelativePath().matches("%_grpc.pb.go") and + not f.getRelativePath().matches("node/pkg/ethereum/abi/%.go") and + not f.getRelativePath().matches("pkg/ethereum/abi/%.go") and + not f instanceof GeneratedFile +} + +predicate isWormholeChainIdType(Type t) { + t.hasQualifiedName("github.com/wormhole-foundation/wormhole/sdk/vaa", "ChainID") + or + t.hasQualifiedName("github.com/certusone/wormhole/sdk/vaa", "ChainID") +} + +predicate isSdkChainIdHelperCall(CallExpr call, string semantics) { + exists(string pkg | + pkg = call.getTarget().getPackage().getPath() and + pkg in [ + "github.com/wormhole-foundation/wormhole/sdk/vaa", + "github.com/certusone/wormhole/sdk/vaa" + ] + ) and + ( + call.getTarget().getName() = "ChainIDFromNumber" and semantics = "wire-valid" + or + call.getTarget().getName() = "KnownChainIDFromNumber" and semantics = "registered-numeric" + or + call.getTarget().getName() = "StringToKnownChainID" and semantics = "registered-string" + ) +} + +predicate isGeneratedProtoPackage(Package pkg) { + pkg.getPath().matches("github.com/wormhole-foundation/wormhole/node/pkg/proto/%") + or + pkg.getPath().matches("github.com/certusone/wormhole/node/pkg/proto/%") +} + +predicate isBoundarySchemaFieldRead(Expr e) { + exists(SelectorExpr sel, Field field | + e = sel and + sel.refersTo(field) and + isGeneratedProtoPackage(field.getPackage()) + ) +} + +predicate isBoundarySchemaGetterCall(Expr e) { + exists(CallExpr call | + e = call and + isGeneratedProtoPackage(call.getTarget().getPackage()) and + call.getTarget().getName().matches("Get%") + ) +} + +predicate boundarySourceBase(Expr source, Expr base) { + exists(SelectorExpr sel | + source = sel and + base = sel.getBase() + ) + or + exists(CallExpr call, SelectorExpr callee | + source = call and + callee = call.getCalleeExpr().stripParens() and + base = callee.getBase() + ) +} + +predicate selectorRoot(Expr e, Expr root) { + root = e + or + exists(SelectorExpr sel | + e = sel and + selectorRoot(sel.getBase(), root) + ) +} + +predicate hasBoundaryOrigin(Expr base) { + exists(Expr root, Parameter p, Ident pRead | + selectorRoot(base, root) and + pRead.refersTo(p) and + DataFlow::localFlow(DataFlow::exprNode(pRead), DataFlow::exprNode(root)) + ) + or + exists(Expr root, Assignment assign, RecvExpr recv, Expr lhs | + selectorRoot(base, root) and + assign.getRhs(0) = recv and + lhs = assign.getLhs(_) and + DataFlow::localFlow(DataFlow::exprNode(lhs), DataFlow::exprNode(root)) + ) + or + exists(Expr root, RecvExpr recv | + selectorRoot(base, root) and + DataFlow::localFlow(DataFlow::exprNode(recv), DataFlow::exprNode(root)) + ) +} + +predicate isIbcMessageChainIdAttributeRead(Expr e) { + exists(CallExpr call, SelectorExpr callee | + e = call and + callee = call.getCalleeExpr().stripParens() and + ( + e.getFile().getRelativePath().matches("node/pkg/watchers/ibc/%.go") or + e.getFile().getRelativePath().matches("pkg/watchers/ibc/%.go") + ) and + call.getTarget() instanceof Method and + call.getTarget().getName() = "GetAsUint" and + call.getTarget().(Method).getReceiverBaseType().getName() = "WasmAttributes" and + call.getArgument(0).getStringValue() = "message.chain_id" and + call.getArgument(1).getIntValue() = 16 + ) +} + +predicate isIbcChannelChainsSelector(Expr e) { + exists(SelectorExpr sel, Field field | + e = sel and + sel.refersTo(field) and + field.getName() = "ChannelChains" + ) +} + +predicate isJsonDecodedNumberRead(Expr e) { + exists(TypeAssertExpr assertion, IndexExpr idx, RangeStmt loop, Ident rangeValue, Ident valueRead, Variable v | + e = assertion and + ( + e.getFile().getRelativePath().matches("node/pkg/watchers/ibc/%.go") or + e.getFile().getRelativePath().matches("pkg/watchers/ibc/%.go") + ) and + assertion.getExpr() = idx and + idx.getIndex().getIntValue() = 1 and + rangeValue = loop.getValue() and + rangeValue.refersTo(v) and + idx.getBase() = valueRead and + valueRead.refersTo(v) and + isIbcChannelChainsSelector(loop.getDomain()) and + assertion.getTypeExpr().toString() = "float64" + ) +} + +predicate isRangeValueFromParameter(Expr e) { + exists(RangeStmt loop, Parameter p, Ident pRead, Ident rangeValue, Ident valueRead, Variable v | + rangeValue = loop.getValue() and + rangeValue.refersTo(v) and + e = valueRead and + ( + e.getFile().getRelativePath().matches("node/pkg/txverifier/%.go") or + e.getFile().getRelativePath().matches("pkg/txverifier/%.go") + ) and + valueRead.refersTo(v) and + pRead.refersTo(p) and + DataFlow::localFlow(DataFlow::exprNode(pRead), DataFlow::exprNode(loop.getDomain())) + ) +} + +predicate isBoundaryChainIdSource(Expr e) { + isProductionNodeFile(e.getFile()) and + ( + ((isBoundarySchemaFieldRead(e) or isBoundarySchemaGetterCall(e)) and + exists(Expr base | boundarySourceBase(e, base) and hasBoundaryOrigin(base))) + or + isIbcMessageChainIdAttributeRead(e) + or + isJsonDecodedNumberRead(e) + or + isRangeValueFromParameter(e) + ) +} + +predicate boundarySourceFlowsTo(Expr source, Expr sink) { + isBoundaryChainIdSource(source) and + ( + DataFlow::localFlow(DataFlow::exprNode(source), DataFlow::exprNode(sink)) + or + (isIbcMessageChainIdAttributeRead(source) or isJsonDecodedNumberRead(source)) and + DataFlow::localFlow( + DataFlow::extractTupleElement(DataFlow::exprNode(source), 0), DataFlow::exprNode(sink) + ) + ) +} + +predicate isDirectChainIdConversion(ConversionExpr conv) { + isProductionNodeFile(conv.getFile()) and + isWormholeChainIdType(conv.getType()) +} + +predicate isStructFieldContext(ConversionExpr conv) { + exists(KeyValueExpr fieldInit | + conv = fieldInit.getValue() + or + conv = fieldInit.getValue().getAChild*() + ) +} + +predicate isCallArgumentContext(ConversionExpr conv) { + exists(CallExpr call, int i | + conv = call.getArgument(i) + or + conv = call.getArgument(i).getAChild*() + ) +} + +predicate isIndexContext(ConversionExpr conv) { + exists(IndexExpr idx | + conv = idx.getIndex() + or + conv = idx.getIndex().getAChild*() + ) +} + +predicate isComparisonContext(ConversionExpr conv) { + exists(ComparisonExpr cmp | + conv = cmp.getLeftOperand() + or + conv = cmp.getLeftOperand().getAChild*() + or + conv = cmp.getRightOperand() + or + conv = cmp.getRightOperand().getAChild*() + ) +} + +predicate isAssignmentContext(ConversionExpr conv) { + exists(Assignment assign | + conv = assign.getRhs(_) + or + conv = assign.getRhs(_).getAChild*() + ) +} + +predicate hasChainIdUseContext(ConversionExpr conv) { + isStructFieldContext(conv) + or + isCallArgumentContext(conv) + or + isIndexContext(conv) + or + isComparisonContext(conv) + or + isAssignmentContext(conv) +} + +predicate bypassesSdkChainIdHelper(ConversionExpr conv, Expr source) { + isDirectChainIdConversion(conv) and + hasChainIdUseContext(conv) and + boundarySourceFlowsTo(source, conv.getOperand()) and + not exists(CallExpr helper, string semantics | + isSdkChainIdHelperCall(helper, semantics) and + DataFlow::localFlow(DataFlow::exprNode(source), DataFlow::exprNode(helper.getAnArgument())) and + DataFlow::localFlow(DataFlow::exprNode(helper), DataFlow::exprNode(conv.getOperand())) + ) +} + +from ConversionExpr conv +where exists(Expr source | bypassesSdkChainIdHelper(conv, source)) +select conv, + "Convert boundary-derived Wormhole chain ID through `vaa.ChainIDFromNumber`, `vaa.KnownChainIDFromNumber`, or `vaa.StringToKnownChainID` according to whether this context needs wire-valid or registered-chain semantics." diff --git a/codeql/src/canonical-vaa-address-parsing.ql b/codeql/src/canonical-vaa-address-parsing.ql new file mode 100644 index 0000000000..71b472d736 --- /dev/null +++ b/codeql/src/canonical-vaa-address-parsing.ql @@ -0,0 +1,429 @@ +/** + * @name Non-canonical Wormhole address parsing + * @description External Wormhole address data must be normalized with vaa.StringToAddress or vaa.BytesToAddress before use as a Wormhole identity. + * @kind problem + * @problem.severity warning + * @precision high + * @id wormhole/go/canonical-vaa-address-parsing + * @tags security + * external/cwe/cwe-20 + */ + +import go +import semmle.go.concepts.GeneratedFile + +predicate isProductionNodeFile(File f) { + ( + f.getRelativePath().matches("node/%.go") or + f.getRelativePath().matches("pkg/%.go") + ) and + not f.getRelativePath().matches("%_test.go") and + not f instanceof GeneratedFile +} + +predicate isVaaAddressType(Type t) { t.getName() = "Address" and t.getPackage().getName() = "vaa" } + +predicate isVaaAddressExpr(Expr e) { isVaaAddressType(e.getType()) } + +predicate isByteLikeExpr(Expr e) { + e.getType() instanceof SliceType or + e.getType() instanceof ArrayType or + e.getType().getName() = "string" +} + +predicate isCanonicalAddressCall(CallExpr call) { + call.getTarget().getName() in ["StringToAddress", "BytesToAddress"] and + call.getTarget().getPackage().getName() = "vaa" +} + +predicate isCanonicalBytesAddressCall(CallExpr call) { + call.getTarget().getName() = "BytesToAddress" and call.getTarget().getPackage().getName() = "vaa" +} + +predicate isCanonicalStringAddressCall(CallExpr call) { + call.getTarget().getName() = "StringToAddress" and call.getTarget().getPackage().getName() = "vaa" +} + +predicate isCanonicalAddressResult(Expr e) { + exists(CallExpr call | + isCanonicalAddressCall(call) and + DataFlow::localFlow(DataFlow::extractTupleElement(DataFlow::exprNode(call), 0), DataFlow::exprNode(e)) + ) +} + +predicate isTypedInternalAddressRead(Expr e) { + exists(SelectorExpr sel, Field field | + e.stripParens() = sel and + sel.refersTo(field) and + field.getName() = "EmitterAddress" and + sel.getBase().getType().getName() in ["MessagePublication", "VAA", "VAAID"] + ) +} + +predicate isEvmPadAddressCopy(CallExpr call) { + exists(FuncDecl f | + call.getEnclosingFunction() = f and + f.getName() = "PadAddress" and + f.getFile().getRelativePath().matches("%/watchers/evm/utils.go") and + call.getTarget().getName() = "copy" and + exists(Parameter p | p = f.getParameter(0) and p.getType().getName() = "Address") + ) +} + +predicate isAptosUint64SenderCopy(CallExpr call) { + exists(FuncDecl f | + call.getEnclosingFunction() = f and + f.getFile().getRelativePath().matches("%/watchers/aptos/watcher.go") and + f.getName() = "observeData" and + call.getTarget().getName() = "copy" + ) +} + +predicate isNearExact32EmitterDigestCopy(CallExpr call) { + exists(FuncDecl f | + call.getEnclosingFunction() = f and + f.getFile().getRelativePath().matches("%/watchers/near/tx_processing.go") and + f.getName() = "processWormholeLog" and + call.getTarget().getName() = "copy" and + hasExact32LengthGuard(call.getArgument(1), call) + ) +} + +predicate isSuiTypedArrayConversion(ConversionExpr conv) { + exists(FuncDecl f | + conv.getEnclosingFunction() = f and + f.getFile().getRelativePath().matches("%/watchers/sui/watcher.go") and + conv.getOperand().getType() instanceof ArrayType + ) +} + +predicate isXrplAccountCopy(CallExpr call) { + exists(FuncDecl f | + call.getEnclosingFunction() = f and + f.getFile().getRelativePath().matches("%/watchers/xrpl/%.go") and + f.getName() in ["CoreEmitterAccount", "addressToEmitter", "calculateEmitterAddress", "calculateGeneratedEmitterAddress"] and + call.getTarget().getName() = "copy" + ) +} + +predicate isCosmwasmCoreStringToAddressCopy(CallExpr call) { + exists(FuncDecl f | + call.getEnclosingFunction() = f and + f.getFile().getRelativePath().matches("%/watchers/cosmwasm/watcher.go") and + f.getName() = "StringToAddress" and + call.getTarget().getName() = "copy" and + cosmwasmDecodedEmitterSource(call.getArgument(1), f) and + hasExact32LengthGuard(call.getArgument(1), call) + ) +} + +predicate isNotaryKnownEmitterConversion(ConversionExpr conv) { + exists(FuncDecl f, SelectorExpr sel | + conv.getEnclosingFunction() = f and + f.getFile().getRelativePath().matches("%/notary/admincommands.go") and + f.getName() = "createTestMessagePublication" and + DataFlow::localFlow(DataFlow::exprNode(sel), DataFlow::exprNode(conv.getOperand())) and + sel.getSelector().getName() = "KnownTokenbridgeEmitters" + ) + or + exists(FuncDecl f, IndexExpr idx, SelectorExpr sel | + conv.getEnclosingFunction() = f and + f.getFile().getRelativePath().matches("%/notary/admincommands.go") and + f.getName() = "createTestMessagePublication" and + DataFlow::localFlow(DataFlow::exprNode(idx), DataFlow::exprNode(conv.getOperand())) and + idx.getBase().stripParens() = sel and + sel.getSelector().getName() = "KnownTokenbridgeEmitters" + ) +} + +predicate isLenCallFor(CallExpr lenCall, Expr e) { + lenCall.getTarget().getName() = "len" and + ( + lenCall.getArgument(0) = e + or + DataFlow::localFlow(DataFlow::exprNode(lenCall.getArgument(0)), DataFlow::exprNode(e)) + or + DataFlow::localFlow(DataFlow::exprNode(e), DataFlow::exprNode(lenCall.getArgument(0))) + ) +} + +predicate exact32LengthConditionFor(Expr condition, Expr e, boolean truth) { + truth = true and + exists(EqExpr eq, CallExpr lenCall | + condition = eq and + ( + isLenCallFor(lenCall, e) and lenCall = eq.getLeftOperand() and eq.getRightOperand().getIntValue() = 32 + or + isLenCallFor(lenCall, e) and lenCall = eq.getRightOperand() and eq.getLeftOperand().getIntValue() = 32 + ) + ) + or + truth = false and + exists(NeqExpr neq, CallExpr lenCall | + condition = neq and + ( + isLenCallFor(lenCall, e) and lenCall = neq.getLeftOperand() and neq.getRightOperand().getIntValue() = 32 + or + isLenCallFor(lenCall, e) and lenCall = neq.getRightOperand() and neq.getLeftOperand().getIntValue() = 32 + ) + ) +} + +predicate hasExact32LengthGuard(Expr e, AstNode use) { + exists(ControlFlow::ConditionGuardNode guard, Expr condition, boolean truth, ControlFlow::Node useNode | + exact32LengthConditionFor(condition, e, truth) and + guard.ensures(DataFlow::exprNode(condition), truth) and + ( + use instanceof Expr and + guard.dominates(DataFlow::exprNode(use.(Expr)).getBasicBlock()) + or + useNode.isFirstNodeOf(use) and + guard.dominates(useNode.getBasicBlock()) + ) + ) +} + +predicate cosmwasmDecodedEmitterSource(Expr decoded, FuncDecl f) { + exists(CallExpr decode, Parameter p, Ident pRead | + p = f.getParameter(0) and + p.getType().getName() = "string" and + pRead.refersTo(p) and + pRead.getEnclosingFunction() = f and + decode.getTarget().getName() = "DecodeString" and + decode.getTarget().getPackage().getName() = "hex" and + DataFlow::localFlow(DataFlow::exprNode(pRead), DataFlow::exprNode(decode.getArgument(0))) and + DataFlow::localFlow(DataFlow::extractTupleElement(DataFlow::exprNode(decode), 0), DataFlow::exprNode(decoded)) + ) +} + +predicate isPublicRpcMessageIdEmitterAddressLookup(Expr e) { + exists(SelectorExpr emitterAddress, SelectorExpr messageId | + e.stripParens() = emitterAddress and + emitterAddress.getSelector().getName() = "EmitterAddress" and + emitterAddress.getBase().stripParens() = messageId and + messageId.getSelector().getName() = "MessageId" + ) +} + +predicate publicRpcMessageIdEmitterAddressDecodeFlowsTo(Expr copied, FuncDecl f) { + exists(CallExpr decode | + decode.getEnclosingFunction() = f and + decode.getTarget().getName() = "DecodeString" and + decode.getTarget().getPackage().getName() = "hex" and + isPublicRpcMessageIdEmitterAddressLookup(decode.getArgument(0)) and + DataFlow::localFlow(DataFlow::extractTupleElement(DataFlow::exprNode(decode), 0), DataFlow::exprNode(copied)) + ) +} + +predicate isPublicRpcExact32MessageIdCopy(CallExpr call) { + exists(FuncDecl f | + call.getEnclosingFunction() = f and + f.getFile().getRelativePath().matches("%/publicrpc/publicrpcserver.go") and + f.getName() = "GetSignedVAA" and + call.getTarget().getName() = "copy" and + publicRpcMessageIdEmitterAddressDecodeFlowsTo(call.getArgument(1), f) and + hasExact32LengthGuard(call.getArgument(1), call) + ) +} + +predicate stringParameterFlowsToBytesToAddressArg(FuncDecl f, Expr bytesArg) { + exists(Parameter p, Ident pRead | + p = f.getParameter(_) and + p.getType().getName() = "string" and + pRead.refersTo(p) and + pRead.getEnclosingFunction() = f and + DataFlow::localFlow(DataFlow::exprNode(pRead), DataFlow::exprNode(bytesArg)) + ) + or + exists(Parameter p, Ident pRead, CallExpr decode | + p = f.getParameter(_) and + p.getType().getName() = "string" and + pRead.refersTo(p) and + pRead.getEnclosingFunction() = f and + decode.getTarget().getName() = "DecodeString" and + decode.getTarget().getPackage().getName() = "hex" and + DataFlow::localFlow(DataFlow::exprNode(pRead), DataFlow::exprNode(decode.getArgument(0))) and + DataFlow::localFlow(DataFlow::extractTupleElement(DataFlow::exprNode(decode), 0), DataFlow::exprNode(bytesArg)) + ) +} + +predicate isExplicitTypedChainAdapter(Expr e) { + exists(CallExpr call | e = call and (isEvmPadAddressCopy(call) or isAptosUint64SenderCopy(call) or isNearExact32EmitterDigestCopy(call) or isXrplAccountCopy(call) or isCosmwasmCoreStringToAddressCopy(call) or isPublicRpcExact32MessageIdCopy(call))) + or + exists(ConversionExpr conv | e = conv and (isSuiTypedArrayConversion(conv) or isNotaryKnownEmitterConversion(conv))) +} + +predicate isFullVaaIdAddressComponent(Expr e) { + exists(IndexExpr component, CallExpr split | + component.getIndex().getIntValue() = 1 and + split.getCalleeName() = "Split" and + split.getNumArgument() = 2 and + split.getArgument(1).getStringValue() = "/" and + ( + exists(Ident idRead | + DataFlow::localFlow(DataFlow::exprNode(idRead), DataFlow::exprNode(split.getArgument(0))) and + idRead.getName().regexpMatch(".*(vaa|Vaa|VAA|id|ID|key|Key).*") + ) + or + exists(SelectorExpr sel | + DataFlow::localFlow(DataFlow::exprNode(sel), DataFlow::exprNode(split.getArgument(0))) and + sel.getSelector().getName().regexpMatch(".*(vaa|Vaa|VAA|id|ID|key|Key).*") + ) + ) and + DataFlow::localFlow(DataFlow::exprNode(split), DataFlow::exprNode(component.getBase())) and + DataFlow::localFlow(DataFlow::exprNode(component), DataFlow::exprNode(e)) + ) +} + +predicate isIdentityStructType(Type t) { + t.getName() in ["MessagePublication", "VAA", "VAAID", "tokenBridgeKey"] +} + +predicate isIdentityFieldSink(Expr sink, string sinkName) { + exists(CompositeLit lit, int i, KeyValueExpr kv | + kv = lit.getElement(i) and + sink = kv.getValue() and + kv.getKey().(Ident).getName() in ["EmitterAddress", "emitterAddr", "targetAddress"] and + isVaaAddressExpr(sink) and + isIdentityStructType(lit.getType()) and + sinkName = kv.getKey().(Ident).getName() + ) +} + +predicate isIdentityReturnSink(Expr sink, string sinkName) { + exists(ReturnStmt ret | + ret.getAnExpr() = sink and + isVaaAddressExpr(sink) and + sinkName = "vaa.Address return" + ) +} + +predicate isIdentitySink(Expr sink, string sinkName) { + isIdentityFieldSink(sink, sinkName) + or + isIdentityReturnSink(sink, sinkName) +} + +predicate flowsToIdentitySink(Expr e, string sinkName) { + exists(Expr sink | + isIdentitySink(sink, sinkName) and + DataFlow::localFlow(DataFlow::exprNode(e), DataFlow::exprNode(sink)) + ) +} + +predicate helperCallResultFlowsToIdentitySink(CallExpr call, string sinkName) { + exists(Expr sink | + isIdentitySink(sink, sinkName) and + DataFlow::localFlow(DataFlow::extractTupleElement(DataFlow::exprNode(call), 0), DataFlow::exprNode(sink)) + ) +} + +predicate isDirectVaaAddressConversion(ConversionExpr conv) { + isProductionNodeFile(conv.getFile()) and + isVaaAddressExpr(conv) and + isByteLikeExpr(conv.getOperand()) and + not isCanonicalAddressResult(conv) and + not isFullVaaIdAddressComponent(conv.getOperand()) and + not isTypedInternalAddressRead(conv.getOperand()) and + not isExplicitTypedChainAdapter(conv) +} + +predicate copyDestBase(CallExpr call, Expr base) { + call.getTarget().getName() = "copy" and + ( + base = call.getArgument(0).stripParens().(SliceExpr).getBase().stripParens() + or + base = call.getArgument(0).stripParens().(IndexExpr).getBase().stripParens() + ) +} + +predicate isManualVaaAddressCopy(CallExpr call, Expr base) { + isProductionNodeFile(call.getFile()) and + copyDestBase(call, base) and + isVaaAddressExpr(base) and + not isCanonicalAddressResult(base) and + not isTypedInternalAddressRead(base) and + not isExplicitTypedChainAdapter(call) +} + +predicate returnsExpr(FuncDecl f, Expr e) { + exists(ReturnStmt ret | ret.getEnclosingFunction() = f and ret.getExpr(0) = e) +} + +predicate isCanonicalAddressWrapper(FuncDecl f) { + isProductionNodeFile(f.getFile()) and + exists(ReturnStmt ret, CallExpr call | + ret.getEnclosingFunction() = f and + ret.getExpr(0) = call and + isCanonicalAddressCall(call) and + ( + isCanonicalStringAddressCall(call) + or + not exists(Parameter p | p = f.getParameter(_) and p.getType().getName() = "string") + ) + ) and + not exists(ReturnStmt ret | + ret.getEnclosingFunction() = f and + not exists(CallExpr call | + ret.getExpr(0) = call and + isCanonicalAddressCall(call) and + ( + isCanonicalStringAddressCall(call) + or + not exists(Parameter p | p = f.getParameter(_) and p.getType().getName() = "string") + ) + ) + ) +} + +predicate isStringToBytesAddressWrapper(FuncDecl f) { + isProductionNodeFile(f.getFile()) and + exists(ReturnStmt ret, CallExpr call | + ret.getEnclosingFunction() = f and + ret.getExpr(0) = call and + isCanonicalBytesAddressCall(call) and + stringParameterFlowsToBytesToAddressArg(f, call.getArgument(0)) + ) +} + +predicate isUnsafeAddressHelper(FuncDecl f) { + isProductionNodeFile(f.getFile()) and + not isCanonicalAddressWrapper(f) and + ( + exists(ConversionExpr conv | isDirectVaaAddressConversion(conv) and returnsExpr(f, conv)) + or + exists(CallExpr copyCall, Expr base | + isManualVaaAddressCopy(copyCall, base) and + returnsExpr(f, base) + ) + or + isStringToBytesAddressWrapper(f) + ) +} + +predicate isUnsafeAddressHelperCall(CallExpr call, string sinkName) { + isProductionNodeFile(call.getFile()) and + call.getTarget().getFuncDecl() = any(FuncDecl f | isUnsafeAddressHelper(f)) and + helperCallResultFlowsToIdentitySink(call, sinkName) and + not isExplicitTypedChainAdapter(call) +} + +from AstNode report, string sinkName +where + exists(ConversionExpr conv | + isDirectVaaAddressConversion(conv) and + flowsToIdentitySink(conv, sinkName) and + report = conv + ) + or + exists(CallExpr call, Expr base | + isManualVaaAddressCopy(call, base) and + flowsToIdentitySink(base, sinkName) and + report = call + ) + or + exists(CallExpr call | + isUnsafeAddressHelperCall(call, sinkName) and + report = call + ) +select report, "External Wormhole address data must be normalized with vaa.StringToAddress or vaa.BytesToAddress before use as " + sinkName + "; this conversion bypasses canonical left-padding, 0x handling, and overlength rejection." diff --git a/codeql/src/canonical-vaa-id-parsing.ql b/codeql/src/canonical-vaa-id-parsing.ql new file mode 100644 index 0000000000..0153031267 --- /dev/null +++ b/codeql/src/canonical-vaa-id-parsing.ql @@ -0,0 +1,116 @@ +/** + * @name Manual VAA ID parsing bypasses canonical parser + * @description Serialized Wormhole VAA IDs should be parsed with the canonical parser before constructing storage identities. + * @kind problem + * @problem.severity warning + * @precision high + * @id wormhole/go/canonical-vaa-id-parsing + * @tags security + * external/cwe/cwe-20 + */ + +import go +import semmle.go.concepts.GeneratedFile + +predicate isProductionNodeFile(File f) { + f.getRelativePath().matches("node/%.go") and + not f.getRelativePath().matches("%_test.go") and + not f.getRelativePath().matches("%.pb.go") and + not f instanceof GeneratedFile +} + +predicate isCanonicalVaaIdParser(FuncDecl f) { + f.getName() in ["VaaIDFromString", "VAAIDFromString"] and + ( + f.getFile().getRelativePath().matches("node/pkg/db/%.go") + or + f.getFile().getRelativePath().matches("pkg/db/%.go") + or + f.getFile().getRelativePath().matches("sdk/vaa/%.go") + ) +} + +predicate isInsideCanonicalVaaIdParser(Expr e) { + isCanonicalVaaIdParser(e.getEnclosingFunction()) +} + +predicate isVaaIdLiteral(CompositeLit lit) { + isProductionNodeFile(lit.getFile()) and + lit.getType().getName() = "VAAID" and + not isCanonicalVaaIdParser(lit.getEnclosingFunction()) +} + +predicate fieldInit(KeyValueExpr field, string name, Expr value) { + field.getKey().(Ident).getName() = name and + value = field.getValue() +} + +predicate hasEmitterAddressField(CompositeLit lit, KeyValueExpr field, Expr value) { + exists(int i | + field = lit.getElement(i) and + fieldInit(field, "EmitterAddress", value) + ) +} + +predicate isSlashStringSplit(CallExpr call) { + call.getCalleeName() = "Split" and + call.getNumArgument() = 2 and + call.getArgument(1).getStringValue() = "/" +} + +predicate splitResultFlowsToBase(CallExpr split, Expr base) { + DataFlow::localFlow(DataFlow::exprNode(split), DataFlow::exprNode(base.stripParens())) +} + +predicate isEmitterAddressSplitComponent(IndexExpr idx, CallExpr split) { + isSlashStringSplit(split) and + idx.getIndex().getIntValue() = 1 and + idx.getEnclosingFunction() = split.getEnclosingFunction() and + splitResultFlowsToBase(split, idx.getBase()) +} + +predicate emitterAddressFieldUsesManualSplit(Expr emitterAddress, IndexExpr splitIndex, CallExpr split) { + isEmitterAddressSplitComponent(splitIndex, split) and + not isInsideCanonicalVaaIdParser(splitIndex) and + DataFlow::localFlow(DataFlow::exprNode(splitIndex), DataFlow::exprNode(emitterAddress)) +} + +predicate isEmitterAddressComponentParser(CallExpr call) { + call.getCalleeName() = "StringToAddress" +} + +DataFlow::Node emitterAddressComponentParserValue(CallExpr call) { + isEmitterAddressComponentParser(call) and + ( + result = DataFlow::exprNode(call) or + result = DataFlow::extractTupleElement(DataFlow::exprNode(call), 0) + ) +} + +predicate emitterAddressFieldUsesManualComponentParser( + Expr emitterAddress, IndexExpr splitIndex, CallExpr split +) { + exists(CallExpr parserCall | + isEmitterAddressSplitComponent(splitIndex, split) and + isEmitterAddressComponentParser(parserCall) and + parserCall.getEnclosingFunction() = split.getEnclosingFunction() and + not isInsideCanonicalVaaIdParser(splitIndex) and + DataFlow::localFlow( + DataFlow::exprNode(splitIndex), DataFlow::exprNode(parserCall.getArgument(0)) + ) and + DataFlow::localFlow( + emitterAddressComponentParserValue(parserCall), DataFlow::exprNode(emitterAddress) + ) + ) +} + +from CompositeLit lit, KeyValueExpr field, Expr emitterAddress, IndexExpr splitIndex, CallExpr split +where + isVaaIdLiteral(lit) and + hasEmitterAddressField(lit, field, emitterAddress) and + ( + emitterAddressFieldUsesManualSplit(emitterAddress, splitIndex, split) or + emitterAddressFieldUsesManualComponentParser(emitterAddress, splitIndex, split) + ) +select lit, + "Parse serialized VAA IDs with the canonical VAA ID parser before constructing storage identities; manual split/reconstruction can misdecode emitter addresses and miss existing signed VAAs." diff --git a/codeql/src/delegate-consensus-canonical-digest.ql b/codeql/src/delegate-consensus-canonical-digest.ql new file mode 100644 index 0000000000..07a4521f61 --- /dev/null +++ b/codeql/src/delegate-consensus-canonical-digest.ql @@ -0,0 +1,186 @@ +/** + * @name Delegate observation quorum bucket keyed by non-canonical digest + * @description Delegate observation quorum buckets must be keyed by the reconstructed MessagePublication VAA signing digest, not by serialized observations, per-guardian metadata, or composite keys. + * @kind problem + * @problem.severity warning + * @precision high + * @id wormhole/go/delegate-consensus-canonical-digest + * @tags security + * external/cwe/cwe-345 + */ + +import go +import semmle.go.concepts.GeneratedFile +import semmle.go.dataflow.DataFlow + +predicate isProductionProcessorFile(File f) { + f.getRelativePath().matches("node/pkg/processor/%.go") and + not f.getRelativePath().matches("%_test.go") and + not f instanceof GeneratedFile +} + +predicate isDelegateStateObservationsSelector(Expr e) { + exists(SelectorExpr observations, SelectorExpr delegateState | + e.stripParens() = observations and + observations.getSelector().getName() = "observations" and + delegateState = observations.getBase().stripParens() and + delegateState.getSelector().getName() = "delegateState" + ) +} + +predicate isDelegateBucketBase(Expr base) { + isDelegateStateObservationsSelector(base) + or + exists(Expr source | + isDelegateStateObservationsSelector(source) and + DataFlow::localFlow(DataFlow::exprNode(source), DataFlow::exprNode(base.stripParens())) + ) + or + exists(SelectorExpr observations, MethodDecl method | + base.stripParens() = observations and + observations.getSelector().getName() = "observations" and + observations.getEnclosingFunction() = method and + method.getReceiverBaseType().getName() = "delegateState" + ) +} + +predicate isDelegateBucketIndex(IndexExpr idx) { + isProductionProcessorFile(idx.getFile()) and + isDelegateBucketBase(idx.getBase()) +} + +predicate isMessagePublicationMethod(CallExpr call, string name) { + call.getCalleeName() = name and + call.getTarget().getPackage().getPath().matches("%/node/pkg/common") +} + +predicate isCreateDigestCall(CallExpr call) { + isMessagePublicationMethod(call, "CreateDigest") +} + +predicate isCreateVaaCall(CallExpr call) { + isMessagePublicationMethod(call, "CreateVAA") +} + +predicate isCanonicalSigningDigestCall(CallExpr signing) { + signing.getCalleeName() = "SigningDigest" and + signing.getTarget().getPackage().getPath().matches("%/node/pkg/common") and + exists(SelectorExpr callee, CallExpr createVaa | + signing.getCalleeExpr().stripParens() = callee and + isCreateVaaCall(createVaa) and + DataFlow::localFlow( + DataFlow::exprNode(createVaa), DataFlow::exprNode(callee.getBase().stripParens()) + ) + ) +} + +predicate isEncodedCanonicalSigningDigest(CallExpr encoded) { + encoded.getCalleeName() = "EncodeToString" and + exists(CallExpr bytes, SelectorExpr bytesCallee, CallExpr signing | + encoded.getArgument(0).stripParens() = bytes and + bytes.getCalleeName() = "Bytes" and + bytes.getCalleeExpr().stripParens() = bytesCallee and + bytesCallee.getBase().stripParens() = signing and + isCanonicalSigningDigestCall(signing) + ) +} + +predicate canonicalDigestExpr(Expr e) { + exists(CallExpr call | e.stripParens() = call and isCreateDigestCall(call)) + or + exists(CallExpr call | e.stripParens() = call and isEncodedCanonicalSigningDigest(call)) + or + exists(CallExpr helper, FuncDecl target, ReturnStmt ret | + e.stripParens() = helper and + helper.getTarget().getFuncDecl() = target and + ret.getEnclosingFunction() = target and + ret.getNumExpr() = 1 and + canonicalDigestExpr(ret.getExpr(0)) + ) +} + +predicate hasAllowedDigestFlow(Expr key) { + exists(CallExpr call | + canonicalDigestExpr(call) and + DataFlow::localFlow(DataFlow::exprNode(call), DataFlow::exprNode(key.stripParens())) + ) +} + +predicate isDelegateBucketKeyHelper(CallExpr call, Expr key) { + exists(MethodDecl method, Parameter keyParam, IndexExpr idx, Ident keyUse | + call.getTarget().getFuncDecl() = method and + method.getReceiverBaseType().getName() = "delegateState" and + keyParam = method.getParameter(0) and + idx.getEnclosingFunction() = method and + isDelegateBucketBase(idx.getBase()) and + idx.getIndex().stripParens() = keyUse and + keyUse.refersTo(keyParam) and + key = call.getArgument(0) + ) +} + +predicate isDelegateBucketKeyHelperParameter(IndexExpr idx, Expr key) { + exists(MethodDecl method, Parameter keyParam, Ident keyUse | + idx.getEnclosingFunction() = method and + method.getReceiverBaseType().getName() = "delegateState" and + keyParam = method.getParameter(0) and + key.stripParens() = keyUse and + keyUse.refersTo(keyParam) + ) +} + +predicate isUnsafeDigestOrSerializationCall(CallExpr call) { + call.getCalleeName() in [ + "MarshalBinary", "Marshal", "Keccak256Hash", "MessageIDString", "Sprintf", "Sprint", "Sprintln" + ] +} + +predicate isNonVaaFieldRead(SelectorExpr sel) { + sel.getSelector().getName() in [ + "IsReobservation", "Unreliable", "verificationState", "TxID", "TxHash", "GuardianAddr", + "GuardianAddress", "Signature", "Signatures" + ] +} + +predicate hasUnsafeKeyFlow(Expr key) { + exists(CallExpr call | + isUnsafeDigestOrSerializationCall(call) and + DataFlow::localFlow(DataFlow::exprNode(call), DataFlow::exprNode(key.stripParens())) + ) + or + exists(SelectorExpr sel | + isNonVaaFieldRead(sel) and + DataFlow::localFlow(DataFlow::exprNode(sel), DataFlow::exprNode(key.stripParens())) + ) + or + exists(AddExpr added | + hasAllowedDigestFlow(added.getAnOperand()) and + DataFlow::localFlow(DataFlow::exprNode(added), DataFlow::exprNode(key.stripParens())) + ) + or + exists(Assignment assign, Ident lhs, Ident keyIdent, Variable v, CallExpr unsafe | + assign.getLhs(_) = lhs and + key.stripParens() = keyIdent and + lhs.refersTo(v) and + keyIdent.refersTo(v) and + isUnsafeDigestOrSerializationCall(unsafe) and + assign.getRhs(_).getAChild*() = unsafe and + assign.getLocation().getStartLine() < key.getLocation().getStartLine() + ) +} + +from AstNode sink, Expr key +where + ( + exists(IndexExpr idx | + sink = key and + isDelegateBucketIndex(idx) and + key = idx.getIndex() and + not isDelegateBucketKeyHelperParameter(idx, key) + ) + or + exists(CallExpr call | sink = key and isDelegateBucketKeyHelper(call, key)) + ) and + (not hasAllowedDigestFlow(key) or hasUnsafeKeyFlow(key)) +select sink, + "Delegate observation quorum bucket key must be the reconstructed MessagePublication VAA signing digest; use CreateDigest or equivalent SigningDigest and exclude per-guardian/non-VAA fields such as IsReobservation, TxID, guardian address, signatures, and serialized delegate observations." diff --git a/codeql/src/delegated-guardian-config-validation.ql b/codeql/src/delegated-guardian-config-validation.ql new file mode 100644 index 0000000000..45fb1b4fed --- /dev/null +++ b/codeql/src/delegated-guardian-config-validation.ql @@ -0,0 +1,78 @@ +/** + * @name Delegated guardian config reaches governance serialization without required validation + * @description Admin-supplied delegated guardian configs must strictly parse guardian addresses, reject duplicate canonical keys, and enforce non-empty threshold bounds/quorum before governance serialization. + * @kind problem + * @problem.severity error + * @precision high + * @id wormhole/go/delegated-guardian-config-validation + * @tags security + * external/cwe/cwe-20 + */ + +import go +import semmle.go.concepts.GeneratedFile + +predicate isProductionNodeFile(File f) { + f.getRelativePath().matches("node/%.go") and + not f.getRelativePath().matches("%_test.go") and + not f instanceof GeneratedFile +} + +predicate isDelegatedGuardianSetConfigSerialize(CallExpr call) { + isProductionNodeFile(call.getFile()) and + call.getCalleeName() = "Serialize" and + call.getTarget() instanceof Method and + call.getTarget().(Method).getReceiverBaseType().getName() = "BodyDelegatedGuardiansSetConfig" and + call.getTarget().getPackage().getPath() = "github.com/wormhole-foundation/wormhole/sdk/vaa" +} + +predicate isGoEthereumCommonCall(CallExpr call, string name) { + call.getCalleeName() = name and + call.getTarget().getPackage().getPath() = "github.com/ethereum/go-ethereum/common" +} + +predicate hasAddressCanonicalization(FuncDecl f) { + exists(CallExpr call | + call.getEnclosingFunction() = f and + isGoEthereumCommonCall(call, "HexToAddress") + ) +} + +predicate hasStrictAddressParse(FuncDecl f) { + exists(CallExpr call | + call.getEnclosingFunction() = f and + isGoEthereumCommonCall(call, "IsHexAddress") + ) +} + +predicate hasRuntimeDelegatedGuardianValidator(FuncDecl f) { + exists(CallExpr call | + call.getEnclosingFunction() = f and + call.getCalleeName() = "NewDelegatedGuardianChainConfig" and + call.getTarget().getPackage().getPath() in [ + "github.com/wormhole-foundation/wormhole/node/pkg/processor", + "github.com/certusone/wormhole/node/pkg/processor" + ] + ) +} + +predicate hasQuorumFloorCheck(FuncDecl f) { + exists(CallExpr call | + call.getEnclosingFunction() = f and + call.getCalleeName() = "CalculateQuorum" and + call.getTarget().getPackage().getPath() = "github.com/wormhole-foundation/wormhole/sdk/vaa" + ) +} + +from CallExpr sink, FuncDecl f +where + isDelegatedGuardianSetConfigSerialize(sink) and + sink.getEnclosingFunction() = f and + hasAddressCanonicalization(f) and + ( + not hasStrictAddressParse(f) or + not hasRuntimeDelegatedGuardianValidator(f) or + not hasQuorumFloorCheck(f) + ) +select sink, + "Delegated guardian config from admin input reaches governance serialization without required validation: strictly parse EVM guardian addresses before HexToAddress, reject post-canonical duplicate keys, and enforce non-empty threshold bounds/quorum before creating the VAA." diff --git a/codeql/src/evm-ccl-signed-message-immutability.ql b/codeql/src/evm-ccl-signed-message-immutability.ql new file mode 100644 index 0000000000..8e84742a45 --- /dev/null +++ b/codeql/src/evm-ccl-signed-message-immutability.ql @@ -0,0 +1,223 @@ +/** + * @name EVM CCL handling mutates signed message fields + * @description EVM custom-consistency-level handling must preserve signed MessagePublication fields after observation and update only release metadata such as effectiveCL or additionalBlocks. + * @kind problem + * @problem.severity warning + * @precision high + * @id wormhole/go/evm-ccl-signed-message-immutability + * @tags security + * external/cwe/cwe-345 + */ + +import go +import semmle.go.concepts.GeneratedFile + +predicate isProductionEvmWatcherFile(File f) { + f.getRelativePath().matches("node/pkg/watchers/evm/%.go") and + not f.getRelativePath().matches("%_test.go") and + not f instanceof GeneratedFile +} + +predicate isCclHandle(MethodDecl m) { + isProductionEvmWatcherFile(m.getFile()) and + m.getName() = "cclHandleMessage" and + m.getReceiverBaseType().hasQualifiedName( + "github.com/certusone/wormhole/node/pkg/watchers/evm", "Watcher" + ) +} + +predicate sameVariable(Expr a, Expr b) { + exists(Entity target | + a.stripParens().(Ident).refersTo(target) and + b.stripParens().(Ident).refersTo(target) + ) +} + +bindingset[earlier] +bindingset[later] +pragma[inline] +predicate occursBefore(AstNode earlier, AstNode later) { + exists(string path, int earlierLine, int earlierColumn, int laterLine, int laterColumn | + earlier.getLocation().hasLocationInfo(path, earlierLine, earlierColumn, _, _) and + later.getLocation().hasLocationInfo(path, laterLine, laterColumn, _, _) and + earlier.getEnclosingFunction() = later.getEnclosingFunction() and + (earlierLine < laterLine or earlierLine = laterLine and earlierColumn < laterColumn) + ) +} + +predicate variableIsNotReassignedBetween(Expr variable, Assignment source, AstNode use) { + not exists(Assignment overwrite, Expr lhs | + lhs = overwrite.getLhs(_) and + sameVariable(variable, lhs) and + overwrite.getEnclosingFunction() = use.getEnclosingFunction() and + occursBefore(source, overwrite) and + occursBefore(overwrite, use) + ) +} + +predicate isCclHandlePendingParameter(Expr e, AstNode use) { + exists(MethodDecl m, Parameter pe | + isCclHandle(m) and + pe = m.getParameter(1) and + use.getEnclosingFunction() = m and + e.stripParens().(Ident).refersTo(pe) + ) +} + +predicate isHelperParameterFromProtectedPending(Expr e, AstNode use) { + exists(CallExpr call, Parameter p | + call.getTarget().getFuncDecl() = p.getFunction() and + p.getIndex() >= 0 and + isCclHandle(call.getEnclosingFunction()) and + isPendingRootAt(call.getArgument(p.getIndex()), call) and + use.getEnclosingFunction() = p.getFunction() and + e.stripParens().(Ident).refersTo(p) + ) +} + +predicate isHelperParameterFromProtectedMessage(Expr e, AstNode use) { + exists(CallExpr call, Parameter p | + call.getTarget().getFuncDecl() = p.getFunction() and + p.getIndex() >= 0 and + isCclHandle(call.getEnclosingFunction()) and + isMessageRootAt(call.getArgument(p.getIndex()), call) and + use.getEnclosingFunction() = p.getFunction() and + e.stripParens().(Ident).refersTo(p) + ) +} + +predicate isHelperParameterFromProtectedPayload(Expr e, AstNode use) { + exists(CallExpr call, Parameter p | + call.getTarget().getFuncDecl() = p.getFunction() and + p.getIndex() >= 0 and + isCclHandle(call.getEnclosingFunction()) and + isPayloadRootAt(call.getArgument(p.getIndex()), call) and + use.getEnclosingFunction() = p.getFunction() and + e.stripParens().(Ident).refersTo(p) + ) +} + +predicate isPendingRootAt(Expr e, AstNode use) { + isCclHandlePendingParameter(e, use) + or + isHelperParameterFromProtectedPending(e, use) + or + exists(Assignment assign, Expr lhs, Expr rhs | + lhs = assign.getLhs(_) and + rhs = assign.getRhs(_) and + sameVariable(e, lhs) and + assign.getEnclosingFunction() = use.getEnclosingFunction() and + occursBefore(assign, use) and + variableIsNotReassignedBetween(e, assign, use) and + isPendingRootAt(rhs, assign) + ) +} + +predicate isMessageField(Expr e, Expr pending) { + exists(SelectorExpr sel | + e.stripParens() = sel and + sel.getSelector().getName() = "message" and + sameVariable(sel.getBase(), pending) + ) +} + +predicate isMessageRootAt(Expr e, AstNode use) { + isHelperParameterFromProtectedMessage(e, use) + or + exists(Expr pending | + isPendingRootAt(pending, use) and + isMessageField(e, pending) + ) + or + exists(Assignment assign, Expr lhs, Expr rhs | + lhs = assign.getLhs(_) and + rhs = assign.getRhs(_) and + sameVariable(e, lhs) and + assign.getEnclosingFunction() = use.getEnclosingFunction() and + occursBefore(assign, use) and + variableIsNotReassignedBetween(e, assign, use) and + isMessageRootAt(rhs, assign) + ) +} + +predicate isPayloadField(Expr e, Expr message) { + exists(SelectorExpr sel | + e.stripParens() = sel and + sel.getSelector().getName() = "Payload" and + (sel.getBase().stripParens() = message.stripParens() or sameVariable(sel.getBase(), message)) + ) +} + +predicate isPayloadRootAt(Expr e, AstNode use) { + isHelperParameterFromProtectedPayload(e, use) + or + exists(Expr message | + isMessageRootAt(message, use) and + isPayloadField(e, message) + ) + or + exists(Assignment assign, Expr lhs, Expr rhs | + lhs = assign.getLhs(_) and + rhs = assign.getRhs(_) and + sameVariable(e, lhs) and + assign.getEnclosingFunction() = use.getEnclosingFunction() and + occursBefore(assign, use) and + variableIsNotReassignedBetween(e, assign, use) and + ( + isPayloadRootAt(rhs, assign) + or exists(SliceExpr slice | rhs.stripParens() = slice and isPayloadRootAt(slice.getBase(), assign)) + ) + ) +} + +predicate isSignedField(string field) { + field in ["Timestamp", "Nonce", "EmitterChain", "EmitterAddress", "Payload", "Sequence", "ConsistencyLevel"] +} + +predicate signedFieldWrite(AstNode sink, string field) { + exists(Assignment assign, SelectorExpr lhs | + sink = lhs and + lhs = assign.getLhs(_).stripParens() and + field = lhs.getSelector().getName() and + isSignedField(field) and + isMessageRootAt(lhs.getBase(), assign) + ) + or + exists(IncDecStmt inc, SelectorExpr operand | + sink = operand and + operand = inc.getOperand().stripParens() and + field = operand.getSelector().getName() and + isSignedField(field) and + isMessageRootAt(operand.getBase(), inc) + ) + or + exists(Assignment assign, SelectorExpr lhs | + sink = lhs and + lhs = assign.getLhs(_).stripParens() and + lhs.getSelector().getName() = "message" and + isPendingRootAt(lhs.getBase(), assign) and + field = "MessagePublication" + ) +} + +predicate payloadByteWrite(AstNode sink) { + exists(Assignment assign, IndexExpr lhs | + sink = lhs and + lhs = assign.getLhs(_).stripParens() and + isPayloadRootAt(lhs.getBase(), assign) + ) + or + exists(CallExpr call | + sink = call and + call.getCalleeName() = "copy" and + isPayloadRootAt(call.getArgument(0), call) + ) +} + +from AstNode sink, string field +where + signedFieldWrite(sink, field) + or payloadByteWrite(sink) and field = "Payload bytes" +select sink, + "CCL handling must not mutate signed MessagePublication field '" + field + + "' after observation; update release metadata such as effectiveCL/additionalBlocks instead so guardians sign the original EVM message body." diff --git a/codeql/src/evm-finality-release-and-reorg-checks.ql b/codeql/src/evm-finality-release-and-reorg-checks.ql new file mode 100644 index 0000000000..cae23a75fc --- /dev/null +++ b/codeql/src/evm-finality-release-and-reorg-checks.ql @@ -0,0 +1,433 @@ +/** + * @name EVM pending publication lacks finality or reorg checks + * @description EVM watcher pending-message release must wait for the effective consistency level and height threshold, then refetch and prove the same receipt transaction/block before verifyAndPublish. + * @kind problem + * @problem.severity warning + * @precision high + * @id wormhole/go/evm-finality-release-and-reorg-checks + * @tags security + * external/cwe/cwe-345 + */ + +import go +import semmle.go.concepts.GeneratedFile +import semmle.go.controlflow.ControlFlowGraph + +predicate isProductionEvmWatcherFile(File f) { + f.getRelativePath().matches("node/pkg/watchers/evm/%.go") and + not f.getRelativePath().matches("%_test.go") and + not f instanceof GeneratedFile +} + +bindingset[earlier] +bindingset[later] +pragma[inline] +predicate occursBefore(AstNode earlier, AstNode later) { + exists(string path, int earlierLine, int earlierColumn, int laterLine, int laterColumn | + earlier.getLocation().hasLocationInfo(path, earlierLine, earlierColumn, _, _) and + later.getLocation().hasLocationInfo(path, laterLine, laterColumn, _, _) and + earlier.getEnclosingFunction() = later.getEnclosingFunction() and + (earlierLine < laterLine or earlierLine = laterLine and earlierColumn < laterColumn) + ) +} + +predicate sameVariable(Expr a, Expr b) { + exists(Entity target | + a.stripParens().(Ident).refersTo(target) and + b.stripParens().(Ident).refersTo(target) + ) +} + +predicate sameValue(Expr a, Expr b) { + sameVariable(a, b) +} + +predicate selectorNamed(Expr e, Expr base, string name) { + exists(SelectorExpr sel | + e.stripParens() = sel and + sel.getSelector().getName() = name and + sameVariable(sel.getBase(), base) + ) +} + +predicate pendingMessageExpr(Expr msg, Expr pending) { + selectorNamed(msg, pending, "message") +} + +predicate pendingEffectiveClExpr(Expr e, Expr pending) { + selectorNamed(e, pending, "effectiveCL") +} + +predicate pendingHeightExpr(Expr e, Expr pending) { + selectorNamed(e, pending, "height") +} + +predicate pendingAdditionalBlocksExpr(Expr e, Expr pending) { + selectorNamed(e, pending, "additionalBlocks") +} + +predicate blockNumberExpr(Expr e, Expr event) { + selectorNamed(e, event, "Number") +} + +predicate blockNumberSourceExpr(Expr e, Expr event) { + blockNumberExpr(e, event) + or exists(CallExpr call, SelectorExpr callee | + e.stripParens() = call and + callee = call.getCalleeExpr().(SelectorExpr) and + callee.getSelector().getName() = "Uint64" and + blockNumberExpr(callee.getBase(), event) + ) +} + +predicate pendingMessageTxIdExpr(Expr e, Expr pending) { + exists(SelectorExpr txSel, SelectorExpr msgSel | + e.stripParens() = txSel and + txSel.getSelector().getName() = "TxID" and + txSel.getBase().stripParens() = msgSel and + msgSel.getSelector().getName() = "message" and + sameVariable(msgSel.getBase(), pending) + ) +} + +predicate receiptTxHashExpr(Expr e, Expr receipt) { + selectorNamed(e, receipt, "TxHash") +} + +predicate receiptBlockHashExpr(Expr e, Expr receipt) { + selectorNamed(e, receipt, "BlockHash") +} + +predicate keyBlockHashExpr(Expr e, Expr key) { + exists(SelectorExpr sel | + e.stripParens() = sel and + sel.getSelector().getName() = "BlockHash" + and sameValue(sel.getBase(), key) + ) +} + +predicate bytesToHashOfPendingTxId(Expr e, Expr pending) { + exists(CallExpr call | + e.stripParens() = call and + call.getCalleeExpr().(SelectorExpr).getSelector().getName() = "BytesToHash" and + pendingMessageTxIdExpr(call.getArgument(0), pending) + ) +} + +predicate localAssignedFromBytesToHashOfPendingTxId(Expr e, Expr pending) { + exists(Assignment assign, Expr lhs, Expr source | + lhs = assign.getLhs(_) and + bytesToHashOfPendingTxId(source, pending) and + source = assign.getRhs(_) and + sameVariable(lhs, e) and + occursBefore(assign, e) + ) +} + +predicate txHashOfPendingTxId(Expr e, Expr pending) { + pendingMessageTxIdExpr(e, pending) + or bytesToHashOfPendingTxId(e, pending) + or localAssignedFromBytesToHashOfPendingTxId(e, pending) +} + +predicate assignmentReceivesTupleElement(Assignment assign, CallExpr call, int index, Expr lhs) { + assign.getRhs(0) = call and + lhs = assign.getLhs(index) +} + +predicate pendingMapOfReceiver(Expr domain, Expr receiver) { + exists(SelectorExpr sel | + domain.stripParens() = sel and + sel.getSelector().getName() = "pending" and + sameVariable(sel.getBase(), receiver) + ) +} + +predicate pendingRangeKey(Expr pending, Expr key, Expr receiver) { + exists(RangeStmt loop, Ident rangeKey, Ident rangeValue, Variable keyVar, Variable valueVar | + rangeKey = loop.getKey() and + rangeValue = loop.getValue() and + pendingMapOfReceiver(loop.getDomain(), receiver) and + rangeKey.refersTo(keyVar) and + rangeValue.refersTo(valueVar) and + pending.(Ident).refersTo(valueVar) and + key.(Ident).refersTo(keyVar) + ) +} + +predicate pendingRangeKey(Expr pending, Expr key) { + exists(RangeStmt loop, Ident rangeKey, Ident rangeValue, Variable keyVar, Variable valueVar | + rangeKey = loop.getKey() and + rangeValue = loop.getValue() and + rangeKey.refersTo(keyVar) and + rangeValue.refersTo(valueVar) and + pending.(Ident).refersTo(valueVar) and + key.(Ident).refersTo(keyVar) + ) +} + +predicate conditionContains(Expr condition, Expr child) { + child = condition or child = condition.getAChild*() +} + +predicate consistencyLevelConstant(Expr e, string name) { + e.stripParens().(Ident).getName() = name + or e.stripParens().(SelectorExpr).getSelector().getName() = name +} + +predicate variableAssignedFrom(Expr variableUse, Expr rhs, Assignment assign) { + exists(Expr lhs | + lhs = assign.getLhs(_) and + rhs = assign.getRhs(_) and + sameVariable(lhs, variableUse) + ) +} + +predicate currentConsistencyLevelExpr(Expr e, AstNode before) { + exists(Variable v, Assignment finalizedAssign, Assignment safeAssign | + e.stripParens().(Ident).refersTo(v) and + e.stripParens().(Ident).getName() = "thisConsistencyLevel" and + variableAssignedFrom(e, _, finalizedAssign) and + consistencyLevelConstant(finalizedAssign.getRhs(_), "ConsistencyLevelFinalized") and + variableAssignedFrom(e, _, safeAssign) and + consistencyLevelConstant(safeAssign.getRhs(_), "ConsistencyLevelSafe") and + finalizedAssign.getEnclosingFunction() = before.getEnclosingFunction() and + safeAssign.getEnclosingFunction() = before.getEnclosingFunction() and + occursBefore(finalizedAssign, before) and + occursBefore(safeAssign, before) + ) +} + +predicate conditionRejectsConsistencyMismatch(Expr condition, Expr pending, AstNode before, Expr proof) { + exists(NotExpr notExpr, CallExpr call | + conditionContains(condition, notExpr) and + proof = notExpr and + notExpr.getOperand().stripParens() = call and + call.getCalleeExpr().(Ident).getName() = "consistencyLevelMatches" and + currentConsistencyLevelExpr(call.getArgument(0), before) and + pendingEffectiveClExpr(call.getArgument(1), pending) + ) +} + +predicate pendingReleaseHeightThreshold(Expr e, Expr pending) { + exists(AddExpr added | + e.stripParens() = added and + pendingHeightExpr(added.getAnOperand(), pending) and + pendingAdditionalBlocksExpr(added.getAnOperand(), pending) + ) +} + +predicate currentBlockNumberExpr(Expr e, AstNode before) { + exists(Assignment assign, Expr event | + e.stripParens().(Ident).getName() = "blockNumberU" and + variableAssignedFrom(e, _, assign) and + blockNumberSourceExpr(assign.getRhs(_), event) and + occursBefore(assign, before) + ) +} + +predicate conditionRejectsHeightNotReached(Expr condition, Expr pending, AstNode before, Expr proof) { + exists(RelationalComparisonExpr cmp | + conditionContains(condition, cmp) and + proof = cmp and + cmp.isStrict() and + pendingReleaseHeightThreshold(cmp.getGreaterOperand(), pending) and + currentBlockNumberExpr(cmp.getLesserOperand(), before) + ) +} + +predicate receiptFetchForPending(CallExpr fetch) { + fetch.getCalleeExpr().(SelectorExpr).getSelector().getName() = "TransactionReceipt" +} + +predicate receiptFetchForSink(CallExpr sink, Expr pending, Expr receipt, Expr err, CallExpr fetch) { + exists(Assignment assign, Expr fetchedReceipt | + receiptFetchForPending(fetch) and + assignmentReceivesTupleElement(assign, fetch, 0, fetchedReceipt) and + assignmentReceivesTupleElement(assign, fetch, 1, err) and + not err.(Ident).getName() = "_" and + txHashOfPendingTxId(fetch.getArgument(1), pending) and + sameValue(fetchedReceipt, receipt) and + occursBefore(fetch, sink) + ) +} + +predicate notFoundErrorSentinel(Expr e) { + e.(SelectorExpr).getSelector().getName() = "ErrNoResult" + or e.(SelectorExpr).getSelector().getName() = "NotFound" +} + +predicate conditionContainsNotFoundCheck(Expr condition, Expr err, Expr proof) { + exists(CallExpr call, Expr checkedErr, SelectorExpr callee | + conditionContains(condition, call) and + proof = call and + callee = call.getCalleeExpr().(SelectorExpr) and + callee.getSelector().getName() = "Is" and + sameValue(call.getArgument(0), err) and + checkedErr = call.getArgument(1) and + notFoundErrorSentinel(checkedErr) + ) +} + +predicate conditionComparesToNil(Expr condition, Expr value, Expr proof) { + exists(EqualityTestExpr eq, Expr operand, Expr nil | + conditionContains(condition, eq) and + proof = eq and + exprRefersToNil(nil) and + eq.hasOperands(operand, nil) and + sameValue(operand, value) + ) +} + +predicate conditionComparesErrNonNil(Expr condition, Expr err, Expr proof) { + exists(NeqExpr neq, Expr operand, Expr nil | + conditionContains(condition, neq) and + proof = neq and + exprRefersToNil(nil) and + neq.hasOperands(operand, nil) and + sameValue(operand, err) + ) +} + +predicate conditionRejectsTxHashMismatch(Expr condition, Expr receipt, Expr pending, Expr proof) { + exists(NeqExpr eq, Expr left, Expr right | + conditionContains(condition, eq) and + proof = eq and + eq.hasOperands(left, right) and + ( + receiptTxHashExpr(left, receipt) and txHashOfPendingTxId(right, pending) + or receiptTxHashExpr(right, receipt) and txHashOfPendingTxId(left, pending) + ) + ) +} + +predicate conditionRejectsBlockHashMismatch(Expr condition, Expr receipt, Expr key, Expr proof) { + exists(NeqExpr eq, Expr left, Expr right | + conditionContains(condition, eq) and + proof = eq and + eq.hasOperands(left, right) and + ( + receiptBlockHashExpr(left, receipt) and keyBlockHashExpr(right, key) + or receiptBlockHashExpr(right, receipt) and keyBlockHashExpr(left, key) + ) + ) +} + +predicate guardDominatesSink(ControlFlow::ConditionGuardNode guard, AstNode sink) { + sink instanceof Expr and guard.dominates(DataFlow::exprNode(sink.(Expr)).getBasicBlock()) + or exists(ControlFlow::Node sinkNode | + sinkNode.isFirstNodeOf(sink) and guard.dominates(sinkNode.getBasicBlock()) + ) +} + +predicate failsClosedIfBefore(IfStmt ifStmt, AstNode sink, Expr proof) { + ifStmt.getEnclosingFunction() = sink.getEnclosingFunction() and + occursBefore(ifStmt, sink) and + ( + exists(ContinueStmt cont | cont = ifStmt.getThen().getAChild()) + or exists(ReturnStmt ret | ret = ifStmt.getThen().getAChild()) + ) and + exists(ControlFlow::ConditionGuardNode guard | + guard.ensures(DataFlow::exprNode(proof), false) and + guardDominatesSink(guard, sink) + ) +} + +predicate hasConsistencyProof(CallExpr sink, Expr pending) { + exists(IfStmt ifStmt, Expr proof | + conditionRejectsConsistencyMismatch(ifStmt.getCond(), pending, sink, proof) and + failsClosedIfBefore(ifStmt, sink, proof) + ) +} + +predicate hasHeightProof(CallExpr sink, Expr pending) { + exists(IfStmt ifStmt, Expr proof | + conditionRejectsHeightNotReached(ifStmt.getCond(), pending, sink, proof) and + failsClosedIfBefore(ifStmt, sink, proof) + ) +} + +predicate hasNotFoundRejection(CallExpr sink, Expr err) { + exists(IfStmt ifStmt, Expr proof | + conditionContainsNotFoundCheck(ifStmt.getCond(), err, proof) and + failsClosedIfBefore(ifStmt, sink, proof) + ) +} + +predicate hasGenericErrRejection(CallExpr sink, Expr err) { + exists(IfStmt ifStmt, Expr proof | + conditionComparesErrNonNil(ifStmt.getCond(), err, proof) and + failsClosedIfBefore(ifStmt, sink, proof) + ) +} + +predicate hasNilReceiptRejection(CallExpr sink, Expr receipt) { + exists(IfStmt ifStmt, Expr proof | + conditionComparesToNil(ifStmt.getCond(), receipt, proof) and + failsClosedIfBefore(ifStmt, sink, proof) + ) +} + +predicate hasTxHashProof(CallExpr sink, Expr receipt, Expr pending) { + exists(IfStmt ifStmt, Expr proof | + conditionRejectsTxHashMismatch(ifStmt.getCond(), receipt, pending, proof) and + failsClosedIfBefore(ifStmt, sink, proof) + ) +} + +predicate hasBlockHashProof(CallExpr sink, Expr receipt, Expr key) { + exists(IfStmt ifStmt, Expr proof | + conditionRejectsBlockHashMismatch(ifStmt.getCond(), receipt, key, proof) and + failsClosedIfBefore(ifStmt, sink, proof) + ) +} + +predicate isPendingVerifyAndPublishSink(CallExpr call, Expr pending, Expr key, Expr receipt) { + isProductionEvmWatcherFile(call.getFile()) and + call.getCalleeExpr().(SelectorExpr).getSelector().getName() = "verifyAndPublish" and + pendingMessageExpr(call.getArgument(0), pending) and + pendingRangeKey(pending, key) and + receipt = call.getArgument(3) +} + +string missingProof(CallExpr sink, Expr pending, Expr key, Expr receipt) { + not pendingRangeKey(pending, key, sink.getCalleeExpr().(SelectorExpr).getBase()) and + result = "same-watcher w.pending range" + or not hasConsistencyProof(sink, pending) and result = "effective consistency-level match" + or not hasHeightProof(sink, pending) and result = "height threshold" + or not exists(CallExpr fetch, Expr err | receiptFetchForSink(sink, pending, receipt, err, fetch)) and + result = "receipt refetch" + or exists(CallExpr fetch, Expr err | + receiptFetchForSink(sink, pending, receipt, err, fetch) and + not hasNotFoundRejection(sink, err) and + result = "not-found/orphaned receipt rejection" + ) + or exists(CallExpr fetch, Expr err | + receiptFetchForSink(sink, pending, receipt, err, fetch) and + not hasGenericErrRejection(sink, err) and + result = "generic receipt error rejection" + ) + or exists(CallExpr fetch, Expr err | + receiptFetchForSink(sink, pending, receipt, err, fetch) and + not hasNilReceiptRejection(sink, receipt) and + result = "nil receipt rejection" + ) + or exists(CallExpr fetch, Expr err | + receiptFetchForSink(sink, pending, receipt, err, fetch) and + not hasTxHashProof(sink, receipt, pending) and + result = "tx-hash match" + ) + or exists(CallExpr fetch, Expr err | + receiptFetchForSink(sink, pending, receipt, err, fetch) and + not hasBlockHashProof(sink, receipt, key) and + result = "block-hash match" + ) +} + +from CallExpr sink, Expr pending, Expr key, Expr receipt, string proof +where + isPendingVerifyAndPublishSink(sink, pending, key, receipt) and + proof = missingProof(sink, pending, key, receipt) +select sink, + "EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish." + + " Missing proof: " + proof + "." diff --git a/codeql/src/evm-require-successful-receipt-before-observation.ql b/codeql/src/evm-require-successful-receipt-before-observation.ql new file mode 100644 index 0000000000..76df335ab8 --- /dev/null +++ b/codeql/src/evm-require-successful-receipt-before-observation.ql @@ -0,0 +1,403 @@ +/** + * @name EVM watcher observes or publishes without a successful receipt check + * @description EVM watcher receipt-log parsing must be backed by the same receipt proven successful before parsing; verifyAndPublish must receive a receipt argument proven successful before publication. + * @kind problem + * @problem.severity warning + * @precision high + * @id wormhole/go/evm-require-successful-receipt-before-observation + * @tags security + * external/cwe/cwe-345 + */ + +import go +import semmle.go.concepts.GeneratedFile +import semmle.go.controlflow.ControlFlowGraph +import semmle.go.dataflow.DataFlow +import semmle.go.dataflow.GlobalValueNumbering + +predicate isProductionEvmWatcherFile(File f) { + f.getRelativePath().matches("node/pkg/watchers/evm/%.go") and + not f.getRelativePath().matches("%_test.go") and + not f instanceof GeneratedFile +} + +bindingset[earlier] +bindingset[later] +pragma[inline] +predicate occursBefore(AstNode earlier, AstNode later) { + exists(string path, int earlierLine, int earlierColumn, int laterLine, int laterColumn | + earlier.getLocation().hasLocationInfo(path, earlierLine, earlierColumn, _, _) and + later.getLocation().hasLocationInfo(path, laterLine, laterColumn, _, _) and + earlier.getEnclosingFunction() = later.getEnclosingFunction() and + ( + earlierLine < laterLine + or + earlierLine = laterLine and earlierColumn < laterColumn + ) + ) +} + +predicate sameVariable(Expr a, Expr b) { + exists(Entity target | + a.stripParens().(Ident).refersTo(target) and + b.stripParens().(Ident).refersTo(target) + ) +} + +predicate isReceiptStatusSelectorFor(Expr statusExpr, Expr receipt) { + exists(SelectorExpr status | + statusExpr.stripParens() = status and + status.getSelector().getName() = "Status" and + sameVariable(status.getBase(), receipt) + ) +} + +predicate isReceiptStatusSuccessfulExpr(Expr success) { + exists(SelectorExpr sel | + success.stripParens() = sel and + sel.getSelector().getName() = "ReceiptStatusSuccessful" + ) +} + +predicate conditionComparesReceiptStatusToSuccess(Expr condition, Expr receipt, boolean successOutcome) { + exists(EqualityTestExpr equality, Expr status, Expr success | + equality = condition.stripParens() and + equality.hasOperands(status, success) and + isReceiptStatusSelectorFor(status, receipt) and + isReceiptStatusSuccessfulExpr(success) and + ( + equality.getPolarity() = true and successOutcome = true + or + equality.getPolarity() = false and successOutcome = false + ) + ) +} + +bindingset[receipt, earlier, later] +pragma[inline] +predicate receiptReassignedBetween(Expr receipt, AstNode earlier, AstNode later) { + exists(Assignment assign, Expr lhs | + lhs = assign.getLhs(_) and + sameVariable(lhs, receipt) and + assign.getEnclosingFunction() = later.getEnclosingFunction() and + earlier.getEnclosingFunction() = later.getEnclosingFunction() and + occursBefore(earlier, assign) and + occursBefore(assign, later) + ) +} + +predicate receiptExplicitlyProvenSuccessfulBefore(Expr receipt, AstNode observationOrigin, AstNode use) { + exists(ControlFlow::ConditionGuardNode guard, Expr condition, boolean outcome | + condition.getEnclosingFunction() = use.getEnclosingFunction() and + observationOrigin.getEnclosingFunction() = use.getEnclosingFunction() and + conditionComparesReceiptStatusToSuccess(condition, receipt, outcome) and + occursBefore(condition, use) and + not receiptReassignedBetween(receipt, condition, use) and + not receiptReassignedBetween(receipt, observationOrigin, condition) and + guard.ensures(DataFlow::exprNode(condition), outcome) and + guard.dominates(DataFlow::exprNode(use.(Expr)).getBasicBlock()) + ) + or + exists(ControlFlow::ConditionGuardNode guard, ControlFlow::Node useNode, Expr condition, boolean outcome | + condition.getEnclosingFunction() = use.getEnclosingFunction() and + observationOrigin.getEnclosingFunction() = use.getEnclosingFunction() and + conditionComparesReceiptStatusToSuccess(condition, receipt, outcome) and + useNode.isFirstNodeOf(use) and + occursBefore(condition, use) and + not receiptReassignedBetween(receipt, condition, use) and + not receiptReassignedBetween(receipt, observationOrigin, condition) and + guard.ensures(DataFlow::exprNode(condition), outcome) and + guard.dominates(useNode.getBasicBlock()) + ) +} + +predicate isParseLogMessagePublishedCall(CallExpr call) { + isProductionEvmWatcherFile(call.getFile()) and + call.getCalleeExpr().(SelectorExpr).getSelector().getName() = "ParseLogMessagePublished" and + call.getTarget().getFuncDecl().getName() = "ParseLogMessagePublished" +} + +predicate receiptBackedBySuccessfulStatus(Expr receipt, AstNode observationOrigin, AstNode use) { + receiptExplicitlyProvenSuccessfulBefore(receipt, observationOrigin, use) +} + +predicate assignmentReceivesTupleElement(Assignment assign, CallExpr call, int index, Expr lhs) { + assign.getRhs(0) = call and + lhs = assign.getLhs(index) +} + +predicate returnsNilErrorAt(ReturnStmt ret, int errorIndex) { + ret.getNumExpr() > errorIndex and + exprRefersToNil(ret.getExpr(errorIndex)) +} + +bindingset[ret, errorIndex] +pragma[inline] +predicate successfulReturnProvesReceiptStatus(ReturnStmt ret, int errorIndex) { + returnsNilErrorAt(ret, errorIndex) and + exists( + Expr receipt, Expr condition, boolean outcome, ControlFlow::ConditionGuardNode guard, + ControlFlow::Node returnNode + | + receipt = ret.getExpr(0) and + conditionComparesReceiptStatusToSuccess(condition, receipt, outcome) and + condition.getEnclosingFunction() = ret.getEnclosingFunction() and + occursBefore(condition, ret) and + not receiptReassignedBetween(receipt, condition, ret) and + returnNode.isFirstNodeOf(ret) and + guard.ensures(DataFlow::exprNode(condition), outcome) and + guard.dominates(returnNode.getBasicBlock()) + ) +} + +bindingset[f] +pragma[inline] +predicate isSafeMessageEventsFunction(FuncDecl f, int messagesIndex, int errorIndex) { + isProductionEvmWatcherFile(f.getFile()) and + f.getName() = "MessageEventsForTransaction" and + f.getFile().getRelativePath() in [ + "node/pkg/watchers/evm/by_transaction.go", "node/pkg/watchers/evm/stubs.go" + ] and + ( + messagesIndex = 1 and errorIndex = 2 + or + messagesIndex = 2 and errorIndex = 3 + ) and + exists(ReturnStmt ret | + ret.getEnclosingFunction() = f and + returnsNilErrorAt(ret, errorIndex) + ) and + not exists(ReturnStmt ret | + ret.getEnclosingFunction() = f and + returnsNilErrorAt(ret, errorIndex) and + not successfulReturnProvesReceiptStatus(ret, errorIndex) + ) +} + +predicate messageEventsTuple( + CallExpr helper, Assignment assign, Expr receipt, Expr messages, Expr err, int messagesIndex, + int errorIndex +) { + isSafeMessageEventsFunction(helper.getTarget().getFuncDecl(), messagesIndex, errorIndex) and + assign.getEnclosingFunction() = helper.getEnclosingFunction() and + assignmentReceivesTupleElement(assign, helper, 0, receipt) and + assignmentReceivesTupleElement(assign, helper, messagesIndex, messages) and + assignmentReceivesTupleElement(assign, helper, errorIndex, err) and + not err.stripParens().(Ident).getName() = "_" +} + +predicate neqNilExprFor(Expr condition, Expr err) { + exists(NeqExpr neq, Expr nil | + condition = neq and + exprRefersToNil(nil) and + ( + exists(Entity target | + neq.getLeftOperand().stripParens().(Ident).refersTo(target) and + err.stripParens().(Ident).refersTo(target) + ) and + neq.getRightOperand() = nil + or + exists(Entity target | + neq.getRightOperand().stripParens().(Ident).refersTo(target) and + err.stripParens().(Ident).refersTo(target) + ) and + neq.getLeftOperand() = nil + ) + ) +} + +predicate errorReassignedBeforeGuard(Expr err, AstNode guardUse) { + exists(Assignment assign, Ident lhs, Entity target | + lhs = assign.getLhs(_).stripParens() and + lhs.refersTo(target) and + err.stripParens().(Ident).refersTo(target) and + occursBefore(err, assign) and + occursBefore(assign, guardUse) + ) +} + +predicate guardProvesNilBefore(Expr err, AstNode use) { + exists(ControlFlow::ConditionGuardNode guard, ControlFlow::Node useNode, Expr errRead, Expr nil | + exprRefersToNil(nil) and + useNode.isFirstNodeOf(use) and + occursBefore(err, errRead) and + globalValueNumber(DataFlow::exprNode(errRead)) = globalValueNumber(DataFlow::exprNode(err)) and + not errorReassignedBeforeGuard(err, errRead) and + guard.ensuresEq(DataFlow::exprNode(errRead), DataFlow::exprNode(nil)) and + guard.dominates(useNode.getBasicBlock()) + ) + or + exists(ControlFlow::ConditionGuardNode guard, ControlFlow::Node useNode, Expr condition | + neqNilExprFor(condition, err) and + occursBefore(err, condition) and + not errorReassignedBeforeGuard(err, condition) and + guard.ensures(DataFlow::exprNode(condition), false) and + ( + use instanceof Expr and + guard.dominates(DataFlow::exprNode(use.(Expr)).getBasicBlock()) + or + useNode.isFirstNodeOf(use) and + guard.dominates(useNode.getBasicBlock()) + ) + ) +} + +bindingset[message, messages, sink] +pragma[inline] +predicate messageReadFromReturnedSlice(Expr message, Expr messages, CallExpr sink) { + exists(RangeStmt loop, Ident rangeValue, Ident messageRead, Variable v | + sameVariable(loop.getDomain(), messages) and + rangeValue = loop.getValue() and + rangeValue.refersTo(v) and + messageRead.refersTo(v) and + messageRead = message.stripParens() and + loop.getBody() = sink.getParent*() and + not receiptReassignedBetween(messageRead, loop, sink) + ) + or + exists(IndexExpr idx | + idx = message.stripParens() and + sameVariable(idx.getBase(), messages) + ) +} + +bindingset[messages, earlier, later] +pragma[inline] +predicate sliceElementAssignedBetween(Expr messages, AstNode earlier, AstNode later) { + exists(Assignment assign, IndexExpr index | + index = assign.getLhs(_).stripParens() and + sameVariable(index.getBase(), messages) and + assign.getEnclosingFunction() = later.getEnclosingFunction() and + occursBefore(earlier, assign) and + occursBefore(assign, later) + ) +} + +bindingset[sink, message, receipt] +pragma[inline] +predicate backedBySuccessfulMessageEventsTuple(CallExpr sink, Expr message, Expr receipt) { + exists( + CallExpr helper, Assignment assign, Expr returnedReceipt, Expr messages, Expr err, + int messagesIndex, int errorIndex + | + messageEventsTuple( + helper, assign, returnedReceipt, messages, err, messagesIndex, errorIndex + ) and + assign.getEnclosingFunction() = sink.getEnclosingFunction() and + sameVariable(receipt, returnedReceipt) and + messageReadFromReturnedSlice(message, messages, sink) and + occursBefore(assign, sink) and + not receiptReassignedBetween(returnedReceipt, assign, sink) and + not receiptReassignedBetween(messages, assign, sink) and + not sliceElementAssignedBetween(messages, assign, sink) and + guardProvesNilBefore(err, sink) + ) +} + +predicate isEvmWatcherVerifyAndPublishCall(CallExpr call, Expr messageArg, Expr receiptArg) { + isProductionEvmWatcherFile(call.getFile()) and + call.getCalleeExpr().(SelectorExpr).getSelector().getName() = "verifyAndPublish" and + call.getTarget().getFuncDecl().getName() = "verifyAndPublish" and + call.getTarget().getFuncDecl().getFile().getRelativePath().matches("node/pkg/watchers/evm/%.go") and + messageArg = call.getArgument(0) and + receiptArg = call.getArgument(3) +} + +predicate isReceiptLogsSelectorFor(Expr logs, Expr receipt) { + exists(SelectorExpr sel | + logs.stripParens() = sel and + sel.getSelector().getName() = "Logs" and + sameVariable(sel.getBase(), receipt) + ) +} + +predicate exprIsReceiptLogsForFrom(Expr logs, Expr receipt, AstNode observationOrigin, AstNode use) { + isReceiptLogsSelectorFor(logs, receipt) and + observationOrigin = use + or + exists(Assignment assign, Expr lhs, Expr rhs | + lhs = assign.getLhs(0) and + assign.getEnclosingFunction() = use.getEnclosingFunction() and + sameVariable(logs, lhs) and + isReceiptLogsSelectorFor(rhs, receipt) and + assign.getRhs(0) = rhs and + occursBefore(assign, use) and + not receiptReassignedBetween(lhs, assign, use) and + observationOrigin = assign + ) +} + +predicate exprReadsLogFromReceipt(Expr logExpr, Expr receipt, AstNode observationOrigin, AstNode use) { + exists(RangeStmt loop, Ident rangeValue, Ident logRead, Variable v | + isProductionEvmWatcherFile(use.getFile()) and + loop.getEnclosingFunction() = use.getEnclosingFunction() and + exprIsReceiptLogsForFrom(loop.getDomain(), receipt, observationOrigin, use) and + rangeValue = loop.getValue() and + rangeValue.refersTo(v) and + logRead.refersTo(v) and + (logRead = logExpr or logRead = logExpr.getAChild()) and + loop.getBody() = use.getParent*() + ) + or + exists(IndexExpr idx | + isProductionEvmWatcherFile(use.getFile()) and + idx.getEnclosingFunction() = use.getEnclosingFunction() and + (idx = logExpr.stripParens() or idx = logExpr.getAChild()) and + exprIsReceiptLogsForFrom(idx.getBase(), receipt, observationOrigin, use) + ) +} + +predicate parseCallReadsLogFromReceipt(CallExpr call, Expr receipt, AstNode observationOrigin) { + isParseLogMessagePublishedCall(call) and + exprReadsLogFromReceipt(call.getArgument(0), receipt, observationOrigin, call) +} + +predicate helperParameterParsed(FuncDecl f, Parameter p) { + exists(CallExpr parse, Ident pRead | + isProductionEvmWatcherFile(f.getFile()) and + parse.getEnclosingFunction() = f and + isParseLogMessagePublishedCall(parse) and + pRead.refersTo(p) and + (pRead = parse.getArgument(0) or pRead = parse.getArgument(0).getAChild()) + ) +} + +predicate helperCallParsesLogFromReceipt(CallExpr call, Expr receipt, AstNode observationOrigin) { + exists(int i, FuncDecl f, Parameter p | + isProductionEvmWatcherFile(call.getFile()) and + f = call.getTarget().getFuncDecl() and + isProductionEvmWatcherFile(f.getFile()) and + p = f.getParameter(i) and + helperParameterParsed(f, p) and + exprReadsLogFromReceipt(call.getArgument(i), receipt, observationOrigin, call) + ) +} + +from CallExpr sink, Expr receipt, AstNode observationOrigin, string kind +where + ( + exists(CallExpr call, Expr message | + sink = call and + observationOrigin = call and + isEvmWatcherVerifyAndPublishCall(call, message, receipt) and + kind = "publishes with (*Watcher).verifyAndPublish" + ) + or + exists(CallExpr call | + sink = call and + ( + parseCallReadsLogFromReceipt(call, receipt, observationOrigin) + or + helperCallParsesLogFromReceipt(call, receipt, observationOrigin) + ) and + kind = "parses LogMessagePublished events from receipt.Logs" + ) + ) and + not receiptBackedBySuccessfulStatus(receipt, observationOrigin, sink) and + not exists(Expr message | + isEvmWatcherVerifyAndPublishCall(sink, message, receipt) and + backedBySuccessfulMessageEventsTuple(sink, message, receipt) + ) +select sink, + "EVM watcher observation must prove the same transaction receipt has Status == ReceiptStatusSuccessful before it " + + kind + "; nil, tx-hash, block-hash, or finality checks do not prove receipt success." diff --git a/codeql/src/evm-verify-and-publish-gate.ql b/codeql/src/evm-verify-and-publish-gate.ql new file mode 100644 index 0000000000..692cad77eb --- /dev/null +++ b/codeql/src/evm-verify-and-publish-gate.ql @@ -0,0 +1,137 @@ +/** + * @name EVM watcher publication bypasses verifyAndPublish + * @description EVM watcher MessagePublication values must be published only through (*Watcher).verifyAndPublish so transfer-verifier state updates cannot be bypassed. + * @kind problem + * @problem.severity warning + * @precision high + * @id wormhole/go/evm-verify-and-publish-gate + * @tags security + * external/cwe/cwe-345 + */ + +import go +import semmle.go.concepts.GeneratedFile + +predicate isProductionEvmWatcherFile(File f) { + f.getRelativePath().matches("node/pkg/watchers/evm/%.go") and + not f.getRelativePath().matches("%_test.go") and + not f instanceof GeneratedFile +} + +predicate isMessagePublicationPointer(Type t) { + t.(PointerType).getBaseType().hasQualifiedName( + "github.com/certusone/wormhole/node/pkg/common", "MessagePublication" + ) +} + +predicate isPublicationChannel(Type t) { + t.(ChanType).canSend() and + isMessagePublicationPointer(t.(ChanType).getElementType()) +} + +predicate isEvmWatcherMsgCField(Field field) { + field.hasQualifiedName("github.com/certusone/wormhole/node/pkg/watchers/evm", "Watcher", "msgC") +} + +predicate isDirectProtectedChannelExpr(Expr channel) { + exists(SelectorExpr selector, Field field | + selector = channel.stripParens() and + selector.refersTo(field) and + isEvmWatcherMsgCField(field) and + isPublicationChannel(selector.getType()) + ) +} + +predicate sameVariable(Expr a, Expr b) { + exists(Entity target | + a.stripParens().(Ident).refersTo(target) and + b.stripParens().(Ident).refersTo(target) + ) +} + +predicate occursBefore(AstNode earlier, AstNode later) { + earlier.getLocation().getStartLine() < later.getLocation().getStartLine() + or + earlier.getLocation().getStartLine() = later.getLocation().getStartLine() and + earlier.getLocation().getStartColumn() < later.getLocation().getStartColumn() +} + +predicate variableIsNotReassignedBetween(Expr variable, Assignment source, AstNode use) { + not exists(Assignment overwrite, Expr lhs | + lhs = overwrite.getLhs(_) and + sameVariable(variable, lhs) and + overwrite.getEnclosingFunction() = use.getEnclosingFunction() and + occursBefore(source, overwrite) and + occursBefore(overwrite, use) + ) +} + +predicate isProtectedChannelExprAt(Expr channel, AstNode use) { + isDirectProtectedChannelExpr(channel) + or + exists(Assignment assign, Expr lhs, int index | + lhs = assign.getLhs(index) and + sameVariable(channel, lhs) and + isPublicationChannel(channel.getType()) and + assign.getEnclosingFunction() = use.getEnclosingFunction() and + occursBefore(assign, use) and + variableIsNotReassignedBetween(channel, assign, use) and + isProtectedChannelExprAt(assign.getRhs(index), assign) + ) +} + +predicate isApprovedVerifyAndPublishBody(SendStmt send) { + exists(MethodDecl method | + method = send.getEnclosingFunction() and + method.getName() = "verifyAndPublish" and + method.getReceiverBaseType().hasQualifiedName( + "github.com/certusone/wormhole/node/pkg/watchers/evm", "Watcher" + ) + ) +} + +predicate sendChannelIsParameter(SendStmt send, Parameter parameter) { + send.getChannel().stripParens().(Ident).refersTo(parameter) +} + +predicate helperCalledWithProtectedChannel(SendStmt send, Parameter parameter, CallExpr call) { + sendChannelIsParameter(send, parameter) and + parameter.getIndex() >= 0 and + call.getTarget().getFuncDecl() = parameter.getFunction() and + isProductionEvmWatcherFile(call.getFile()) and + isProtectedChannelExprAt(call.getArgument(parameter.getIndex()), call) +} + +predicate isDirectOrAliasBypass(SendStmt send) { + isProductionEvmWatcherFile(send.getFile()) and + isProtectedChannelExprAt(send.getChannel(), send) +} + +predicate isThinHelperBypass(SendStmt send) { + isProductionEvmWatcherFile(send.getFile()) and + exists(Parameter parameter, CallExpr call | + helperCalledWithProtectedChannel(send, parameter, call) and + isPublicationChannel(send.getChannel().getType()) + ) +} + +string bypassKind(SendStmt send) { + isThinHelperBypass(send) and + result = "this thin helper send is reachable from a call that passes the watcher's msgC channel" + or + isDirectProtectedChannelExpr(send.getChannel()) and + result = "this send writes to the watcher's msgC field directly" + or + isProtectedChannelExprAt(send.getChannel(), send) and + not isDirectProtectedChannelExpr(send.getChannel()) and + result = "this send writes through a local alias of the watcher's msgC channel" +} + +from SendStmt send, string kind +where + kind = bypassKind(send) and + (isDirectOrAliasBypass(send) or isThinHelperBypass(send)) and + not isApprovedVerifyAndPublishBody(send) +select send, + "EVM watcher publications must go through (*Watcher).verifyAndPublish; " + kind + + " and can bypass transfer-verifier state updates." diff --git a/codeql/src/governance-vaa-typed-payload.ql b/codeql/src/governance-vaa-typed-payload.ql new file mode 100644 index 0000000000..0eb6f306ff --- /dev/null +++ b/codeql/src/governance-vaa-typed-payload.ql @@ -0,0 +1,148 @@ +/** + * @name Governance VAA payload must come from checked typed serializer + * @description Production governance VAA construction must pass CreateGovernanceVAA a payload produced by a checked SDK typed governance serializer or EmptyPayloadVaa. + * @kind problem + * @problem.severity warning + * @precision high + * @id wormhole/go/governance-vaa-typed-payload + * @tags security + * external/cwe/cwe-20 + */ + +import go +import semmle.go.concepts.GeneratedFile +import semmle.go.dataflow.DataFlow +import semmle.go.dataflow.GlobalValueNumbering + +predicate inNodeProduction(AstNode n) { + n.getFile().getRelativePath().matches("node/%.go") and + not n.getFile().getRelativePath().matches("%_test.go") and + not n.getFile() instanceof GeneratedFile +} + +predicate isCreateGovernanceVaa(CallExpr call) { + call.getTarget().getPackage().getPath() = "github.com/wormhole-foundation/wormhole/sdk/vaa" and + call.getTarget().getName() = "CreateGovernanceVAA" +} + +predicate isEmptyPayloadVaa(CallExpr call) { + call.getTarget().getPackage().getPath() = "github.com/wormhole-foundation/wormhole/sdk/vaa" and + call.getTarget().getName() = "EmptyPayloadVaa" +} + +predicate isTypedGovernanceSerialize(CallExpr call) { + exists(Method m, string receiver | + m = call.getTarget() and + m.getPackage().getPath() = "github.com/wormhole-foundation/wormhole/sdk/vaa" and + m.getName() = "Serialize" and + receiver = m.getReceiverBaseType().getName() and + receiver.matches("Body%") and + not receiver.matches("%VAA%") + ) +} + +predicate sameExpr(Expr a, Expr b) { + globalValueNumber(DataFlow::exprNode(a.stripParens())) = + globalValueNumber(DataFlow::exprNode(b.stripParens())) + or + exists(Entity e | a.stripParens().(Ident).refersTo(e) and b.stripParens().(Ident).refersTo(e)) +} + +predicate assignedTuple(CallExpr producer, Expr bytesLhs, Expr errLhs) { + exists(Assignment assign | + assign.getRhs(0) = producer and + bytesLhs = assign.getLhs(0) and + errLhs = assign.getLhs(1) + ) +} + +predicate isGovernancePayloadProducer(CallExpr producer) { + isTypedGovernanceSerialize(producer) + or + isEmptyPayloadVaa(producer) +} + +predicate payloadFlowsToSink(CallExpr producer, CallExpr sink) { + DataFlow::localFlow( + DataFlow::extractTupleElement(DataFlow::exprNode(producer), 0), + DataFlow::exprNode(sink.getArgument(4)) + ) +} + +predicate errIsIgnored(Expr errLhs) { errLhs.toString() = "_" } + +predicate errGuardDominates(Expr errLhs, CallExpr sink) { + exists(IfStmt guard | + sameExpr(errLhs, any(Expr e | e = guard.getCond().getAChild*())) and + guard.getThen().getAChild*() instanceof ReturnStmt and + DataFlow::exprNode(guard.getCond()).getBasicBlock().dominates( + DataFlow::exprNode(sink).getBasicBlock() + ) + ) +} + +predicate occursBetween(AstNode middle, AstNode first, AstNode last) { + middle.getEnclosingFunction() = last.getEnclosingFunction() and + first.getLocation().getStartLine() < middle.getLocation().getStartLine() and + middle.getLocation().getStartLine() < last.getLocation().getStartLine() +} + +predicate payloadReassignedBetween(Expr bytesLhs, CallExpr producer, CallExpr sink) { + exists(Assignment later | + occursBetween(later, producer, sink) and + later.getRhs(0) != producer and + sameExpr(bytesLhs, later.getLhs(_)) + ) +} + +predicate payloadMutatedBetween(Expr bytesLhs, CallExpr producer, CallExpr sink) { + exists(CallExpr mutation | + occursBetween(mutation, producer, sink) and + ( + mutation.getCalleeName() = "append" and sameExpr(bytesLhs, mutation.getArgument(0)) + or + mutation.getCalleeName() = "copy" and sameExpr(bytesLhs, mutation.getArgument(0)) + ) + ) +} + +predicate hasCheckedStablePayload(CallExpr sink) { + exists(CallExpr producer, Expr bytesLhs, Expr errLhs | + isGovernancePayloadProducer(producer) and + assignedTuple(producer, bytesLhs, errLhs) and + payloadFlowsToSink(producer, sink) and + not errIsIgnored(errLhs) and + errGuardDominates(errLhs, sink) and + not payloadReassignedBetween(bytesLhs, producer, sink) and + not payloadMutatedBetween(bytesLhs, producer, sink) + ) +} + +predicate hasUncheckedSerializerPayload(CallExpr sink) { + exists(CallExpr producer, Expr bytesLhs, Expr errLhs | + isGovernancePayloadProducer(producer) and + assignedTuple(producer, bytesLhs, errLhs) and + payloadFlowsToSink(producer, sink) and + ( + errIsIgnored(errLhs) + or + not errGuardDominates(errLhs, sink) + ) + ) +} + +from CallExpr sink, string message +where + inNodeProduction(sink) and + isCreateGovernanceVaa(sink) and + not hasCheckedStablePayload(sink) and + ( + hasUncheckedSerializerPayload(sink) and + message = + "check the governance payload serializer error before passing its bytes to CreateGovernanceVAA" + or + not hasUncheckedSerializerPayload(sink) and + message = + "governance VAA payload must come from a checked SDK typed serializer or EmptyPayloadVaa before CreateGovernanceVAA" + ) +select sink, message diff --git a/codeql/src/guardian-signer-exact-digest-length.ql b/codeql/src/guardian-signer-exact-digest-length.ql new file mode 100644 index 0000000000..388be58d78 --- /dev/null +++ b/codeql/src/guardian-signer-exact-digest-length.ql @@ -0,0 +1,124 @@ +/** + * @name Guardian signer signs non-exact digest length + * @description GuardianSigner.Sign implementations must reject non-32-byte digest input before passing the digest to a local or remote signing primitive. + * @kind problem + * @problem.severity warning + * @precision high + * @id wormhole/go/guardian-signer-exact-digest-length + * @tags security + * external/cwe/cwe-345 + */ + +import go +import semmle.go.concepts.GeneratedFile +import semmle.go.dataflow.DataFlow + +predicate isProductionGuardianSignerFile(File f) { + f.getRelativePath().matches("node/pkg/guardiansigner/%.go") and + not f.getRelativePath().matches("%_test.go") and + not f instanceof GeneratedFile +} + +predicate isGuardianSignerSignMethod(MethodDecl m) { + isProductionGuardianSignerFile(m.getFile()) and + m.getName() = "Sign" and + m.getFunction().(Method).implements( + "github.com/certusone/wormhole/node/pkg/guardiansigner", "GuardianSigner", "Sign" + ) and + // Currently documented as an unsafe/test helper and not returned by production URI constructors. + not m.getReceiverBaseType().getName() = "GeneratedSigner" +} + +Parameter signerDigestParam(MethodDecl m) { + isGuardianSignerSignMethod(m) and + result = m.getParameter(1) +} + +predicate exprReceivesSignerDigest(MethodDecl m, Expr e) { + DataFlow::localFlow(DataFlow::parameterNode(signerDigestParam(m)), DataFlow::exprNode(e.stripParens())) + or + exists(SliceExpr slice | + e.stripParens() = slice and + DataFlow::localFlow( + DataFlow::parameterNode(signerDigestParam(m)), DataFlow::exprNode(slice.getBase().stripParens()) + ) + ) +} + +predicate isLocalSigningPrimitiveArg(CallExpr call, Expr arg) { + call.getCalleeName() = "Sign" and + call.getTarget().getQualifiedName().matches("github.com/ethereum/go-ethereum/crypto.Sign") and + arg = call.getArgument(0) +} + +predicate isKmsSigningPrimitiveArg(CallExpr call, Expr arg) { + call.getCalleeName() = "Sign" and + call.getTarget().(Method).hasQualifiedName("github.com/aws/aws-sdk-go-v2/service/kms", "Client", "Sign") and + exists(CompositeLit input, KeyValueExpr field | + input = call.getArgument(1).(AddressExpr).getOperand() and + field = input.getElement(_) and + field.getKey().(Ident).getName() = "Message" and + arg = field.getValue() + ) +} + +predicate isSigningPrimitiveDigestArg(MethodDecl m, CallExpr call, Expr arg) { + call.getEnclosingFunction() = m and + (isLocalSigningPrimitiveArg(call, arg) or isKmsSigningPrimitiveArg(call, arg)) and + exprReceivesSignerDigest(m, arg) +} + +predicate isExact32(Expr e) { e.getIntValue() = 32 or e.getExactValue().toInt() = 32 } + +predicate isLenOfSignerDigest(MethodDecl m, CallExpr lenCall) { + lenCall.getCalleeName() = "len" and + lenCall.getTarget().getQualifiedName() = "len" and + exprReceivesSignerDigest(m, lenCall.getArgument(0)) +} + +predicate isExactLenNeq32Guard(MethodDecl m, IfStmt guard) { + exists(NeqExpr cmp, CallExpr lenCall, Expr length | + guard.getEnclosingFunction() = m and + cmp = guard.getCond().stripParens() and + isLenOfSignerDigest(m, lenCall) and + ( + cmp.getLeftOperand() = lenCall and length = cmp.getRightOperand() + or + cmp.getRightOperand() = lenCall and length = cmp.getLeftOperand() + ) and + isExact32(length) + ) +} + +predicate thenBranchFailsClosed(IfStmt guard) { + exists(ReturnStmt ret | + ret.getParent*() = guard.getThen() and + ret.getNumExpr() = 2 and + ret.getExpr(0).toString() = "nil" and + not ret.getExpr(1).toString() = "nil" + ) +} + +predicate dominates(AstNode before, AstNode after) { + exists(ControlFlow::Node beforeNode, ControlFlow::Node afterNode | + beforeNode.isFirstNodeOf(before) and + afterNode.isFirstNodeOf(after) and + beforeNode.getBasicBlock().dominates(afterNode.getBasicBlock()) + ) +} + +predicate hasExactFailClosedDigestGuard(MethodDecl m, CallExpr primitive) { + exists(IfStmt guard | + isExactLenNeq32Guard(m, guard) and + thenBranchFailsClosed(guard) and + dominates(guard, primitive) + ) +} + +from MethodDecl m, CallExpr primitive, Expr arg +where + isGuardianSignerSignMethod(m) and + isSigningPrimitiveDigestArg(m, primitive, arg) and + not hasExactFailClosedDigestGuard(m, primitive) +select m, + "GuardianSigner.Sign implementation reaches a signing primitive without first rejecting non-32-byte digest input; add a dominating len(hash) == 32 fail-closed check before signing." diff --git a/codeql/src/message-publication-canonical-timestamp.ql b/codeql/src/message-publication-canonical-timestamp.ql new file mode 100644 index 0000000000..60e1b29341 --- /dev/null +++ b/codeql/src/message-publication-canonical-timestamp.ql @@ -0,0 +1,237 @@ +/** + * @name MessagePublication timestamp bypasses canonical VAA conversion + * @description MessagePublication timestamps derived through Unix-second conversion must use vaa.TimeFromUnix and fail closed on conversion errors before publication. + * @kind problem + * @problem.severity warning + * @precision high + * @id wormhole/go/message-publication-canonical-timestamp + * @tags security + * external/cwe/cwe-190 + */ + +import go +import semmle.go.concepts.GeneratedFile +import semmle.go.controlflow.ControlFlowGraph +import semmle.go.dataflow.GlobalValueNumbering + +predicate isProductionNodeFile(File f) { + f.getRelativePath().matches("node/%.go") and + not f.getRelativePath().matches("%_test.go") and + not f instanceof GeneratedFile +} + +predicate isCommonMessagePublicationType(Type t) { + t.getName() = "MessagePublication" and + t.getPackage().getPath().matches("%/node/pkg/common") +} + +predicate isMessagePublicationLiteral(CompositeLit lit) { + isProductionNodeFile(lit.getFile()) and + isCommonMessagePublicationType(lit.getType()) +} + +predicate isTimestampField(KeyValueExpr field, Expr timestampValue) { + field.getKey().(Ident).getName() = "Timestamp" and + timestampValue = field.getValue() +} + +predicate isMessagePublicationTimestampSink(KeyValueExpr field, Expr timestampValue) { + exists(CompositeLit lit, int i | + isMessagePublicationLiteral(lit) and + field = lit.getElement(i) and + isTimestampField(field, timestampValue) + ) +} + +predicate isTimeUnixCall(CallExpr call) { + call.getCalleeExpr().(SelectorExpr).getSelector().getName() = "Unix" and + call.getTarget().getQualifiedName() = "time.Unix" +} + +predicate isTimeNowCall(CallExpr call) { + call.getCalleeExpr().(SelectorExpr).getSelector().getName() = "Now" and + call.getTarget().getQualifiedName() = "time.Now" +} + +predicate isLocalWallClockUnixSeconds(CallExpr call) { + call.getCalleeExpr().(SelectorExpr).getSelector().getName() = "Unix" and + exists(CallExpr nowCall | + isTimeNowCall(nowCall) and + call.getCalleeExpr().(SelectorExpr).getBase() = nowCall + ) +} + +predicate isLocalWallClockRoundTrip(CallExpr call) { + isTimeUnixCall(call) and + exists(CallExpr secondsCall | + isLocalWallClockUnixSeconds(secondsCall) and + DataFlow::localFlow(DataFlow::exprNode(secondsCall), DataFlow::exprNode(call.getArgument(0))) + ) +} + +predicate isVaaTimeFromUnixCall(CallExpr call) { + call.getCalleeExpr().(SelectorExpr).getSelector().getName() = "TimeFromUnix" and + call.getTarget().getFuncDecl().getName() = "TimeFromUnix" and + call.getTarget().getFuncDecl().getFile().getRelativePath().matches("%sdk/vaa/%.go") +} + +DataFlow::Node timeFromUnixValue(CallExpr call) { + isVaaTimeFromUnixCall(call) and + result = DataFlow::extractTupleElement(DataFlow::exprNode(call), 0) +} + +predicate assignmentReceivesTupleElement(Assignment assign, CallExpr call, int index, Expr lhs) { + assign.getRhs(0) = call and + lhs = assign.getLhs(index) +} + +predicate pairedTimeFromUnixErrorExpr(CallExpr call, Expr err) { + exists(Assignment assign | + assign.getEnclosingFunction() = call.getEnclosingFunction() and + assignmentReceivesTupleElement(assign, call, 1, err) and + not err.(Ident).getName() = "_" + ) +} + +predicate neqNilExprFor(Expr condition, Expr err) { + exists(NeqExpr neq, Expr nil | + condition = neq and + exprRefersToNil(nil) and + ( + exists(Entity target | + neq.getLeftOperand().(Ident).refersTo(target) and err.(Ident).refersTo(target) + ) and + neq.getRightOperand() = nil + or + exists(Entity target | + neq.getRightOperand().(Ident).refersTo(target) and err.(Ident).refersTo(target) + ) and + neq.getLeftOperand() = nil + ) + ) +} + +predicate eqNilExprFor(Expr condition, Expr err) { + exists(EqExpr eq, Expr nil | + condition = eq and + exprRefersToNil(nil) and + ( + exists(Entity target | + eq.getLeftOperand().(Ident).refersTo(target) and err.(Ident).refersTo(target) + ) and + eq.getRightOperand() = nil + or + exists(Entity target | + eq.getRightOperand().(Ident).refersTo(target) and err.(Ident).refersTo(target) + ) and + eq.getLeftOperand() = nil + ) + ) +} + +predicate errorReassignedBeforeGuard(Expr err, AstNode guardUse) { + exists(Assignment assign, Ident lhs, Entity target | + lhs = assign.getLhs(_).stripParens() and + lhs.refersTo(target) and + err.(Ident).refersTo(target) and + assign.getLocation().getStartLine() > err.getLocation().getStartLine() and + assign.getLocation().getStartLine() < guardUse.getLocation().getStartLine() + ) +} + +predicate guardProvesNilBefore(Expr err, AstNode use) { + exists(ControlFlow::ConditionGuardNode guard, ControlFlow::Node useNode, Expr errRead, Expr nil | + exprRefersToNil(nil) and + useNode.isFirstNodeOf(use) and + errRead.getLocation().getStartLine() > err.getLocation().getStartLine() and + globalValueNumber(DataFlow::exprNode(errRead)) = globalValueNumber(DataFlow::exprNode(err)) and + not errorReassignedBeforeGuard(err, errRead) and + guard.ensuresEq(DataFlow::exprNode(errRead), DataFlow::exprNode(nil)) and + guard.dominates(useNode.getBasicBlock()) + ) + or + exists(ControlFlow::ConditionGuardNode guard, ControlFlow::Node useNode, Expr condition | + neqNilExprFor(condition, err) and + condition.getLocation().getStartLine() > err.getLocation().getStartLine() and + not errorReassignedBeforeGuard(err, condition) and + guard.ensures(DataFlow::exprNode(condition), false) and + ( + use instanceof Expr and + guard.dominates(DataFlow::exprNode(use.(Expr)).getBasicBlock()) + or + useNode.isFirstNodeOf(use) and + guard.dominates(useNode.getBasicBlock()) + ) + ) + or + exists(ControlFlow::ConditionGuardNode guard, ControlFlow::Node useNode, Expr condition | + eqNilExprFor(condition, err) and + condition.getLocation().getStartLine() > err.getLocation().getStartLine() and + not errorReassignedBeforeGuard(err, condition) and + guard.ensures(DataFlow::exprNode(condition), true) and + ( + use instanceof Expr and + guard.dominates(DataFlow::exprNode(use.(Expr)).getBasicBlock()) + or + useNode.isFirstNodeOf(use) and + guard.dominates(useNode.getBasicBlock()) + ) + ) +} + +predicate timeFromUnixErrorRejectedBefore(CallExpr call, AstNode use) { + exists(Expr err | + pairedTimeFromUnixErrorExpr(call, err) and + guardProvesNilBefore(err, use) + ) +} + +predicate timestampHasUnsafeUnixProvenance(Expr timestampValue, CallExpr unixCall) { + isTimeUnixCall(unixCall) and + not isLocalWallClockRoundTrip(unixCall) and + DataFlow::localFlow(DataFlow::exprNode(unixCall), DataFlow::exprNode(timestampValue)) +} + +predicate returnsOnlyTimeUnix(FuncDecl f, CallExpr unixCall) { + isProductionNodeFile(f.getFile()) and + exists(ReturnStmt ret | + ret.getEnclosingFunction() = f and + DataFlow::localFlow(DataFlow::exprNode(unixCall), DataFlow::exprNode(ret.getExpr(0))) + ) and + not exists(ReturnStmt ret | + ret.getEnclosingFunction() = f and + not exists(CallExpr returnedUnix | + isTimeUnixCall(returnedUnix) and + not isLocalWallClockRoundTrip(returnedUnix) and + DataFlow::localFlow(DataFlow::exprNode(returnedUnix), DataFlow::exprNode(ret.getExpr(0))) + ) + ) and + isTimeUnixCall(unixCall) and + not isLocalWallClockRoundTrip(unixCall) +} + +predicate timestampHasUnsafeUnixWrapperProvenance(Expr timestampValue, CallExpr unixCall) { + exists(CallExpr wrapperCall, FuncDecl wrapper | + wrapperCall.getTarget().getFuncDecl() = wrapper and + returnsOnlyTimeUnix(wrapper, unixCall) and + DataFlow::localFlow(DataFlow::exprNode(wrapperCall), DataFlow::exprNode(timestampValue)) + ) +} + +predicate timestampHasUncheckedTimeFromUnixProvenance(Expr timestampValue, KeyValueExpr field, CallExpr timeFromUnixCall) { + DataFlow::localFlow(timeFromUnixValue(timeFromUnixCall), DataFlow::exprNode(timestampValue)) and + not timeFromUnixErrorRejectedBefore(timeFromUnixCall, field) +} + +from KeyValueExpr field, Expr timestampValue, CallExpr report +where + isMessagePublicationTimestampSink(field, timestampValue) and + ( + timestampHasUnsafeUnixProvenance(timestampValue, report) + or + timestampHasUnsafeUnixWrapperProvenance(timestampValue, report) + or + timestampHasUncheckedTimeFromUnixProvenance(timestampValue, field, report) + ) +select report, + "MessagePublication timestamp from chain-derived Unix seconds must be validated with vaa.TimeFromUnix and must not publish on conversion error; this timestamp appears to bypass the VAA uint32 wire-format check." diff --git a/codeql/src/message-publication-safe-serialization.ql b/codeql/src/message-publication-safe-serialization.ql new file mode 100644 index 0000000000..6d2a6d07a4 --- /dev/null +++ b/codeql/src/message-publication-safe-serialization.ql @@ -0,0 +1,140 @@ +/** + * @name MessagePublication serialized with deprecated unsafe format + * @description Production MessagePublication serialization must use MarshalBinary and UnmarshalBinary because the deprecated helpers omit Unreliable and verificationState. + * @kind problem + * @problem.severity warning + * @precision high + * @id wormhole/go/message-publication-safe-serialization + * @tags security + * external/cwe/cwe-345 + */ + +import go +import semmle.go.concepts.GeneratedFile + +predicate isProductionNodeFile(File f) { + f.getRelativePath().matches("node/%.go") and + not f.getRelativePath().matches("%_test.go") and + not f instanceof GeneratedFile +} + +predicate isMessagePublicationDeclaration(Function target) { + target.getFuncDecl().getFile().getRelativePath() = "node/pkg/common/chainlock.go" +} + +predicate isDeprecatedMessagePublicationMarshalTarget(Function target) { + isMessagePublicationDeclaration(target) and + target instanceof Method and + target.getName() = "Marshal" and + target.(Method).getReceiverBaseType().getName() = "MessagePublication" +} + +predicate isDeprecatedMessagePublicationUnmarshalTarget(Function target) { + isMessagePublicationDeclaration(target) and + not target instanceof Method and + target.getName() = "UnmarshalMessagePublication" +} + +predicate callTargets(CallExpr call, Function target) { + target = call.getTarget() + or + call.getCalleeExpr().stripParens().(SelectorExpr).refersTo(target) +} + +predicate isDeprecatedMessagePublicationMarshal(CallExpr call) { + isProductionNodeFile(call.getFile()) and + exists(Function target | + callTargets(call, target) and + isDeprecatedMessagePublicationMarshalTarget(target) + ) +} + +predicate isDeprecatedMessagePublicationUnmarshal(CallExpr call) { + isProductionNodeFile(call.getFile()) and + exists(Function target | + callTargets(call, target) and + isDeprecatedMessagePublicationUnmarshalTarget(target) + ) +} + +predicate isOldParameterReference(Expr expr, FuncDecl function) { + exists(Parameter oldParameter, int i | + oldParameter = function.getParameter(i) and + oldParameter.getName() = "isOld" and + expr.stripParens().(Ident).refersTo(oldParameter) + ) +} + +predicate conditionIsOld(Expr condition, FuncDecl function) { + isOldParameterReference(condition, function) + or + exists(EqualityTestExpr equality, Expr oldOperand, Expr trueOperand | + equality = condition.stripParens() and + equality.getPolarity() = true and + equality.hasOperands(oldOperand, trueOperand) and + isOldParameterReference(oldOperand, function) and + trueOperand.stripParens().getBoolValue() = true + ) +} + +predicate isInTrueBranchOfIsOld(Expr expr, FuncDecl function) { + exists(IfStmt ifStmt | + conditionIsOld(ifStmt.getCond(), function) and + expr = ifStmt.getThen().getAChild*() + ) +} + +predicate isLegacyGovernorOldRead(CallExpr call) { + call.getFile().getRelativePath() = "node/pkg/db/governor.go" and + call.getEnclosingFunction().getName() = "UnmarshalPendingTransfer" and + isDeprecatedMessagePublicationUnmarshal(call) and + isInTrueBranchOfIsOld(call, call.getEnclosingFunction()) +} + +predicate isCallCalleeSelector(SelectorExpr sel) { + exists(CallExpr call | call.getCalleeExpr().stripParens() = sel) +} + +predicate capturesDeprecatedMessagePublicationHelper(SelectorExpr sel, string helper) { + isProductionNodeFile(sel.getFile()) and + not isCallCalleeSelector(sel) and + exists(Function target | + sel.refersTo(target) and + ( + isDeprecatedMessagePublicationMarshalTarget(target) and helper = "Marshal" + or + isDeprecatedMessagePublicationUnmarshalTarget(target) and helper = "UnmarshalMessagePublication" + ) + ) +} + +from AstNode node, string replacement, string deprecated +where + exists(CallExpr call | + node = call and + isDeprecatedMessagePublicationMarshal(call) and + replacement = "MarshalBinary" and + deprecated = "Marshal" + ) + or + exists(CallExpr call | + node = call and + isDeprecatedMessagePublicationUnmarshal(call) and + not isLegacyGovernorOldRead(call) and + replacement = "UnmarshalBinary" and + deprecated = "UnmarshalMessagePublication" + ) + or + exists(SelectorExpr sel | + node = sel and + capturesDeprecatedMessagePublicationHelper(sel, deprecated) and + ( + deprecated = "Marshal" and replacement = "MarshalBinary" + or + deprecated = "UnmarshalMessagePublication" and replacement = "UnmarshalBinary" + ) + ) +select node, + "MessagePublication current-format serialization must use " + replacement + "; deprecated " + + deprecated + + " omits Unreliable and verificationState and is allowed only for explicit old Governor migration reads." diff --git a/codeql/src/near-finalized-receipt-outcome-before-publication.ql b/codeql/src/near-finalized-receipt-outcome-before-publication.ql new file mode 100644 index 0000000000..a343834839 --- /dev/null +++ b/codeql/src/near-finalized-receipt-outcome-before-publication.ql @@ -0,0 +1,215 @@ +/** + * @name NEAR receipt outcome publication without same-outcome finality proof + * @description NEAR watcher receipt logs must only be published after the same receipt_outcome block_hash has been proven finalized. + * @kind problem + * @problem.severity error + * @precision high + * @id wormhole/go/near-finalized-receipt-outcome-before-publication + * @tags security + * external/cwe/cwe-345 + */ + +import go +import semmle.go.concepts.GeneratedFile + +predicate isProductionNearWatcherFile(File f) { + f.getRelativePath().matches("node/pkg/watchers/near/%.go") and + not f.getRelativePath().matches("%_test.go") and + not f instanceof GeneratedFile +} + +predicate sameVariable(Expr a, Expr b) { + exists(Entity target | + a.stripParens().(Ident).refersTo(target) and + b.stripParens().(Ident).refersTo(target) + ) +} + +bindingset[earlier, later] +pragma[inline] +predicate occursBefore(AstNode earlier, AstNode later) { + exists(string path, int earlierLine, int earlierColumn, int laterLine, int laterColumn | + earlier.getLocation().hasLocationInfo(path, earlierLine, earlierColumn, _, _) and + later.getLocation().hasLocationInfo(path, laterLine, laterColumn, _, _) and + earlier.getEnclosingFunction() = later.getEnclosingFunction() and + ( + earlierLine < laterLine + or + earlierLine = laterLine and earlierColumn < laterColumn + ) + ) +} + +bindingset[variable, earlier, later] +predicate variableReassignedBetween(Expr variable, AstNode earlier, AstNode later) { + exists( + Assignment assign, Expr lhs, string path, int earlierLine, int earlierColumn, int assignLine, + int assignColumn, int laterLine, int laterColumn + | + lhs = assign.getLhs(_) and + sameVariable(lhs, variable) and + assign.getEnclosingFunction() = later.getEnclosingFunction() and + earlier.getEnclosingFunction() = later.getEnclosingFunction() and + earlier.getLocation().hasLocationInfo(path, earlierLine, earlierColumn, _, _) and + assign.getLocation().hasLocationInfo(path, assignLine, assignColumn, _, _) and + later.getLocation().hasLocationInfo(path, laterLine, laterColumn, _, _) and + ( + earlierLine < assignLine + or + earlierLine = assignLine and earlierColumn < assignColumn + ) and + ( + assignLine < laterLine + or + assignLine = laterLine and assignColumn < laterColumn + ) + ) +} + +predicate isStringLiteral(Expr e, string s) { e.getStringValue() = s } + +predicate isGjsonGetCall(CallExpr call, Expr base, string key) { + call.getCalleeExpr().(SelectorExpr).getSelector().getName() = "Get" and + ( + base = call.getCalleeExpr().(SelectorExpr).getBase() + or + sameVariable(base, call.getCalleeExpr().(SelectorExpr).getBase()) + ) and + isStringLiteral(call.getArgument(0), key) +} + +predicate isProcessWormholeLogCall(CallExpr call, Expr watcherReceiver) { + isProductionNearWatcherFile(call.getFile()) and + call.getCalleeName() = "processWormholeLog" and + call.getTarget() instanceof Method and + call.getTarget().(Method).getReceiverBaseType().hasQualifiedName( + "github.com/certusone/wormhole/node/pkg/watchers/near", "Watcher" + ) and + exists(SelectorExpr callee | + callee = call.getCalleeExpr().(SelectorExpr) and + watcherReceiver = callee.getBase() + ) +} + +predicate assignedGjsonGetFor(Expr resultExpr, Expr base, string key, AstNode use, AstNode origin) { + exists(Assignment assign, Expr lhs, CallExpr getCall | + lhs = assign.getLhs(0) and + isGjsonGetCall(getCall, base, key) and + assign.getRhs(0) = getCall and + assign.getEnclosingFunction() = use.getEnclosingFunction() and + occursBefore(assign, use) and + sameVariable(lhs, resultExpr) and + not variableReassignedBetween(lhs, assign, use) and + origin = assign + ) +} + +predicate directLoopReadsLogForReceiptOutcome(Expr logExpr, Expr receiptOutcome, AstNode use, AstNode origin) { + exists(RangeStmt loop, Ident rangeValue, Variable v, CallExpr arrayCall, Expr logs, Expr outcome, AstNode logsOrigin | + loop.getEnclosingFunction() = use.getEnclosingFunction() and + loop.getBody() = use.getParent*() and + rangeValue = loop.getValue() and + rangeValue.refersTo(v) and + logExpr.stripParens().(Ident).refersTo(v) and + loop.getDomain().stripParens() = arrayCall and + arrayCall.getCalleeExpr().(SelectorExpr).getSelector().getName() = "Array" and + sameVariable(arrayCall.getCalleeExpr().(SelectorExpr).getBase(), logs) and + assignedGjsonGetFor(outcome, receiptOutcome, "outcome", use, origin) and + assignedGjsonGetFor(logs, outcome, "logs", use, logsOrigin) + ) +} + +predicate isReceiptDerivedPublication(CallExpr call, Expr receiptOutcome, AstNode origin, Expr watcherReceiver) { + isProcessWormholeLogCall(call, watcherReceiver) and + directLoopReadsLogForReceiptOutcome(call.getArgument(5), receiptOutcome, call, origin) +} + +predicate isFinalizerIsFinalizedCall(CallExpr call, Expr watcherReceiver) { + exists(Method method | + isProductionNearWatcherFile(call.getFile()) and + call.getCalleeExpr().(SelectorExpr).getSelector().refersTo(method) and + method.getName() = "isFinalized" and + method.getReceiverBaseType().hasQualifiedName( + "github.com/certusone/wormhole/node/pkg/watchers/near", "Finalizer" + ) and + call.getNumArgument() = 3 and + exists(SelectorExpr callee, SelectorExpr finalizer, Field field | + callee = call.getCalleeExpr().(SelectorExpr) and + finalizer = callee.getBase().(SelectorExpr) and + finalizer.refersTo(field) and + field.getName() = "finalizer" and + sameVariable(finalizer.getBase(), watcherReceiver) + ) + ) +} + +predicate finalityCallUsesBlockHashFor(CallExpr proofCall, Expr receiptOutcome, AstNode hashOrigin) { + exists(Assignment assign, Expr lhs, CallExpr getCall, CallExpr stringCall | + assign.getEnclosingFunction() = proofCall.getEnclosingFunction() and + lhs = assign.getLhs(0) and + assign.getRhs(0) = getCall and + isGjsonGetCall(getCall, receiptOutcome, "block_hash") and + proofCall.getArgument(2).stripParens() = stringCall and + stringCall.getCalleeExpr().(SelectorExpr).getSelector().getName() = "String" and + sameVariable(stringCall.getCalleeExpr().(SelectorExpr).getBase(), lhs) and + occursBefore(assign, proofCall) and + not variableReassignedBetween(lhs, assign, proofCall) and + hashOrigin = assign + ) +} + +predicate boolResultOfFinalityCall(Expr boolExpr, CallExpr proofCall) { + exists(Assignment assign, Expr lhs | + assign.getRhs(0) = proofCall and + lhs = assign.getLhs(1) and + sameVariable(lhs, boolExpr) + ) +} + +predicate headerResultOfFinalityCall(Expr headerExpr, CallExpr proofCall) { + exists(Assignment assign, Expr lhs | + assign.getRhs(0) = proofCall and + lhs = assign.getLhs(0) and + sameVariable(lhs, headerExpr) + ) +} + +predicate finalityHeaderPassedToPublication(CallExpr proofCall, CallExpr sink) { + headerResultOfFinalityCall(sink.getArgument(3), proofCall) and + not variableReassignedBetween(sink.getArgument(3), proofCall, sink) +} + +predicate finalityBooleanGuardDominates(Expr boolExpr, AstNode sink, boolean outcome) { + exists(ControlFlow::ConditionGuardNode guard, ControlFlow::Node sinkNode | + outcome = true and + sinkNode.isFirstNodeOf(sink) and + guard.ensures(DataFlow::exprNode(boolExpr), true) and + guard.dominates(sinkNode.getBasicBlock()) + ) +} + +predicate hasSameOutcomeFinalityProof( + Expr receiptOutcome, AstNode observationOrigin, AstNode sink, Expr watcherReceiver +) { + exists(CallExpr proofCall, Expr boolExpr, AstNode hashOrigin | + proofCall.getEnclosingFunction() = sink.getEnclosingFunction() and + observationOrigin.getEnclosingFunction() = sink.getEnclosingFunction() and + isFinalizerIsFinalizedCall(proofCall, watcherReceiver) and + finalityCallUsesBlockHashFor(proofCall, receiptOutcome, hashOrigin) and + occursBefore(proofCall, sink) and + occursBefore(hashOrigin, proofCall) and + boolResultOfFinalityCall(boolExpr, proofCall) and + finalityBooleanGuardDominates(boolExpr, sink, true) and + finalityHeaderPassedToPublication(proofCall, sink) and + not variableReassignedBetween(boolExpr, proofCall, sink) and + not variableReassignedBetween(receiptOutcome, proofCall, sink) and + not variableReassignedBetween(receiptOutcome, observationOrigin, proofCall) + ) +} + +from CallExpr sink, Expr receiptOutcome, AstNode observationOrigin, Expr watcherReceiver +where + isReceiptDerivedPublication(sink, receiptOutcome, observationOrigin, watcherReceiver) and + not hasSameOutcomeFinalityProof(receiptOutcome, observationOrigin, sink, watcherReceiver) +select sink, + "NEAR receipt_outcome logs must not be published before proving that the same receipt_outcome.block_hash is finalized." diff --git a/codeql/src/run-with-scissors-error-return.ql b/codeql/src/run-with-scissors-error-return.ql new file mode 100644 index 0000000000..b9ffb495d4 --- /dev/null +++ b/codeql/src/run-with-scissors-error-return.ql @@ -0,0 +1,201 @@ +/** + * @name RunWithScissors runnable sends directly to wrapper error channel + * @description Runnables passed to common.RunWithScissors should return fatal errors and let the wrapper forward them without blocking, not send directly to the same error channel. + * @kind problem + * @problem.severity warning + * @precision high + * @id wormhole/go/run-with-scissors-error-return + * @tags security + * external/cwe/cwe-667 + */ + +import go +import semmle.go.concepts.GeneratedFile +import semmle.go.dataflow.GlobalValueNumbering + +predicate isProductionNodeGoFile(File f) { + f.getRelativePath().matches("node/%.go") and + not f.getRelativePath().matches("%_test.go") and + not f instanceof GeneratedFile +} + +predicate isRunWithScissorsCall(CallExpr call) { + isProductionNodeGoFile(call.getFile()) and + call.getTarget().getName() = "RunWithScissors" and + call.getTarget().getPackage().getName() = "common" and + call.getTarget().getPackage().getPath() in [ + "github.com/certusone/wormhole/node/pkg/common", + "github.com/wormhole-foundation/wormhole/node/pkg/common" + ] +} + +predicate sameLocalVariable(Expr a, Expr b) { + exists(Ident ai, Ident bi, Variable v | + a.stripParens() = ai and + b.stripParens() = bi and + ai.refersTo(v) and + bi.refersTo(v) + ) +} + +predicate sameValue(Expr a, Expr b) { + globalValueNumber(DataFlow::exprNode(a.stripParens())) = + globalValueNumber(DataFlow::exprNode(b.stripParens())) + or + DataFlow::localFlow(DataFlow::exprNode(a.stripParens()), DataFlow::exprNode(b.stripParens())) + or + DataFlow::localFlow(DataFlow::exprNode(b.stripParens()), DataFlow::exprNode(a.stripParens())) + or + sameLocalVariable(a, b) +} + +predicate sameReceiverField(Expr wrapperChannel, Expr candidateChannel) { + exists(SelectorExpr wrapperSel, SelectorExpr candidateSel, Field field | + wrapperChannel.stripParens() = wrapperSel and + candidateChannel.stripParens() = candidateSel and + wrapperSel.refersTo(field) and + candidateSel.refersTo(field) and + sameValue(wrapperSel.getBase(), candidateSel.getBase()) + ) +} + +predicate sameChannel(Expr wrapperChannel, Expr candidateChannel) { + sameValue(wrapperChannel, candidateChannel) + or + sameReceiverField(wrapperChannel, candidateChannel) +} + +predicate exprStrictlyBefore(Expr earlier, Expr later) { + earlier.getLocation().getStartLine() < later.getLocation().getStartLine() + or + earlier.getLocation().getStartLine() = later.getLocation().getStartLine() and + earlier.getLocation().getStartColumn() < later.getLocation().getStartColumn() +} + +predicate assignmentStrictlyBefore(Assignment earlier, Expr later) { + earlier.getLocation().getStartLine() < later.getLocation().getStartLine() + or + earlier.getLocation().getStartLine() = later.getLocation().getStartLine() and + earlier.getLocation().getStartColumn() < later.getLocation().getStartColumn() +} + +predicate assignmentToLocal(Assignment assign, LocalVariable v) { + exists(Ident lhs | assign.getLhs(_) = lhs and lhs.refersTo(v)) +} + +predicate localFunctionValueRunnable(CallExpr call, FuncLit lit) { + exists(Ident runnable, LocalVariable v, Assignment assign | + call.getArgument(3) = runnable and + runnable.refersTo(v) and + assignmentToLocal(assign, v) and + assign.getRhs() = lit and + assign.getEnclosingFunction() = call.getEnclosingFunction() and + assignmentStrictlyBefore(assign, call) and + not exists(Assignment overwrite | + assignmentToLocal(overwrite, v) and + overwrite.getEnclosingFunction() = call.getEnclosingFunction() and + exprStrictlyBefore(assign.getRhs(), overwrite.getRhs()) and + assignmentStrictlyBefore(overwrite, call) + ) + ) +} + +predicate methodValueRunnable(CallExpr call, SelectorExpr runnable, Method method) { + call.getArgument(3) = runnable and + runnable.getSelector().refersTo(method) +} + +predicate runnableBody(CallExpr call, FuncDef body) { + isRunWithScissorsCall(call) and + ( + body = call.getArgument(3).(FuncLit) + or + exists(FuncLit lit | localFunctionValueRunnable(call, lit) and body = lit) + or + exists(SelectorExpr runnable, Method method | + methodValueRunnable(call, runnable, method) and + body = method.getFuncDecl() + ) + ) +} + +predicate methodReceiverFieldSend(CallExpr call, SendStmt send) { + exists( + SelectorExpr runnable, Method method, SelectorExpr wrapperSel, SelectorExpr sendSel, Field field, + ReceiverVariable receiver + | + isRunWithScissorsCall(call) and + methodValueRunnable(call, runnable, method) and + call.getArgument(1).stripParens() = wrapperSel and + send.getChannel().stripParens() = sendSel and + wrapperSel.refersTo(field) and + sendSel.refersTo(field) and + sameValue(wrapperSel.getBase(), runnable.getBase()) and + receiver.isReceiverOf(method.getFuncDecl()) and + sendSel.getBase().(Ident).refersTo(receiver) and + send.getEnclosingFunction() = method.getFuncDecl() + ) +} + +predicate directRunnableSend(CallExpr call, SendStmt send) { + exists(FuncDef body | + runnableBody(call, body) and + send.getEnclosingFunction() = body and + sameChannel(call.getArgument(1), send.getChannel()) + ) + or + methodReceiverFieldSend(call, send) +} + +predicate helperParameterSend(CallExpr runCall, CallExpr helperCall, SendStmt send) { + exists(FuncDef body, FuncDecl helper, Parameter parameter, Ident parameterRead, int i | + runnableBody(runCall, body) and + helperCall.getEnclosingFunction() = body and + helperCall.getTarget().getFuncDecl() = helper and + helper.getParameter(i) = parameter and + sameChannel(runCall.getArgument(1), helperCall.getArgument(i)) and + parameterRead.refersTo(parameter) and + parameterRead.getEnclosingFunction() = helper and + send.getEnclosingFunction() = helper and + sameValue(parameterRead, send.getChannel()) + ) +} + +predicate helperReceiverSend(CallExpr runCall, CallExpr helperCall, SendStmt send) { + exists( + FuncDef body, SelectorExpr helperSelector, Method helper, SelectorExpr wrapperSel, + SelectorExpr sendSel, Field field, ReceiverVariable receiver + | + runnableBody(runCall, body) and + helperCall.getEnclosingFunction() = body and + helperCall.getCalleeExpr() = helperSelector and + helperSelector.getSelector().refersTo(helper) and + runCall.getArgument(1).stripParens() = wrapperSel and + send.getChannel().stripParens() = sendSel and + wrapperSel.refersTo(field) and + sendSel.refersTo(field) and + sameValue(wrapperSel.getBase(), helperSelector.getBase()) and + receiver.isReceiverOf(helper.getFuncDecl()) and + sendSel.getBase().(Ident).refersTo(receiver) and + send.getEnclosingFunction() = helper.getFuncDecl() + ) +} + +predicate isGoLaunchedCall(CallExpr call) { + exists(GoStmt go | call = go.getCall() or call.getParent*() = go) +} + +predicate oneHopHelperSend(CallExpr runCall, SendStmt send) { + exists(CallExpr helperCall | + not isGoLaunchedCall(helperCall) and + helperParameterSend(runCall, helperCall, send) + or + not isGoLaunchedCall(helperCall) and + helperReceiverSend(runCall, helperCall, send) + ) +} + +from CallExpr runCall, SendStmt send +where isProductionNodeGoFile(send.getFile()) and (directRunnableSend(runCall, send) or oneHopHelperSend(runCall, send)) +select send, + "Return this runnable error instead of sending directly to the RunWithScissors error channel; RunWithScissors forwards returned errors without blocking." diff --git a/codeql/src/solana-alt-owner-before-decode.ql b/codeql/src/solana-alt-owner-before-decode.ql new file mode 100644 index 0000000000..88912b38a7 --- /dev/null +++ b/codeql/src/solana-alt-owner-before-decode.ql @@ -0,0 +1,160 @@ +/** + * @name Solana ALT account decoded without owner proof + * @description Solana watcher code must prove an RPC-fetched address lookup table account exists and is owned by the address lookup table program before decoding its bytes. + * @kind problem + * @problem.severity error + * @precision high + * @id wormhole/go/solana-alt-owner-before-decode + * @tags security + * external/cwe/cwe-20 + */ + +import go +import semmle.go.concepts.GeneratedFile +import semmle.go.controlflow.ControlFlowGraph +import semmle.go.dataflow.GlobalValueNumbering + +predicate isProductionNodeFile(File f) { + f.getRelativePath().matches("node/%.go") and + not f.getRelativePath().matches("%_test.go") and + not f.getRelativePath().matches("node/hack/%") and + not f instanceof GeneratedFile +} + +predicate sameLocalVariable(Expr a, Expr b) { + exists(Ident ai, Ident bi, Variable v | + a.stripParens() = ai and + b.stripParens() = bi and + ai.refersTo(v) and + bi.refersTo(v) + ) +} + +predicate sameValue(Expr a, Expr b) { + globalValueNumber(DataFlow::exprNode(a.stripParens())) = + globalValueNumber(DataFlow::exprNode(b.stripParens())) + or + DataFlow::localFlow(DataFlow::exprNode(a.stripParens()), DataFlow::exprNode(b.stripParens())) + or + DataFlow::localFlow(DataFlow::exprNode(b.stripParens()), DataFlow::exprNode(a.stripParens())) + or + sameLocalVariable(a, b) +} + +predicate before(AstNode earlier, AstNode later) { + earlier.getLocation().getStartLine() < later.getLocation().getStartLine() + or + earlier.getLocation().getStartLine() = later.getLocation().getStartLine() and + earlier.getLocation().getStartColumn() < later.getLocation().getStartColumn() +} + +predicate isGetAccountInfoCall(CallExpr call) { + call.getCalleeName() in ["GetAccountInfo", "GetAccountInfoWithOpts"] +} + +predicate rpcAccountInfoResult(Expr info) { + exists(Assignment assign, CallExpr rpcCall | + assign.getRhs(0) = rpcCall and + isGetAccountInfoCall(rpcCall) and + assign.getLhs(0) = info + ) +} + +predicate valueFieldForInfo(Expr value, Expr info) { + exists(SelectorExpr sel, Field field | + value.stripParens() = sel and + sel.refersTo(field) and + field.getName() = "Value" and + sameValue(sel.getBase(), info) + ) +} + +predicate ownerFieldForInfo(Expr owner, Expr info) { + exists(SelectorExpr sel, Field field, Expr value | + owner.stripParens() = sel and + sel.refersTo(field) and + field.getName() = "Owner" and + valueFieldForInfo(value, info) and + sameValue(sel.getBase(), value) + ) +} + +predicate isAltProgramIdExpr(Expr e) { e.stripParens().(Ident).getName() = "addressLookupTableProgramID" } + +predicate isOwnerEqualsAltProgramCall(CallExpr call, Expr info) { + call.getCalleeName() = "Equals" and + call.getNumArgument() = 1 and + exists(SelectorExpr callee | + call.getCalleeExpr().stripParens() = callee and + ( + ownerFieldForInfo(callee.getBase(), info) and isAltProgramIdExpr(call.getArgument(0)) + or + isAltProgramIdExpr(callee.getBase()) and ownerFieldForInfo(call.getArgument(0), info) + ) + ) +} + +predicate isGetBinaryOnInfo(CallExpr call, Expr info) { + call.getCalleeName() = "GetBinary" and + exists(SelectorExpr callee | + call.getCalleeExpr().stripParens() = callee and + sameValue(callee.getBase(), info) + ) +} + +predicate isAltDecodeSink(CallExpr sink, Expr info) { + isProductionNodeFile(sink.getFile()) and + sink.getCalleeName() = "DecodeAddressLookupTableState" and + sink.getNumArgument() = 1 and + rpcAccountInfoResult(info) and + exists(CallExpr getBinary | + sink.getArgument(0).stripParens() = getBinary and + isGetBinaryOnInfo(getBinary, info) + ) +} + +predicate assignmentToSameLocal(Assignment assign, Expr local) { + exists(Ident lhs, Ident localIdent, Variable v | + assign.getLhs(_) = lhs and + local.stripParens() = localIdent and + lhs.refersTo(v) and + localIdent.refersTo(v) + ) +} + +predicate localReassignedBetween(Expr local, AstNode proof, AstNode sink) { + exists(Assignment assign | + assignmentToSameLocal(assign, local) and + assign.getEnclosingFunction() = sink.getEnclosingFunction() and + before(proof, assign) and + before(assign, sink) + ) +} + +predicate hasNilProofBefore(Expr info, AstNode sink) { + exists(ControlFlow::ConditionGuardNode guard, ControlFlow::Node sinkNode, Expr valueRead, Expr nil | + exprRefersToNil(nil) and + valueFieldForInfo(valueRead, info) and + sinkNode.isFirstNodeOf(sink) and + guard.ensuresNeq(DataFlow::exprNode(valueRead), DataFlow::exprNode(nil)) and + guard.dominates(sinkNode.getBasicBlock()) and + not localReassignedBetween(info, valueRead, sink) + ) +} + +predicate hasOwnerProofBefore(Expr info, AstNode sink) { + exists(ControlFlow::ConditionGuardNode guard, ControlFlow::Node sinkNode, CallExpr equalsCall | + isOwnerEqualsAltProgramCall(equalsCall, info) and + sinkNode.isFirstNodeOf(sink) and + guard.ensures(DataFlow::exprNode(equalsCall), true) and + guard.dominates(sinkNode.getBasicBlock()) and + not localReassignedBetween(info, equalsCall, sink) + ) +} + +from CallExpr sink, Expr info +where + isAltDecodeSink(sink, info) and + (not hasNilProofBefore(info, sink) or not hasOwnerProofBefore(info, sink)) +select sink, + "Solana ALT account data from RPC is decoded or used before proving the same account result is non-nil and owned by the address lookup table program; add fail-closed Value != nil and Owner.Equals(addressLookupTableProgramID) checks before decoding or resolving lookups." diff --git a/codeql/src/solana-commitment-match-before-publication.ql b/codeql/src/solana-commitment-match-before-publication.ql new file mode 100644 index 0000000000..4f81e84fd8 --- /dev/null +++ b/codeql/src/solana-commitment-match-before-publication.ql @@ -0,0 +1,355 @@ +/** + * @name Solana message published without commitment match proof + * @description Solana watcher paths must check the decoded message commitment against the watcher's configured commitment before scheduling or publishing a MessagePublication. + * @kind problem + * @problem.severity error + * @precision high + * @id wormhole/go/solana-commitment-match-before-publication + * @tags security + * external/cwe/cwe-345 + */ + +import go +import semmle.go.concepts.GeneratedFile + +predicate isProductionSolanaWatcherFile(File f) { + f.getRelativePath().matches("node/pkg/watchers/solana/%.go") and + not f.getRelativePath().matches("%_test.go") and + not f instanceof GeneratedFile +} + +predicate before(AstNode earlier, AstNode later) { + earlier.getLocation().getStartLine() < later.getLocation().getStartLine() + or + earlier.getLocation().getStartLine() = later.getLocation().getStartLine() and + earlier.getLocation().getStartColumn() < later.getLocation().getStartColumn() +} + +predicate syntacticallyNestedIn(AstNode inner, AstNode outer) { + inner.getLocation().getStartLine() >= outer.getLocation().getStartLine() and + inner.getLocation().getEndLine() <= outer.getLocation().getEndLine() +} + +predicate guardedOnlyOnBypassedBranch(IfStmt ifStmt, AstNode sink) { + exists(IfStmt outer | + outer.getEnclosingFunction() = sink.getEnclosingFunction() and + outer != ifStmt and + syntacticallyNestedIn(ifStmt, outer) and + outer.getLocation().getEndLine() < sink.getLocation().getStartLine() + ) +} + +predicate branchReturnsDirectly(Stmt branch) { + exists(ReturnStmt ret | ret = branch.getAChild()) +} + +predicate sameLocalVariable(Expr a, Expr b) { + exists(Ident ai, Ident bi, Entity v | + a.stripParens() = ai and + b.stripParens() = bi and + ai.refersTo(v) and + bi.refersTo(v) + ) +} + +predicate sameValue(Expr a, Expr b) { + a.stripParens() = b.stripParens() + or + sameLocalVariable(a, b) +} + +predicate assignmentToSameLocal(Assignment assign, Expr local) { + exists(Ident lhs, Ident use, Entity v | + assign.getLhs(_) = lhs and + local.stripParens() = use and + lhs.refersTo(v) and + use.refersTo(v) + ) +} + +predicate localReassignedBetween(Expr local, AstNode proof, AstNode sink) { + exists(Assignment assign | + assignmentToSameLocal(assign, local) and + assign.getEnclosingFunction() = sink.getEnclosingFunction() and + before(proof, assign) and + before(assign, sink) + ) +} + +predicate isConsistencyLevelRead(Expr e) { + exists(SelectorExpr sel, Field field | + e.stripParens() = sel and + sel.refersTo(field) and + field.getName() = "ConsistencyLevel" + ) +} + +predicate isCommitmentConversionCall(CallExpr call) { + isProductionSolanaWatcherFile(call.getFile()) and + ( + call.getCalleeName() = "accountConsistencyLevelToCommitment" and + call.getNumArgument() = 1 and + isConsistencyLevelRead(call.getArgument(0)) + or + call.getCalleeName() = "Commitment" and + exists(SelectorExpr callee, SelectorExpr levelRead | + call.getCalleeExpr().stripParens() = callee and + callee.getBase().stripParens() = levelRead and + isConsistencyLevelRead(levelRead) + ) + ) +} + +predicate assignmentReceivesTupleElement(Assignment assign, CallExpr call, int index, Expr lhs) { + assign.getRhs(0) = call and + lhs = assign.getLhs(index) +} + +predicate convertedCommitment(CallExpr conversion, Expr commitment) { + isCommitmentConversionCall(conversion) and + exists(Assignment assign | assignmentReceivesTupleElement(assign, conversion, 0, commitment)) +} + +predicate convertedInstructionCommitment(CallExpr conversion, Expr commitment) { + convertedCommitment(conversion, commitment) and + exists(SelectorExpr callee, SelectorExpr levelRead | + conversion.getCalleeExpr().stripParens() = callee and + callee.getBase().stripParens() = levelRead and + isConsistencyLevelRead(levelRead) and + levelRead.getBase().getType().getName() = "PostMessageData" + ) +} + +predicate pairedConversionErrorExpr(CallExpr conversion, Expr err) { + exists(Assignment assign | + assign.getEnclosingFunction() = conversion.getEnclosingFunction() and + assignmentReceivesTupleElement(assign, conversion, 1, err) + ) +} + +predicate isCheckCommitmentCall(CallExpr call, Expr commitment) { + isProductionSolanaWatcherFile(call.getFile()) and + call.getEnclosingFunction() = commitment.getEnclosingFunction() and + call.getCalleeName() = "checkCommitment" and + call.getTarget() instanceof Method and + call.getTarget().(Method).getReceiverBaseType().getName() = "SolanaWatcher" and + call.getNumArgument() = 2 and + sameValue(call.getArgument(0), commitment) +} + +predicate checkCommitmentReceiver(CallExpr call, Expr receiver) { + exists(SelectorExpr callee | + callee = call.getCalleeExpr().(SelectorExpr) and + receiver = callee.getBase() + ) +} + +predicate msgCSendReceiver(SendStmt send, Expr receiver) { + exists(SelectorExpr channel, Field field | + send.getChannel().stripParens() = channel and + channel.refersTo(field) and + field.getName() = "msgC" and + receiver = channel.getBase() + ) +} + +predicate retryFetchReceiver(CallExpr call, Expr receiver) { + call.getCalleeName() = "retryFetchMessageAccount" and + call.getTarget() instanceof Method and + call.getTarget().(Method).getReceiverBaseType().getName() = "SolanaWatcher" and + exists(SelectorExpr callee | + callee = call.getCalleeExpr().(SelectorExpr) and + receiver = callee.getBase() + ) +} + +predicate sinkWatcherReceiver(AstNode sink, Expr receiver) { + exists(SendStmt send | + sink = send and + isSolanaWatcherMsgCSend(send) and + msgCSendReceiver(send, receiver) + ) + or + exists(CallExpr call | + sink = call and + isInstructionAccountFetchSink(call) and + retryFetchReceiver(call, receiver) + ) + or + exists(CallExpr call, FuncDef body, CallExpr retry | + sink = call and + runnableBody(call, body) and + retry.getEnclosingFunction() = body and + isInstructionAccountFetchSink(retry) and + retryFetchReceiver(retry, receiver) + ) +} + +predicate sameWatcherReceiver(CallExpr check, AstNode sink) { + exists(Expr checkReceiver, Expr sinkReceiver | + checkCommitmentReceiver(check, checkReceiver) and + sinkWatcherReceiver(sink, sinkReceiver) and + sameValue(checkReceiver, sinkReceiver) + ) +} + +predicate condIsCheckTrue(Expr cond, CallExpr check) { cond.stripParens() = check } + +predicate condIsCheckFalse(Expr cond, CallExpr check) { + exists(NotExpr neg | + cond.stripParens() = neg and + neg.getOperand().stripParens() = check + ) +} + +predicate guardProvesCallTrueBefore(CallExpr check, AstNode sink) { + exists(IfStmt ifStmt | + ifStmt.getEnclosingFunction() = sink.getEnclosingFunction() and + before(ifStmt, sink) and + condIsCheckFalse(ifStmt.getCond(), check) and + branchReturnsDirectly(ifStmt.getThen()) and + not guardedOnlyOnBypassedBranch(ifStmt, sink) + ) + or + exists(IfStmt ifStmt | + ifStmt.getEnclosingFunction() = sink.getEnclosingFunction() and + before(ifStmt, sink) and + condIsCheckTrue(ifStmt.getCond(), check) and + syntacticallyNestedIn(sink, ifStmt.getThen()) + ) + or + exists(IfStmt ifStmt | + ifStmt.getEnclosingFunction() = sink.getEnclosingFunction() and + before(ifStmt, sink) and + condIsCheckFalse(ifStmt.getCond(), check) and + syntacticallyNestedIn(sink, ifStmt.getElse()) + ) +} + +predicate guardProvesErrorNilBefore(Expr err, AstNode proof, AstNode sink) { + exists(IfStmt ifStmt, NeqExpr neq, Expr errRead, Expr nil | + exprRefersToNil(nil) and + errRead.getEnclosingFunction() = sink.getEnclosingFunction() and + ifStmt.getEnclosingFunction() = sink.getEnclosingFunction() and + before(proof, ifStmt) and + before(ifStmt, sink) and + syntacticallyNestedIn(neq, ifStmt.getCond()) and + ( + neq.getLeftOperand() = errRead and neq.getRightOperand() = nil + or + neq.getRightOperand() = errRead and neq.getLeftOperand() = nil + ) and + sameValue(errRead, err) and + branchReturnsDirectly(ifStmt.getThen()) and + not guardedOnlyOnBypassedBranch(ifStmt, sink) + ) +} + +predicate hasCommitmentProofBefore(Expr commitment, AstNode sink) { + exists(CallExpr check | + isCheckCommitmentCall(check, commitment) and + before(check, sink) and + sameWatcherReceiver(check, sink) and + guardProvesCallTrueBefore(check, sink) and + not localReassignedBetween(commitment, check, sink) + ) +} + +predicate hasConversionErrorProofBefore(CallExpr conversion, AstNode sink) { + exists(Expr err | + pairedConversionErrorExpr(conversion, err) and + guardProvesErrorNilBefore(err, conversion, sink) and + not localReassignedBetween(err, conversion, sink) + ) +} + +predicate decodedCommitmentLacksProofBefore(CallExpr conversion, Expr commitment, AstNode sink) { + convertedCommitment(conversion, commitment) and + conversion.getEnclosingFunction() = sink.getEnclosingFunction() and + before(conversion, sink) and + ( + not hasConversionErrorProofBefore(conversion, sink) + or + not hasCommitmentProofBefore(commitment, sink) + ) +} + +predicate isSolanaWatcherMsgCSend(SendStmt send) { + isProductionSolanaWatcherFile(send.getFile()) and + exists(SelectorExpr channel, Field field | + send.getChannel().stripParens() = channel and + channel.refersTo(field) and + field.getName() = "msgC" + ) +} + +predicate isInstructionAccountFetchSink(CallExpr call) { + isProductionSolanaWatcherFile(call.getFile()) and + call.getCalleeName() = "retryFetchMessageAccount" +} + +predicate runnableBody(CallExpr call, FuncDef body) { + call.getCalleeName() = "RunWithScissors" and + call.getNumArgument() = 4 and + body = call.getArgument(3).(FuncLit) +} + +predicate runnableCallsRetryFetchMessageAccount(CallExpr call) { + exists(FuncDef body, CallExpr retry | + runnableBody(call, body) and + retry.getEnclosingFunction() = body and + isInstructionAccountFetchSink(retry) + ) +} + +predicate isInstructionAccountFetchSchedule(CallExpr call) { + isInstructionAccountFetchSink(call) + or + ( + isProductionSolanaWatcherFile(call.getFile()) and + call.getCalleeName() = "RunWithScissors" and + call.getNumArgument() = 4 and + call.getArgument(2).getStringValue() = "retryFetchMessageAccount" and + runnableCallsRetryFetchMessageAccount(call) + ) +} + +predicate sinkLacksMatchingProof(AstNode sink, CallExpr conversion, Expr commitment) { + exists(SendStmt send | + sink = send and + isSolanaWatcherMsgCSend(send) and + conversion.getFile() = send.getFile() and + not convertedInstructionCommitment(conversion, commitment) and + decodedCommitmentLacksProofBefore(conversion, commitment, sink) + ) + or + exists(CallExpr call | + sink = call and + isInstructionAccountFetchSchedule(call) and + conversion.getFile() = call.getFile() and + convertedInstructionCommitment(conversion, commitment) and + conversion.getEnclosingFunction() = sink.getEnclosingFunction() and + before(conversion, sink) and + ( + not hasConversionErrorProofBefore(conversion, sink) + or + not hasCommitmentProofBefore(commitment, sink) + ) + ) +} + +class PublicationSink extends AstNode { + PublicationSink() { + exists(SendStmt send | this = send and isSolanaWatcherMsgCSend(send)) + or + exists(CallExpr call | this = call and isInstructionAccountFetchSchedule(call)) + } +} + +from PublicationSink sink +where + exists(CallExpr conversion, Expr commitment | + sinkLacksMatchingProof(sink, conversion, commitment) + ) +select sink, + "Solana watcher must prove the decoded message commitment matches the watcher commitment before scheduling or publishing the observation." diff --git a/codeql/src/solana-message-account-validation.ql b/codeql/src/solana-message-account-validation.ql new file mode 100644 index 0000000000..4ef4c62897 --- /dev/null +++ b/codeql/src/solana-message-account-validation.ql @@ -0,0 +1,222 @@ +/** + * @name Solana message account parsed without constructor validation + * @description Solana watcher message account data must be created by NewMessageAccountData before parsing or processing so the discriminator and length checks are enforced. + * @kind problem + * @problem.severity error + * @precision high + * @id wormhole/go/solana-message-account-validation + * @tags security + * external/cwe/cwe-20 + */ + +import go +import semmle.go.concepts.GeneratedFile +import semmle.go.controlflow.ControlFlowGraph +import semmle.go.dataflow.GlobalValueNumbering + +predicate isProductionSolanaWatcherFile(File f) { + f.getRelativePath().matches("node/pkg/watchers/solana/%.go") and + not f.getRelativePath().matches("%_test.go") and + not f instanceof GeneratedFile +} + +predicate hasSolanaWatcherTarget(CallExpr call, string name) { + isProductionSolanaWatcherFile(call.getFile()) and + call.getTarget().getFuncDecl().getFile().getRelativePath().matches("node/pkg/watchers/solana/%.go") and + call.getTarget().getFuncDecl().getName() = name +} + +predicate isMessageAccountConstructorCall(CallExpr call) { + hasSolanaWatcherTarget(call, "NewMessageAccountData") +} + +predicate isParserOrProcessorImplementation(FuncDecl f) { + isProductionSolanaWatcherFile(f.getFile()) and + f.getName() in ["ParseMessagePublicationAccount", "processMessageAccount"] +} + +predicate isMessageAccountSink(CallExpr call, Expr arg) { + hasSolanaWatcherTarget(call, "ParseMessagePublicationAccount") and + not isParserOrProcessorImplementation(call.getEnclosingFunction()) and + arg = call.getArgument(0) + or + hasSolanaWatcherTarget(call, "processMessageAccount") and + arg = call.getArgument(1) +} + +DataFlow::Node constructorValue(CallExpr call) { + isMessageAccountConstructorCall(call) and + result = DataFlow::extractTupleElement(DataFlow::exprNode(call), 0) +} + +predicate assignmentReceivesTupleElement(Assignment assign, CallExpr call, int index, Expr lhs) { + assign.getRhs(0) = call and + lhs = assign.getLhs(index) +} + +predicate pairedConstructorErrorExpr(CallExpr call, Expr err) { + exists(Assignment assign | + assign.getEnclosingFunction() = call.getEnclosingFunction() and + assignmentReceivesTupleElement(assign, call, 1, err) + ) +} + +predicate neqNilExprFor(Expr condition, Expr err) { + exists(NeqExpr neq, Expr nil | + condition = neq and + exprRefersToNil(nil) and + ( + exists(Entity target | + neq.getLeftOperand().(Ident).refersTo(target) and err.(Ident).refersTo(target) + ) and + neq.getRightOperand() = nil + or + exists(Entity target | + neq.getRightOperand().(Ident).refersTo(target) and err.(Ident).refersTo(target) + ) and + neq.getLeftOperand() = nil + ) + ) +} + +predicate errorReassignedBeforeGuard(Expr err, AstNode guardUse) { + exists(Assignment assign, Ident lhs, Entity target | + lhs = assign.getLhs(_).stripParens() and + lhs.refersTo(target) and + err.(Ident).refersTo(target) and + assign.getLocation().getStartLine() > err.getLocation().getStartLine() and + assign.getLocation().getStartLine() < guardUse.getLocation().getStartLine() + ) +} + +predicate guardProvesNilBefore(Expr err, AstNode use) { + exists(ControlFlow::ConditionGuardNode guard, ControlFlow::Node useNode, Expr errRead, Expr nil | + exprRefersToNil(nil) and + useNode.isFirstNodeOf(use) and + errRead.getLocation().getStartLine() > err.getLocation().getStartLine() and + globalValueNumber(DataFlow::exprNode(errRead)) = globalValueNumber(DataFlow::exprNode(err)) and + not errorReassignedBeforeGuard(err, errRead) and + guard.ensuresEq(DataFlow::exprNode(errRead), DataFlow::exprNode(nil)) and + guard.dominates(useNode.getBasicBlock()) + ) + or + exists(ControlFlow::ConditionGuardNode guard, ControlFlow::Node useNode, Expr condition | + neqNilExprFor(condition, err) and + condition.getLocation().getStartLine() > err.getLocation().getStartLine() and + not errorReassignedBeforeGuard(err, condition) and + guard.ensures(DataFlow::exprNode(condition), false) and + ( + use instanceof Expr and + guard.dominates(DataFlow::exprNode(use.(Expr)).getBasicBlock()) + or + useNode.isFirstNodeOf(use) and + guard.dominates(useNode.getBasicBlock()) + ) + ) +} + +predicate constructorErrorRejectedBefore(CallExpr ctor, AstNode use) { + exists(Expr err | + pairedConstructorErrorExpr(ctor, err) and + guardProvesNilBefore(err, use) + ) +} + +predicate returnsNilError(ReturnStmt ret) { + ret.getNumExpr() >= 2 and + exprRefersToNil(ret.getExpr(1)) +} + +predicate returnValueHasCheckedConstructorProvenance(ReturnStmt ret) { + exists(CallExpr ctor | + DataFlow::localFlow(constructorValue(ctor), DataFlow::exprNode(ret.getExpr(0))) and + constructorErrorRejectedBefore(ctor, ret) + ) +} + +predicate returnValueHasCheckedProvenance(ReturnStmt ret, int helperDepth) { + ( + helperDepth in [0 .. 1] and + returnValueHasCheckedConstructorProvenance(ret) + ) + or + exists(CallExpr helper, int nestedDepth | + helperDepth in [1 .. 1] and + nestedDepth = helperDepth - 1 and + DataFlow::localFlow( + DataFlow::extractTupleElement(DataFlow::exprNode(helper), 0), DataFlow::exprNode(ret.getExpr(0)) + ) and + isSafeMessageAccountFactoryAtDepth(helper.getTarget().getFuncDecl(), nestedDepth) and + helperErrorRejectedBefore(helper, ret) + ) +} + +predicate isSafeMessageAccountFactoryAtDepth(FuncDecl f, int helperDepth) { + helperDepth in [0 .. 1] and + isProductionSolanaWatcherFile(f.getFile()) and + not f.getName() = "NewMessageAccountData" and + exists(ReturnStmt ret | + ret.getEnclosingFunction() = f and + returnsNilError(ret) + ) and + not exists(ReturnStmt ret | + ret.getEnclosingFunction() = f and + returnsNilError(ret) and + not returnValueHasCheckedProvenance(ret, helperDepth) + ) +} + +predicate isSafeMessageAccountFactory(FuncDecl f) { + isSafeMessageAccountFactoryAtDepth(f, 1) +} + +DataFlow::Node helperValue(CallExpr call) { + isSafeMessageAccountFactory(call.getTarget().getFuncDecl()) and + result = DataFlow::extractTupleElement(DataFlow::exprNode(call), 0) +} + +predicate pairedHelperErrorExpr(CallExpr call, Expr err) { + exists(Assignment assign | + assign.getEnclosingFunction() = call.getEnclosingFunction() and + assign.getRhs(0) = call and + assign.getLhs(1) = err + ) +} + +predicate localValueOrPointerRoundTripFlow(DataFlow::Node source, DataFlow::Node sink) { + DataFlow::localFlow(source, sink) + or + exists(Assignment assign, AddressExpr addr, StarExpr deref | + assign.getRhs(0) = addr and + deref = sink.asExpr().stripParens() and + exists(Entity target | + assign.getLhs(0).(Ident).refersTo(target) and deref.getAChild().(Ident).refersTo(target) + ) and + DataFlow::localFlow(source, DataFlow::exprNode(addr.getOperand())) + ) +} + +predicate helperErrorRejectedBefore(CallExpr helper, AstNode use) { + exists(Expr err | + pairedHelperErrorExpr(helper, err) and + guardProvesNilBefore(err, use) + ) +} + +predicate hasSuccessfulConstructorProvenance(Expr arg, CallExpr sink) { + exists(CallExpr ctor | + localValueOrPointerRoundTripFlow(constructorValue(ctor), DataFlow::exprNode(arg)) and + constructorErrorRejectedBefore(ctor, sink) + ) + or + exists(CallExpr helper | + localValueOrPointerRoundTripFlow(helperValue(helper), DataFlow::exprNode(arg)) and + helperErrorRejectedBefore(helper, sink) + ) +} + +from CallExpr sink, Expr arg +where + isMessageAccountSink(sink, arg) and + not hasSuccessfulConstructorProvenance(arg, sink) +select sink, "Solana message account data must be created by NewMessageAccountData before parsing or processing." diff --git a/codeql/src/solana-require-successful-transaction-meta.ql b/codeql/src/solana-require-successful-transaction-meta.ql new file mode 100644 index 0000000000..703d96ecfb --- /dev/null +++ b/codeql/src/solana-require-successful-transaction-meta.ql @@ -0,0 +1,278 @@ +/** + * @name Solana transaction parsed without successful metadata proof + * @description Solana watcher transaction paths must validate transaction metadata is present and successful before reading metadata, extracting transactions, or processing observations. + * @kind problem + * @problem.severity error + * @precision high + * @id wormhole/go/solana-require-successful-transaction-meta + * @tags security + * external/cwe/cwe-20 + */ + +import go +import semmle.go.concepts.GeneratedFile +import semmle.go.controlflow.ControlFlowGraph +import semmle.go.dataflow.GlobalValueNumbering + +predicate isProductionSolanaWatcherFile(File f) { + f.getRelativePath().matches("node/pkg/watchers/solana/%.go") and + not f.getRelativePath().matches("%_test.go") and + not f instanceof GeneratedFile +} + +predicate sameLocalVariable(Expr a, Expr b) { + exists(Ident ai, Ident bi, Variable v | + a.stripParens() = ai and + b.stripParens() = bi and + ai.refersTo(v) and + bi.refersTo(v) + ) +} + +predicate sameValue(Expr a, Expr b) { + globalValueNumber(DataFlow::exprNode(a.stripParens())) = + globalValueNumber(DataFlow::exprNode(b.stripParens())) + or + DataFlow::localFlow(DataFlow::exprNode(a.stripParens()), DataFlow::exprNode(b.stripParens())) + or + DataFlow::localFlow(DataFlow::exprNode(b.stripParens()), DataFlow::exprNode(a.stripParens())) + or + sameLocalVariable(a, b) +} + +predicate before(AstNode earlier, AstNode later) { + earlier.getLocation().getStartLine() < later.getLocation().getStartLine() + or + earlier.getLocation().getStartLine() = later.getLocation().getStartLine() and + earlier.getLocation().getStartColumn() < later.getLocation().getStartColumn() +} + +predicate fieldReadFor(SelectorExpr selector, Expr base, string fieldName) { + exists(Field field | + selector.refersTo(field) and + field.getName() = fieldName and + sameValue(selector.getBase(), base) + ) +} + +predicate sameFieldRead(Expr a, Expr b, string fieldName) { + exists(SelectorExpr asel, SelectorExpr bsel, Field afield, Field bfield | + a.stripParens() = asel and + b.stripParens() = bsel and + asel.refersTo(afield) and + bsel.refersTo(bfield) and + afield.getName() = fieldName and + bfield.getName() = fieldName and + sameValue(asel.getBase(), bsel.getBase()) + ) +} + +predicate sameMetaValue(Expr a, Expr b) { + sameValue(a, b) + or + sameFieldRead(a, b, "Meta") +} + +predicate isProcessTransactionSink(CallExpr call, Expr meta) { + isProductionSolanaWatcherFile(call.getFile()) and + call.getTarget().getFuncDecl().getName() = "processTransaction" and + ( + call.getNumArgument() > 3 and + meta = call.getArgument(3) + or + call.getNumArgument() = 2 and + meta = call.getArgument(1) + ) +} + +predicate isTransactionExtractionSink(CallExpr call) { + isProductionSolanaWatcherFile(call.getFile()) and + call.getCalleeName() in ["GetTransaction", "GetParsedTransaction"] and + exists(SelectorExpr callee, SelectorExpr transactionField | + call.getCalleeExpr().stripParens() = callee and + callee.getBase().stripParens() = transactionField and + exists(Expr response | fieldReadFor(transactionField, response, "Transaction")) + ) + or + isProductionSolanaWatcherFile(call.getFile()) and + call.getCalleeName() in ["GetTransaction", "GetParsedTransaction"] and + exists(SelectorExpr callee | + call.getCalleeExpr().stripParens() = callee and + callee.getBase().getType().getName().matches("%Transaction%") + ) +} + +predicate extractionResponse(CallExpr call, Expr response) { + exists(SelectorExpr callee, SelectorExpr transactionField | + isTransactionExtractionSink(call) and + call.getCalleeExpr().stripParens() = callee and + callee.getBase().stripParens() = transactionField and + fieldReadFor(transactionField, response, "Transaction") + ) + or + exists(SelectorExpr callee | + isTransactionExtractionSink(call) and + call.getCalleeExpr().stripParens() = callee and + response = callee.getBase() + ) +} + +predicate isMetadataUseSink(SelectorExpr use, Expr meta) { + isProductionSolanaWatcherFile(use.getFile()) and + exists(Field field | + use.refersTo(field) and + field.getName() in [ + "LogMessages", "InnerInstructions", "PreBalances", "PostBalances", "PreTokenBalances", + "PostTokenBalances", "Rewards", "LoadedAddresses", "ReturnData", "ComputeUnitsConsumed" + ] and + meta = use.getBase() + ) +} + +predicate isSink(AstNode sink, Expr meta) { + exists(CallExpr call | sink = call and isProcessTransactionSink(call, meta)) + or + exists(SelectorExpr use | sink = use and isMetadataUseSink(use, meta)) +} + +predicate assignmentToSameLocal(Assignment assign, Expr meta) { + exists(Ident lhs, Ident metaIdent, Variable v | + assign.getLhs(_) = lhs and + meta.stripParens() = metaIdent and + lhs.refersTo(v) and + metaIdent.refersTo(v) + ) +} + +predicate metaReassignedBetween(Expr meta, AstNode proof, AstNode sink) { + exists(Assignment assign | + assignmentToSameLocal(assign, meta) and + assign.getEnclosingFunction() = sink.getEnclosingFunction() and + before(proof, assign) and + before(assign, sink) + ) +} + +predicate localReassignedBetween(Expr local, AstNode proof, AstNode sink) { + exists(Assignment assign | + assignmentToSameLocal(assign, local) and + assign.getEnclosingFunction() = sink.getEnclosingFunction() and + before(proof, assign) and + before(assign, sink) + ) +} + +predicate assignmentReceivesTupleElement(Assignment assign, CallExpr call, int index, Expr lhs) { + assign.getRhs(0) = call and + lhs = assign.getLhs(index) +} + +predicate pairedValidatorErrorExpr(CallExpr call, Expr err) { + exists(Assignment assign | + assign.getEnclosingFunction() = call.getEnclosingFunction() and + assignmentReceivesTupleElement(assign, call, 0, err) + ) +} + +predicate isValidateTransactionMetaCall(CallExpr call, Expr meta) { + isProductionSolanaWatcherFile(call.getFile()) and + call.getTarget().getFuncDecl().getName() = "validateTransactionMeta" and + call.getNumArgument() = 1 and + sameMetaValue(call.getArgument(0), meta) +} + +predicate guardProvesErrorNilBefore(Expr err, AstNode use) { + exists(ControlFlow::ConditionGuardNode guard, ControlFlow::Node useNode, Expr errRead, Expr nil | + exprRefersToNil(nil) and + useNode.isFirstNodeOf(use) and + sameValue(errRead, err) and + guard.ensuresEq(DataFlow::exprNode(errRead), DataFlow::exprNode(nil)) and + guard.dominates(useNode.getBasicBlock()) + ) + or + exists(ControlFlow::ConditionGuardNode guard, ControlFlow::Node useNode, NeqExpr neq, Expr errRead, Expr nil | + exprRefersToNil(nil) and + useNode.isFirstNodeOf(use) and + ( + neq.getLeftOperand() = errRead and neq.getRightOperand() = nil + or + neq.getRightOperand() = errRead and neq.getLeftOperand() = nil + ) and + sameValue(errRead, err) and + guard.ensures(DataFlow::exprNode(neq), false) and + guard.dominates(useNode.getBasicBlock()) + ) +} + +predicate hasValidatorProofBefore(Expr meta, AstNode sink) { + exists(CallExpr validator, Expr err | + isValidateTransactionMetaCall(validator, meta) and + before(validator, sink) and + pairedValidatorErrorExpr(validator, err) and + guardProvesErrorNilBefore(err, sink) and + not metaReassignedBetween(meta, validator, sink) and + not localReassignedBetween(err, validator, sink) + ) +} + +predicate directNilProofBefore(Expr meta, AstNode sink, AstNode proof) { + exists(ControlFlow::ConditionGuardNode guard, ControlFlow::Node sinkNode, NeqExpr neq, Expr nil, Expr metaRead | + exprRefersToNil(nil) and + proof = neq and + sinkNode.isFirstNodeOf(sink) and + ( + neq.getLeftOperand() = metaRead and neq.getRightOperand() = nil + or + neq.getRightOperand() = metaRead and neq.getLeftOperand() = nil + ) and + sameValue(metaRead, meta) and + guard.ensures(DataFlow::exprNode(neq), true) and + guard.dominates(sinkNode.getBasicBlock()) + ) +} + +predicate directErrNilProofBefore(Expr meta, AstNode sink, AstNode proof) { + exists(ControlFlow::ConditionGuardNode guard, ControlFlow::Node sinkNode, EqExpr eq, Expr nil, SelectorExpr errRead | + exprRefersToNil(nil) and + proof = eq and + sinkNode.isFirstNodeOf(sink) and + ( + eq.getLeftOperand() = errRead and eq.getRightOperand() = nil + or + eq.getRightOperand() = errRead and eq.getLeftOperand() = nil + ) and + fieldReadFor(errRead, meta, "Err") and + guard.ensures(DataFlow::exprNode(eq), true) and + guard.dominates(sinkNode.getBasicBlock()) + ) +} + +predicate hasDirectEquivalentProofBefore(Expr meta, AstNode sink) { + exists(AstNode nilProof, AstNode errProof | + directNilProofBefore(meta, sink, nilProof) and + directErrNilProofBefore(meta, sink, errProof) and + not metaReassignedBetween(meta, nilProof, sink) and + not metaReassignedBetween(meta, errProof, sink) + ) +} + +predicate hasSuccessfulMetaProofBefore(Expr meta, AstNode sink) { + hasValidatorProofBefore(meta, sink) + or + hasDirectEquivalentProofBefore(meta, sink) +} + +predicate hasExtractionMetaProofBefore(CallExpr call) { + exists(Expr response, SelectorExpr meta | + extractionResponse(call, response) and + fieldReadFor(meta, response, "Meta") and + hasSuccessfulMetaProofBefore(meta, call) + ) +} + +from AstNode sink +where + exists(Expr meta | isSink(sink, meta) and not hasSuccessfulMetaProofBefore(meta, sink)) + or + exists(CallExpr call | sink = call and isTransactionExtractionSink(call) and not hasExtractionMetaProofBefore(call)) +select sink, "Solana transaction metadata must be validated successful before transaction parsing or metadata use." diff --git a/codeql/src/untrusted-vaa-use-before-verification.ql b/codeql/src/untrusted-vaa-use-before-verification.ql new file mode 100644 index 0000000000..ad54c70bc0 --- /dev/null +++ b/codeql/src/untrusted-vaa-use-before-verification.ql @@ -0,0 +1,134 @@ +/** + * @name Parsed VAA used before quorum verification + * @description Parsed signed VAAs from untrusted node boundaries must be verified with the complete guardian set before storage or external delivery; vaa.Unmarshal only checks wire format. + * @kind problem + * @problem.severity warning + * @precision high + * @id wormhole/go/untrusted-vaa-use-before-verification + * @tags security + * external/cwe/cwe-345 + */ + +import go +import semmle.go.concepts.GeneratedFile +import semmle.go.controlflow.ControlFlowGraph +import semmle.go.dataflow.DataFlow + +predicate isProductionNodeFile(File f) { + f.getRelativePath().matches("%node/%.go") and + not f.getRelativePath().matches("%_test.go") and + not f instanceof GeneratedFile +} + +bindingset[earlier, later] +pragma[inline] +predicate occursBefore(AstNode earlier, AstNode later) { + exists(string path, int earlierLine, int earlierColumn, int laterLine, int laterColumn | + earlier.getLocation().hasLocationInfo(path, earlierLine, earlierColumn, _, _) and + later.getLocation().hasLocationInfo(path, laterLine, laterColumn, _, _) and + earlier.getEnclosingFunction() = later.getEnclosingFunction() and + ( + earlierLine < laterLine + or + earlierLine = laterLine and earlierColumn < laterColumn + ) + ) +} + +predicate isVaaUnmarshal(CallExpr call) { + isProductionNodeFile(call.getFile()) and + call.getTarget().getName() = "Unmarshal" and + call.getTarget().getPackage().getName() = "vaa" +} + +predicate unmarshalResult(CallExpr unmarshal, Expr e) { + isVaaUnmarshal(unmarshal) and + DataFlow::localFlow(DataFlow::extractTupleElement(DataFlow::exprNode(unmarshal), 0), DataFlow::exprNode(e)) +} + +predicate sameObjectVerify(CallExpr verify, CallExpr unmarshal) { + isProductionNodeFile(verify.getFile()) and + exists(SelectorExpr callee | + verify.getCalleeExpr().stripParens() = callee and + callee.getSelector().getName() = "Verify" and + unmarshalResult(unmarshal, callee.getBase()) + ) +} + +predicate verifySuccessReturnsOnError(CallExpr verify) { + exists(IfStmt ifs | + ifs.getInit().getAChild*() = verify and + ifs.getThen().getAChild*() instanceof ReturnStmt + ) + or + exists(IfStmt ifs | + ifs.getCond().getAChild*() = verify and + ifs.getThen().getAChild*() instanceof ReturnStmt + ) +} + +predicate directSameObjectProofBefore(CallExpr unmarshal, AstNode sink) { + exists(CallExpr verify, ControlFlow::Node sinkNode | + sameObjectVerify(verify, unmarshal) and + verifySuccessReturnsOnError(verify) and + occursBefore(verify, sink) and + sinkNode.isFirstNodeOf(sink) and + DataFlow::exprNode(verify).getBasicBlock().dominates(sinkNode.getBasicBlock()) + ) +} + +predicate spyHelperProofBefore(CallExpr unmarshal, AstNode sink) { + exists(CallExpr helper, ControlFlow::Node sinkNode | + helper.getFile().getRelativePath() = "node/cmd/spy/spy.go" and + helper.getCalleeName() = "verifyVAA" and + helper.getNumArgument() >= 1 and + helper.getEnclosingFunction() = sink.getEnclosingFunction() and + unmarshalResult(unmarshal, helper.getArgument(0)) and + occursBefore(helper, sink) and + sinkNode.isFirstNodeOf(sink) and + DataFlow::exprNode(helper).getBasicBlock().dominates(sinkNode.getBasicBlock()) + ) +} + +predicate hasVerificationProofBefore(CallExpr unmarshal, AstNode sink) { + directSameObjectProofBefore(unmarshal, sink) + or + spyHelperProofBefore(unmarshal, sink) +} + +predicate isSignedVaaStorageSink(CallExpr sink, CallExpr unmarshal) { + isProductionNodeFile(sink.getFile()) and + sink.getTarget().getName() in ["storeSignedVAA", "StoreSignedVAA", "StoreSignedVAABatch"] and + exists(int i | + i >= 0 and i < sink.getNumArgument() and + unmarshalResult(unmarshal, sink.getArgument(i)) + ) +} + +predicate isSpySubscriberSendSink(SendStmt sink, CallExpr unmarshal) { + sink.getFile().getRelativePath() = "node/cmd/spy/spy.go" and + unmarshal.getEnclosingFunction() = sink.getEnclosingFunction() and + occursBefore(unmarshal, sink) +} + +predicate isSecuritySink(AstNode sink, CallExpr unmarshal, string sinkKind) { + exists(CallExpr call | + sink = call and + isSignedVaaStorageSink(call, unmarshal) and + sinkKind = "stores or queues the parsed signed VAA" + ) + or + exists(SendStmt send | + sink = send and + isSpySubscriberSendSink(send, unmarshal) and + sinkKind = "delivers signed VAA bytes to an external spy subscriber" + ) +} + +from AstNode sink, CallExpr unmarshal, string sinkKind +where + isSecuritySink(sink, unmarshal, sinkKind) and + not hasVerificationProofBefore(unmarshal, sink) +select sink, + "Verify this parsed VAA with the complete guardian set before treating it as authenticated; vaa.Unmarshal only checks wire format, and this sink " + + sinkKind + " before quorum verification." diff --git a/codeql/src/xrpl-derived-generated-emitter.ql b/codeql/src/xrpl-derived-generated-emitter.ql new file mode 100644 index 0000000000..01493c9feb --- /dev/null +++ b/codeql/src/xrpl-derived-generated-emitter.ql @@ -0,0 +1,342 @@ +/** + * @name XRPL generated publication uses non-domain-separated emitter + * @description XRPL watcher-generated XTCF, XACK, and NTT publications must use their family-specific domain-separated emitter derivation. + * @kind problem + * @problem.severity warning + * @precision high + * @id wormhole/go/xrpl-derived-generated-emitter + * @tags security + * external/cwe/cwe-20 + */ + +import go +import semmle.go.concepts.GeneratedFile + +predicate isProductionXrplWatcherFile(File f) { + f.getRelativePath().matches("node/pkg/watchers/xrpl/%.go") and + not f.getRelativePath().matches("%_test.go") and + not f instanceof GeneratedFile +} + +predicate fieldInit(KeyValueExpr field, string name, Expr value) { + field.getKey().(Ident).getName() = name and + value = field.getValue() +} + +predicate isMessagePublicationLiteral(CompositeLit lit) { + isProductionXrplWatcherFile(lit.getFile()) and + lit.getType().getName() = "MessagePublication" +} + +predicate hasXrplEmitterChain(CompositeLit lit) { + exists(int i, KeyValueExpr field, SelectorExpr chain | + field = lit.getElement(i) and + fieldInit(field, "EmitterChain", chain) and + chain.getSelector().getName() = "ChainIDXRPL" + ) +} + +predicate hasEmitterAddressField(CompositeLit lit, KeyValueExpr field, Expr emitter) { + exists(int i | + field = lit.getElement(i) and + fieldInit(field, "EmitterAddress", emitter) + ) +} + +predicate subtreeMentionsIdent(Expr e, string name) { + exists(Ident id | + id = e.getAChild*() and + id.getName() = name + ) +} + +predicate functionMentionsIdent(FuncDecl f, string name) { + exists(Ident id | + id.getEnclosingFunction() = f and + id.getName() = name + ) +} + +predicate functionCalls(FuncDecl f, string calleeName) { + exists(CallExpr call | + call.getEnclosingFunction() = f and + call.getTarget().getFuncDecl().getName() = calleeName + ) +} + +predicate constructsGeneratedFamily(FuncDecl f, string family) { + isProductionXrplWatcherFile(f.getFile()) and + ( + family = "XTCF" and + functionMentionsIdent(f, "xtcfPrefix") + or + family = "XACK" and + functionMentionsIdent(f, "xackPrefix") + or + family = "NTT" and + f.getName() = "parseNttTransaction" and + functionCalls(f, "buildNTTPayload") + ) +} + +predicate isXrplGeneratedPublication(CompositeLit lit, string family) { + isMessagePublicationLiteral(lit) and + hasXrplEmitterChain(lit) and + constructsGeneratedFamily(lit.getEnclosingFunction(), family) +} + +predicate sameLocal(Expr a, Expr b) { + exists(Entity target | + a.stripParens().(Ident).refersTo(target) and + b.stripParens().(Ident).refersTo(target) + ) +} + +predicate choosesEmitterFor(Expr chosen, Expr emitter) { + chosen = emitter.stripParens() + or + exists(Assignment assign | + sameLocal(assign.getLhs(0), emitter) and + chosen = assign.getRhs(0).stripParens() and + assign.getEnclosingFunction() = emitter.getEnclosingFunction() and + not exists(Assignment other | + other != assign and + other.getEnclosingFunction() = emitter.getEnclosingFunction() and + sameLocal(other.getLhs(0), emitter) + ) + ) +} + +predicate reportLocationFor(Expr emitter, AstNode report) { + exists(Assignment assign | + sameLocal(assign.getLhs(0), emitter) and + assign.getEnclosingFunction() = emitter.getEnclosingFunction() and + not exists(Assignment other | + other != assign and + other.getEnclosingFunction() = emitter.getEnclosingFunction() and + sameLocal(other.getLhs(0), emitter) + ) and + report = assign + ) + or + not exists(Assignment assign | + sameLocal(assign.getLhs(0), emitter) and + assign.getEnclosingFunction() = emitter.getEnclosingFunction() and + not exists(Assignment other | + other != assign and + other.getEnclosingFunction() = emitter.getEnclosingFunction() and + sameLocal(other.getLhs(0), emitter) + ) + ) and + report = emitter +} + +predicate callToHelper(Expr e, string helperName, FuncDecl helper) { + exists(CallExpr call | + call = e.stripParens() and + call.getTarget().getFuncDecl() = helper and + helper.getName() = helperName + ) +} + +predicate isCopyCall(CallExpr call) { call.getTarget().getName() = "copy" } + +predicate isKeccak256Call(CallExpr call) { call.getTarget().getName() = "Keccak256" } + +predicate isSliceOfLocal(Expr e, Entity target, int low, int high) { + exists(SliceExpr slice, Ident base | + slice = e.stripParens() and + base = slice.getBase().stripParens() and + base.refersTo(target) and + ( + low = 0 and not exists(slice.getLow()) + or + slice.getLow().getIntValue() = low + ) and + slice.getHigh().getIntValue() = high + ) +} + +predicate isSliceOfLocalFromStart(Expr e, Entity target, FuncDecl helper) { + exists(SliceExpr slice, Ident base | + slice.getEnclosingFunction() = helper and + slice = e.stripParens() and + base = slice.getBase().stripParens() and + base.refersTo(target) and + ( + not exists(slice.getLow()) + or + slice.getLow().getIntValue() = 0 + ) + ) +} + +predicate isNttManagerSliceOfLocal(Expr e, Entity target, FuncDecl helper) { + exists(SliceExpr slice, Ident base | + slice.getEnclosingFunction() = helper and + slice = e.stripParens() and + base = slice.getBase().stripParens() and + base.refersTo(target) and + ( + slice.getLow().getIntValue() = 3 + or + subtreeMentionsIdent(slice.getLow(), "nttEmitterDomainLen") + ) and + ( + slice.getHigh().getIntValue() = 35 + or + subtreeMentionsIdent(slice.getHigh(), "nttEmitterDomainLen") and + subtreeMentionsIdent(slice.getHigh(), "addrLen") + ) + ) +} + +predicate isNttTokenSliceOfLocal(Expr e, Entity target, FuncDecl helper) { + exists(SliceExpr slice, Ident base | + slice.getEnclosingFunction() = helper and + slice = e.stripParens() and + base = slice.getBase().stripParens() and + base.refersTo(target) and + ( + slice.getLow().getIntValue() = 35 + or + subtreeMentionsIdent(slice.getLow(), "nttEmitterDomainLen") and + subtreeMentionsIdent(slice.getLow(), "addrLen") + ) and + ( + not exists(slice.getHigh()) + or + slice.getHigh().getIntValue() = 67 + ) + ) +} + +predicate noIndexedWritesToLocal(FuncDecl helper, Entity target) { + not exists(Assignment assign, Expr lhs, Ident written | + assign.getEnclosingFunction() = helper and + lhs = assign.getLhs(_) and + written = lhs.getAChild*() and + written.refersTo(target) and + not lhs.stripParens() instanceof Ident + ) +} + +predicate noOtherCopiesToLocal(FuncDecl helper, Entity target, CallExpr approvedCopy) { + not exists(CallExpr otherCopy, SliceExpr destination, Ident base | + otherCopy != approvedCopy and + otherCopy.getEnclosingFunction() = helper and + isCopyCall(otherCopy) and + destination = otherCopy.getArgument(0).stripParens() and + base = destination.getBase().stripParens() and + base.refersTo(target) + ) +} + +predicate generatedEmitterHelperHasRequiredOverlay(FuncDecl helper) { + helper.getName() = "calculateGeneratedEmitterAddress" and + exists(ReturnStmt ret, Ident returned, Entity target, CallExpr seedCall, CallExpr copyCall | + ret.getEnclosingFunction() = helper and + returned = ret.getExpr(0).stripParens() and + returned.refersTo(target) and + seedCall.getEnclosingFunction() = helper and + callToHelper(seedCall, "addressToEmitter", _) and + choosesEmitterFor(seedCall, returned) and + copyCall.getEnclosingFunction() = helper and + isCopyCall(copyCall) and + isSliceOfLocal(copyCall.getArgument(0), target, 0, 4) and + subtreeMentionsIdent(copyCall.getArgument(1), "generatedEmitterPrefix") and + noIndexedWritesToLocal(helper, target) and + noOtherCopiesToLocal(helper, target, copyCall) + ) +} + +predicate hashCallFeedsReturnedEmitter(FuncDecl helper, CallExpr hashCall) { + exists(ReturnStmt ret | + ret.getEnclosingFunction() = helper and + DataFlow::localFlow( + DataFlow::exprNode(hashCall), DataFlow::exprNode(ret.getExpr(0).stripParens()) + ) + ) + or + exists(ReturnStmt ret, Ident returned, Entity target, CallExpr resultCopy | + ret.getEnclosingFunction() = helper and + returned = ret.getExpr(0).stripParens() and + returned.refersTo(target) and + resultCopy.getEnclosingFunction() = helper and + isCopyCall(resultCopy) and + isSliceOfLocalFromStart(resultCopy.getArgument(0), target, helper) and + DataFlow::localFlow( + DataFlow::exprNode(hashCall), DataFlow::exprNode(resultCopy.getArgument(1)) + ) + ) +} + +predicate nttEmitterHelperHasRequiredDomain(FuncDecl helper) { + helper.getName() = "calculateEmitterAddress" and + exists(CallExpr hashCall, Ident buf, Entity bufTarget, CallExpr prefixCopy, CallExpr managerCopy, CallExpr tokenCopy | + hashCall.getEnclosingFunction() = helper and + isKeccak256Call(hashCall) and + hashCallFeedsReturnedEmitter(helper, hashCall) and + buf = hashCall.getArgument(0).stripParens() and + buf.refersTo(bufTarget) and + prefixCopy.getEnclosingFunction() = helper and + isCopyCall(prefixCopy) and + isSliceOfLocalFromStart(prefixCopy.getArgument(0), bufTarget, helper) and + prefixCopy.getArgument(1).getStringValue() = "ntt" and + managerCopy.getEnclosingFunction() = helper and + isCopyCall(managerCopy) and + isNttManagerSliceOfLocal(managerCopy.getArgument(0), bufTarget, helper) and + subtreeMentionsIdent(managerCopy.getArgument(1), "sourceNTTManager") and + tokenCopy.getEnclosingFunction() = helper and + isCopyCall(tokenCopy) and + isNttTokenSliceOfLocal(tokenCopy.getArgument(0), bufTarget, helper) and + subtreeMentionsIdent(tokenCopy.getArgument(1), "sourceToken") + ) +} + +predicate approvedEmitterDerivation(string family, Expr emitter) { + exists(Expr chosen, FuncDecl helper | + choosesEmitterFor(chosen, emitter) and + ( + family in ["XTCF", "XACK"] and + callToHelper(chosen, "calculateGeneratedEmitterAddress", helper) and + generatedEmitterHelperHasRequiredOverlay(helper) + or + family = "NTT" and + callToHelper(chosen, "calculateEmitterAddress", helper) and + nttEmitterHelperHasRequiredDomain(helper) + ) + ) +} + +string requiredDerivation(string family) { + family in ["XTCF", "XACK"] and + result = "the \"XRPL\" generated account emitter" + or + family = "NTT" and + result = "keccak256(\"ntt\" + source manager + source token)" +} + +string observedDerivation(Expr emitter) { + exists(Expr chosen, FuncDecl helper | + choosesEmitterFor(chosen, emitter) and + callToHelper(chosen, "addressToEmitter", helper) and + result = "a raw account emitter" + ) + or + not exists(Expr chosen, FuncDecl helper | + choosesEmitterFor(chosen, emitter) and + callToHelper(chosen, "addressToEmitter", helper) + ) and + result = "a non-approved emitter derivation" +} + +from CompositeLit lit, KeyValueExpr field, Expr emitter, AstNode report, string family +where + isXrplGeneratedPublication(lit, family) and + hasEmitterAddressField(lit, field, emitter) and + reportLocationFor(emitter, report) and + not approvedEmitterDerivation(family, emitter) +select report, + "XRPL generated " + family + " publication must use the domain-separated emitter (" + + requiredDerivation(family) + "); this expression appears to use " + observedDerivation(emitter) + "." diff --git a/codeql/src/xrpl-first-memo-only.ql b/codeql/src/xrpl-first-memo-only.ql new file mode 100644 index 0000000000..92f3fe8526 --- /dev/null +++ b/codeql/src/xrpl-first-memo-only.ql @@ -0,0 +1,179 @@ +/** + * @name XRPL parser scans beyond the first memo + * @description XRPL Wormhole Core and NTT parsing must inspect only memo index zero to avoid reinterpreting later or future memo formats as canonical Wormhole messages. + * @kind problem + * @problem.severity error + * @precision high + * @id wormhole/go/xrpl-first-memo-only + * @tags security + * external/cwe/cwe-20 + */ + +import go +import semmle.go.concepts.GeneratedFile + +predicate isProductionXrplWatcherFile(File f) { + f.getRelativePath().matches("node/pkg/watchers/xrpl/%.go") and + not f.getRelativePath().matches("%_test.go") and + not f instanceof GeneratedFile +} + +predicate isCoreOrNttMemoFormatSource(Expr e) { + exists(Ident id | + id = e.getAChild*() and + id.getName() in ["coreMemoFormat", "nttMemoFormat"] + ) +} + +predicate isCoreOrNttMemoFormatExpr(Expr e) { + isCoreOrNttMemoFormatSource(e) + or + exists(FuncDecl helper, Parameter parameter, Ident parameterRead, CallExpr call, int i | + parameter = helper.getParameter(i) and + parameterRead.refersTo(parameter) and + parameterRead.getEnclosingFunction() = helper and + call.getTarget().getFuncDecl() = helper and + isCoreOrNttMemoFormatExpr(call.getArgument(i)) and + DataFlow::localFlow( + DataFlow::exprNode(parameterRead), DataFlow::exprNode(e.stripParens()) + ) + ) +} + +predicate recognizesCoreOrNttMemoFormat(FuncDecl f) { + isProductionXrplWatcherFile(f.getFile()) and + exists(Expr recognition | + recognition.getEnclosingFunction() = f and + isCoreOrNttMemoFormatSource(recognition) and + ( + recognition instanceof EqualityTestExpr + or + exists(CallExpr call, int i | + recognition = call.getArgument(i) + ) + ) + ) +} + +predicate calls(FuncDecl caller, FuncDecl callee) { + exists(CallExpr call | + call.getEnclosingFunction() = caller and + call.getTarget().getFuncDecl() = callee + ) +} + +predicate isReachableFromCoreOrNttRecognizer(FuncDecl f) { + exists(FuncDecl root | + recognizesCoreOrNttMemoFormat(root) and + calls*(root, f) + ) +} + +predicate isMemoCollectionSource(Expr e) { + exists(SelectorExpr memos, Field field | + e.stripParens() = memos and + memos.refersTo(field) and + field.getName() = "Memos" and + memos.getBase().getType().getName() = "Transaction" + ) + or + exists(IndexExpr transactionMemos, FuncDecl f, Parameter parameter, Ident parameterRead | + e.stripParens() = transactionMemos and + transactionMemos.getIndex().getStringValue() = "Memos" and + parameterRead.refersTo(parameter) and + parameterRead.getEnclosingFunction() = f and + isReachableFromCoreOrNttRecognizer(f) and + parameter.getType().getName() = "FlatTransaction" and + DataFlow::localFlow( + DataFlow::exprNode(parameterRead), DataFlow::exprNode(transactionMemos.getBase().stripParens()) + ) + ) +} + +predicate isMemoCollection(Expr e) { + exists(Expr source | + isMemoCollectionSource(source) and + DataFlow::localFlow(DataFlow::exprNode(source), DataFlow::exprNode(e.stripParens())) + ) + or + exists(FuncDecl helper, Parameter parameter, Ident parameterRead, CallExpr call, int i | + parameter = helper.getParameter(i) and + parameterRead.refersTo(parameter) and + parameterRead.getEnclosingFunction() = helper and + call.getTarget().getFuncDecl() = helper and + isMemoCollection(call.getArgument(i)) and + DataFlow::localFlow( + DataFlow::exprNode(parameterRead), DataFlow::exprNode(e.stripParens()) + ) + ) +} + +predicate isFirstMemoIndex(IndexExpr idx) { idx.getIndex().getIntValue() = 0 } + +predicate scansMemoCollection(FuncDecl f, RangeStmt loop) { + loop.getEnclosingFunction() = f and + isMemoCollection(loop.getDomain()) +} + +predicate usesNonFirstMemoIndex(FuncDecl f, IndexExpr idx) { + idx.getEnclosingFunction() = f and + isMemoCollection(idx.getBase()) and + not isFirstMemoIndex(idx) +} + +predicate isScanningHelper(FuncDecl f) { + exists(RangeStmt loop | scansMemoCollection(f, loop)) + or + exists(IndexExpr idx | usesNonFirstMemoIndex(f, idx)) + or + exists(CallExpr call, FuncDecl helper | + call.getEnclosingFunction() = f and + call.getTarget().getFuncDecl() = helper and + helper != f and + isScanningHelper(helper) and + exists(int memoArg, int formatArg | + isMemoCollection(call.getArgument(memoArg)) and + isCoreOrNttMemoFormatExpr(call.getArgument(formatArg)) + ) + ) +} + +predicate isCoreOrNttScanningHelperCall(CallExpr call, FuncDecl helper) { + call.getTarget().getFuncDecl() = helper and + helper != call.getEnclosingFunction() and + isScanningHelper(helper) and + exists(int memoArg, int formatArg | + isMemoCollection(call.getArgument(memoArg)) and + isCoreOrNttMemoFormatExpr(call.getArgument(formatArg)) + ) +} + +predicate hasCoreOrNttScanningHelperCall(FuncDecl helper) { + exists(CallExpr call | + isCoreOrNttScanningHelperCall(call, helper) + ) +} + +from AstNode report +where + exists(FuncDecl f, RangeStmt loop | + isReachableFromCoreOrNttRecognizer(f) and + (f = loop.getEnclosingFunction() and (recognizesCoreOrNttMemoFormat(f) or hasCoreOrNttScanningHelperCall(f))) and + scansMemoCollection(f, loop) and + report = loop + ) + or + exists(FuncDecl f, IndexExpr idx | + isReachableFromCoreOrNttRecognizer(f) and + (f = idx.getEnclosingFunction() and (recognizesCoreOrNttMemoFormat(f) or hasCoreOrNttScanningHelperCall(f))) and + usesNonFirstMemoIndex(f, idx) and + report = idx + ) + or + exists(FuncDecl f, CallExpr call, FuncDecl helper | + recognizesCoreOrNttMemoFormat(f) and + call.getEnclosingFunction() = f and + isCoreOrNttScanningHelperCall(call, helper) and + report = call + ) +select report, "XRPL Wormhole Core/NTT parsing must inspect only Memos[0]." diff --git a/codeql/src/xrpl-require-validated-transaction.ql b/codeql/src/xrpl-require-validated-transaction.ql new file mode 100644 index 0000000000..803e97207f --- /dev/null +++ b/codeql/src/xrpl-require-validated-transaction.ql @@ -0,0 +1,186 @@ +/** + * @name XRPL parser call without validated transaction proof + * @description XRPL parser entry points can construct Wormhole observations and must only be called after proving the source transaction is in a validated ledger. + * @kind problem + * @problem.severity error + * @precision high + * @id wormhole/go/xrpl-require-validated-transaction + * @tags security + * external/cwe/cwe-345 + */ + +import go +import semmle.go.concepts.GeneratedFile +import semmle.go.controlflow.ControlFlowGraph +import semmle.go.dataflow.GlobalValueNumbering + +predicate isProductionXrplWatcherFile(File f) { + f.getRelativePath().matches("node/pkg/watchers/xrpl/%.go") and + not f.getRelativePath().matches("%_test.go") and + not f instanceof GeneratedFile +} + +predicate isParserEntryPoint(CallExpr call) { + isProductionXrplWatcherFile(call.getFile()) and + exists(MethodDecl method | + method = call.getTarget().getFuncDecl() and + method.getName() in ["ParseTransactionStream", "ParseTxResponse"] and + method.getReceiverBaseType().getName() = "Parser" and + isProductionXrplWatcherFile(method.getFile()) + ) +} + +predicate isValidatedFieldReadFor(SelectorExpr validated, Expr tx) { + exists(Field field | + validated.refersTo(field) and + field.getName() = "Validated" and + globalValueNumber(DataFlow::exprNode(validated.getBase())) = globalValueNumber(DataFlow::exprNode(tx)) + ) +} + +predicate wrapsTxResponseValue(CompositeLit wrapper, Expr tx) { + exists(int i, KeyValueExpr fieldInit | + fieldInit = wrapper.getElement(i) and + fieldInit.getKey().(Ident).getName() = "TxResponse" and + globalValueNumber(DataFlow::exprNode(fieldInit.getValue())) = globalValueNumber(DataFlow::exprNode(tx)) + ) +} + +predicate addressWrapsTxResponseValue(AddressExpr wrapper, Expr tx) { + wrapsTxResponseValue(wrapper.getOperand().stripParens().(CompositeLit), tx) +} + +predicate wrapperExprWrapsTxResponseValue(Expr wrapper, Expr tx) { + wrapsTxResponseValue(wrapper.stripParens().(CompositeLit), tx) + or + addressWrapsTxResponseValue(wrapper.stripParens().(AddressExpr), tx) +} + +predicate isParseTxResponseWrapperFor(CallExpr call, Expr wrapper, Expr tx) { + call.getTarget().getFuncDecl().getName() = "ParseTxResponse" and + wrapperExprWrapsTxResponseValue(wrapper, tx) and + DataFlow::localFlow(DataFlow::exprNode(wrapper), DataFlow::exprNode(call.getArgument(0))) +} + +predicate isParsedTransactionArgument(CallExpr call, Expr tx) { + call.getTarget().getFuncDecl().getName() = "ParseTransactionStream" and + globalValueNumber(DataFlow::exprNode(call.getArgument(0))) = globalValueNumber(DataFlow::exprNode(tx)) + or + exists(Expr wrapper | isParseTxResponseWrapperFor(call, wrapper, tx)) +} + +predicate isValidatedProofExprFor(Expr proof, Expr tx, SelectorExpr validated) { + proof = validated and + isValidatedFieldReadFor(validated, tx) + or + exists(Assignment assign | + assign.getRhs(0) = validated and + assign.getLhs(0) = proof and + isValidatedFieldReadFor(validated, tx) + ) +} + +predicate occursAfterGuardBeforeCall(AstNode node, SelectorExpr validated, CallExpr call) { + node.getEnclosingFunction() = call.getEnclosingFunction() and + ( + node.getLocation().getStartLine() > validated.getLocation().getStartLine() + or + node.getLocation().getStartLine() = validated.getLocation().getStartLine() and + node.getLocation().getStartColumn() > validated.getLocation().getStartColumn() + ) and + ( + node.getLocation().getStartLine() < call.getLocation().getStartLine() + or + node.getLocation().getStartLine() = call.getLocation().getStartLine() and + node.getLocation().getStartColumn() < call.getLocation().getStartColumn() + ) +} + +predicate sameLocalVariable(Expr a, Expr b) { + exists(Entity target | + a.stripParens().(Ident).refersTo(target) and + b.stripParens().(Ident).refersTo(target) + ) +} + +predicate fieldReadFor(SelectorExpr selector, Expr base, string fieldName) { + exists(Field field | + selector.refersTo(field) and + field.getName() = fieldName and + ( + globalValueNumber(DataFlow::exprNode(selector.getBase())) = globalValueNumber(DataFlow::exprNode(base)) + or + sameLocalVariable(selector.getBase(), base) + ) + ) +} + +predicate transactionMutatedAfterGuardBeforeCall(Expr tx, SelectorExpr validated, CallExpr call) { + exists(Assignment assign, Expr lhs, SelectorExpr mutatedField, Field mutatedFieldDecl | + lhs = assign.getLhs(_) and + occursAfterGuardBeforeCall(assign, validated, call) and + ( + sameLocalVariable(lhs, tx) + or + mutatedField = lhs.stripParens() and + mutatedField.refersTo(mutatedFieldDecl) and + mutatedFieldDecl.getName() = "Validated" and + sameLocalVariable(mutatedField.getBase(), tx) + ) + ) +} + +predicate wrapperMutatedAfterGuardBeforeCall(Expr wrapper, SelectorExpr validated, CallExpr call) { + exists(Assignment assign, Expr lhs | + lhs = assign.getLhs(_) and + occursAfterGuardBeforeCall(assign, validated, call) and + ( + sameLocalVariable(lhs, call.getArgument(0)) and + assign.getLocation().getStartLine() > wrapper.getLocation().getStartLine() + or + fieldReadFor(lhs.stripParens().(SelectorExpr), call.getArgument(0), "TxResponse") + or + fieldReadFor(lhs.stripParens().(SelectorExpr), call.getArgument(0), "Validated") + or + exists(SelectorExpr txResponseField | + fieldReadFor(txResponseField, call.getArgument(0), "TxResponse") and + fieldReadFor(lhs.stripParens().(SelectorExpr), txResponseField, "Validated") + ) + or + sameLocalVariable(lhs, wrapper) and + assign.getLocation().getStartLine() > wrapper.getLocation().getStartLine() + or + fieldReadFor(lhs.stripParens().(SelectorExpr), wrapper, "TxResponse") + or + fieldReadFor(lhs.stripParens().(SelectorExpr), wrapper, "Validated") + or + exists(SelectorExpr wrappedTxResponseField | + fieldReadFor(wrappedTxResponseField, wrapper, "TxResponse") and + fieldReadFor(lhs.stripParens().(SelectorExpr), wrappedTxResponseField, "Validated") + ) + ) + ) +} + +predicate proofInvalidatedAfterGuardBeforeCall(Expr tx, SelectorExpr validated, CallExpr call) { + transactionMutatedAfterGuardBeforeCall(tx, validated, call) + or + exists(Expr wrapper | isParseTxResponseWrapperFor(call, wrapper, tx) | + wrapperMutatedAfterGuardBeforeCall(wrapper, validated, call) + ) +} + +predicate callHasValidatedProof(CallExpr call) { + exists(Expr tx, Expr proof, Expr proofRead, SelectorExpr validated, ControlFlow::ConditionGuardNode guard | + isParsedTransactionArgument(call, tx) and + isValidatedProofExprFor(proof, tx, validated) and + globalValueNumber(DataFlow::exprNode(proofRead)) = globalValueNumber(DataFlow::exprNode(proof)) and + guard.ensures(DataFlow::exprNode(proofRead), true) and + guard.dominates(DataFlow::exprNode(call).getBasicBlock()) and + not proofInvalidatedAfterGuardBeforeCall(tx, validated, call) + ) +} + +from CallExpr call +where isParserEntryPoint(call) and not callHasValidatedProof(call) +select call, "XRPL transaction is parsed before proving Validated == true." diff --git a/codeql/suites/wormhole-go.qls b/codeql/suites/wormhole-go.qls new file mode 100644 index 0000000000..00028c0cf6 --- /dev/null +++ b/codeql/suites/wormhole-go.qls @@ -0,0 +1,25 @@ +- query: src/already-locked-receiver-mutex.ql +- query: src/algorand-publication-field-length-check.ql +- query: src/canonical-chain-id-parsing.ql +- query: src/canonical-vaa-address-parsing.ql +- query: src/canonical-vaa-id-parsing.ql +- query: src/delegate-consensus-canonical-digest.ql +- query: src/delegated-guardian-config-validation.ql +- query: src/evm-ccl-signed-message-immutability.ql +- query: src/evm-finality-release-and-reorg-checks.ql +- query: src/evm-require-successful-receipt-before-observation.ql +- query: src/evm-verify-and-publish-gate.ql +- query: src/governance-vaa-typed-payload.ql +- query: src/guardian-signer-exact-digest-length.ql +- query: src/message-publication-canonical-timestamp.ql +- query: src/message-publication-safe-serialization.ql +- query: src/near-finalized-receipt-outcome-before-publication.ql +- query: src/run-with-scissors-error-return.ql +- query: src/solana-alt-owner-before-decode.ql +- query: src/solana-commitment-match-before-publication.ql +- query: src/solana-message-account-validation.ql +- query: src/solana-require-successful-transaction-meta.ql +- query: src/untrusted-vaa-use-before-verification.ql +- query: src/xrpl-derived-generated-emitter.ql +- query: src/xrpl-first-memo-only.ql +- query: src/xrpl-require-validated-transaction.ql diff --git a/codeql/test/algorand-publication-field-length-check/algorand-publication-field-length-check.expected b/codeql/test/algorand-publication-field-length-check/algorand-publication-field-length-check.expected new file mode 100644 index 0000000000..7816efb18a --- /dev/null +++ b/codeql/test/algorand-publication-field-length-check/algorand-publication-field-length-check.expected @@ -0,0 +1,17 @@ +| node/pkg/watchers/algorand/positive.go:13:43:13:92 | call to Uint64 | Algorand publishMessage nonce/sequence bytes must be proven exactly 8 bytes before Uint64 decoding; malformed lengths can panic the watcher before the observation is skipped. | +| node/pkg/watchers/algorand/positive.go:21:43:21:92 | call to Uint64 | Algorand publishMessage nonce/sequence bytes must be proven exactly 8 bytes before Uint64 decoding; malformed lengths can panic the watcher before the observation is skipped. | +| node/pkg/watchers/algorand/positive.go:29:43:29:92 | call to Uint64 | Algorand publishMessage nonce/sequence bytes must be proven exactly 8 bytes before Uint64 decoding; malformed lengths can panic the watcher before the observation is skipped. | +| node/pkg/watchers/algorand/positive.go:37:39:37:91 | call to Uint64 | Algorand publishMessage nonce/sequence bytes must be proven exactly 8 bytes before Uint64 decoding; malformed lengths can panic the watcher before the observation is skipped. | +| node/pkg/watchers/algorand/positive.go:45:39:45:91 | call to Uint64 | Algorand publishMessage nonce/sequence bytes must be proven exactly 8 bytes before Uint64 decoding; malformed lengths can panic the watcher before the observation is skipped. | +| node/pkg/watchers/algorand/positive.go:53:39:53:91 | call to Uint64 | Algorand publishMessage nonce/sequence bytes must be proven exactly 8 bytes before Uint64 decoding; malformed lengths can panic the watcher before the observation is skipped. | +| node/pkg/watchers/algorand/positive.go:62:44:62:78 | call to Uint64 | Algorand publishMessage nonce/sequence bytes must be proven exactly 8 bytes before Uint64 decoding; malformed lengths can panic the watcher before the observation is skipped. | +| node/pkg/watchers/algorand/positive.go:72:40:72:72 | call to Uint64 | Algorand publishMessage nonce/sequence bytes must be proven exactly 8 bytes before Uint64 decoding; malformed lengths can panic the watcher before the observation is skipped. | +| node/pkg/watchers/algorand/positive.go:80:43:80:89 | call to Uint64 | Algorand publishMessage nonce/sequence bytes must be proven exactly 8 bytes before Uint64 decoding; malformed lengths can panic the watcher before the observation is skipped. | +| node/pkg/watchers/algorand/positive.go:89:43:89:92 | call to Uint64 | Algorand publishMessage nonce/sequence bytes must be proven exactly 8 bytes before Uint64 decoding; malformed lengths can panic the watcher before the observation is skipped. | +| node/pkg/watchers/algorand/positive.go:100:43:100:87 | call to decodeUint64Unsafe | Algorand publishMessage nonce/sequence bytes must be proven exactly 8 bytes before Uint64 decoding; malformed lengths can panic the watcher before the observation is skipped. | +| node/pkg/watchers/algorand/positive.go:114:14:114:55 | call to readUint64Exact | Algorand publishMessage nonce/sequence bytes must be proven exactly 8 bytes before Uint64 decoding; malformed lengths can panic the watcher before the observation is skipped. | +| node/pkg/watchers/algorand/positive.go:127:43:127:77 | call to Uint64 | Algorand publishMessage nonce/sequence bytes must be proven exactly 8 bytes before Uint64 decoding; malformed lengths can panic the watcher before the observation is skipped. | +| node/pkg/watchers/algorand/positive.go:138:43:138:92 | call to Uint64 | Algorand publishMessage nonce/sequence bytes must be proven exactly 8 bytes before Uint64 decoding; malformed lengths can panic the watcher before the observation is skipped. | +| node/pkg/watchers/algorand/positive.go:149:39:149:91 | call to Uint64 | Algorand publishMessage nonce/sequence bytes must be proven exactly 8 bytes before Uint64 decoding; malformed lengths can panic the watcher before the observation is skipped. | +| node/pkg/watchers/algorand/positive.go:157:43:157:81 | call to Uint64 | Algorand publishMessage nonce/sequence bytes must be proven exactly 8 bytes before Uint64 decoding; malformed lengths can panic the watcher before the observation is skipped. | +| node/pkg/watchers/algorand/positive.go:164:16:164:57 | call to readUint64Exact | Algorand publishMessage nonce/sequence bytes must be proven exactly 8 bytes before Uint64 decoding; malformed lengths can panic the watcher before the observation is skipped. | diff --git a/codeql/test/algorand-publication-field-length-check/algorand-publication-field-length-check.qlref b/codeql/test/algorand-publication-field-length-check/algorand-publication-field-length-check.qlref new file mode 100644 index 0000000000..dae32ecc50 --- /dev/null +++ b/codeql/test/algorand-publication-field-length-check/algorand-publication-field-length-check.qlref @@ -0,0 +1 @@ +../../src/algorand-publication-field-length-check.ql diff --git a/codeql/test/algorand-publication-field-length-check/go.mod b/codeql/test/algorand-publication-field-length-check/go.mod new file mode 100644 index 0000000000..64f803608a --- /dev/null +++ b/codeql/test/algorand-publication-field-length-check/go.mod @@ -0,0 +1,3 @@ +module codeql/algorandpublicationfieldlengthcheck + +go 1.22 diff --git a/codeql/test/algorand-publication-field-length-check/node/pkg/watchers/algorand/generated.go b/codeql/test/algorand-publication-field-length-check/node/pkg/watchers/algorand/generated.go new file mode 100644 index 0000000000..d308ddf4af --- /dev/null +++ b/codeql/test/algorand-publication-field-length-check/node/pkg/watchers/algorand/generated.go @@ -0,0 +1,12 @@ +// Code generated by fixture. DO NOT EDIT. + +package algorand + +import "encoding/binary" + +func generatedUnguardedDecode(at SignedTxnWithAD) { + if !isPublishMessage(at) { + return + } + publish(MessagePublication{Nonce: uint32(binary.BigEndian.Uint64(at.Txn.ApplicationArgs[2]))}) +} diff --git a/codeql/test/algorand-publication-field-length-check/node/pkg/watchers/algorand/negative.go b/codeql/test/algorand-publication-field-length-check/node/pkg/watchers/algorand/negative.go new file mode 100644 index 0000000000..8c38f39ba3 --- /dev/null +++ b/codeql/test/algorand-publication-field-length-check/node/pkg/watchers/algorand/negative.go @@ -0,0 +1,79 @@ +package algorand + +import "encoding/binary" + +func negativeExactNonceGuard(at SignedTxnWithAD) { + if !isPublishMessage(at) { + return + } + if len(at.Txn.ApplicationArgs[2]) != 8 { + return + } + publish(MessagePublication{Nonce: uint32(binary.BigEndian.Uint64(at.Txn.ApplicationArgs[2]))}) +} + +func negativeExactSequenceGuard(at SignedTxnWithAD) { + if !isPublishMessage(at) || len(at.EvalDelta.Logs) == 0 { + return + } + if len([]byte(at.EvalDelta.Logs[0])) != 8 { + return + } + publish(MessagePublication{Sequence: binary.BigEndian.Uint64([]byte(at.EvalDelta.Logs[0]))}) +} + +func negativeAliasExactAcceptGuard(at SignedTxnWithAD) { + if !isPublishMessage(at) { + return + } + nonceBytes := at.Txn.ApplicationArgs[2] + if len(nonceBytes) == 8 { + publish(MessagePublication{Nonce: uint32(binary.BigEndian.Uint64(nonceBytes))}) + } +} + +func negativeHelperErrorHandled(at SignedTxnWithAD) { + if !isPublishMessage(at) { + return + } + nonce, err := readUint64Exact(at.Txn.ApplicationArgs[2]) + if err != nil { + return + } + publish(MessagePublication{Nonce: uint32(nonce)}) +} + +func negativeUnrelatedUint64(at SignedTxnWithAD, unrelated []byte) { + if len(unrelated) != 8 { + return + } + _ = binary.BigEndian.Uint64(unrelated) +} + +func negativeContainerBoundsOnly(at SignedTxnWithAD) { + if len(at.Txn.ApplicationArgs) <= 2 || len(at.EvalDelta.Logs) == 0 { + return + } + publish(MessagePublication{}) +} + +func negativeLocalBigEndianAliasExactGuard(at SignedTxnWithAD) { + if !isPublishMessage(at) { + return + } + if len(at.Txn.ApplicationArgs[2]) != 8 { + return + } + order := binary.BigEndian + publish(MessagePublication{Nonce: uint32(order.Uint64(at.Txn.ApplicationArgs[2]))}) +} + +func negativeCheckedHelperResultUnusedNotPublished(at SignedTxnWithAD) { + if !isPublishMessage(at) { + return + } + nonce, err := readUint64Exact(at.Txn.ApplicationArgs[2]) + _ = nonce + _ = err + publish(MessagePublication{}) +} diff --git a/codeql/test/algorand-publication-field-length-check/node/pkg/watchers/algorand/positive.go b/codeql/test/algorand-publication-field-length-check/node/pkg/watchers/algorand/positive.go new file mode 100644 index 0000000000..2e4b5bcf0c --- /dev/null +++ b/codeql/test/algorand-publication-field-length-check/node/pkg/watchers/algorand/positive.go @@ -0,0 +1,170 @@ +package algorand + +import ( + bin "encoding/binary" + "encoding/binary" +) + +func positiveEmptyNonce(at SignedTxnWithAD) { + if !isPublishMessage(at) { + return + } + at.Txn.ApplicationArgs[2] = []byte{} + publish(MessagePublication{Nonce: uint32(binary.BigEndian.Uint64(at.Txn.ApplicationArgs[2]))}) +} + +func positiveShortNonce(at SignedTxnWithAD) { + if !isPublishMessage(at) { + return + } + at.Txn.ApplicationArgs[2] = []byte{1, 2, 3, 4, 5, 6, 7} + publish(MessagePublication{Nonce: uint32(binary.BigEndian.Uint64(at.Txn.ApplicationArgs[2]))}) +} + +func positiveOversizedNonce(at SignedTxnWithAD) { + if !isPublishMessage(at) { + return + } + at.Txn.ApplicationArgs[2] = []byte{1, 2, 3, 4, 5, 6, 7, 8, 9} + publish(MessagePublication{Nonce: uint32(binary.BigEndian.Uint64(at.Txn.ApplicationArgs[2]))}) +} + +func positiveEmptySequence(at SignedTxnWithAD) { + if !isPublishMessage(at) || len(at.EvalDelta.Logs) == 0 { + return + } + at.EvalDelta.Logs[0] = string([]byte{}) + publish(MessagePublication{Sequence: binary.BigEndian.Uint64([]byte(at.EvalDelta.Logs[0]))}) +} + +func positiveShortSequence(at SignedTxnWithAD) { + if !isPublishMessage(at) || len(at.EvalDelta.Logs) == 0 { + return + } + at.EvalDelta.Logs[0] = string([]byte{1, 2, 3, 4, 5, 6, 7}) + publish(MessagePublication{Sequence: binary.BigEndian.Uint64([]byte(at.EvalDelta.Logs[0]))}) +} + +func positiveOversizedSequence(at SignedTxnWithAD) { + if !isPublishMessage(at) || len(at.EvalDelta.Logs) == 0 { + return + } + at.EvalDelta.Logs[0] = string([]byte{1, 2, 3, 4, 5, 6, 7, 8, 9}) + publish(MessagePublication{Sequence: binary.BigEndian.Uint64([]byte(at.EvalDelta.Logs[0]))}) +} + +func positiveNonceAliasNonExactGuard(at SignedTxnWithAD) { + if !isPublishMessage(at) { + return + } + nonceBytes := at.Txn.ApplicationArgs[2] + if len(nonceBytes) > 0 { + publish(MessagePublication{Nonce: uint32(binary.BigEndian.Uint64(nonceBytes))}) + } +} + +func positiveSequenceAliasNonExactGuard(at SignedTxnWithAD) { + if !isPublishMessage(at) || len(at.EvalDelta.Logs) == 0 { + return + } + seqBytes := []byte(at.EvalDelta.Logs[0]) + if len(seqBytes) >= 8 { + publish(MessagePublication{Sequence: binary.BigEndian.Uint64(seqBytes)}) + } +} + +func positiveImportAlias(at SignedTxnWithAD) { + if !isPublishMessage(at) { + return + } + publish(MessagePublication{Nonce: uint32(bin.BigEndian.Uint64(at.Txn.ApplicationArgs[2]))}) +} + +type aliasedTxn = SignedTxnWithAD + +func positiveTypeAlias(at aliasedTxn) { + if !isPublishMessage(at) { + return + } + publish(MessagePublication{Nonce: uint32(binary.BigEndian.Uint64(at.Txn.ApplicationArgs[2]))}) +} + +func decodeUint64Unsafe(b []byte) uint64 { + return binary.BigEndian.Uint64(b) +} + +func positiveUnsafeHelper(at SignedTxnWithAD) { + if !isPublishMessage(at) { + return + } + publish(MessagePublication{Nonce: uint32(decodeUint64Unsafe(at.Txn.ApplicationArgs[2]))}) +} + +func readUint64Exact(b []byte) (uint64, error) { + if len(b) != 8 { + return 0, errBadLength + } + return binary.BigEndian.Uint64(b), nil +} + +func positiveHelperErrorIgnored(at SignedTxnWithAD) { + if !isPublishMessage(at) { + return + } + nonce, _ := readUint64Exact(at.Txn.ApplicationArgs[2]) + publish(MessagePublication{Nonce: uint32(nonce)}) +} + +func positiveReassignedAfterExactGuard(at SignedTxnWithAD, other []byte) { + if !isPublishMessage(at) { + return + } + nonceBytes := at.Txn.ApplicationArgs[2] + if len(nonceBytes) != 8 { + return + } + nonceBytes = other + publish(MessagePublication{Nonce: uint32(binary.BigEndian.Uint64(nonceBytes))}) +} + +func positiveApplicationArgMutatedAfterExactGuard(at SignedTxnWithAD, other []byte) { + if !isPublishMessage(at) { + return + } + if len(at.Txn.ApplicationArgs[2]) != 8 { + return + } + at.Txn.ApplicationArgs[2] = other + publish(MessagePublication{Nonce: uint32(binary.BigEndian.Uint64(at.Txn.ApplicationArgs[2]))}) +} + +func positiveLogMutatedAfterExactGuard(at SignedTxnWithAD, other string) { + if !isPublishMessage(at) || len(at.EvalDelta.Logs) == 0 { + return + } + if len([]byte(at.EvalDelta.Logs[0])) != 8 { + return + } + at.EvalDelta.Logs[0] = other + publish(MessagePublication{Sequence: binary.BigEndian.Uint64([]byte(at.EvalDelta.Logs[0]))}) +} + +func positiveLocalBigEndianAlias(at SignedTxnWithAD) { + if !isPublishMessage(at) { + return + } + order := binary.BigEndian + publish(MessagePublication{Nonce: uint32(order.Uint64(at.Txn.ApplicationArgs[2]))}) +} + +func positiveHelperPublicationBeforeErrorCheck(at SignedTxnWithAD) { + if !isPublishMessage(at) { + return + } + nonce, err := readUint64Exact(at.Txn.ApplicationArgs[2]) + publish(MessagePublication{Nonce: uint32(nonce)}) + if err != nil { + return + } + publish(MessagePublication{Nonce: uint32(nonce)}) +} diff --git a/codeql/test/algorand-publication-field-length-check/node/pkg/watchers/algorand/stubs.go b/codeql/test/algorand-publication-field-length-check/node/pkg/watchers/algorand/stubs.go new file mode 100644 index 0000000000..a890d7dbd8 --- /dev/null +++ b/codeql/test/algorand-publication-field-length-check/node/pkg/watchers/algorand/stubs.go @@ -0,0 +1,34 @@ +package algorand + +import "errors" + +const publishMessage = "publishMessage" + +var errBadLength = errors.New("bad length") + +type SignedTxnWithAD struct { + Txn Transaction + EvalDelta EvalDelta +} + +type Transaction struct { + ApplicationArgs [][]byte + ApplicationID uint64 +} + +type EvalDelta struct { + Logs []string +} + +type MessagePublication struct { + Nonce uint32 + Sequence uint64 +} + +func isPublishMessage(at SignedTxnWithAD) bool { + return len(at.Txn.ApplicationArgs) > 2 && string(at.Txn.ApplicationArgs[0]) == publishMessage +} + +func publish(mp MessagePublication) { + _ = mp +} diff --git a/codeql/test/algorand-publication-field-length-check/node/pkg/watchers/algorand/watcher_test.go b/codeql/test/algorand-publication-field-length-check/node/pkg/watchers/algorand/watcher_test.go new file mode 100644 index 0000000000..60e9968b6c --- /dev/null +++ b/codeql/test/algorand-publication-field-length-check/node/pkg/watchers/algorand/watcher_test.go @@ -0,0 +1,10 @@ +package algorand + +import "encoding/binary" + +func testFixtureUnguardedDecode(at SignedTxnWithAD) { + if !isPublishMessage(at) { + return + } + publish(MessagePublication{Nonce: uint32(binary.BigEndian.Uint64(at.Txn.ApplicationArgs[2]))}) +} diff --git a/codeql/test/algorand-publication-field-length-check/node/pkg/watchers/evm/near_miss.go b/codeql/test/algorand-publication-field-length-check/node/pkg/watchers/evm/near_miss.go new file mode 100644 index 0000000000..d5c2a50889 --- /dev/null +++ b/codeql/test/algorand-publication-field-length-check/node/pkg/watchers/evm/near_miss.go @@ -0,0 +1,15 @@ +package evm + +import "encoding/binary" + +type MessagePublication struct { + Nonce uint32 +} + +type Tx struct { + ApplicationArgs [][]byte +} + +func nonAlgorandUnguardedDecode(tx Tx) MessagePublication { + return MessagePublication{Nonce: uint32(binary.BigEndian.Uint64(tx.ApplicationArgs[2]))} +} diff --git a/codeql/test/already-locked-receiver-mutex/already-locked-receiver-mutex.expected b/codeql/test/already-locked-receiver-mutex/already-locked-receiver-mutex.expected new file mode 100644 index 0000000000..25c295e28f --- /dev/null +++ b/codeql/test/already-locked-receiver-mutex/already-locked-receiver-mutex.expected @@ -0,0 +1,30 @@ +| node/cmd/ccq/positive.go:4:2:4:31 | call to updateMetricsAlreadyLocked | AlreadyLocked helper must be called only while holding the required write lock on the same receiver. | +| node/cmd/ccq/positive.go:10:2:10:31 | call to updateMetricsAlreadyLocked | AlreadyLocked helper must be called only while holding the required write lock on the same receiver. | +| node/pkg/accountant/accountant_stubs.go:18:2:18:53 | call to deletePendingTransferAlreadyLocked | AlreadyLocked helper must be called only while holding the required write lock on the same receiver. | +| node/pkg/accountant/accountant_stubs.go:23:2:23:48 | call to deletePendingTransferAlreadyLocked | AlreadyLocked helper must be called only while holding the required write lock on the same receiver. | +| node/pkg/accountant/accountant_stubs.go:29:2:29:62 | call to publishTransferAlreadyLocked | AlreadyLocked helper must be called only while holding the required write lock on the same receiver. | +| node/pkg/accountant/accountant_stubs.go:32:3:32:62 | call to publishTransferAlreadyLocked | AlreadyLocked helper must be called only while holding the required write lock on the same receiver. | +| node/pkg/accountant/accountant_stubs.go:37:2:37:48 | call to deletePendingTransferAlreadyLocked | AlreadyLocked helper must be called only while holding the required write lock on the same receiver. | +| node/pkg/accountant/positive.go:4:2:4:38 | call to publishTransferAlreadyLocked | AlreadyLocked helper must be called only while holding the required write lock on the same receiver. | +| node/pkg/accountant/positive.go:10:2:10:41 | call to addPendingTransferAlreadyLocked | AlreadyLocked helper must be called only while holding the required write lock on the same receiver. | +| node/pkg/accountant/positive.go:16:2:16:48 | call to deletePendingTransferAlreadyLocked | AlreadyLocked helper must be called only while holding the required write lock on the same receiver. | +| node/pkg/accountant/positive.go:22:2:22:38 | call to publishTransferAlreadyLocked | AlreadyLocked helper must be called only while holding the required write lock on the same receiver. | +| node/pkg/accountant/positive.go:28:2:28:41 | call to addPendingTransferAlreadyLocked | AlreadyLocked helper must be called only while holding the required write lock on the same receiver. | +| node/pkg/accountant/positive.go:36:2:36:48 | call to deletePendingTransferAlreadyLocked | AlreadyLocked helper must be called only while holding the required write lock on the same receiver. | +| node/pkg/accountant/positive.go:40:2:40:38 | call to publishTransferAlreadyLocked | AlreadyLocked helper must be called only while holding the required write lock on the same receiver. | +| node/pkg/accountant/positive.go:47:2:47:44 | call to addPendingTransferAlreadyLocked | AlreadyLocked helper must be called only while holding the required write lock on the same receiver. | +| node/pkg/accountant/positive.go:55:2:55:38 | call to publishTransferAlreadyLocked | AlreadyLocked helper must be called only while holding the required write lock on the same receiver. | +| node/pkg/accountant/positive.go:65:2:65:38 | call to publishTransferAlreadyLocked | AlreadyLocked helper must be called only while holding the required write lock on the same receiver. | +| node/pkg/accountant/positive.go:76:2:76:38 | call to publishTransferAlreadyLocked | AlreadyLocked helper must be called only while holding the required write lock on the same receiver. | +| node/pkg/accountant/positive.go:81:2:81:38 | call to publishTransferAlreadyLocked | AlreadyLocked helper must be called only while holding the required write lock on the same receiver. | +| node/pkg/accountant/positive.go:86:2:86:38 | call to publishTransferAlreadyLocked | AlreadyLocked helper must be called only while holding the required write lock on the same receiver. | +| node/pkg/accountant/positive.go:93:2:93:38 | call to publishTransferAlreadyLocked | AlreadyLocked helper must be called only while holding the required write lock on the same receiver. | +| node/pkg/accountant/positive.go:100:2:100:38 | call to publishTransferAlreadyLocked | AlreadyLocked helper must be called only while holding the required write lock on the same receiver. | +| node/pkg/accountant/positive.go:106:2:106:38 | call to publishTransferAlreadyLocked | AlreadyLocked helper must be called only while holding the required write lock on the same receiver. | +| node/pkg/accountant/positive.go:113:2:113:39 | call to publishTransferAlreadyLocked | AlreadyLocked helper must be called only while holding the required write lock on the same receiver. | +| node/pkg/accountant/positive.go:118:5:118:41 | call to publishTransferAlreadyLocked | AlreadyLocked helper must be called only while holding the required write lock on the same receiver. | +| node/pkg/accountant/positive.go:124:8:124:44 | call to publishTransferAlreadyLocked | AlreadyLocked helper must be called only while holding the required write lock on the same receiver. | +| node/pkg/governor/positive.go:4:6:4:35 | call to parseMsgAlreadyLocked | AlreadyLocked helper must be called only while holding the required write lock on the same receiver. | +| node/pkg/governor/positive.go:10:6:10:34 | call to loadFromDBAlreadyLocked | AlreadyLocked helper must be called only while holding the required write lock on the same receiver. | +| node/pkg/governor/positive.go:16:6:16:35 | call to parseMsgAlreadyLocked | AlreadyLocked helper must be called only while holding the required write lock on the same receiver. | +| node/pkg/governor/positive.go:22:6:22:34 | call to loadFromDBAlreadyLocked | AlreadyLocked helper must be called only while holding the required write lock on the same receiver. | diff --git a/codeql/test/already-locked-receiver-mutex/already-locked-receiver-mutex.qlref b/codeql/test/already-locked-receiver-mutex/already-locked-receiver-mutex.qlref new file mode 100644 index 0000000000..4728e709a9 --- /dev/null +++ b/codeql/test/already-locked-receiver-mutex/already-locked-receiver-mutex.qlref @@ -0,0 +1 @@ +../../src/already-locked-receiver-mutex.ql diff --git a/codeql/test/already-locked-receiver-mutex/go.mod b/codeql/test/already-locked-receiver-mutex/go.mod new file mode 100644 index 0000000000..6b5170c363 --- /dev/null +++ b/codeql/test/already-locked-receiver-mutex/go.mod @@ -0,0 +1,3 @@ +module github.com/wormhole-foundation/wormhole + +go 1.22 diff --git a/codeql/test/already-locked-receiver-mutex/node/cmd/ccq/negative.go b/codeql/test/already-locked-receiver-mutex/node/cmd/ccq/negative.go new file mode 100644 index 0000000000..691cfca16a --- /dev/null +++ b/codeql/test/already-locked-receiver-mutex/node/cmd/ccq/negative.go @@ -0,0 +1,13 @@ +package ccq + +func negativeCCQLocked(p *PendingResponses) { + p.mu.Lock() + p.updateMetricsAlreadyLocked() + p.mu.Unlock() +} + +func negativeCCQDeferUnlock(p *PendingResponses) { + p.mu.Lock() + defer p.mu.Unlock() + p.updateMetricsAlreadyLocked() +} diff --git a/codeql/test/already-locked-receiver-mutex/node/cmd/ccq/pending_request_stubs.go b/codeql/test/already-locked-receiver-mutex/node/cmd/ccq/pending_request_stubs.go new file mode 100644 index 0000000000..62f0d359da --- /dev/null +++ b/codeql/test/already-locked-receiver-mutex/node/cmd/ccq/pending_request_stubs.go @@ -0,0 +1,10 @@ +package ccq + +import "sync" + +type PendingResponses struct { + mu sync.Mutex + pendingResponses map[string]int +} + +func (p *PendingResponses) updateMetricsAlreadyLocked() {} diff --git a/codeql/test/already-locked-receiver-mutex/node/cmd/ccq/positive.go b/codeql/test/already-locked-receiver-mutex/node/cmd/ccq/positive.go new file mode 100644 index 0000000000..d177fa2ee1 --- /dev/null +++ b/codeql/test/already-locked-receiver-mutex/node/cmd/ccq/positive.go @@ -0,0 +1,11 @@ +package ccq + +func positiveCCQDirectNoLock(p *PendingResponses) { + p.updateMetricsAlreadyLocked() +} + +func positiveCCQDifferentReceiver(p *PendingResponses, other *PendingResponses) { + other.mu.Lock() + defer other.mu.Unlock() + p.updateMetricsAlreadyLocked() +} diff --git a/codeql/test/already-locked-receiver-mutex/node/pkg/accountant/accountant_stubs.go b/codeql/test/already-locked-receiver-mutex/node/pkg/accountant/accountant_stubs.go new file mode 100644 index 0000000000..3ad7c90aa5 --- /dev/null +++ b/codeql/test/already-locked-receiver-mutex/node/pkg/accountant/accountant_stubs.go @@ -0,0 +1,38 @@ +package accountant + +import "sync" + +type Accountant struct { + pendingTransfersLock sync.RWMutex + otherLock sync.Mutex + msgChan chan *struct{} + pendingTransfers map[string]*pendingEntry +} + +type pendingEntry struct{ msg string } + +var otherAcct *Accountant + +func (acct *Accountant) publishTransferAlreadyLocked(pe *pendingEntry) { + acct.deletePendingTransferAlreadyLocked(pe.msg) + otherAcct.deletePendingTransferAlreadyLocked(pe.msg) +} + +func (acct *Accountant) addPendingTransferAlreadyLocked(pe *pendingEntry) { + acct = otherAcct + acct.deletePendingTransferAlreadyLocked(pe.msg) +} + +func (acct *Accountant) deletePendingTransferAlreadyLocked(msgId string) { + alias := acct + alias = otherAcct + alias.publishTransferAlreadyLocked(&pendingEntry{msg: msgId}) + // Conservatively do not inherit the enclosing helper precondition into closures. + func() { + acct.publishTransferAlreadyLocked(&pendingEntry{msg: msgId}) + }() +} + +func (acct *Accountant) cleanupAlreadyLocked(pe *pendingEntry) { + acct.deletePendingTransferAlreadyLocked(pe.msg) +} diff --git a/codeql/test/already-locked-receiver-mutex/node/pkg/accountant/negative.go b/codeql/test/already-locked-receiver-mutex/node/pkg/accountant/negative.go new file mode 100644 index 0000000000..a12f123a39 --- /dev/null +++ b/codeql/test/already-locked-receiver-mutex/node/pkg/accountant/negative.go @@ -0,0 +1,55 @@ +package accountant + +func negativeSameReceiverLocked(acct *Accountant, pe *pendingEntry) { + acct.pendingTransfersLock.Lock() + acct.publishTransferAlreadyLocked(pe) + acct.pendingTransfersLock.Unlock() +} + +func negativeDeferUnlock(acct *Accountant, pe *pendingEntry) { + acct.pendingTransfersLock.Lock() + defer acct.pendingTransfersLock.Unlock() + acct.addPendingTransferAlreadyLocked(pe) +} + +func negativeTwoCallsProtected(acct *Accountant, pe *pendingEntry) { + acct.pendingTransfersLock.Lock() + defer acct.pendingTransfersLock.Unlock() + acct.addPendingTransferAlreadyLocked(pe) + acct.publishTransferAlreadyLocked(pe) +} + +func negativeDominatedAfterEarlyReturn(acct *Accountant, pe *pendingEntry, err error) { + acct.pendingTransfersLock.Lock() + defer acct.pendingTransfersLock.Unlock() + if err != nil { + return + } + acct.deletePendingTransferAlreadyLocked(pe.msg) +} + +func negativeAliasReceiver(acct *Accountant, pe *pendingEntry) { + acct.pendingTransfersLock.Lock() + defer acct.pendingTransfersLock.Unlock() + alias := acct + alias.publishTransferAlreadyLocked(pe) +} + +func negativeDifferentReceiversEachProtected(acct *Accountant, other *Accountant, pe *pendingEntry) { + acct.pendingTransfersLock.Lock() + acct.publishTransferAlreadyLocked(pe) + acct.pendingTransfersLock.Unlock() + other.pendingTransfersLock.Lock() + defer other.pendingTransfersLock.Unlock() + other.deletePendingTransferAlreadyLocked(pe.msg) +} + +func negativeConditionalUnlockThenRelock(acct *Accountant, pe *pendingEntry, release bool) { + acct.pendingTransfersLock.Lock() + if release { + acct.pendingTransfersLock.Unlock() + acct.pendingTransfersLock.Lock() + } + acct.publishTransferAlreadyLocked(pe) + acct.pendingTransfersLock.Unlock() +} diff --git a/codeql/test/already-locked-receiver-mutex/node/pkg/accountant/positive.go b/codeql/test/already-locked-receiver-mutex/node/pkg/accountant/positive.go new file mode 100644 index 0000000000..bf0b752bee --- /dev/null +++ b/codeql/test/already-locked-receiver-mutex/node/pkg/accountant/positive.go @@ -0,0 +1,126 @@ +package accountant + +func positiveDirectNoLock(acct *Accountant, pe *pendingEntry) { + acct.publishTransferAlreadyLocked(pe) +} + +func positiveRLockWrongLockType(acct *Accountant, pe *pendingEntry) { + acct.pendingTransfersLock.RLock() + defer acct.pendingTransfersLock.RUnlock() + acct.addPendingTransferAlreadyLocked(pe) +} + +func positiveWrongField(acct *Accountant, pe *pendingEntry) { + acct.otherLock.Lock() + defer acct.otherLock.Unlock() + acct.deletePendingTransferAlreadyLocked(pe.msg) +} + +func positiveUnlockedBeforeCall(acct *Accountant, pe *pendingEntry) { + acct.pendingTransfersLock.Lock() + acct.pendingTransfersLock.Unlock() + acct.publishTransferAlreadyLocked(pe) +} + +func positiveDifferentReceiver(acct *Accountant, other *Accountant, pe *pendingEntry) { + other.pendingTransfersLock.Lock() + defer other.pendingTransfersLock.Unlock() + acct.addPendingTransferAlreadyLocked(pe) +} + +func positiveRelockDifferentReceiver(acct *Accountant, other *Accountant, pe *pendingEntry) { + acct.pendingTransfersLock.Lock() + acct.pendingTransfersLock.Unlock() + other.pendingTransfersLock.Lock() + defer other.pendingTransfersLock.Unlock() + acct.deletePendingTransferAlreadyLocked(pe.msg) +} + +func TestPositiveNonTestFileStillChecked(acct *Accountant, pe *pendingEntry) { + acct.publishTransferAlreadyLocked(pe) +} + +func positiveNilReceiverAfterDifferentLock(acct *Accountant, pe *pendingEntry) { + var nilAcct *Accountant + acct.pendingTransfersLock.Lock() + defer acct.pendingTransfersLock.Unlock() + nilAcct.addPendingTransferAlreadyLocked(pe) +} + +func positiveConditionalUnlockBeforeCall(acct *Accountant, pe *pendingEntry, release bool) { + acct.pendingTransfersLock.Lock() + if release { + acct.pendingTransfersLock.Unlock() + } + acct.publishTransferAlreadyLocked(pe) +} + +func positiveRelockBypassedByBreak(acct *Accountant, pe *pendingEntry, release bool) { + acct.pendingTransfersLock.Lock() + for release { + acct.pendingTransfersLock.Unlock() + break + acct.pendingTransfersLock.Lock() + } + acct.publishTransferAlreadyLocked(pe) +} + +func positiveRelockBypassedByGoto(acct *Accountant, pe *pendingEntry, release bool) { + acct.pendingTransfersLock.Lock() + if release { + acct.pendingTransfersLock.Unlock() + goto publish + acct.pendingTransfersLock.Lock() + } +publish: + acct.publishTransferAlreadyLocked(pe) +} + +func positiveDeferredInitialLock(acct *Accountant, pe *pendingEntry) { + defer acct.pendingTransfersLock.Lock() + acct.publishTransferAlreadyLocked(pe) +} + +func positiveGoInitialLock(acct *Accountant, pe *pendingEntry) { + go acct.pendingTransfersLock.Lock() + acct.publishTransferAlreadyLocked(pe) +} + +func positiveDeferredRelock(acct *Accountant, pe *pendingEntry) { + acct.pendingTransfersLock.Lock() + acct.pendingTransfersLock.Unlock() + defer acct.pendingTransfersLock.Lock() + acct.publishTransferAlreadyLocked(pe) +} + +func positiveGoRelock(acct *Accountant, pe *pendingEntry) { + acct.pendingTransfersLock.Lock() + acct.pendingTransfersLock.Unlock() + go acct.pendingTransfersLock.Lock() + acct.publishTransferAlreadyLocked(pe) +} + +func positiveReceiverReassignedAfterLock(acct *Accountant, other *Accountant, pe *pendingEntry) { + acct.pendingTransfersLock.Lock() + acct = other + acct.publishTransferAlreadyLocked(pe) +} + +func positiveAliasReassignedAfterLock(acct *Accountant, other *Accountant, pe *pendingEntry) { + alias := acct + alias.pendingTransfersLock.Lock() + alias = other + alias.publishTransferAlreadyLocked(pe) +} + +func positiveGoHelperAfterLock(acct *Accountant, pe *pendingEntry) { + acct.pendingTransfersLock.Lock() + go acct.publishTransferAlreadyLocked(pe) + acct.pendingTransfersLock.Unlock() +} + +func positiveDeferredHelperAfterUnlock(acct *Accountant, pe *pendingEntry) { + acct.pendingTransfersLock.Lock() + defer acct.publishTransferAlreadyLocked(pe) + acct.pendingTransfersLock.Unlock() +} diff --git a/codeql/test/already-locked-receiver-mutex/node/pkg/accountant/positive_test.go b/codeql/test/already-locked-receiver-mutex/node/pkg/accountant/positive_test.go new file mode 100644 index 0000000000..3841443cd5 --- /dev/null +++ b/codeql/test/already-locked-receiver-mutex/node/pkg/accountant/positive_test.go @@ -0,0 +1,5 @@ +package accountant + +func TestExcludedTestFile(acct *Accountant, pe *pendingEntry) { + acct.publishTransferAlreadyLocked(pe) +} diff --git a/codeql/test/already-locked-receiver-mutex/node/pkg/governor/governor_stubs.go b/codeql/test/already-locked-receiver-mutex/node/pkg/governor/governor_stubs.go new file mode 100644 index 0000000000..7a4d49914b --- /dev/null +++ b/codeql/test/already-locked-receiver-mutex/node/pkg/governor/governor_stubs.go @@ -0,0 +1,11 @@ +package governor + +import "sync" + +type ChainGovernor struct { + mutex sync.Mutex + rw sync.RWMutex +} + +func (gov *ChainGovernor) parseMsgAlreadyLocked(msg []byte) error { return nil } +func (gov *ChainGovernor) loadFromDBAlreadyLocked() error { return nil } diff --git a/codeql/test/already-locked-receiver-mutex/node/pkg/governor/negative.go b/codeql/test/already-locked-receiver-mutex/node/pkg/governor/negative.go new file mode 100644 index 0000000000..8cf347044f --- /dev/null +++ b/codeql/test/already-locked-receiver-mutex/node/pkg/governor/negative.go @@ -0,0 +1,20 @@ +package governor + +func negativeGovernorLocked(gov *ChainGovernor) { + gov.mutex.Lock() + _ = gov.parseMsgAlreadyLocked(nil) + gov.mutex.Unlock() +} + +func negativeGovernorDeferUnlock(gov *ChainGovernor) { + gov.mutex.Lock() + defer gov.mutex.Unlock() + _ = gov.loadFromDBAlreadyLocked() +} + +func negativeGovernorBothHelpers(gov *ChainGovernor) { + gov.mutex.Lock() + defer gov.mutex.Unlock() + _ = gov.parseMsgAlreadyLocked(nil) + _ = gov.loadFromDBAlreadyLocked() +} diff --git a/codeql/test/already-locked-receiver-mutex/node/pkg/governor/positive.go b/codeql/test/already-locked-receiver-mutex/node/pkg/governor/positive.go new file mode 100644 index 0000000000..c90a683e32 --- /dev/null +++ b/codeql/test/already-locked-receiver-mutex/node/pkg/governor/positive.go @@ -0,0 +1,23 @@ +package governor + +func positiveGovernorDirectNoLock(gov *ChainGovernor) { + _ = gov.parseMsgAlreadyLocked(nil) +} + +func positiveGovernorDifferentReceiver(gov *ChainGovernor, other *ChainGovernor) { + other.mutex.Lock() + defer other.mutex.Unlock() + _ = gov.loadFromDBAlreadyLocked() +} + +func positiveGovernorMismatchedReadLock(gov *ChainGovernor) { + gov.rw.RLock() + defer gov.rw.RUnlock() + _ = gov.parseMsgAlreadyLocked(nil) +} + +func positiveGovernorUnlockedBeforeCall(gov *ChainGovernor) { + gov.mutex.Lock() + gov.mutex.Unlock() + _ = gov.loadFromDBAlreadyLocked() +} diff --git a/codeql/test/canonical-chain-id-parsing/canonical-chain-id-parsing.expected b/codeql/test/canonical-chain-id-parsing/canonical-chain-id-parsing.expected new file mode 100644 index 0000000000..2a356d956a --- /dev/null +++ b/codeql/test/canonical-chain-id-parsing/canonical-chain-id-parsing.expected @@ -0,0 +1,9 @@ +| node/pkg/adminrpc/positive.go:27:58:27:81 | type conversion | Convert boundary-derived Wormhole chain ID through `vaa.ChainIDFromNumber`, `vaa.KnownChainIDFromNumber`, or `vaa.StringToKnownChainID` according to whether this context needs wire-valid or registered-chain semantics. | +| node/pkg/adminrpc/positive.go:34:21:34:49 | type conversion | Convert boundary-derived Wormhole chain ID through `vaa.ChainIDFromNumber`, `vaa.KnownChainIDFromNumber`, or `vaa.StringToKnownChainID` according to whether this context needs wire-valid or registered-chain semantics. | +| node/pkg/adminrpc/positive.go:43:16:43:39 | type conversion | Convert boundary-derived Wormhole chain ID through `vaa.ChainIDFromNumber`, `vaa.KnownChainIDFromNumber`, or `vaa.StringToKnownChainID` according to whether this context needs wire-valid or registered-chain semantics. | +| node/pkg/adminrpc/positive.go:51:9:51:32 | type conversion | Convert boundary-derived Wormhole chain ID through `vaa.ChainIDFromNumber`, `vaa.KnownChainIDFromNumber`, or `vaa.StringToKnownChainID` according to whether this context needs wire-valid or registered-chain semantics. | +| node/pkg/adminrpc/positive.go:59:13:59:36 | type conversion | Convert boundary-derived Wormhole chain ID through `vaa.ChainIDFromNumber`, `vaa.KnownChainIDFromNumber`, or `vaa.StringToKnownChainID` according to whether this context needs wire-valid or registered-chain semantics. | +| node/pkg/adminrpc/positive.go:60:13:60:39 | type conversion | Convert boundary-derived Wormhole chain ID through `vaa.ChainIDFromNumber`, `vaa.KnownChainIDFromNumber`, or `vaa.StringToKnownChainID` according to whether this context needs wire-valid or registered-chain semantics. | +| node/pkg/txverifier/utils.go:8:27:8:44 | type conversion | Convert boundary-derived Wormhole chain ID through `vaa.ChainIDFromNumber`, `vaa.KnownChainIDFromNumber`, or `vaa.StringToKnownChainID` according to whether this context needs wire-valid or registered-chain semantics. | +| node/pkg/watchers/ibc/watcher.go:26:42:26:59 | type conversion | Convert boundary-derived Wormhole chain ID through `vaa.ChainIDFromNumber`, `vaa.KnownChainIDFromNumber`, or `vaa.StringToKnownChainID` according to whether this context needs wire-valid or registered-chain semantics. | +| node/pkg/watchers/ibc/watcher.go:47:14:47:31 | type conversion | Convert boundary-derived Wormhole chain ID through `vaa.ChainIDFromNumber`, `vaa.KnownChainIDFromNumber`, or `vaa.StringToKnownChainID` according to whether this context needs wire-valid or registered-chain semantics. | diff --git a/codeql/test/canonical-chain-id-parsing/canonical-chain-id-parsing.qlref b/codeql/test/canonical-chain-id-parsing/canonical-chain-id-parsing.qlref new file mode 100644 index 0000000000..c81412a3c9 --- /dev/null +++ b/codeql/test/canonical-chain-id-parsing/canonical-chain-id-parsing.qlref @@ -0,0 +1 @@ +../../src/canonical-chain-id-parsing.ql diff --git a/codeql/test/canonical-chain-id-parsing/go.mod b/codeql/test/canonical-chain-id-parsing/go.mod new file mode 100644 index 0000000000..6b5170c363 --- /dev/null +++ b/codeql/test/canonical-chain-id-parsing/go.mod @@ -0,0 +1,3 @@ +module github.com/wormhole-foundation/wormhole + +go 1.22 diff --git a/codeql/test/canonical-chain-id-parsing/node/pkg/adminrpc/negative.go b/codeql/test/canonical-chain-id-parsing/node/pkg/adminrpc/negative.go new file mode 100644 index 0000000000..d64633cf78 --- /dev/null +++ b/codeql/test/canonical-chain-id-parsing/node/pkg/adminrpc/negative.go @@ -0,0 +1,94 @@ +package adminrpc + +import ( + "strconv" + + proto "github.com/wormhole-foundation/wormhole/node/pkg/proto/adminrpc" + "github.com/wormhole-foundation/wormhole/sdk/vaa" +) + +func CanonicalWireValidHelper(req *proto.GovernanceRequest) (BodyContractUpgrade, error) { + chain, err := vaa.ChainIDFromNumber[uint32](req.ChainId) + if err != nil { + return BodyContractUpgrade{}, err + } + return BodyContractUpgrade{TargetChainID: chain}, nil +} + +func CanonicalKnownHelper(req *proto.ReobserveRequest, reobservers map[vaa.ChainID]string) (string, error) { + chain, err := vaa.KnownChainIDFromNumber[uint32](req.GetChainId()) + if err != nil { + return "", err + } + return reobservers[chain], nil +} + +func CanonicalStringKnownHelper(chainIDStr string, out map[vaa.ChainID]bool) error { + chain, err := vaa.StringToKnownChainID(chainIDStr) + if err != nil { + return err + } + out[chain] = true + return nil +} + +func InternalTypedValue(chain vaa.ChainID) BodyContractUpgrade { + return BodyContractUpgrade{TargetChainID: chain} +} + +func InternalTypedConstant() BodyContractUpgrade { + return BodyContractUpgrade{TargetChainID: vaa.ChainIDPythNet} +} + +func NonChainIDVersion(req *proto.GovernanceRequest) uint16 { + if req.Version > 255 { + return 0 + } + return uint16(req.Version) +} + +func GovernanceVersionSkewUsesWireValidHelper(req *proto.GovernanceRequest) (BodyContractUpgrade, error) { + chain, err := vaa.ChainIDFromNumber[uint32](req.GetChainId()) + if err != nil { + return BodyContractUpgrade{}, err + } + return BodyContractUpgrade{TargetChainID: chain}, nil +} + +func WireValidStringException(chainIDStr string) (vaa.ChainID, error) { + parsed, err := strconv.ParseUint(chainIDStr, 10, 16) + if err != nil { + return 0, err + } + return vaa.ChainIDFromNumber[uint64](parsed) +} + +type Attributes struct{} + +func (Attributes) GetAsUint(_ string, _ int) (uint64, error) { + return 0, nil +} + +func SameNamedIbcAttributeOutsideIbcWatcher(attributes Attributes) BodyContractUpgrade { + chain, err := attributes.GetAsUint("message.chain_id", 16) + if err != nil { + return BodyContractUpgrade{} + } + return BodyContractUpgrade{TargetChainID: vaa.ChainID(chain)} +} + +func JsonFloat64OutsideIbcWatcher(entry []any) vaa.ChainID { + chain, ok := entry[1].(float64) + if !ok { + return 0 + } + return vaa.ChainID(chain) +} + +func ParameterRangeOutsideTxVerifier(input []uint) []vaa.ChainID { + result := make([]vaa.ChainID, 0, len(input)) + for _, chain := range input { + result = append(result, vaa.ChainID(chain)) + } + return result +} diff --git a/codeql/test/canonical-chain-id-parsing/node/pkg/adminrpc/positive.go b/codeql/test/canonical-chain-id-parsing/node/pkg/adminrpc/positive.go new file mode 100644 index 0000000000..22e57de63b --- /dev/null +++ b/codeql/test/canonical-chain-id-parsing/node/pkg/adminrpc/positive.go @@ -0,0 +1,62 @@ +package adminrpc + +import ( + "math" + + proto "github.com/wormhole-foundation/wormhole/node/pkg/proto/adminrpc" + "github.com/wormhole-foundation/wormhole/sdk/vaa" +) + +type BodyContractUpgrade struct { + TargetChainID vaa.ChainID +} + +type BodyGuardianSetUpdate struct { + OldChain vaa.ChainID + NewChain vaa.ChainID +} + +func submitGovernanceBody(body BodyContractUpgrade) {} + +func useKnownChain(chain vaa.ChainID) {} + +func DirectFieldCastWireValid(req *proto.GovernanceRequest) { + if req.ChainId > math.MaxUint16 { + return + } + submitGovernanceBody(BodyContractUpgrade{TargetChainID: vaa.ChainID(req.ChainId)}) +} + +func DirectGetterCastKnown(req *proto.ReobserveRequest, reobservers map[vaa.ChainID]string) string { + if req.GetChainId() > math.MaxUint16 { + return "" + } + return reobservers[vaa.ChainID(req.GetChainId())] +} + +func AliasThenCallArgument(req *proto.ReobserveRequest) { + alias := req + chainNumber := alias.GetChainId() + if chainNumber > math.MaxUint16 { + return + } + useKnownChain(vaa.ChainID(chainNumber)) +} + +func ChannelReceiveBoundary(ch <-chan *proto.ReobserveRequest, expected vaa.ChainID) bool { + req := <-ch + if req.ChainId > math.MaxUint16 { + return false + } + return vaa.ChainID(req.ChainId) == expected +} + +func MultipleGeneratedFields(req *proto.GovernanceRequest) BodyGuardianSetUpdate { + if req.ChainId > math.MaxUint16 || req.NewChainId > math.MaxUint16 { + return BodyGuardianSetUpdate{} + } + return BodyGuardianSetUpdate{ + OldChain: vaa.ChainID(req.ChainId), + NewChain: vaa.ChainID(req.NewChainId), + } +} diff --git a/codeql/test/canonical-chain-id-parsing/node/pkg/proto/adminrpc/requests.pb.go b/codeql/test/canonical-chain-id-parsing/node/pkg/proto/adminrpc/requests.pb.go new file mode 100644 index 0000000000..266fdf084e --- /dev/null +++ b/codeql/test/canonical-chain-id-parsing/node/pkg/proto/adminrpc/requests.pb.go @@ -0,0 +1,32 @@ +package adminrpc + +type ReobserveRequest struct { + ChainId uint32 +} + +func (r *ReobserveRequest) GetChainId() uint32 { + if r == nil { + return 0 + } + return r.ChainId +} + +type GovernanceRequest struct { + ChainId uint32 + NewChainId uint32 + Version uint32 +} + +func (r *GovernanceRequest) GetChainId() uint32 { + if r == nil { + return 0 + } + return r.ChainId +} + +func (r *GovernanceRequest) GetNewChainId() uint32 { + if r == nil { + return 0 + } + return r.NewChainId +} diff --git a/codeql/test/canonical-chain-id-parsing/node/pkg/txverifier/utils.go b/codeql/test/canonical-chain-id-parsing/node/pkg/txverifier/utils.go new file mode 100644 index 0000000000..576eb34972 --- /dev/null +++ b/codeql/test/canonical-chain-id-parsing/node/pkg/txverifier/utils.go @@ -0,0 +1,20 @@ +package txverifier + +import "github.com/wormhole-foundation/wormhole/sdk/vaa" + +func ParameterRange(input []uint) []vaa.ChainID { + result := make([]vaa.ChainID, 0, len(input)) + for _, chain := range input { + result = append(result, vaa.ChainID(chain)) + } + return result +} + +func LocalRangeNearMiss() []vaa.ChainID { + input := []uint{1, 2} + result := make([]vaa.ChainID, 0, len(input)) + for _, chain := range input { + result = append(result, vaa.ChainID(chain)) + } + return result +} diff --git a/codeql/test/canonical-chain-id-parsing/node/pkg/watchers/ibc/watcher.go b/codeql/test/canonical-chain-id-parsing/node/pkg/watchers/ibc/watcher.go new file mode 100644 index 0000000000..8e4ba1d2e9 --- /dev/null +++ b/codeql/test/canonical-chain-id-parsing/node/pkg/watchers/ibc/watcher.go @@ -0,0 +1,105 @@ +package ibc + +import "github.com/wormhole-foundation/wormhole/sdk/vaa" + +type MessagePublication struct { + EmitterChain vaa.ChainID +} + +type WasmAttributes struct{} + +func (*WasmAttributes) GetAsUint(_ string, _ int) (uint64, error) { + return 0, nil +} + +type OtherAttributes struct{} + +func (*OtherAttributes) GetAsUint(_ string, _ int) (uint64, error) { + return 0, nil +} + +func IbcMessageChainID(attributes *WasmAttributes) MessagePublication { + chain, err := attributes.GetAsUint("message.chain_id", 16) + if err != nil { + return MessagePublication{} + } + return MessagePublication{EmitterChain: vaa.ChainID(chain)} +} + +type queryResults struct { + Data struct { + ChannelChains [][]any + OtherChains [][]any + } +} + +func JsonChannelMapping(result queryResults) map[string]vaa.ChainID { + ret := make(map[string]vaa.ChainID) + for _, entry := range result.Data.ChannelChains { + channelID, ok := entry[0].(string) + if !ok { + return ret + } + chain, ok := entry[1].(float64) + if !ok { + return ret + } + chainID := vaa.ChainID(chain) + ret[channelID] = chainID + } + return ret +} + +func IbcNearMissWrongReceiver(attributes *OtherAttributes) MessagePublication { + chain, err := attributes.GetAsUint("message.chain_id", 16) + if err != nil { + return MessagePublication{} + } + return MessagePublication{EmitterChain: vaa.ChainID(chain)} +} + +func IbcNearMissWrongAttribute(attributes *WasmAttributes) MessagePublication { + chain, err := attributes.GetAsUint("message.nonce", 16) + if err != nil { + return MessagePublication{} + } + return MessagePublication{EmitterChain: vaa.ChainID(chain)} +} + +func IbcNearMissWrongBitSize(attributes *WasmAttributes) MessagePublication { + chain, err := attributes.GetAsUint("message.chain_id", 32) + if err != nil { + return MessagePublication{} + } + return MessagePublication{EmitterChain: vaa.ChainID(chain)} +} + +func JsonNearMissWrongIndex(result queryResults) vaa.ChainID { + for _, entry := range result.Data.ChannelChains { + chain, ok := entry[0].(float64) + if !ok { + return 0 + } + return vaa.ChainID(chain) + } + return 0 +} + +func JsonNearMissWrongDataShape(result queryResults) vaa.ChainID { + for _, entry := range result.Data.OtherChains { + chain, ok := entry[1].(float64) + if !ok { + return 0 + } + return vaa.ChainID(chain) + } + return 0 +} + +func JsonNearMissDirectParameter(entry []any) vaa.ChainID { + chain, ok := entry[0].(float64) + if !ok { + return 0 + } + return vaa.ChainID(chain) +} diff --git a/codeql/test/canonical-chain-id-parsing/sdk/vaa/structs.go b/codeql/test/canonical-chain-id-parsing/sdk/vaa/structs.go new file mode 100644 index 0000000000..e5e6d6bd63 --- /dev/null +++ b/codeql/test/canonical-chain-id-parsing/sdk/vaa/structs.go @@ -0,0 +1,29 @@ +package vaa + +import "fmt" + +type ChainID uint16 + +const ChainIDPythNet ChainID = 26 + +type integer interface { + ~uint16 | ~uint32 | ~uint64 | ~int | ~int32 | ~int64 +} + +func ChainIDFromNumber[T integer](n T) (ChainID, error) { + if n < 0 || n > 65535 { + return 0, fmt.Errorf("chain id out of range") + } + return ChainID(n), nil +} + +func KnownChainIDFromNumber[T integer](n T) (ChainID, error) { + return ChainIDFromNumber(n) +} + +func StringToKnownChainID(s string) (ChainID, error) { + if s == "pythnet" { + return ChainIDPythNet, nil + } + return 0, fmt.Errorf("unknown chain") +} diff --git a/codeql/test/canonical-vaa-address-parsing/canonical-vaa-address-parsing.expected b/codeql/test/canonical-vaa-address-parsing/canonical-vaa-address-parsing.expected new file mode 100644 index 0000000000..9a7209cfe8 --- /dev/null +++ b/codeql/test/canonical-vaa-address-parsing/canonical-vaa-address-parsing.expected @@ -0,0 +1,12 @@ +| node/pkg/addresscases/positive.go:12:10:12:31 | type conversion | External Wormhole address data must be normalized with vaa.StringToAddress or vaa.BytesToAddress before use as EmitterAddress; this conversion bypasses canonical left-padding, 0x handling, and overlength rejection. | +| node/pkg/addresscases/positive.go:17:10:17:23 | type conversion | External Wormhole address data must be normalized with vaa.StringToAddress or vaa.BytesToAddress before use as EmitterAddress; this conversion bypasses canonical left-padding, 0x handling, and overlength rejection. | +| node/pkg/addresscases/positive.go:24:2:24:23 | call to copy | External Wormhole address data must be normalized with vaa.StringToAddress or vaa.BytesToAddress before use as EmitterAddress; this conversion bypasses canonical left-padding, 0x handling, and overlength rejection. | +| node/pkg/addresscases/positive.go:39:2:39:23 | call to copy | External Wormhole address data must be normalized with vaa.StringToAddress or vaa.BytesToAddress before use as vaa.Address return; this conversion bypasses canonical left-padding, 0x handling, and overlength rejection. | +| node/pkg/addresscases/positive.go:44:13:44:36 | call to StringViaBytesAddress | External Wormhole address data must be normalized with vaa.StringToAddress or vaa.BytesToAddress before use as EmitterAddress; this conversion bypasses canonical left-padding, 0x handling, and overlength rejection. | +| node/pkg/addresscases/positive.go:58:34:58:63 | type conversion | External Wormhole address data must be normalized with vaa.StringToAddress or vaa.BytesToAddress before use as EmitterAddress; this conversion bypasses canonical left-padding, 0x handling, and overlength rejection. | +| node/pkg/notary/admincommands.go:11:59:11:79 | type conversion | External Wormhole address data must be normalized with vaa.StringToAddress or vaa.BytesToAddress before use as EmitterAddress; this conversion bypasses canonical left-padding, 0x handling, and overlength rejection. | +| node/pkg/publicrpc/publicrpcserver.go:37:2:37:46 | call to copy | External Wormhole address data must be normalized with vaa.StringToAddress or vaa.BytesToAddress before use as EmitterAddress; this conversion bypasses canonical left-padding, 0x handling, and overlength rejection. | +| node/pkg/publicrpc/publicrpcserver.go:47:2:47:36 | call to copy | External Wormhole address data must be normalized with vaa.StringToAddress or vaa.BytesToAddress before use as EmitterAddress; this conversion bypasses canonical left-padding, 0x handling, and overlength rejection. | +| node/pkg/watchers/cosmwasm/watcher.go:13:10:13:35 | type conversion | External Wormhole address data must be normalized with vaa.StringToAddress or vaa.BytesToAddress before use as vaa.Address return; this conversion bypasses canonical left-padding, 0x handling, and overlength rejection. | +| node/pkg/watchers/cosmwasm/watcher.go:36:2:36:26 | call to copy | External Wormhole address data must be normalized with vaa.StringToAddress or vaa.BytesToAddress before use as vaa.Address return; this conversion bypasses canonical left-padding, 0x handling, and overlength rejection. | +| node/pkg/watchers/cosmwasm/watcher.go:50:2:50:26 | call to copy | External Wormhole address data must be normalized with vaa.StringToAddress or vaa.BytesToAddress before use as vaa.Address return; this conversion bypasses canonical left-padding, 0x handling, and overlength rejection. | diff --git a/codeql/test/canonical-vaa-address-parsing/canonical-vaa-address-parsing.qlref b/codeql/test/canonical-vaa-address-parsing/canonical-vaa-address-parsing.qlref new file mode 100644 index 0000000000..d721222d9b --- /dev/null +++ b/codeql/test/canonical-vaa-address-parsing/canonical-vaa-address-parsing.qlref @@ -0,0 +1 @@ +../../src/canonical-vaa-address-parsing.ql diff --git a/codeql/test/canonical-vaa-address-parsing/go.mod b/codeql/test/canonical-vaa-address-parsing/go.mod new file mode 100644 index 0000000000..ea27de8565 --- /dev/null +++ b/codeql/test/canonical-vaa-address-parsing/go.mod @@ -0,0 +1,3 @@ +module codeql/canonicalvaaaddressparsing + +go 1.22 diff --git a/codeql/test/canonical-vaa-address-parsing/node/pkg/addresscases/negative.go b/codeql/test/canonical-vaa-address-parsing/node/pkg/addresscases/negative.go new file mode 100644 index 0000000000..2da0e9f392 --- /dev/null +++ b/codeql/test/canonical-vaa-address-parsing/node/pkg/addresscases/negative.go @@ -0,0 +1,72 @@ +package addresscases + +import ( + "strings" + + "codeql/canonicalvaaaddressparsing/node/pkg/common" + "codeql/canonicalvaaaddressparsing/node/pkg/db" + "codeql/canonicalvaaaddressparsing/sdk/vaa" +) + +func CanonicalString(s string) (common.MessagePublication, error) { + addr, err := vaa.StringToAddress(s) + if err != nil { + return common.MessagePublication{}, err + } + return common.MessagePublication{EmitterAddress: addr}, nil +} + +func CanonicalBytes(b []byte) (common.MessagePublication, error) { + addr, err := vaa.BytesToAddress(b) + if err != nil { + return common.MessagePublication{}, err + } + return common.MessagePublication{EmitterAddress: addr}, nil +} + +func CanonicalWrapper(s string) (common.MessagePublication, error) { + addr, err := WrappedStringToAddress(s) + if err != nil { + return common.MessagePublication{}, err + } + return common.MessagePublication{EmitterAddress: addr}, nil +} + +func WrappedStringToAddress(s string) (vaa.Address, error) { + return vaa.StringToAddress(s) +} + +func UnrelatedStringBytesWrapper(s string, b []byte) (common.MessagePublication, error) { + _ = s + addr, err := BytesAddressWithUnrelatedString(s, b) + if err != nil { + return common.MessagePublication{}, err + } + return common.MessagePublication{EmitterAddress: addr}, nil +} + +func BytesAddressWithUnrelatedString(s string, b []byte) (vaa.Address, error) { + _ = s + return vaa.BytesToAddress(b) +} + +func TypedPropagation(msg common.MessagePublication) db.VAAID { + return db.VAAID{EmitterAddress: msg.EmitterAddress} +} + +func VaaFieldPropagation(v vaa.VAA) db.VAAID { + return db.VAAID{EmitterAddress: v.EmitterAddress} +} + +func WholeIDParser(id string) (*db.VAAID, error) { + return db.VaaIDFromString(id) +} + +func SplitWholeVaaIDOwnedBySibling(vaaID string) db.VAAID { + parts := strings.Split(vaaID, "/") + return db.VAAID{EmitterAddress: vaa.Address([]byte(parts[1]))} +} + +func HashNormalization(txHash string) (vaa.Hash, error) { + return vaa.StringToHash(txHash) +} diff --git a/codeql/test/canonical-vaa-address-parsing/node/pkg/addresscases/positive.go b/codeql/test/canonical-vaa-address-parsing/node/pkg/addresscases/positive.go new file mode 100644 index 0000000000..2080ccfd46 --- /dev/null +++ b/codeql/test/canonical-vaa-address-parsing/node/pkg/addresscases/positive.go @@ -0,0 +1,59 @@ +package addresscases + +import ( + "encoding/hex" + + "codeql/canonicalvaaaddressparsing/node/pkg/common" + "codeql/canonicalvaaaddressparsing/node/pkg/db" + "codeql/canonicalvaaaddressparsing/sdk/vaa" +) + +func DirectStringConversion(s string) common.MessagePublication { + addr := vaa.Address([]byte(s)) + return common.MessagePublication{EmitterAddress: addr} +} + +func DirectByteConversion(b []byte) common.MessagePublication { + addr := vaa.Address(b) + return common.MessagePublication{EmitterAddress: addr} +} + +func ManualCopy(req string) db.VAAID { + decoded, _ := hex.DecodeString(req) + addr := vaa.Address{} + copy(addr[:], decoded) + return db.VAAID{EmitterAddress: addr} +} + +func UnsafeHelperUse(s string) common.MessagePublication { + addr, _ := ParseEmitterAddress(s) + return common.MessagePublication{EmitterAddress: addr} +} + +func ParseEmitterAddress(s string) (vaa.Address, error) { + decoded, err := hex.DecodeString(s) + if err != nil { + return vaa.Address{}, err + } + addr := vaa.Address{} + copy(addr[:], decoded) + return addr, nil +} + +func StringViaBytesHelperUse(s string) common.MessagePublication { + addr, _ := StringViaBytesAddress(s) + return common.MessagePublication{EmitterAddress: addr} +} + +func StringViaBytesAddress(s string) (vaa.Address, error) { + decoded, err := hex.DecodeString(s) + if err != nil { + return vaa.Address{}, err + } + return vaa.BytesToAddress(decoded) +} + +func ManualWholeIDComponent(id string, component string) db.VAAID { + _ = id + return db.VAAID{EmitterAddress: vaa.Address([]byte(component))} +} diff --git a/codeql/test/canonical-vaa-address-parsing/node/pkg/common/common.go b/codeql/test/canonical-vaa-address-parsing/node/pkg/common/common.go new file mode 100644 index 0000000000..8d63572b28 --- /dev/null +++ b/codeql/test/canonical-vaa-address-parsing/node/pkg/common/common.go @@ -0,0 +1,7 @@ +package common + +import "codeql/canonicalvaaaddressparsing/sdk/vaa" + +type MessagePublication struct { + EmitterAddress vaa.Address +} diff --git a/codeql/test/canonical-vaa-address-parsing/node/pkg/db/db.go b/codeql/test/canonical-vaa-address-parsing/node/pkg/db/db.go new file mode 100644 index 0000000000..81517e1885 --- /dev/null +++ b/codeql/test/canonical-vaa-address-parsing/node/pkg/db/db.go @@ -0,0 +1,15 @@ +package db + +import "codeql/canonicalvaaaddressparsing/sdk/vaa" + +type VAAID struct { + EmitterAddress vaa.Address +} + +func VaaIDFromString(id string) (*VAAID, error) { + addr, err := vaa.StringToAddress(id) + if err != nil { + return nil, err + } + return &VAAID{EmitterAddress: addr}, nil +} diff --git a/codeql/test/canonical-vaa-address-parsing/node/pkg/notary/admincommands.go b/codeql/test/canonical-vaa-address-parsing/node/pkg/notary/admincommands.go new file mode 100644 index 0000000000..c96dd21f5d --- /dev/null +++ b/codeql/test/canonical-vaa-address-parsing/node/pkg/notary/admincommands.go @@ -0,0 +1,13 @@ +package notary + +import ( + "codeql/canonicalvaaaddressparsing/node/pkg/common" + "codeql/canonicalvaaaddressparsing/sdk/vaa" +) + +func createTestMessagePublication(external []byte) (common.MessagePublication, common.MessagePublication) { + tokenBridge := vaa.KnownTokenbridgeEmitters[1] + known := common.MessagePublication{EmitterAddress: vaa.Address(tokenBridge)} + adversarial := common.MessagePublication{EmitterAddress: vaa.Address(external)} + return known, adversarial +} diff --git a/codeql/test/canonical-vaa-address-parsing/node/pkg/publicrpc/publicrpcserver.go b/codeql/test/canonical-vaa-address-parsing/node/pkg/publicrpc/publicrpcserver.go new file mode 100644 index 0000000000..7c09f25677 --- /dev/null +++ b/codeql/test/canonical-vaa-address-parsing/node/pkg/publicrpc/publicrpcserver.go @@ -0,0 +1,51 @@ +package publicrpc + +import ( + "encoding/hex" + "errors" + + "codeql/canonicalvaaaddressparsing/node/pkg/db" + "codeql/canonicalvaaaddressparsing/sdk/vaa" +) + +type MessageID struct { + EmitterAddress string +} + +type GetSignedVAARequest struct { + MessageId MessageID + StandaloneEmitter string + OtherAddress string +} + +func GetSignedVAA(req GetSignedVAARequest) (db.VAAID, db.VAAID, error) { + decoded, err := hex.DecodeString(req.MessageId.EmitterAddress) + if err != nil { + return db.VAAID{}, db.VAAID{}, err + } + if len(decoded) != 32 { + return db.VAAID{}, db.VAAID{}, errors.New("invalid emitter length") + } + messageIDAddress := vaa.Address{} + copy(messageIDAddress[:], decoded) + + standaloneDecoded, err := hex.DecodeString(req.StandaloneEmitter) + if err != nil { + return db.VAAID{}, db.VAAID{}, err + } + standaloneAddress := vaa.Address{} + copy(standaloneAddress[:], standaloneDecoded) + + otherDecoded, err := hex.DecodeString(req.OtherAddress) + if err != nil { + return db.VAAID{}, db.VAAID{}, err + } + if len(otherDecoded) != 32 { + return db.VAAID{}, db.VAAID{}, errors.New("invalid other length") + } + otherAddress := vaa.Address{} + copy(otherAddress[:], otherDecoded) + _ = db.VAAID{EmitterAddress: otherAddress} + + return db.VAAID{EmitterAddress: messageIDAddress}, db.VAAID{EmitterAddress: standaloneAddress}, nil +} diff --git a/codeql/test/canonical-vaa-address-parsing/node/pkg/watchers/cosmwasm/watcher.go b/codeql/test/canonical-vaa-address-parsing/node/pkg/watchers/cosmwasm/watcher.go new file mode 100644 index 0000000000..0f921fb0c1 --- /dev/null +++ b/codeql/test/canonical-vaa-address-parsing/node/pkg/watchers/cosmwasm/watcher.go @@ -0,0 +1,52 @@ +package cosmwasm + +import ( + "encoding/hex" + "errors" + + "codeql/canonicalvaaaddressparsing/sdk/vaa" +) + +// StringToAddress decodes the authenticated core contract's fixed-width [u8; 32] emitter. +func StringToAddress(value string) (vaa.Address, error) { + if value == "adversarial" { + return vaa.Address([]byte(value)), nil + } + var address vaa.Address + decoded, err := hex.DecodeString(value) + if err != nil { + return address, err + } + if len(decoded) != 32 { + return address, errors.New("invalid emitter length") + } + copy(address[:], decoded) + return address, nil +} + +func UnsafeCopy(value string) (vaa.Address, error) { + var address vaa.Address + decoded, err := hex.DecodeString(value) + if err != nil { + return address, err + } + if len(decoded) == 32 { + _ = value + } + copy(address[:], decoded) + return address, nil +} + +func GenericSenderUse(sender string) (vaa.Address, error) { + return GenericSenderToAddress(sender) +} + +func GenericSenderToAddress(sender string) (vaa.Address, error) { + var address vaa.Address + decoded, err := hex.DecodeString(sender) + if err != nil { + return address, err + } + copy(address[:], decoded) + return address, nil +} diff --git a/codeql/test/canonical-vaa-address-parsing/node/pkg/watchers/evm/utils.go b/codeql/test/canonical-vaa-address-parsing/node/pkg/watchers/evm/utils.go new file mode 100644 index 0000000000..56a036c5a5 --- /dev/null +++ b/codeql/test/canonical-vaa-address-parsing/node/pkg/watchers/evm/utils.go @@ -0,0 +1,11 @@ +package evm + +import "codeql/canonicalvaaaddressparsing/sdk/vaa" + +type Address [20]byte + +func PadAddress(address Address) vaa.Address { + paddedAddress := vaa.Address{} + copy(paddedAddress[12:], address[:]) + return paddedAddress +} diff --git a/codeql/test/canonical-vaa-address-parsing/node/pkg/watchers/sui/watcher.go b/codeql/test/canonical-vaa-address-parsing/node/pkg/watchers/sui/watcher.go new file mode 100644 index 0000000000..913219ec55 --- /dev/null +++ b/codeql/test/canonical-vaa-address-parsing/node/pkg/watchers/sui/watcher.go @@ -0,0 +1,15 @@ +package sui + +import ( + "codeql/canonicalvaaaddressparsing/node/pkg/common" + "codeql/canonicalvaaaddressparsing/sdk/vaa" +) + +type Event struct { + Sender [32]byte +} + +func TypedSender(event Event) common.MessagePublication { + addr := vaa.Address(event.Sender) + return common.MessagePublication{EmitterAddress: addr} +} diff --git a/codeql/test/canonical-vaa-address-parsing/node/pkg/watchers/xrpl/watcher.go b/codeql/test/canonical-vaa-address-parsing/node/pkg/watchers/xrpl/watcher.go new file mode 100644 index 0000000000..50ea2d20e8 --- /dev/null +++ b/codeql/test/canonical-vaa-address-parsing/node/pkg/watchers/xrpl/watcher.go @@ -0,0 +1,9 @@ +package xrpl + +import "codeql/canonicalvaaaddressparsing/sdk/vaa" + +func CoreEmitterAccount(account [20]byte) vaa.Address { + emitter := vaa.Address{} + copy(emitter[12:], account[:]) + return emitter +} diff --git a/codeql/test/canonical-vaa-address-parsing/sdk/vaa/vaa.go b/codeql/test/canonical-vaa-address-parsing/sdk/vaa/vaa.go new file mode 100644 index 0000000000..c5253f8dce --- /dev/null +++ b/codeql/test/canonical-vaa-address-parsing/sdk/vaa/vaa.go @@ -0,0 +1,22 @@ +package vaa + +type Address [32]byte +type Hash [32]byte + +type VAA struct { + EmitterAddress Address +} + +var KnownTokenbridgeEmitters = map[uint16][]byte{1: []byte{1, 2, 3}} + +func StringToAddress(s string) (Address, error) { + return Address([]byte(s)), nil +} + +func BytesToAddress(b []byte) (Address, error) { + return Address(b), nil +} + +func StringToHash(s string) (Hash, error) { + return Hash([]byte(s)), nil +} diff --git a/codeql/test/canonical-vaa-id-parsing/canonical-vaa-id-parsing.expected b/codeql/test/canonical-vaa-id-parsing/canonical-vaa-id-parsing.expected new file mode 100644 index 0000000000..33a4968752 --- /dev/null +++ b/codeql/test/canonical-vaa-id-parsing/canonical-vaa-id-parsing.expected @@ -0,0 +1,6 @@ +| node/pkg/adminrpc/canonical_parser_alias.go:16:10:16:87 | struct literal | Parse serialized VAA IDs with the canonical VAA ID parser before constructing storage identities; manual split/reconstruction can misdecode emitter addresses and miss existing signed VAAs. | +| node/pkg/adminrpc/positive.go:32:11:36:2 | struct literal | Parse serialized VAA IDs with the canonical VAA ID parser before constructing storage identities; manual split/reconstruction can misdecode emitter addresses and miss existing signed VAAs. | +| node/pkg/adminrpc/positive.go:44:9:48:2 | struct literal | Parse serialized VAA IDs with the canonical VAA ID parser before constructing storage identities; manual split/reconstruction can misdecode emitter addresses and miss existing signed VAAs. | +| node/pkg/adminrpc/positive.go:65:9:69:2 | struct literal | Parse serialized VAA IDs with the canonical VAA ID parser before constructing storage identities; manual split/reconstruction can misdecode emitter addresses and miss existing signed VAAs. | +| node/pkg/adminrpc/positive.go:74:9:78:2 | struct literal | Parse serialized VAA IDs with the canonical VAA ID parser before constructing storage identities; manual split/reconstruction can misdecode emitter addresses and miss existing signed VAAs. | +| node/pkg/adminrpc/positive.go:84:9:88:2 | struct literal | Parse serialized VAA IDs with the canonical VAA ID parser before constructing storage identities; manual split/reconstruction can misdecode emitter addresses and miss existing signed VAAs. | diff --git a/codeql/test/canonical-vaa-id-parsing/canonical-vaa-id-parsing.qlref b/codeql/test/canonical-vaa-id-parsing/canonical-vaa-id-parsing.qlref new file mode 100644 index 0000000000..0b94062225 --- /dev/null +++ b/codeql/test/canonical-vaa-id-parsing/canonical-vaa-id-parsing.qlref @@ -0,0 +1 @@ +../../src/canonical-vaa-id-parsing.ql diff --git a/codeql/test/canonical-vaa-id-parsing/go.mod b/codeql/test/canonical-vaa-id-parsing/go.mod new file mode 100644 index 0000000000..bda8941371 --- /dev/null +++ b/codeql/test/canonical-vaa-id-parsing/go.mod @@ -0,0 +1,3 @@ +module codeql/canonicalvaaidparsing + +go 1.22 diff --git a/codeql/test/canonical-vaa-id-parsing/node/pkg/adminrpc/canonical_parser_alias.go b/codeql/test/canonical-vaa-id-parsing/node/pkg/adminrpc/canonical_parser_alias.go new file mode 100644 index 0000000000..123f1aff34 --- /dev/null +++ b/codeql/test/canonical-vaa-id-parsing/node/pkg/adminrpc/canonical_parser_alias.go @@ -0,0 +1,17 @@ +package adminrpc + +import ( + guardianDB "codeql/canonicalvaaidparsing/node/pkg/db" + "codeql/canonicalvaaidparsing/node/pkg/vaa" + "strconv" + "strings" +) + +func VaaIDFromString(id string) (*guardianDB.VAAID, error) { + parts := strings.Split(id, "/") + seq, err := strconv.ParseUint(parts[2], 10, 64) + if err != nil { + return nil, err + } + return &guardianDB.VAAID{EmitterAddress: vaa.Address([]byte(parts[1])), Sequence: seq}, nil +} diff --git a/codeql/test/canonical-vaa-id-parsing/node/pkg/adminrpc/generated.pb.go b/codeql/test/canonical-vaa-id-parsing/node/pkg/adminrpc/generated.pb.go new file mode 100644 index 0000000000..df749fa508 --- /dev/null +++ b/codeql/test/canonical-vaa-id-parsing/node/pkg/adminrpc/generated.pb.go @@ -0,0 +1,12 @@ +package adminrpc + +import ( + guardianDB "codeql/canonicalvaaidparsing/node/pkg/db" + "codeql/canonicalvaaidparsing/node/pkg/vaa" + "strings" +) + +func generatedManualParse(id string) guardianDB.VAAID { + parts := strings.Split(id, "/") + return guardianDB.VAAID{EmitterAddress: vaa.Address([]byte(parts[1]))} +} diff --git a/codeql/test/canonical-vaa-id-parsing/node/pkg/adminrpc/manual_parse_test.go b/codeql/test/canonical-vaa-id-parsing/node/pkg/adminrpc/manual_parse_test.go new file mode 100644 index 0000000000..c905ecba51 --- /dev/null +++ b/codeql/test/canonical-vaa-id-parsing/node/pkg/adminrpc/manual_parse_test.go @@ -0,0 +1,12 @@ +package adminrpc + +import ( + guardianDB "codeql/canonicalvaaidparsing/node/pkg/db" + "codeql/canonicalvaaidparsing/node/pkg/vaa" + "strings" +) + +func testOnlyManualParse(id string) guardianDB.VAAID { + parts := strings.Split(id, "/") + return guardianDB.VAAID{EmitterAddress: vaa.Address([]byte(parts[1]))} +} diff --git a/codeql/test/canonical-vaa-id-parsing/node/pkg/adminrpc/negative.go b/codeql/test/canonical-vaa-id-parsing/node/pkg/adminrpc/negative.go new file mode 100644 index 0000000000..ee88bd2e04 --- /dev/null +++ b/codeql/test/canonical-vaa-id-parsing/node/pkg/adminrpc/negative.go @@ -0,0 +1,66 @@ +package adminrpc + +import ( + guardianDB "codeql/canonicalvaaidparsing/node/pkg/db" + "codeql/canonicalvaaidparsing/node/pkg/vaa" + "fmt" + "strconv" + "strings" +) + +type typedRequest struct { + EmitterChain uint16 + EmitterAddress vaa.Address + Sequence uint64 +} + +func canonicalPinnedParser(id string, s *server) (bool, error) { + vaaID, err := guardianDB.VaaIDFromString(id) + if err != nil { + return false, err + } + return s.db.HasVAA(*vaaID) +} + +func canonicalFutureParser(id string) (*vaa.VAAID, error) { + parsed, err := vaa.VAAIDFromString(id) + if err != nil { + return nil, err + } + if parsed.EmitterChain == 0 { + return nil, fmt.Errorf("unsupported emitter chain") + } + return parsed, nil +} + +func typedFieldConstruction(req typedRequest) guardianDB.VAAID { + return guardianDB.VAAID{ + EmitterChain: vaa.ChainID(req.EmitterChain), + EmitterAddress: req.EmitterAddress, + Sequence: req.Sequence, + } +} + +func stringProducerOnly(req typedRequest) string { + return fmt.Sprintf("%d/%s/%d", req.EmitterChain, req.EmitterAddress, req.Sequence) +} + +type msgID struct { + EmitterChain vaa.ChainID + EmitterAddress vaa.Address + Sequence uint64 +} + +func txverifierNearMiss(id string) (msgID, error) { + parts := strings.Split(id, "/") + seq, err := strconv.ParseUint(parts[2], 10, 64) + if err != nil { + return msgID{}, err + } + return msgID{EmitterAddress: vaa.Address([]byte(parts[1])), Sequence: seq}, nil +} + +func cliNearMiss(id string) (string, string, string) { + parts := strings.Split(id, "/") + return parts[0], parts[1], parts[2] +} diff --git a/codeql/test/canonical-vaa-id-parsing/node/pkg/adminrpc/positive.go b/codeql/test/canonical-vaa-id-parsing/node/pkg/adminrpc/positive.go new file mode 100644 index 0000000000..f7c098dc30 --- /dev/null +++ b/codeql/test/canonical-vaa-id-parsing/node/pkg/adminrpc/positive.go @@ -0,0 +1,89 @@ +package adminrpc + +import ( + guardianDB "codeql/canonicalvaaidparsing/node/pkg/db" + "codeql/canonicalvaaidparsing/node/pkg/vaa" + "strconv" + "strings" +) + +type MissingVAA struct { + VaaKey string +} + +type server struct { + db database +} + +type database interface { + HasVAA(id guardianDB.VAAID) (bool, error) +} + +func (s *server) observeMissingASCIIRegression(missingVAA MissingVAA) (bool, error) { + splits := strings.Split(missingVAA.VaaKey, "/") + chain, err := strconv.ParseUint(splits[0], 10, 16) + if err != nil { + return false, err + } + sequence, err := strconv.ParseUint(splits[2], 10, 64) + if err != nil { + return false, err + } + vaaID := guardianDB.VAAID{ + EmitterChain: vaa.ChainID(chain), + EmitterAddress: vaa.Address([]byte(splits[1])), + Sequence: sequence, + } + return s.db.HasVAA(vaaID) +} + +func renamedAliasesStillBypassCanonicalParser(input MissingVAA) guardianDB.VAAID { + messageID := input.VaaKey + fields := strings.Split(messageID, "/") + seq, _ := strconv.ParseUint(fields[2], 10, 64) + return guardianDB.VAAID{ + EmitterChain: vaa.ChainID(2), + EmitterAddress: vaa.Address([]byte(fields[1])), + Sequence: seq, + } +} + +func helperBypassWithComponentAddressParser(vaaKey string) (guardianDB.VAAID, error) { + parts := strings.Split(vaaKey, "/") + chain, err := strconv.ParseUint(parts[0], 10, 16) + if err != nil { + return guardianDB.VAAID{}, err + } + addr, err := vaa.StringToAddress(parts[1]) + if err != nil { + return guardianDB.VAAID{}, err + } + seq, err := strconv.ParseUint(parts[2], 10, 64) + if err != nil { + return guardianDB.VAAID{}, err + } + return guardianDB.VAAID{ + EmitterChain: vaa.ChainID(chain), + EmitterAddress: addr, + Sequence: seq, + }, nil +} + +func helperBypassWithShortHex(vaaKey string) guardianDB.VAAID { + segments := strings.Split(vaaKey, "/") + return guardianDB.VAAID{ + EmitterChain: vaa.ChainID(2), + EmitterAddress: vaa.Address([]byte(segments[1])), + Sequence: 7, + } +} + +func ignoredCanonicalResultThenManualParse(vaaKey string) guardianDB.VAAID { + _, _ = guardianDB.VaaIDFromString(vaaKey) + parts := strings.Split(vaaKey, "/") + return guardianDB.VAAID{ + EmitterChain: vaa.ChainID(2), + EmitterAddress: vaa.Address([]byte(parts[1])), + Sequence: 9, + } +} diff --git a/codeql/test/canonical-vaa-id-parsing/node/pkg/db/db.go b/codeql/test/canonical-vaa-id-parsing/node/pkg/db/db.go new file mode 100644 index 0000000000..a2f3dedb9c --- /dev/null +++ b/codeql/test/canonical-vaa-id-parsing/node/pkg/db/db.go @@ -0,0 +1,17 @@ +package db + +import "codeql/canonicalvaaidparsing/node/pkg/vaa" + +type VAAID struct { + EmitterChain vaa.ChainID + EmitterAddress vaa.Address + Sequence uint64 +} + +func VaaIDFromString(id string) (*VAAID, error) { + return &VAAID{}, nil +} + +func VAAIDFromString(id string) (*VAAID, error) { + return &VAAID{}, nil +} diff --git a/codeql/test/canonical-vaa-id-parsing/node/pkg/vaa/vaa.go b/codeql/test/canonical-vaa-id-parsing/node/pkg/vaa/vaa.go new file mode 100644 index 0000000000..8ee7c50f61 --- /dev/null +++ b/codeql/test/canonical-vaa-id-parsing/node/pkg/vaa/vaa.go @@ -0,0 +1,19 @@ +package vaa + +type ChainID uint16 + +type Address [32]byte + +func StringToAddress(s string) (Address, error) { + return Address{}, nil +} + +func VAAIDFromString(id string) (*VAAID, error) { + return &VAAID{}, nil +} + +type VAAID struct { + EmitterChain ChainID + EmitterAddress Address + Sequence uint64 +} diff --git a/codeql/test/delegate-consensus-canonical-digest/delegate-consensus-canonical-digest.expected b/codeql/test/delegate-consensus-canonical-digest/delegate-consensus-canonical-digest.expected new file mode 100644 index 0000000000..d11aa0e31b --- /dev/null +++ b/codeql/test/delegate-consensus-canonical-digest/delegate-consensus-canonical-digest.expected @@ -0,0 +1,16 @@ +| node/pkg/processor/observation.go:81:35:81:38 | hash | Delegate observation quorum bucket key must be the reconstructed MessagePublication VAA signing digest; use CreateDigest or equivalent SigningDigest and exclude per-guardian/non-VAA fields such as IsReobservation, TxID, guardian address, signatures, and serialized delegate observations. | +| node/pkg/processor/observation.go:87:35:87:38 | hash | Delegate observation quorum bucket key must be the reconstructed MessagePublication VAA signing digest; use CreateDigest or equivalent SigningDigest and exclude per-guardian/non-VAA fields such as IsReobservation, TxID, guardian address, signatures, and serialized delegate observations. | +| node/pkg/processor/observation.go:93:35:93:38 | hash | Delegate observation quorum bucket key must be the reconstructed MessagePublication VAA signing digest; use CreateDigest or equivalent SigningDigest and exclude per-guardian/non-VAA fields such as IsReobservation, TxID, guardian address, signatures, and serialized delegate observations. | +| node/pkg/processor/observation.go:99:35:99:38 | hash | Delegate observation quorum bucket key must be the reconstructed MessagePublication VAA signing digest; use CreateDigest or equivalent SigningDigest and exclude per-guardian/non-VAA fields such as IsReobservation, TxID, guardian address, signatures, and serialized delegate observations. | +| node/pkg/processor/observation.go:104:35:104:38 | hash | Delegate observation quorum bucket key must be the reconstructed MessagePublication VAA signing digest; use CreateDigest or equivalent SigningDigest and exclude per-guardian/non-VAA fields such as IsReobservation, TxID, guardian address, signatures, and serialized delegate observations. | +| node/pkg/processor/observation.go:109:35:109:38 | hash | Delegate observation quorum bucket key must be the reconstructed MessagePublication VAA signing digest; use CreateDigest or equivalent SigningDigest and exclude per-guardian/non-VAA fields such as IsReobservation, TxID, guardian address, signatures, and serialized delegate observations. | +| node/pkg/processor/observation.go:114:35:114:38 | hash | Delegate observation quorum bucket key must be the reconstructed MessagePublication VAA signing digest; use CreateDigest or equivalent SigningDigest and exclude per-guardian/non-VAA fields such as IsReobservation, TxID, guardian address, signatures, and serialized delegate observations. | +| node/pkg/processor/observation.go:119:35:119:38 | hash | Delegate observation quorum bucket key must be the reconstructed MessagePublication VAA signing digest; use CreateDigest or equivalent SigningDigest and exclude per-guardian/non-VAA fields such as IsReobservation, TxID, guardian address, signatures, and serialized delegate observations. | +| node/pkg/processor/observation.go:128:35:128:38 | hash | Delegate observation quorum bucket key must be the reconstructed MessagePublication VAA signing digest; use CreateDigest or equivalent SigningDigest and exclude per-guardian/non-VAA fields such as IsReobservation, TxID, guardian address, signatures, and serialized delegate observations. | +| node/pkg/processor/observation.go:133:35:133:38 | hash | Delegate observation quorum bucket key must be the reconstructed MessagePublication VAA signing digest; use CreateDigest or equivalent SigningDigest and exclude per-guardian/non-VAA fields such as IsReobservation, TxID, guardian address, signatures, and serialized delegate observations. | +| node/pkg/processor/observation.go:142:35:142:38 | hash | Delegate observation quorum bucket key must be the reconstructed MessagePublication VAA signing digest; use CreateDigest or equivalent SigningDigest and exclude per-guardian/non-VAA fields such as IsReobservation, TxID, guardian address, signatures, and serialized delegate observations. | +| node/pkg/processor/observation.go:149:10:149:13 | hash | Delegate observation quorum bucket key must be the reconstructed MessagePublication VAA signing digest; use CreateDigest or equivalent SigningDigest and exclude per-guardian/non-VAA fields such as IsReobservation, TxID, guardian address, signatures, and serialized delegate observations. | +| node/pkg/processor/observation.go:155:35:155:38 | hash | Delegate observation quorum bucket key must be the reconstructed MessagePublication VAA signing digest; use CreateDigest or equivalent SigningDigest and exclude per-guardian/non-VAA fields such as IsReobservation, TxID, guardian address, signatures, and serialized delegate observations. | +| node/pkg/processor/observation.go:167:35:167:38 | hash | Delegate observation quorum bucket key must be the reconstructed MessagePublication VAA signing digest; use CreateDigest or equivalent SigningDigest and exclude per-guardian/non-VAA fields such as IsReobservation, TxID, guardian address, signatures, and serialized delegate observations. | +| node/pkg/processor/observation.go:177:34:177:37 | hash | Delegate observation quorum bucket key must be the reconstructed MessagePublication VAA signing digest; use CreateDigest or equivalent SigningDigest and exclude per-guardian/non-VAA fields such as IsReobservation, TxID, guardian address, signatures, and serialized delegate observations. | +| node/pkg/processor/observation.go:183:24:183:27 | hash | Delegate observation quorum bucket key must be the reconstructed MessagePublication VAA signing digest; use CreateDigest or equivalent SigningDigest and exclude per-guardian/non-VAA fields such as IsReobservation, TxID, guardian address, signatures, and serialized delegate observations. | diff --git a/codeql/test/delegate-consensus-canonical-digest/delegate-consensus-canonical-digest.qlref b/codeql/test/delegate-consensus-canonical-digest/delegate-consensus-canonical-digest.qlref new file mode 100644 index 0000000000..7ae5f0f67c --- /dev/null +++ b/codeql/test/delegate-consensus-canonical-digest/delegate-consensus-canonical-digest.qlref @@ -0,0 +1 @@ +../../src/delegate-consensus-canonical-digest.ql diff --git a/codeql/test/delegate-consensus-canonical-digest/go.mod b/codeql/test/delegate-consensus-canonical-digest/go.mod new file mode 100644 index 0000000000..6b5170c363 --- /dev/null +++ b/codeql/test/delegate-consensus-canonical-digest/go.mod @@ -0,0 +1,3 @@ +module github.com/wormhole-foundation/wormhole + +go 1.22 diff --git a/codeql/test/delegate-consensus-canonical-digest/node/pkg/common/chainlock.go b/codeql/test/delegate-consensus-canonical-digest/node/pkg/common/chainlock.go new file mode 100644 index 0000000000..b579b92cc3 --- /dev/null +++ b/codeql/test/delegate-consensus-canonical-digest/node/pkg/common/chainlock.go @@ -0,0 +1,36 @@ +package common + +type Address [32]byte + +func (a Address) Hex() string { return "addr" } + +type MessagePublication struct { + Timestamp uint32 + Nonce uint32 + Sequence uint64 + ConsistencyLevel uint8 + EmitterChain uint16 + EmitterAddress Address + Payload []byte + IsReobservation bool + Unreliable bool + verificationState string + TxID []byte + TxHash []byte +} + +type Hash struct{ b []byte } + +func (h Hash) Bytes() []byte { return h.b } +func (h Hash) Hex() string { return "hash" } + +type VAA struct{ GuardianSetIndex uint32 } + +func (v *VAA) SigningDigest() Hash { return Hash{} } + +func (m *MessagePublication) CreateVAA(index uint32) *VAA { return &VAA{GuardianSetIndex: index} } +func (m *MessagePublication) CreateDigest() string { return "digest" } +func (m *MessagePublication) MarshalBinary() ([]byte, error) { return m.Payload, nil } +func (m *MessagePublication) Marshal() ([]byte, error) { return m.Payload, nil } +func (m *MessagePublication) MessageIDString() string { return "chain/address/sequence" } +func (m *MessagePublication) NormalizeForDelegateConsensus() {} diff --git a/codeql/test/delegate-consensus-canonical-digest/node/pkg/crypto/crypto.go b/codeql/test/delegate-consensus-canonical-digest/node/pkg/crypto/crypto.go new file mode 100644 index 0000000000..b3d742c00d --- /dev/null +++ b/codeql/test/delegate-consensus-canonical-digest/node/pkg/crypto/crypto.go @@ -0,0 +1,7 @@ +package crypto + +type Hash struct{} + +func (h Hash) Hex() string { return "hash" } + +func Keccak256Hash(data []byte) Hash { return Hash{} } diff --git a/codeql/test/delegate-consensus-canonical-digest/node/pkg/notary/notary.go b/codeql/test/delegate-consensus-canonical-digest/node/pkg/notary/notary.go new file mode 100644 index 0000000000..966d0bc5be --- /dev/null +++ b/codeql/test/delegate-consensus-canonical-digest/node/pkg/notary/notary.go @@ -0,0 +1,11 @@ +package notary + +import ( + whcommon "github.com/wormhole-foundation/wormhole/node/pkg/common" + whcrypto "github.com/wormhole-foundation/wormhole/node/pkg/crypto" +) + +func serializationOutsideProcessor(mp *whcommon.MessagePublication) string { + buf, _ := mp.MarshalBinary() + return whcrypto.Keccak256Hash(buf).Hex() +} diff --git a/codeql/test/delegate-consensus-canonical-digest/node/pkg/processor/observation.go b/codeql/test/delegate-consensus-canonical-digest/node/pkg/processor/observation.go new file mode 100644 index 0000000000..83a609d759 --- /dev/null +++ b/codeql/test/delegate-consensus-canonical-digest/node/pkg/processor/observation.go @@ -0,0 +1,221 @@ +package processor + +import ( + "encoding/hex" + "fmt" + + whcommon "github.com/wormhole-foundation/wormhole/node/pkg/common" + whcrypto "github.com/wormhole-foundation/wormhole/node/pkg/crypto" + gproto "github.com/wormhole-foundation/wormhole/node/pkg/proto" +) + +type DelegateObservation struct { + IsReobservation bool + Unreliable bool + TxID []byte + TxHash []byte + GuardianAddr whcommon.Address + GuardianAddress whcommon.Address + Signature []byte + Signatures [][]byte +} + +type delegateObservationState struct { + observations map[string]*DelegateObservation +} + +type delegateObservationMap map[string]*delegateObservationState + +type delegateState struct { + observations delegateObservationMap +} + +type processor struct { + delegateState *delegateState + state *state +} + +type state struct { + signatures map[string]int +} + +func delegateObservationToMessagePublication(m *DelegateObservation) (*whcommon.MessagePublication, error) { + return &whcommon.MessagePublication{TxID: m.TxID, TxHash: m.TxHash, IsReobservation: m.IsReobservation, Unreliable: m.Unreliable}, nil +} + +func (p *processor) currentSignedDelegateEntryPoint(m DelegateObservation) { + mp, _ := delegateObservationToMessagePublication(&m) + hash := mp.CreateDigest() + _ = p.delegateState.observations[hash] +} + +func (p *processor) currentCanonicalDelegateEntryPoint(mp *whcommon.MessagePublication) { + hash := mp.CreateDigest() + s := p.delegateState.observations[hash] + if s == nil { + p.delegateState.observations[hash] = &delegateObservationState{} + } +} + +func (p *processor) equivalentCreateVAASigningDigest(mp *whcommon.MessagePublication) { + v := mp.CreateVAA(17) + hash := hex.EncodeToString(v.SigningDigest().Bytes()) + _ = p.delegateState.observations[hash] +} + +func delegateConsensusDigest(mp *whcommon.MessagePublication) string { + return mp.CreateDigest() +} + +func (p *processor) thinCompliantHelper(mp *whcommon.MessagePublication) { + hash := delegateConsensusDigest(mp) + _ = p.delegateState.observations[hash] +} + +type unrelatedDigestBuilder struct{} + +func (unrelatedDigestBuilder) SigningDigest() whcommon.Hash { return whcommon.Hash{} } + +func (p *processor) unrelatedSameNameSigningDigestNearMiss(b unrelatedDigestBuilder) { + hash := hex.EncodeToString(b.SigningDigest().Bytes()) + _ = p.delegateState.observations[hash] +} + +func (p *processor) historicalMarshalBinaryKeccakShape(mp *whcommon.MessagePublication) { + buf, _ := mp.MarshalBinary() + hash := whcrypto.Keccak256Hash(buf).Hex() + _ = p.delegateState.observations[hash] +} + +func (p *processor) deprecatedFullMessageMarshal(mp *whcommon.MessagePublication) { + buf, _ := mp.Marshal() + hash := whcrypto.Keccak256Hash(buf).Hex() + _ = p.delegateState.observations[hash] +} + +func (p *processor) serializedDelegateObservation(m *DelegateObservation) { + buf, _ := gproto.Marshal(m) + hash := whcrypto.Keccak256Hash(buf).Hex() + _ = p.delegateState.observations[hash] +} + +func (p *processor) signedWrapperSignatureConfusion(m *DelegateObservation) { + hash := hex.EncodeToString(m.Signature) + _ = p.delegateState.observations[hash] +} + +func (p *processor) explicitIsReobservationDiscriminator(mp *whcommon.MessagePublication, m *DelegateObservation) { + hash := mp.CreateDigest() + fmt.Sprint(m.IsReobservation) + _ = p.delegateState.observations[hash] +} + +func (p *processor) explicitTxIDDiscriminator(mp *whcommon.MessagePublication, m *DelegateObservation) { + hash := mp.CreateDigest() + hex.EncodeToString(m.TxHash) + _ = p.delegateState.observations[hash] +} + +func (p *processor) guardianAddressDiscriminator(mp *whcommon.MessagePublication, m *DelegateObservation) { + hash := fmt.Sprintf("%s/%s", mp.CreateDigest(), m.GuardianAddr.Hex()) + _ = p.delegateState.observations[hash] +} + +func manualDigestWithNonVAAField(mp *whcommon.MessagePublication, m *DelegateObservation) string { + return fmt.Sprintf("%d/%d/%x/%t", mp.Timestamp, mp.Sequence, mp.Payload, m.Unreliable) +} + +func (p *processor) manualDigestAddsNonVAAField(mp *whcommon.MessagePublication, m *DelegateObservation) { + hash := manualDigestWithNonVAAField(mp, m) + _ = p.delegateState.observations[hash] +} + +func (p *processor) messageIDStringOmitFields(mp *whcommon.MessagePublication) { + hash := mp.MessageIDString() + _ = p.delegateState.observations[hash] +} + +func (p *processor) fallbackToSerializedDelegate(mp *whcommon.MessagePublication, m *DelegateObservation) { + hash := mp.CreateDigest() + if hash == "" { + buf, _ := gproto.Marshal(m) + hash = whcrypto.Keccak256Hash(buf).Hex() + } + _ = p.delegateState.observations[hash] +} + +func (p *processor) aliasOfDelegateMap(mp *whcommon.MessagePublication) { + buf, _ := mp.MarshalBinary() + hash := whcrypto.Keccak256Hash(buf).Hex() + obs := p.delegateState.observations + _ = obs[hash] +} + +func (p *processor) reassignedKeyFromNonVAAField(mp *whcommon.MessagePublication, m *DelegateObservation) { + hash := mp.CreateDigest() + hash = hex.EncodeToString(m.TxID) + _ = p.delegateState.observations[hash] +} + +func hashSerializedMP(mp *whcommon.MessagePublication) []byte { + buf, _ := mp.MarshalBinary() + return buf +} + +func hashBytes(buf []byte) string { return whcrypto.Keccak256Hash(buf).Hex() } + +func (p *processor) keyAcrossTwoHelpers(mp *whcommon.MessagePublication) { + hash := hashBytes(hashSerializedMP(mp)) + _ = p.delegateState.observations[hash] +} + +func (d *delegateState) getOrCreate(hash string) *delegateObservationState { + return d.observations[hash] +} + +func (p *processor) wrapperMapTypeCallArg(mp *whcommon.MessagePublication) { + buf, _ := mp.MarshalBinary() + hash := whcrypto.Keccak256Hash(buf).Hex() + _ = p.delegateState.getOrCreate(hash) +} + +func (d *delegateState) getOrCreateInternal(mp *whcommon.MessagePublication, m *DelegateObservation) *delegateObservationState { + buf, _ := gproto.Marshal(m) + hash := whcrypto.Keccak256Hash(buf).Hex() + return d.observations[hash] +} + +func (p *processor) helperComputesKeyInternally(mp *whcommon.MessagePublication, m *DelegateObservation) { + _ = p.delegateState.getOrCreateInternal(mp, m) +} + +func (p *processor) postBucketTxIDWarning(mp *whcommon.MessagePublication, m *DelegateObservation) { + s := p.delegateState.observations[mp.CreateDigest()] + if s != nil && len(m.TxHash) > 0 { + _ = fmt.Sprintf("warn %x", m.TxHash) + } +} + +func (p *processor) postQuorumDeterministicTxID(s *delegateObservationState) { + for _, obs := range s.observations { + _ = obs.TxID + } +} + +func (p *processor) metadataNormalization(mp *whcommon.MessagePublication) { + mp.NormalizeForDelegateConsensus() +} + +func (p *processor) loggingMetricsOnly(mp *whcommon.MessagePublication, m *DelegateObservation) { + buf, _ := mp.MarshalBinary() + _ = fmt.Sprintf("%x %x %t", buf, m.TxID, m.IsReobservation) +} + +func (p *processor) replayDedupAuthenticationOnly(m *DelegateObservation) { + buf, _ := gproto.Marshal(m) + _ = whcrypto.Keccak256Hash(buf).Hex() +} + +func (p *processor) ordinaryCanonicalAggregation(mp *whcommon.MessagePublication) { + v := mp.CreateVAA(0) + hash := hex.EncodeToString(v.SigningDigest().Bytes()) + p.state.signatures[hash]++ +} diff --git a/codeql/test/delegate-consensus-canonical-digest/node/pkg/processor/observation_test.go b/codeql/test/delegate-consensus-canonical-digest/node/pkg/processor/observation_test.go new file mode 100644 index 0000000000..48500c4ab3 --- /dev/null +++ b/codeql/test/delegate-consensus-canonical-digest/node/pkg/processor/observation_test.go @@ -0,0 +1,12 @@ +package processor + +import ( + whcommon "github.com/wormhole-foundation/wormhole/node/pkg/common" + whcrypto "github.com/wormhole-foundation/wormhole/node/pkg/crypto" +) + +func testHistoricalFixtureIsOutOfScope(p *processor, mp *whcommon.MessagePublication) { + buf, _ := mp.MarshalBinary() + hash := whcrypto.Keccak256Hash(buf).Hex() + _ = p.delegateState.observations[hash] +} diff --git a/codeql/test/delegate-consensus-canonical-digest/node/pkg/proto/proto.go b/codeql/test/delegate-consensus-canonical-digest/node/pkg/proto/proto.go new file mode 100644 index 0000000000..df7e945ff6 --- /dev/null +++ b/codeql/test/delegate-consensus-canonical-digest/node/pkg/proto/proto.go @@ -0,0 +1,3 @@ +package proto + +func Marshal(v interface{}) ([]byte, error) { return nil, nil } diff --git a/codeql/test/evm-finality-release-and-reorg-checks/ethereum/errors.go b/codeql/test/evm-finality-release-and-reorg-checks/ethereum/errors.go new file mode 100644 index 0000000000..c328e9d615 --- /dev/null +++ b/codeql/test/evm-finality-release-and-reorg-checks/ethereum/errors.go @@ -0,0 +1,5 @@ +package ethereum + +import "errors" + +var NotFound = errors.New("not found") diff --git a/codeql/test/evm-finality-release-and-reorg-checks/evm-finality-release-and-reorg-checks.expected b/codeql/test/evm-finality-release-and-reorg-checks/evm-finality-release-and-reorg-checks.expected new file mode 100644 index 0000000000..fbb90b0000 --- /dev/null +++ b/codeql/test/evm-finality-release-and-reorg-checks/evm-finality-release-and-reorg-checks.expected @@ -0,0 +1,67 @@ +| node/pkg/watchers/evm/positive.go:37:10:37:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: effective consistency-level match. | +| node/pkg/watchers/evm/positive.go:37:10:37:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: height threshold. | +| node/pkg/watchers/evm/positive.go:70:10:70:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: effective consistency-level match. | +| node/pkg/watchers/evm/positive.go:70:10:70:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: height threshold. | +| node/pkg/watchers/evm/positive.go:101:10:101:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: effective consistency-level match. | +| node/pkg/watchers/evm/positive.go:101:10:101:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: height threshold. | +| node/pkg/watchers/evm/positive.go:127:10:127:91 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: effective consistency-level match. | +| node/pkg/watchers/evm/positive.go:127:10:127:91 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: height threshold. | +| node/pkg/watchers/evm/positive.go:127:10:127:91 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: receipt refetch. | +| node/pkg/watchers/evm/positive.go:157:10:157:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: effective consistency-level match. | +| node/pkg/watchers/evm/positive.go:157:10:157:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: height threshold. | +| node/pkg/watchers/evm/positive.go:157:10:157:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: not-found/orphaned receipt rejection. | +| node/pkg/watchers/evm/positive.go:187:10:187:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: effective consistency-level match. | +| node/pkg/watchers/evm/positive.go:187:10:187:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: height threshold. | +| node/pkg/watchers/evm/positive.go:187:10:187:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: tx-hash match. | +| node/pkg/watchers/evm/positive.go:217:10:217:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: block-hash match. | +| node/pkg/watchers/evm/positive.go:217:10:217:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: effective consistency-level match. | +| node/pkg/watchers/evm/positive.go:217:10:217:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: height threshold. | +| node/pkg/watchers/evm/positive.go:252:10:252:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: block-hash match. | +| node/pkg/watchers/evm/positive.go:252:10:252:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: effective consistency-level match. | +| node/pkg/watchers/evm/positive.go:252:10:252:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: height threshold. | +| node/pkg/watchers/evm/positive.go:286:10:286:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: effective consistency-level match. | +| node/pkg/watchers/evm/positive.go:286:10:286:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: generic receipt error rejection. | +| node/pkg/watchers/evm/positive.go:286:10:286:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: height threshold. | +| node/pkg/watchers/evm/positive.go:320:10:320:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: block-hash match. | +| node/pkg/watchers/evm/positive.go:320:10:320:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: effective consistency-level match. | +| node/pkg/watchers/evm/positive.go:320:10:320:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: height threshold. | +| node/pkg/watchers/evm/positive.go:320:10:320:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: nil receipt rejection. | +| node/pkg/watchers/evm/positive.go:320:10:320:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: tx-hash match. | +| node/pkg/watchers/evm/positive.go:347:17:347:101 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: effective consistency-level match. | +| node/pkg/watchers/evm/positive.go:347:17:347:101 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: generic receipt error rejection. | +| node/pkg/watchers/evm/positive.go:347:17:347:101 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: height threshold. | +| node/pkg/watchers/evm/positive.go:347:17:347:101 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: nil receipt rejection. | +| node/pkg/watchers/evm/positive.go:389:10:389:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: block-hash match. | +| node/pkg/watchers/evm/positive.go:389:10:389:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: effective consistency-level match. | +| node/pkg/watchers/evm/positive.go:389:10:389:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: height threshold. | +| node/pkg/watchers/evm/positive.go:423:10:423:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: effective consistency-level match. | +| node/pkg/watchers/evm/positive.go:423:10:423:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: height threshold. | +| node/pkg/watchers/evm/positive.go:423:10:423:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: receipt refetch. | +| node/pkg/watchers/evm/positive.go:457:10:457:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: effective consistency-level match. | +| node/pkg/watchers/evm/positive.go:457:10:457:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: height threshold. | +| node/pkg/watchers/evm/positive.go:491:10:491:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: effective consistency-level match. | +| node/pkg/watchers/evm/positive.go:491:10:491:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: height threshold. | +| node/pkg/watchers/evm/positive.go:525:10:525:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: effective consistency-level match. | +| node/pkg/watchers/evm/positive.go:525:10:525:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: height threshold. | +| node/pkg/watchers/evm/positive.go:559:10:559:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: effective consistency-level match. | +| node/pkg/watchers/evm/positive.go:559:10:559:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: height threshold. | +| node/pkg/watchers/evm/positive.go:592:10:592:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: effective consistency-level match. | +| node/pkg/watchers/evm/positive.go:592:10:592:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: height threshold. | +| node/pkg/watchers/evm/positive.go:592:10:592:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: tx-hash match. | +| node/pkg/watchers/evm/positive.go:625:10:625:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: effective consistency-level match. | +| node/pkg/watchers/evm/positive.go:625:10:625:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: height threshold. | +| node/pkg/watchers/evm/positive.go:625:10:625:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: tx-hash match. | +| node/pkg/watchers/evm/positive.go:658:10:658:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: block-hash match. | +| node/pkg/watchers/evm/positive.go:658:10:658:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: effective consistency-level match. | +| node/pkg/watchers/evm/positive.go:658:10:658:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: height threshold. | +| node/pkg/watchers/evm/positive.go:691:10:691:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: block-hash match. | +| node/pkg/watchers/evm/positive.go:691:10:691:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: effective consistency-level match. | +| node/pkg/watchers/evm/positive.go:691:10:691:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: height threshold. | +| node/pkg/watchers/evm/positive.go:731:10:731:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: effective consistency-level match. | +| node/pkg/watchers/evm/positive.go:770:10:770:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: effective consistency-level match. | +| node/pkg/watchers/evm/positive.go:809:10:809:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: height threshold. | +| node/pkg/watchers/evm/positive.go:844:10:844:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: effective consistency-level match. | +| node/pkg/watchers/evm/positive.go:844:10:844:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: height threshold. | +| node/pkg/watchers/evm/positive.go:882:10:882:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: same-watcher w.pending range. | +| node/pkg/watchers/evm/positive.go:922:10:922:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: effective consistency-level match. | +| node/pkg/watchers/evm/positive.go:962:10:962:94 | call to verifyAndPublish | EVM pending-message release must reach the effective consistency level and height threshold, refetch the receipt, and fail closed on not-found, generic error, nil receipt, tx-hash mismatch, and block-hash mismatch before verifyAndPublish. Missing proof: tx-hash match. | diff --git a/codeql/test/evm-finality-release-and-reorg-checks/evm-finality-release-and-reorg-checks.qlref b/codeql/test/evm-finality-release-and-reorg-checks/evm-finality-release-and-reorg-checks.qlref new file mode 100644 index 0000000000..206fc5f7ef --- /dev/null +++ b/codeql/test/evm-finality-release-and-reorg-checks/evm-finality-release-and-reorg-checks.qlref @@ -0,0 +1 @@ +../../src/evm-finality-release-and-reorg-checks.ql diff --git a/codeql/test/evm-finality-release-and-reorg-checks/gethtypes/types.go b/codeql/test/evm-finality-release-and-reorg-checks/gethtypes/types.go new file mode 100644 index 0000000000..1b3aeb4484 --- /dev/null +++ b/codeql/test/evm-finality-release-and-reorg-checks/gethtypes/types.go @@ -0,0 +1,13 @@ +package gethtypes + +type Hash [32]byte + +type Receipt struct { + TxHash Hash + BlockHash Hash + Status uint64 +} + +const ReceiptStatusSuccessful = 1 + +func BytesToHash(b []byte) Hash { return Hash{} } diff --git a/codeql/test/evm-finality-release-and-reorg-checks/go.mod b/codeql/test/evm-finality-release-and-reorg-checks/go.mod new file mode 100644 index 0000000000..7eec094e1e --- /dev/null +++ b/codeql/test/evm-finality-release-and-reorg-checks/go.mod @@ -0,0 +1,3 @@ +module codeql/evmfinalityrelease + +go 1.22 diff --git a/codeql/test/evm-finality-release-and-reorg-checks/node/pkg/common/message.go b/codeql/test/evm-finality-release-and-reorg-checks/node/pkg/common/message.go new file mode 100644 index 0000000000..5a728720f4 --- /dev/null +++ b/codeql/test/evm-finality-release-and-reorg-checks/node/pkg/common/message.go @@ -0,0 +1,6 @@ +package common + +type MessagePublication struct { + TxID []byte + ConsistencyLevel uint8 +} diff --git a/codeql/test/evm-finality-release-and-reorg-checks/node/pkg/watchers/evm/negative.go b/codeql/test/evm-finality-release-and-reorg-checks/node/pkg/watchers/evm/negative.go new file mode 100644 index 0000000000..8ceac27840 --- /dev/null +++ b/codeql/test/evm-finality-release-and-reorg-checks/node/pkg/watchers/evm/negative.go @@ -0,0 +1,140 @@ +package evm + +import ( + "context" + "errors" + + "codeql/evmfinalityrelease/ethereum" + "codeql/evmfinalityrelease/gethtypes" + "codeql/evmfinalityrelease/rpc" +) + +func negativeCanonicalPendingRelease(w *Watcher, ctx context.Context, ev NewBlock) error { + blockNumberU := ev.Number + thisConsistencyLevel := ConsistencyLevelFinalized + if ev.Finality == Safe { + thisConsistencyLevel = ConsistencyLevelSafe + } + + for key, pLock := range w.pending { + if !consistencyLevelMatches(thisConsistencyLevel, pLock.effectiveCL) { + continue + } + if pLock.height+pLock.additionalBlocks > blockNumberU { + continue + } + txreceipt, err := w.ethConn.TransactionReceipt(ctx, gethtypes.BytesToHash(pLock.message.TxID)) + if errors.Is(err, rpc.ErrNoResult) || errors.Is(err, ethereum.NotFound) { + delete(w.pending, key) + continue + } + if err != nil { + continue + } + if txreceipt == nil { + delete(w.pending, key) + continue + } + expectedTxHash := gethtypes.BytesToHash(pLock.message.TxID) + if txreceipt.TxHash != expectedTxHash { + delete(w.pending, key) + continue + } + if txreceipt.BlockHash != key.BlockHash { + delete(w.pending, key) + continue + } + txHash := gethtypes.Hash(pLock.message.TxID) + if err := w.verifyAndPublish(pLock.message, ctx, txHash, txreceipt); err != nil { + return err + } + } + return nil +} + +func negativeReceiptSuccessDelegated(w *Watcher, ctx context.Context, ev NewBlock) error { + blockNumberU := ev.Number + thisConsistencyLevel := ConsistencyLevelFinalized + if ev.Finality == Safe { + thisConsistencyLevel = ConsistencyLevelSafe + } + for key, pLock := range w.pending { + if !consistencyLevelMatches(thisConsistencyLevel, pLock.effectiveCL) { + continue + } + if pLock.height+pLock.additionalBlocks > blockNumberU { + continue + } + txreceipt, err := w.ethConn.TransactionReceipt(ctx, gethtypes.BytesToHash(pLock.message.TxID)) + if errors.Is(err, rpc.ErrNoResult) || errors.Is(err, ethereum.NotFound) { + delete(w.pending, key) + continue + } + if err != nil { + continue + } + if txreceipt == nil { + delete(w.pending, key) + continue + } + if txreceipt.TxHash != gethtypes.BytesToHash(pLock.message.TxID) { + delete(w.pending, key) + continue + } + if txreceipt.BlockHash != key.BlockHash { + delete(w.pending, key) + continue + } + return w.verifyAndPublish(pLock.message, ctx, gethtypes.Hash(pLock.message.TxID), txreceipt) + } + return nil +} + +func negativeInstantPublicationExcluded(w *Watcher, ctx context.Context, msgTx gethtypes.Hash) error { + msg := &struct{ TxID []byte }{} + receipt, err := w.ethConn.TransactionReceipt(ctx, msgTx) + if err != nil || receipt == nil { + return err + } + _ = msg + return nil +} + +func negativeRenamedAliasesAndSafeHeight(w *Watcher, ctx context.Context, ev NewBlock) error { + blockNumberU := ev.Number + thisConsistencyLevel := ConsistencyLevelFinalized + if ev.Finality == Safe { + thisConsistencyLevel = ConsistencyLevelSafe + } + for pendingKeyAlias, pendingAlias := range w.pending { + if !consistencyLevelMatches(thisConsistencyLevel, pendingAlias.effectiveCL) { + continue + } + if blockNumberU < pendingAlias.height+pendingAlias.additionalBlocks { + continue + } + gotReceipt, fetchProblem := w.ethConn.TransactionReceipt(ctx, gethtypes.BytesToHash(pendingAlias.message.TxID)) + if errors.Is(fetchProblem, rpc.ErrNoResult) || errors.Is(fetchProblem, ethereum.NotFound) { + delete(w.pending, pendingKeyAlias) + continue + } + if fetchProblem != nil { + continue + } + if gotReceipt == nil { + delete(w.pending, pendingKeyAlias) + continue + } + wantHash := gethtypes.BytesToHash(pendingAlias.message.TxID) + if gotReceipt.TxHash != wantHash { + delete(w.pending, pendingKeyAlias) + continue + } + if gotReceipt.BlockHash != pendingKeyAlias.BlockHash { + delete(w.pending, pendingKeyAlias) + continue + } + return w.verifyAndPublish(pendingAlias.message, ctx, gethtypes.Hash(pendingAlias.message.TxID), gotReceipt) + } + return nil +} diff --git a/codeql/test/evm-finality-release-and-reorg-checks/node/pkg/watchers/evm/positive.go b/codeql/test/evm-finality-release-and-reorg-checks/node/pkg/watchers/evm/positive.go new file mode 100644 index 0000000000..43fcd4a095 --- /dev/null +++ b/codeql/test/evm-finality-release-and-reorg-checks/node/pkg/watchers/evm/positive.go @@ -0,0 +1,965 @@ +package evm + +import ( + "context" + "errors" + + "codeql/evmfinalityrelease/ethereum" + "codeql/evmfinalityrelease/gethtypes" + "codeql/evmfinalityrelease/rpc" +) + +func positiveMissingConsistencyGuard(w *Watcher, ctx context.Context, ev NewBlock) error { + for key, pLock := range w.pending { + if pLock.height+pLock.additionalBlocks > ev.Number { + continue + } + txreceipt, err := w.ethConn.TransactionReceipt(ctx, gethtypes.BytesToHash(pLock.message.TxID)) + if errors.Is(err, rpc.ErrNoResult) || errors.Is(err, ethereum.NotFound) { + delete(w.pending, key) + continue + } + if err != nil { + continue + } + if txreceipt == nil { + delete(w.pending, key) + continue + } + if txreceipt.TxHash != gethtypes.BytesToHash(pLock.message.TxID) { + delete(w.pending, key) + continue + } + if txreceipt.BlockHash != key.BlockHash { + delete(w.pending, key) + continue + } + return w.verifyAndPublish(pLock.message, ctx, gethtypes.Hash(pLock.message.TxID), txreceipt) + } + return nil +} + +func positiveWrongConsistencySource(w *Watcher, ctx context.Context, ev NewBlock) error { + for key, pLock := range w.pending { + if !consistencyLevelMatches(ConsistencyLevelFinalized, pLock.message.ConsistencyLevel) { + continue + } + if pLock.height+pLock.additionalBlocks > ev.Number { + continue + } + txreceipt, err := w.ethConn.TransactionReceipt(ctx, gethtypes.BytesToHash(pLock.message.TxID)) + if errors.Is(err, rpc.ErrNoResult) || errors.Is(err, ethereum.NotFound) { + delete(w.pending, key) + continue + } + if err != nil { + continue + } + if txreceipt == nil { + delete(w.pending, key) + continue + } + if txreceipt.TxHash != gethtypes.BytesToHash(pLock.message.TxID) { + delete(w.pending, key) + continue + } + if txreceipt.BlockHash != key.BlockHash { + delete(w.pending, key) + continue + } + return w.verifyAndPublish(pLock.message, ctx, gethtypes.Hash(pLock.message.TxID), txreceipt) + } + return nil +} + +func positiveMissingHeightGuard(w *Watcher, ctx context.Context, ev NewBlock) error { + blockCL := currentBlockConsistencyLevel(ev) + for key, pLock := range w.pending { + if !consistencyLevelMatches(blockCL, pLock.effectiveCL) { + continue + } + txreceipt, err := w.ethConn.TransactionReceipt(ctx, gethtypes.BytesToHash(pLock.message.TxID)) + if errors.Is(err, rpc.ErrNoResult) || errors.Is(err, ethereum.NotFound) { + delete(w.pending, key) + continue + } + if err != nil { + continue + } + if txreceipt == nil { + delete(w.pending, key) + continue + } + if txreceipt.TxHash != gethtypes.BytesToHash(pLock.message.TxID) { + delete(w.pending, key) + continue + } + if txreceipt.BlockHash != key.BlockHash { + delete(w.pending, key) + continue + } + return w.verifyAndPublish(pLock.message, ctx, gethtypes.Hash(pLock.message.TxID), txreceipt) + } + return nil +} + +func positiveNoReceiptRefetch(w *Watcher, ctx context.Context, cached *gethtypes.Receipt) error { + blockCL := ConsistencyLevelFinalized + for key, pLock := range w.pending { + if !consistencyLevelMatches(blockCL, pLock.effectiveCL) { + continue + } + if pLock.height+pLock.additionalBlocks > 100 { + continue + } + if cached == nil { + delete(w.pending, key) + continue + } + if cached.TxHash != gethtypes.BytesToHash(pLock.message.TxID) { + delete(w.pending, key) + continue + } + if cached.BlockHash != key.BlockHash { + delete(w.pending, key) + continue + } + return w.verifyAndPublish(pLock.message, ctx, gethtypes.Hash(pLock.message.TxID), cached) + } + return nil +} + +func positiveMissingNotFoundRejection(w *Watcher, ctx context.Context) error { + blockCL := ConsistencyLevelFinalized + for key, pLock := range w.pending { + if !consistencyLevelMatches(blockCL, pLock.effectiveCL) { + continue + } + if pLock.height+pLock.additionalBlocks > 100 { + continue + } + txreceipt, err := w.ethConn.TransactionReceipt(ctx, gethtypes.BytesToHash(pLock.message.TxID)) + if err != nil { + continue + } + if txreceipt == nil { + delete(w.pending, key) + continue + } + if txreceipt.TxHash != gethtypes.BytesToHash(pLock.message.TxID) { + delete(w.pending, key) + continue + } + if txreceipt.BlockHash != key.BlockHash { + delete(w.pending, key) + continue + } + return w.verifyAndPublish(pLock.message, ctx, gethtypes.Hash(pLock.message.TxID), txreceipt) + } + return nil +} + +func positiveMissingTxHashMatch(w *Watcher, ctx context.Context) error { + blockCL := ConsistencyLevelFinalized + for key, pLock := range w.pending { + if !consistencyLevelMatches(blockCL, pLock.effectiveCL) { + continue + } + if pLock.height+pLock.additionalBlocks > 100 { + continue + } + txreceipt, err := w.ethConn.TransactionReceipt(ctx, gethtypes.BytesToHash(pLock.message.TxID)) + if errors.Is(err, rpc.ErrNoResult) || errors.Is(err, ethereum.NotFound) { + delete(w.pending, key) + continue + } + if err != nil { + continue + } + if txreceipt == nil { + delete(w.pending, key) + continue + } + if txreceipt.BlockHash != key.BlockHash { + delete(w.pending, key) + continue + } + return w.verifyAndPublish(pLock.message, ctx, gethtypes.Hash(pLock.message.TxID), txreceipt) + } + return nil +} + +func positiveMissingBlockHashMatch(w *Watcher, ctx context.Context) error { + blockCL := ConsistencyLevelFinalized + for key, pLock := range w.pending { + if !consistencyLevelMatches(blockCL, pLock.effectiveCL) { + continue + } + if pLock.height+pLock.additionalBlocks > 100 { + continue + } + txreceipt, err := w.ethConn.TransactionReceipt(ctx, gethtypes.BytesToHash(pLock.message.TxID)) + if errors.Is(err, rpc.ErrNoResult) || errors.Is(err, ethereum.NotFound) { + delete(w.pending, key) + continue + } + if err != nil { + continue + } + if txreceipt == nil { + delete(w.pending, key) + continue + } + if txreceipt.TxHash != gethtypes.BytesToHash(pLock.message.TxID) { + delete(w.pending, key) + continue + } + return w.verifyAndPublish(pLock.message, ctx, gethtypes.Hash(pLock.message.TxID), txreceipt) + } + return nil +} + +func positiveWrongBlockHashSource(w *Watcher, ctx context.Context, ev NewBlock) error { + currentBlockHash := gethtypes.Hash{} + blockCL := currentBlockConsistencyLevel(ev) + for key, pLock := range w.pending { + if !consistencyLevelMatches(blockCL, pLock.effectiveCL) { + continue + } + if pLock.height+pLock.additionalBlocks > ev.Number { + continue + } + txreceipt, err := w.ethConn.TransactionReceipt(ctx, gethtypes.BytesToHash(pLock.message.TxID)) + if errors.Is(err, rpc.ErrNoResult) || errors.Is(err, ethereum.NotFound) { + delete(w.pending, key) + continue + } + if err != nil { + continue + } + if txreceipt == nil { + delete(w.pending, key) + continue + } + if txreceipt.TxHash != gethtypes.BytesToHash(pLock.message.TxID) { + delete(w.pending, key) + continue + } + if txreceipt.BlockHash != currentBlockHash { + delete(w.pending, key) + continue + } + return w.verifyAndPublish(pLock.message, ctx, gethtypes.Hash(pLock.message.TxID), txreceipt) + } + return nil +} + +func positiveWrongErrorChecked(w *Watcher, ctx context.Context, ev NewBlock, unrelated error) error { + blockCL := currentBlockConsistencyLevel(ev) + for key, pLock := range w.pending { + if !consistencyLevelMatches(blockCL, pLock.effectiveCL) { + continue + } + if pLock.height+pLock.additionalBlocks > ev.Number { + continue + } + txreceipt, receiptErr := w.ethConn.TransactionReceipt(ctx, gethtypes.BytesToHash(pLock.message.TxID)) + if errors.Is(receiptErr, rpc.ErrNoResult) || errors.Is(receiptErr, ethereum.NotFound) { + delete(w.pending, key) + continue + } + if unrelated != nil { + continue + } + if txreceipt == nil { + delete(w.pending, key) + continue + } + if txreceipt.TxHash != gethtypes.BytesToHash(pLock.message.TxID) { + delete(w.pending, key) + continue + } + if txreceipt.BlockHash != key.BlockHash { + delete(w.pending, key) + continue + } + return w.verifyAndPublish(pLock.message, ctx, gethtypes.Hash(pLock.message.TxID), txreceipt) + } + return nil +} + +func positiveWrongReceiptChecked(w *Watcher, ctx context.Context, ev NewBlock, otherReceipt *gethtypes.Receipt) error { + blockCL := currentBlockConsistencyLevel(ev) + for key, pLock := range w.pending { + if !consistencyLevelMatches(blockCL, pLock.effectiveCL) { + continue + } + if pLock.height+pLock.additionalBlocks > ev.Number { + continue + } + txreceipt, err := w.ethConn.TransactionReceipt(ctx, gethtypes.BytesToHash(pLock.message.TxID)) + if errors.Is(err, rpc.ErrNoResult) || errors.Is(err, ethereum.NotFound) { + delete(w.pending, key) + continue + } + if err != nil { + continue + } + if otherReceipt == nil { + delete(w.pending, key) + continue + } + if otherReceipt.TxHash != gethtypes.BytesToHash(pLock.message.TxID) { + delete(w.pending, key) + continue + } + if otherReceipt.BlockHash != key.BlockHash { + delete(w.pending, key) + continue + } + return w.verifyAndPublish(pLock.message, ctx, gethtypes.Hash(pLock.message.TxID), txreceipt) + } + return nil +} + +func positivePostChecksDoNotDominate(w *Watcher, ctx context.Context, ev NewBlock) error { + blockCL := currentBlockConsistencyLevel(ev) + for key, pLock := range w.pending { + if !consistencyLevelMatches(blockCL, pLock.effectiveCL) { + continue + } + if pLock.height+pLock.additionalBlocks > ev.Number { + continue + } + txreceipt, err := w.ethConn.TransactionReceipt(ctx, gethtypes.BytesToHash(pLock.message.TxID)) + if errors.Is(err, rpc.ErrNoResult) || errors.Is(err, ethereum.NotFound) { + delete(w.pending, key) + continue + } + if txreceipt.TxHash != gethtypes.BytesToHash(pLock.message.TxID) { + delete(w.pending, key) + continue + } + if txreceipt.BlockHash != key.BlockHash { + delete(w.pending, key) + continue + } + publishErr := w.verifyAndPublish(pLock.message, ctx, gethtypes.Hash(pLock.message.TxID), txreceipt) + if err != nil { + continue + } + if txreceipt == nil { + delete(w.pending, key) + continue + } + return publishErr + } + return nil +} + +func positiveWrongPendingKey(w *Watcher, ctx context.Context, ev NewBlock, wrongKey pendingKey) error { + blockCL := currentBlockConsistencyLevel(ev) + for key, pLock := range w.pending { + if !consistencyLevelMatches(blockCL, pLock.effectiveCL) { + continue + } + if pLock.height+pLock.additionalBlocks > ev.Number { + continue + } + txreceipt, err := w.ethConn.TransactionReceipt(ctx, gethtypes.BytesToHash(pLock.message.TxID)) + if errors.Is(err, rpc.ErrNoResult) || errors.Is(err, ethereum.NotFound) { + delete(w.pending, key) + continue + } + if err != nil { + continue + } + if txreceipt == nil { + delete(w.pending, key) + continue + } + if txreceipt.TxHash != gethtypes.BytesToHash(pLock.message.TxID) { + delete(w.pending, key) + continue + } + if txreceipt.BlockHash != wrongKey.BlockHash { + delete(w.pending, key) + continue + } + return w.verifyAndPublish(pLock.message, ctx, gethtypes.Hash(pLock.message.TxID), txreceipt) + } + return nil +} + +func positiveWrongPendingMessageFetch(w *Watcher, ctx context.Context, ev NewBlock, other *pendingMessage) error { + blockCL := currentBlockConsistencyLevel(ev) + for key, pLock := range w.pending { + if !consistencyLevelMatches(blockCL, pLock.effectiveCL) { + continue + } + if pLock.height+pLock.additionalBlocks > ev.Number { + continue + } + txreceipt, err := w.ethConn.TransactionReceipt(ctx, gethtypes.BytesToHash(other.message.TxID)) + if errors.Is(err, rpc.ErrNoResult) || errors.Is(err, ethereum.NotFound) { + delete(w.pending, key) + continue + } + if err != nil { + continue + } + if txreceipt == nil { + delete(w.pending, key) + continue + } + if txreceipt.TxHash != gethtypes.BytesToHash(pLock.message.TxID) { + delete(w.pending, key) + continue + } + if txreceipt.BlockHash != key.BlockHash { + delete(w.pending, key) + continue + } + return w.verifyAndPublish(pLock.message, ctx, gethtypes.Hash(pLock.message.TxID), txreceipt) + } + return nil +} + +func positiveInvertedConsistencyGuard(w *Watcher, ctx context.Context, ev NewBlock) error { + blockCL := currentBlockConsistencyLevel(ev) + for key, pLock := range w.pending { + if consistencyLevelMatches(blockCL, pLock.effectiveCL) { + continue + } + if pLock.height+pLock.additionalBlocks > ev.Number { + continue + } + txreceipt, err := w.ethConn.TransactionReceipt(ctx, gethtypes.BytesToHash(pLock.message.TxID)) + if errors.Is(err, rpc.ErrNoResult) || errors.Is(err, ethereum.NotFound) { + delete(w.pending, key) + continue + } + if err != nil { + continue + } + if txreceipt == nil { + delete(w.pending, key) + continue + } + if txreceipt.TxHash != gethtypes.BytesToHash(pLock.message.TxID) { + delete(w.pending, key) + continue + } + if txreceipt.BlockHash != key.BlockHash { + delete(w.pending, key) + continue + } + return w.verifyAndPublish(pLock.message, ctx, gethtypes.Hash(pLock.message.TxID), txreceipt) + } + return nil +} + +func positiveLogOnlyConsistencyGuard(w *Watcher, ctx context.Context, ev NewBlock) error { + blockCL := currentBlockConsistencyLevel(ev) + for key, pLock := range w.pending { + if !consistencyLevelMatches(blockCL, pLock.effectiveCL) { + delete(w.pending, key) + } + if pLock.height+pLock.additionalBlocks > ev.Number { + continue + } + txreceipt, err := w.ethConn.TransactionReceipt(ctx, gethtypes.BytesToHash(pLock.message.TxID)) + if errors.Is(err, rpc.ErrNoResult) || errors.Is(err, ethereum.NotFound) { + delete(w.pending, key) + continue + } + if err != nil { + continue + } + if txreceipt == nil { + delete(w.pending, key) + continue + } + if txreceipt.TxHash != gethtypes.BytesToHash(pLock.message.TxID) { + delete(w.pending, key) + continue + } + if txreceipt.BlockHash != key.BlockHash { + delete(w.pending, key) + continue + } + return w.verifyAndPublish(pLock.message, ctx, gethtypes.Hash(pLock.message.TxID), txreceipt) + } + return nil +} + +func positiveInvertedHeightGuard(w *Watcher, ctx context.Context, ev NewBlock) error { + blockCL := currentBlockConsistencyLevel(ev) + for key, pLock := range w.pending { + if !consistencyLevelMatches(blockCL, pLock.effectiveCL) { + continue + } + if pLock.height+pLock.additionalBlocks <= ev.Number { + continue + } + txreceipt, err := w.ethConn.TransactionReceipt(ctx, gethtypes.BytesToHash(pLock.message.TxID)) + if errors.Is(err, rpc.ErrNoResult) || errors.Is(err, ethereum.NotFound) { + delete(w.pending, key) + continue + } + if err != nil { + continue + } + if txreceipt == nil { + delete(w.pending, key) + continue + } + if txreceipt.TxHash != gethtypes.BytesToHash(pLock.message.TxID) { + delete(w.pending, key) + continue + } + if txreceipt.BlockHash != key.BlockHash { + delete(w.pending, key) + continue + } + return w.verifyAndPublish(pLock.message, ctx, gethtypes.Hash(pLock.message.TxID), txreceipt) + } + return nil +} + +func positiveLogOnlyHeightGuard(w *Watcher, ctx context.Context, ev NewBlock) error { + blockCL := currentBlockConsistencyLevel(ev) + for key, pLock := range w.pending { + if !consistencyLevelMatches(blockCL, pLock.effectiveCL) { + continue + } + if pLock.height+pLock.additionalBlocks > ev.Number { + delete(w.pending, key) + } + txreceipt, err := w.ethConn.TransactionReceipt(ctx, gethtypes.BytesToHash(pLock.message.TxID)) + if errors.Is(err, rpc.ErrNoResult) || errors.Is(err, ethereum.NotFound) { + delete(w.pending, key) + continue + } + if err != nil { + continue + } + if txreceipt == nil { + delete(w.pending, key) + continue + } + if txreceipt.TxHash != gethtypes.BytesToHash(pLock.message.TxID) { + delete(w.pending, key) + continue + } + if txreceipt.BlockHash != key.BlockHash { + delete(w.pending, key) + continue + } + return w.verifyAndPublish(pLock.message, ctx, gethtypes.Hash(pLock.message.TxID), txreceipt) + } + return nil +} + +func positiveInvertedTxHashGuard(w *Watcher, ctx context.Context, ev NewBlock) error { + blockCL := currentBlockConsistencyLevel(ev) + for key, pLock := range w.pending { + if !consistencyLevelMatches(blockCL, pLock.effectiveCL) { + continue + } + if pLock.height+pLock.additionalBlocks > ev.Number { + continue + } + txreceipt, err := w.ethConn.TransactionReceipt(ctx, gethtypes.BytesToHash(pLock.message.TxID)) + if errors.Is(err, rpc.ErrNoResult) || errors.Is(err, ethereum.NotFound) { + delete(w.pending, key) + continue + } + if err != nil { + continue + } + if txreceipt == nil { + delete(w.pending, key) + continue + } + if txreceipt.TxHash == gethtypes.BytesToHash(pLock.message.TxID) { + continue + } + if txreceipt.BlockHash != key.BlockHash { + delete(w.pending, key) + continue + } + return w.verifyAndPublish(pLock.message, ctx, gethtypes.Hash(pLock.message.TxID), txreceipt) + } + return nil +} + +func positiveLogOnlyTxHashGuard(w *Watcher, ctx context.Context, ev NewBlock) error { + blockCL := currentBlockConsistencyLevel(ev) + for key, pLock := range w.pending { + if !consistencyLevelMatches(blockCL, pLock.effectiveCL) { + continue + } + if pLock.height+pLock.additionalBlocks > ev.Number { + continue + } + txreceipt, err := w.ethConn.TransactionReceipt(ctx, gethtypes.BytesToHash(pLock.message.TxID)) + if errors.Is(err, rpc.ErrNoResult) || errors.Is(err, ethereum.NotFound) { + delete(w.pending, key) + continue + } + if err != nil { + continue + } + if txreceipt == nil { + delete(w.pending, key) + continue + } + if txreceipt.TxHash != gethtypes.BytesToHash(pLock.message.TxID) { + delete(w.pending, key) + } + if txreceipt.BlockHash != key.BlockHash { + delete(w.pending, key) + continue + } + return w.verifyAndPublish(pLock.message, ctx, gethtypes.Hash(pLock.message.TxID), txreceipt) + } + return nil +} + +func positiveInvertedBlockHashGuard(w *Watcher, ctx context.Context, ev NewBlock) error { + blockCL := currentBlockConsistencyLevel(ev) + for key, pLock := range w.pending { + if !consistencyLevelMatches(blockCL, pLock.effectiveCL) { + continue + } + if pLock.height+pLock.additionalBlocks > ev.Number { + continue + } + txreceipt, err := w.ethConn.TransactionReceipt(ctx, gethtypes.BytesToHash(pLock.message.TxID)) + if errors.Is(err, rpc.ErrNoResult) || errors.Is(err, ethereum.NotFound) { + delete(w.pending, key) + continue + } + if err != nil { + continue + } + if txreceipt == nil { + delete(w.pending, key) + continue + } + if txreceipt.TxHash != gethtypes.BytesToHash(pLock.message.TxID) { + delete(w.pending, key) + continue + } + if txreceipt.BlockHash == key.BlockHash { + continue + } + return w.verifyAndPublish(pLock.message, ctx, gethtypes.Hash(pLock.message.TxID), txreceipt) + } + return nil +} + +func positiveLogOnlyBlockHashGuard(w *Watcher, ctx context.Context, ev NewBlock) error { + blockCL := currentBlockConsistencyLevel(ev) + for key, pLock := range w.pending { + if !consistencyLevelMatches(blockCL, pLock.effectiveCL) { + continue + } + if pLock.height+pLock.additionalBlocks > ev.Number { + continue + } + txreceipt, err := w.ethConn.TransactionReceipt(ctx, gethtypes.BytesToHash(pLock.message.TxID)) + if errors.Is(err, rpc.ErrNoResult) || errors.Is(err, ethereum.NotFound) { + delete(w.pending, key) + continue + } + if err != nil { + continue + } + if txreceipt == nil { + delete(w.pending, key) + continue + } + if txreceipt.TxHash != gethtypes.BytesToHash(pLock.message.TxID) { + delete(w.pending, key) + continue + } + if txreceipt.BlockHash != key.BlockHash { + delete(w.pending, key) + } + return w.verifyAndPublish(pLock.message, ctx, gethtypes.Hash(pLock.message.TxID), txreceipt) + } + return nil +} + +func positiveNestedConditionalConsistencyExit(w *Watcher, ctx context.Context, ev NewBlock) error { + blockNumberU := ev.Number + thisConsistencyLevel := ConsistencyLevelFinalized + if ev.Finality == Safe { + thisConsistencyLevel = ConsistencyLevelSafe + } + for key, pLock := range w.pending { + if !consistencyLevelMatches(thisConsistencyLevel, pLock.effectiveCL) { + if blockNumberU > 0 { + continue + } + } + if pLock.height+pLock.additionalBlocks > blockNumberU { + continue + } + txreceipt, err := w.ethConn.TransactionReceipt(ctx, gethtypes.BytesToHash(pLock.message.TxID)) + if errors.Is(err, rpc.ErrNoResult) || errors.Is(err, ethereum.NotFound) { + delete(w.pending, key) + continue + } + if err != nil { + continue + } + if txreceipt == nil { + delete(w.pending, key) + continue + } + if txreceipt.TxHash != gethtypes.BytesToHash(pLock.message.TxID) { + delete(w.pending, key) + continue + } + if txreceipt.BlockHash != key.BlockHash { + delete(w.pending, key) + continue + } + return w.verifyAndPublish(pLock.message, ctx, gethtypes.Hash(pLock.message.TxID), txreceipt) + } + return nil +} + +func positiveStaleConsistencyLevel(w *Watcher, ctx context.Context, ev NewBlock) error { + blockNumberU := ev.Number + thisConsistencyLevel := ConsistencyLevelFinalized + staleConsistencyLevel := thisConsistencyLevel + if ev.Finality == Safe { + thisConsistencyLevel = ConsistencyLevelSafe + } + for key, pLock := range w.pending { + if !consistencyLevelMatches(staleConsistencyLevel, pLock.effectiveCL) { + continue + } + if pLock.height+pLock.additionalBlocks > blockNumberU { + continue + } + txreceipt, err := w.ethConn.TransactionReceipt(ctx, gethtypes.BytesToHash(pLock.message.TxID)) + if errors.Is(err, rpc.ErrNoResult) || errors.Is(err, ethereum.NotFound) { + delete(w.pending, key) + continue + } + if err != nil { + continue + } + if txreceipt == nil { + delete(w.pending, key) + continue + } + if txreceipt.TxHash != gethtypes.BytesToHash(pLock.message.TxID) { + delete(w.pending, key) + continue + } + if txreceipt.BlockHash != key.BlockHash { + delete(w.pending, key) + continue + } + return w.verifyAndPublish(pLock.message, ctx, gethtypes.Hash(pLock.message.TxID), txreceipt) + } + return nil +} + +func positiveWrongBlockNumber(w *Watcher, ctx context.Context, ev NewBlock) error { + blockNumberU := ev.Number + wrongBlockNumberU := blockNumberU + 1 + thisConsistencyLevel := ConsistencyLevelFinalized + if ev.Finality == Safe { + thisConsistencyLevel = ConsistencyLevelSafe + } + for key, pLock := range w.pending { + if !consistencyLevelMatches(thisConsistencyLevel, pLock.effectiveCL) { + continue + } + if pLock.height+pLock.additionalBlocks > wrongBlockNumberU { + continue + } + txreceipt, err := w.ethConn.TransactionReceipt(ctx, gethtypes.BytesToHash(pLock.message.TxID)) + if errors.Is(err, rpc.ErrNoResult) || errors.Is(err, ethereum.NotFound) { + delete(w.pending, key) + continue + } + if err != nil { + continue + } + if txreceipt == nil { + delete(w.pending, key) + continue + } + if txreceipt.TxHash != gethtypes.BytesToHash(pLock.message.TxID) { + delete(w.pending, key) + continue + } + if txreceipt.BlockHash != key.BlockHash { + delete(w.pending, key) + continue + } + return w.verifyAndPublish(pLock.message, ctx, gethtypes.Hash(pLock.message.TxID), txreceipt) + } + return nil +} + +func positiveConstantBlockNumber(w *Watcher, ctx context.Context, ev NewBlock) error { + _ = ev + thisConsistencyLevel := ConsistencyLevelFinalized + for key, pLock := range w.pending { + if !consistencyLevelMatches(thisConsistencyLevel, pLock.effectiveCL) { + continue + } + if pLock.height+pLock.additionalBlocks > 100 { + continue + } + txreceipt, err := w.ethConn.TransactionReceipt(ctx, gethtypes.BytesToHash(pLock.message.TxID)) + if errors.Is(err, rpc.ErrNoResult) || errors.Is(err, ethereum.NotFound) { + delete(w.pending, key) + continue + } + if err != nil { + continue + } + if txreceipt == nil { + delete(w.pending, key) + continue + } + if txreceipt.TxHash != gethtypes.BytesToHash(pLock.message.TxID) { + delete(w.pending, key) + continue + } + if txreceipt.BlockHash != key.BlockHash { + delete(w.pending, key) + continue + } + return w.verifyAndPublish(pLock.message, ctx, gethtypes.Hash(pLock.message.TxID), txreceipt) + } + return nil +} + +func positiveNonWPendingRange(w *Watcher, other *Watcher, ctx context.Context, ev NewBlock) error { + blockNumberU := ev.Number + thisConsistencyLevel := ConsistencyLevelFinalized + if ev.Finality == Safe { + thisConsistencyLevel = ConsistencyLevelSafe + } + for key, pLock := range other.pending { + if !consistencyLevelMatches(thisConsistencyLevel, pLock.effectiveCL) { + continue + } + if pLock.height+pLock.additionalBlocks > blockNumberU { + continue + } + txreceipt, err := w.ethConn.TransactionReceipt(ctx, gethtypes.BytesToHash(pLock.message.TxID)) + if errors.Is(err, rpc.ErrNoResult) || errors.Is(err, ethereum.NotFound) { + delete(other.pending, key) + continue + } + if err != nil { + continue + } + if txreceipt == nil { + delete(other.pending, key) + continue + } + if txreceipt.TxHash != gethtypes.BytesToHash(pLock.message.TxID) { + delete(other.pending, key) + continue + } + if txreceipt.BlockHash != key.BlockHash { + delete(other.pending, key) + continue + } + return w.verifyAndPublish(pLock.message, ctx, gethtypes.Hash(pLock.message.TxID), txreceipt) + } + return nil +} + +func positiveUnrelatedNestedConsistencyGuard(w *Watcher, ctx context.Context, ev NewBlock) error { + blockNumberU := ev.Number + thisConsistencyLevel := ConsistencyLevelFinalized + if ev.Finality == Safe { + thisConsistencyLevel = ConsistencyLevelSafe + } + for key, pLock := range w.pending { + if blockNumberU > 0 { + if !consistencyLevelMatches(thisConsistencyLevel, pLock.effectiveCL) { + continue + } + } + if pLock.height+pLock.additionalBlocks > blockNumberU { + continue + } + txreceipt, err := w.ethConn.TransactionReceipt(ctx, gethtypes.BytesToHash(pLock.message.TxID)) + if errors.Is(err, rpc.ErrNoResult) || errors.Is(err, ethereum.NotFound) { + delete(w.pending, key) + continue + } + if err != nil { + continue + } + if txreceipt == nil { + delete(w.pending, key) + continue + } + if txreceipt.TxHash != gethtypes.BytesToHash(pLock.message.TxID) { + delete(w.pending, key) + continue + } + if txreceipt.BlockHash != key.BlockHash { + delete(w.pending, key) + continue + } + return w.verifyAndPublish(pLock.message, ctx, gethtypes.Hash(pLock.message.TxID), txreceipt) + } + return nil +} + +func positiveUnrelatedNestedTxHashGuard(w *Watcher, ctx context.Context, ev NewBlock) error { + blockNumberU := ev.Number + thisConsistencyLevel := ConsistencyLevelFinalized + if ev.Finality == Safe { + thisConsistencyLevel = ConsistencyLevelSafe + } + for key, pLock := range w.pending { + if !consistencyLevelMatches(thisConsistencyLevel, pLock.effectiveCL) { + continue + } + if pLock.height+pLock.additionalBlocks > blockNumberU { + continue + } + txreceipt, err := w.ethConn.TransactionReceipt(ctx, gethtypes.BytesToHash(pLock.message.TxID)) + if errors.Is(err, rpc.ErrNoResult) || errors.Is(err, ethereum.NotFound) { + delete(w.pending, key) + continue + } + if err != nil { + continue + } + if txreceipt == nil { + delete(w.pending, key) + continue + } + if blockNumberU > 0 { + if txreceipt.TxHash != gethtypes.BytesToHash(pLock.message.TxID) { + delete(w.pending, key) + continue + } + } + if txreceipt.BlockHash != key.BlockHash { + delete(w.pending, key) + continue + } + return w.verifyAndPublish(pLock.message, ctx, gethtypes.Hash(pLock.message.TxID), txreceipt) + } + return nil +} diff --git a/codeql/test/evm-finality-release-and-reorg-checks/node/pkg/watchers/evm/stubs.go b/codeql/test/evm-finality-release-and-reorg-checks/node/pkg/watchers/evm/stubs.go new file mode 100644 index 0000000000..6f27e40d17 --- /dev/null +++ b/codeql/test/evm-finality-release-and-reorg-checks/node/pkg/watchers/evm/stubs.go @@ -0,0 +1,58 @@ +package evm + +import ( + "context" + + "codeql/evmfinalityrelease/gethtypes" + "codeql/evmfinalityrelease/node/pkg/common" +) + +const ( + ConsistencyLevelPublishImmediately uint8 = 200 + ConsistencyLevelSafe uint8 = 201 + ConsistencyLevelFinalized uint8 = 202 + Latest uint8 = 1 + Safe uint8 = 2 + Finalized uint8 = 3 +) + +type Connector struct{} + +type Watcher struct { + ethConn Connector + pending map[pendingKey]*pendingMessage +} + +type pendingKey struct { + TxHash gethtypes.Hash + BlockHash gethtypes.Hash +} + +type pendingMessage struct { + message *common.MessagePublication + height uint64 + additionalBlocks uint64 + effectiveCL uint8 +} + +type NewBlock struct { + Number uint64 + Finality uint8 +} + +func consistencyLevelMatches(blockCL uint8, msgCL uint8) bool { return blockCL == msgCL } + +func currentBlockConsistencyLevel(ev NewBlock) uint8 { + if ev.Finality == Safe { + return ConsistencyLevelSafe + } + return ConsistencyLevelFinalized +} + +func (c Connector) TransactionReceipt(ctx context.Context, tx gethtypes.Hash) (*gethtypes.Receipt, error) { + return &gethtypes.Receipt{}, nil +} + +func (w *Watcher) verifyAndPublish(msg *common.MessagePublication, ctx context.Context, txHash gethtypes.Hash, receipt *gethtypes.Receipt) error { + return nil +} diff --git a/codeql/test/evm-finality-release-and-reorg-checks/rpc/errors.go b/codeql/test/evm-finality-release-and-reorg-checks/rpc/errors.go new file mode 100644 index 0000000000..196bf7f0cf --- /dev/null +++ b/codeql/test/evm-finality-release-and-reorg-checks/rpc/errors.go @@ -0,0 +1,5 @@ +package rpc + +import "errors" + +var ErrNoResult = errors.New("no result") diff --git a/codeql/test/evm-require-successful-receipt-before-observation/evm-require-successful-receipt-before-observation.expected b/codeql/test/evm-require-successful-receipt-before-observation/evm-require-successful-receipt-before-observation.expected new file mode 100644 index 0000000000..aef52b2dbc --- /dev/null +++ b/codeql/test/evm-require-successful-receipt-before-observation/evm-require-successful-receipt-before-observation.expected @@ -0,0 +1,23 @@ +| node/pkg/watchers/evm/positive.go:16:9:16:49 | call to verifyAndPublish | EVM watcher observation must prove the same transaction receipt has Status == ReceiptStatusSuccessful before it publishes with (*Watcher).verifyAndPublish; nil, tx-hash, block-hash, or finality checks do not prove receipt success. | +| node/pkg/watchers/evm/positive.go:31:9:31:47 | call to verifyAndPublish | EVM watcher observation must prove the same transaction receipt has Status == ReceiptStatusSuccessful before it publishes with (*Watcher).verifyAndPublish; nil, tx-hash, block-hash, or finality checks do not prove receipt success. | +| node/pkg/watchers/evm/positive.go:39:12:39:52 | call to verifyAndPublish | EVM watcher observation must prove the same transaction receipt has Status == ReceiptStatusSuccessful before it publishes with (*Watcher).verifyAndPublish; nil, tx-hash, block-hash, or finality checks do not prove receipt success. | +| node/pkg/watchers/evm/positive.go:58:9:58:49 | call to verifyAndPublish | EVM watcher observation must prove the same transaction receipt has Status == ReceiptStatusSuccessful before it publishes with (*Watcher).verifyAndPublish; nil, tx-hash, block-hash, or finality checks do not prove receipt success. | +| node/pkg/watchers/evm/positive.go:66:9:66:49 | call to verifyAndPublish | EVM watcher observation must prove the same transaction receipt has Status == ReceiptStatusSuccessful before it publishes with (*Watcher).verifyAndPublish; nil, tx-hash, block-hash, or finality checks do not prove receipt success. | +| node/pkg/watchers/evm/positive.go:78:13:78:52 | call to ParseLogMessagePublished | EVM watcher observation must prove the same transaction receipt has Status == ReceiptStatusSuccessful before it parses LogMessagePublished events from receipt.Logs; nil, tx-hash, block-hash, or finality checks do not prove receipt success. | +| node/pkg/watchers/evm/positive.go:96:13:96:52 | call to ParseLogMessagePublished | EVM watcher observation must prove the same transaction receipt has Status == ReceiptStatusSuccessful before it parses LogMessagePublished events from receipt.Logs; nil, tx-hash, block-hash, or finality checks do not prove receipt success. | +| node/pkg/watchers/evm/positive.go:111:13:111:56 | call to ParseLogMessagePublished | EVM watcher observation must prove the same transaction receipt has Status == ReceiptStatusSuccessful before it parses LogMessagePublished events from receipt.Logs; nil, tx-hash, block-hash, or finality checks do not prove receipt success. | +| node/pkg/watchers/evm/positive.go:125:13:125:64 | call to ParseLogMessagePublished | EVM watcher observation must prove the same transaction receipt has Status == ReceiptStatusSuccessful before it parses LogMessagePublished events from receipt.Logs; nil, tx-hash, block-hash, or finality checks do not prove receipt success. | +| node/pkg/watchers/evm/positive.go:144:13:144:40 | call to parseOneLog | EVM watcher observation must prove the same transaction receipt has Status == ReceiptStatusSuccessful before it parses LogMessagePublished events from receipt.Logs; nil, tx-hash, block-hash, or finality checks do not prove receipt success. | +| node/pkg/watchers/evm/positive.go:157:17:157:56 | call to ParseLogMessagePublished | EVM watcher observation must prove the same transaction receipt has Status == ReceiptStatusSuccessful before it parses LogMessagePublished events from receipt.Logs; nil, tx-hash, block-hash, or finality checks do not prove receipt success. | +| node/pkg/watchers/evm/positive.go:163:10:163:50 | call to verifyAndPublish | EVM watcher observation must prove the same transaction receipt has Status == ReceiptStatusSuccessful before it publishes with (*Watcher).verifyAndPublish; nil, tx-hash, block-hash, or finality checks do not prove receipt success. | +| node/pkg/watchers/evm/positive.go:187:9:187:47 | call to verifyAndPublish | EVM watcher observation must prove the same transaction receipt has Status == ReceiptStatusSuccessful before it publishes with (*Watcher).verifyAndPublish; nil, tx-hash, block-hash, or finality checks do not prove receipt success. | +| node/pkg/watchers/evm/positive.go:204:16:204:55 | call to ParseLogMessagePublished | EVM watcher observation must prove the same transaction receipt has Status == ReceiptStatusSuccessful before it parses LogMessagePublished events from receipt.Logs; nil, tx-hash, block-hash, or finality checks do not prove receipt success. | +| node/pkg/watchers/evm/positive.go:214:13:214:53 | call to verifyAndPublish | EVM watcher observation must prove the same transaction receipt has Status == ReceiptStatusSuccessful before it publishes with (*Watcher).verifyAndPublish; nil, tx-hash, block-hash, or finality checks do not prove receipt success. | +| node/pkg/watchers/evm/positive.go:228:13:228:53 | call to verifyAndPublish | EVM watcher observation must prove the same transaction receipt has Status == ReceiptStatusSuccessful before it publishes with (*Watcher).verifyAndPublish; nil, tx-hash, block-hash, or finality checks do not prove receipt success. | +| node/pkg/watchers/evm/positive.go:245:13:245:53 | call to verifyAndPublish | EVM watcher observation must prove the same transaction receipt has Status == ReceiptStatusSuccessful before it publishes with (*Watcher).verifyAndPublish; nil, tx-hash, block-hash, or finality checks do not prove receipt success. | +| node/pkg/watchers/evm/positive.go:262:13:262:53 | call to verifyAndPublish | EVM watcher observation must prove the same transaction receipt has Status == ReceiptStatusSuccessful before it publishes with (*Watcher).verifyAndPublish; nil, tx-hash, block-hash, or finality checks do not prove receipt success. | +| node/pkg/watchers/evm/positive.go:276:13:276:53 | call to verifyAndPublish | EVM watcher observation must prove the same transaction receipt has Status == ReceiptStatusSuccessful before it publishes with (*Watcher).verifyAndPublish; nil, tx-hash, block-hash, or finality checks do not prove receipt success. | +| node/pkg/watchers/evm/positive.go:290:13:290:53 | call to verifyAndPublish | EVM watcher observation must prove the same transaction receipt has Status == ReceiptStatusSuccessful before it publishes with (*Watcher).verifyAndPublish; nil, tx-hash, block-hash, or finality checks do not prove receipt success. | +| node/pkg/watchers/evm/positive.go:303:12:303:56 | call to verifyAndPublish | EVM watcher observation must prove the same transaction receipt has Status == ReceiptStatusSuccessful before it publishes with (*Watcher).verifyAndPublish; nil, tx-hash, block-hash, or finality checks do not prove receipt success. | +| node/pkg/watchers/evm/unsafe/positive_unsafe_helper.go:36:15:36:52 | call to ParseLogMessagePublished | EVM watcher observation must prove the same transaction receipt has Status == ReceiptStatusSuccessful before it parses LogMessagePublished events from receipt.Logs; nil, tx-hash, block-hash, or finality checks do not prove receipt success. | +| node/pkg/watchers/evm/unsafe/positive_unsafe_helper.go:51:13:51:53 | call to verifyAndPublish | EVM watcher observation must prove the same transaction receipt has Status == ReceiptStatusSuccessful before it publishes with (*Watcher).verifyAndPublish; nil, tx-hash, block-hash, or finality checks do not prove receipt success. | diff --git a/codeql/test/evm-require-successful-receipt-before-observation/evm-require-successful-receipt-before-observation.qlref b/codeql/test/evm-require-successful-receipt-before-observation/evm-require-successful-receipt-before-observation.qlref new file mode 100644 index 0000000000..a23855665c --- /dev/null +++ b/codeql/test/evm-require-successful-receipt-before-observation/evm-require-successful-receipt-before-observation.qlref @@ -0,0 +1 @@ +../../src/evm-require-successful-receipt-before-observation.ql diff --git a/codeql/test/evm-require-successful-receipt-before-observation/gethtypes/types.go b/codeql/test/evm-require-successful-receipt-before-observation/gethtypes/types.go new file mode 100644 index 0000000000..2e3d89c594 --- /dev/null +++ b/codeql/test/evm-require-successful-receipt-before-observation/gethtypes/types.go @@ -0,0 +1,16 @@ +package gethtypes + +const ReceiptStatusSuccessful uint64 = 1 + +type Hash [32]byte + +type Log struct { + Removed bool +} + +type Receipt struct { + Status uint64 + Logs []*Log + TxHash Hash + BlockHash Hash +} diff --git a/codeql/test/evm-require-successful-receipt-before-observation/go.mod b/codeql/test/evm-require-successful-receipt-before-observation/go.mod new file mode 100644 index 0000000000..23e88f9224 --- /dev/null +++ b/codeql/test/evm-require-successful-receipt-before-observation/go.mod @@ -0,0 +1,3 @@ +module codeql/evmrequiresuccessfulreceipt + +go 1.22 diff --git a/codeql/test/evm-require-successful-receipt-before-observation/node/pkg/common/message.go b/codeql/test/evm-require-successful-receipt-before-observation/node/pkg/common/message.go new file mode 100644 index 0000000000..a41803c0da --- /dev/null +++ b/codeql/test/evm-require-successful-receipt-before-observation/node/pkg/common/message.go @@ -0,0 +1,5 @@ +package common + +type MessagePublication struct { + TxID []byte +} diff --git a/codeql/test/evm-require-successful-receipt-before-observation/node/pkg/watchers/evm/evm_test.go b/codeql/test/evm-require-successful-receipt-before-observation/node/pkg/watchers/evm/evm_test.go new file mode 100644 index 0000000000..9e0d160a77 --- /dev/null +++ b/codeql/test/evm-require-successful-receipt-before-observation/node/pkg/watchers/evm/evm_test.go @@ -0,0 +1,13 @@ +package evm + +import ( + "context" + + "codeql/evmrequiresuccessfulreceipt/gethtypes" + "codeql/evmrequiresuccessfulreceipt/node/pkg/common" +) + +func TestExcludedVerifyAndPublish(t interface{ Fatal(...interface{}) }) { + w := &Watcher{} + _ = w.verifyAndPublish(&common.MessagePublication{}, context.Background(), gethtypes.Hash{}, &gethtypes.Receipt{}) +} diff --git a/codeql/test/evm-require-successful-receipt-before-observation/node/pkg/watchers/evm/negative.go b/codeql/test/evm-require-successful-receipt-before-observation/node/pkg/watchers/evm/negative.go new file mode 100644 index 0000000000..88b4c5f04f --- /dev/null +++ b/codeql/test/evm-require-successful-receipt-before-observation/node/pkg/watchers/evm/negative.go @@ -0,0 +1,163 @@ +package evm + +import ( + "context" + "fmt" + + "codeql/evmrequiresuccessfulreceipt/gethtypes" + "codeql/evmrequiresuccessfulreceipt/node/pkg/common" +) + +func negativeCheckedBeforePublish(w *Watcher, ctx context.Context, msg *common.MessagePublication, tx gethtypes.Hash) error { + receipt, err := w.ethConn.TransactionReceipt(ctx, tx) + if receipt == nil || err != nil { + return err + } + if receipt.Status != gethtypes.ReceiptStatusSuccessful { + return fmt.Errorf("failed") + } + return w.verifyAndPublish(msg, ctx, tx, receipt) +} + +func negativeSuccessBranch(w *Watcher, ctx context.Context, msg *common.MessagePublication, tx gethtypes.Hash) error { + receipt, err := w.ethConn.TransactionReceipt(ctx, tx) + if err != nil { + return err + } + if receipt.Status == gethtypes.ReceiptStatusSuccessful { + return w.verifyAndPublish(msg, ctx, tx, receipt) + } + return fmt.Errorf("failed") +} + +func negativeMessageEventsReceiptBoundary(w *Watcher, ctx context.Context, tx gethtypes.Hash) error { + receipt, _, msgs, err := MessageEventsForTransaction(ctx, w.ethConn, w.contract, tx) + if err != nil { + return err + } + for _, msg := range msgs { + if err := w.verifyAndPublish(msg, ctx, tx, receipt); err != nil { + return err + } + } + return nil +} + +func negativeMessageEventsRenamedTuple(w *Watcher, ctx context.Context, tx gethtypes.Hash) error { + r, _, publications, helperErr := MessageEventsForTransaction(ctx, w.ethConn, w.contract, tx) + if helperErr != nil { + return helperErr + } + for _, publication := range publications { + if err := w.verifyAndPublish(publication, ctx, tx, r); err != nil { + return err + } + } + return nil +} + +func negativeMessageEventsPredeclaredTuple(w *Watcher, ctx context.Context, tx gethtypes.Hash) error { + var receipt *gethtypes.Receipt + var msgs []*common.MessagePublication + var helperErr error + + receipt, _, msgs, helperErr = MessageEventsForTransaction(ctx, w.ethConn, w.contract, tx) + if helperErr != nil { + return helperErr + } + for _, msg := range msgs { + if err := w.verifyAndPublish(msg, ctx, tx, receipt); err != nil { + return err + } + } + return nil +} + +func negativeParseReceiptLogsAfterStatus(w *Watcher, ctx context.Context, tx gethtypes.Hash) error { + receipt, err := w.ethConn.TransactionReceipt(ctx, tx) + if err != nil { + return err + } + if receipt.Status != gethtypes.ReceiptStatusSuccessful { + return fmt.Errorf("failed") + } + for _, log := range receipt.Logs { + if log == nil { + continue + } + _, err := w.ethConn.ParseLogMessagePublished(*log) + if err != nil { + return err + } + } + return nil +} + +func negativeParseLogsAliasAfterStatus(w *Watcher, ctx context.Context, tx gethtypes.Hash) error { + receipt, err := w.ethConn.TransactionReceipt(ctx, tx) + if err != nil { + return err + } + if receipt.Status != gethtypes.ReceiptStatusSuccessful { + return fmt.Errorf("failed") + } + logs := receipt.Logs + for _, log := range logs { + _, err := w.ethConn.ParseLogMessagePublished(*log) + if err != nil { + return err + } + } + return nil +} + +func negativeParseIndexedLogsAfterStatus(w *Watcher, ctx context.Context, tx gethtypes.Hash) error { + receipt, err := w.ethConn.TransactionReceipt(ctx, tx) + if err != nil { + return err + } + if receipt.Status != gethtypes.ReceiptStatusSuccessful { + return fmt.Errorf("failed") + } + for i := range receipt.Logs { + _, err := w.ethConn.ParseLogMessagePublished(*receipt.Logs[i]) + if err != nil { + return err + } + } + return nil +} + +func negativeParseLocalIndexedLogsAfterStatus(w *Watcher, ctx context.Context, tx gethtypes.Hash) error { + receipt, err := w.ethConn.TransactionReceipt(ctx, tx) + if err != nil { + return err + } + if receipt.Status != gethtypes.ReceiptStatusSuccessful { + return fmt.Errorf("failed") + } + logs := receipt.Logs + for i := range logs { + _, err := w.ethConn.ParseLogMessagePublished(*logs[i]) + if err != nil { + return err + } + } + return nil +} + +func negativeHelperParameterParseAfterStatus(w *Watcher, ctx context.Context, tx gethtypes.Hash) error { + receipt, err := w.ethConn.TransactionReceipt(ctx, tx) + if err != nil { + return err + } + if receipt.Status != gethtypes.ReceiptStatusSuccessful { + return fmt.Errorf("failed") + } + for _, log := range receipt.Logs { + if err := parseOneLog(w.ethConn, *log); err != nil { + return err + } + } + return nil +} diff --git a/codeql/test/evm-require-successful-receipt-before-observation/node/pkg/watchers/evm/positive.go b/codeql/test/evm-require-successful-receipt-before-observation/node/pkg/watchers/evm/positive.go new file mode 100644 index 0000000000..b0c194b342 --- /dev/null +++ b/codeql/test/evm-require-successful-receipt-before-observation/node/pkg/watchers/evm/positive.go @@ -0,0 +1,307 @@ +package evm + +import ( + "context" + "fmt" + + "codeql/evmrequiresuccessfulreceipt/gethtypes" + "codeql/evmrequiresuccessfulreceipt/node/pkg/common" +) + +func positiveHistoricalInstantPublication(w *Watcher, ctx context.Context, msg *common.MessagePublication, tx gethtypes.Hash) error { + receipt, err := w.ethConn.TransactionReceipt(ctx, tx) + if receipt == nil || err != nil { + return err + } + return w.verifyAndPublish(msg, ctx, tx, receipt) +} + +func positiveDifferentReceiptChecked(w *Watcher, ctx context.Context, msg *common.MessagePublication, tx gethtypes.Hash) error { + checked, err := w.ethConn.TransactionReceipt(ctx, tx) + if err != nil { + return err + } + if checked.Status != gethtypes.ReceiptStatusSuccessful { + return fmt.Errorf("failed") + } + other, err := w.ethConn.TransactionReceipt(ctx, tx) + if err != nil { + return err + } + return w.verifyAndPublish(msg, ctx, tx, other) +} + +func positivePostCheck(w *Watcher, ctx context.Context, msg *common.MessagePublication, tx gethtypes.Hash) error { + receipt, err := w.ethConn.TransactionReceipt(ctx, tx) + if err != nil { + return err + } + pubErr := w.verifyAndPublish(msg, ctx, tx, receipt) + if receipt.Status != gethtypes.ReceiptStatusSuccessful { + return fmt.Errorf("failed") + } + return pubErr +} + +func positiveReassignedAfterCheck(w *Watcher, ctx context.Context, msg *common.MessagePublication, tx gethtypes.Hash) error { + receipt, err := w.ethConn.TransactionReceipt(ctx, tx) + if err != nil { + return err + } + if receipt.Status != gethtypes.ReceiptStatusSuccessful { + return fmt.Errorf("failed") + } + receipt, err = w.ethConn.TransactionReceipt(ctx, tx) + if err != nil { + return err + } + return w.verifyAndPublish(msg, ctx, tx, receipt) +} + +func positiveNilReceiptOnly(w *Watcher, ctx context.Context, msg *common.MessagePublication, tx gethtypes.Hash) error { + receipt, err := w.ethConn.TransactionReceipt(ctx, tx) + if receipt == nil || err != nil { + return err + } + return w.verifyAndPublish(msg, ctx, tx, receipt) +} + +func positiveParseReceiptLogsWithoutStatus(w *Watcher, ctx context.Context, tx gethtypes.Hash) error { + receipt, err := w.ethConn.TransactionReceipt(ctx, tx) + if err != nil { + return err + } + for _, log := range receipt.Logs { + if log == nil { + continue + } + _, err := w.ethConn.ParseLogMessagePublished(*log) + if err != nil { + return err + } + } + if receipt.Status != gethtypes.ReceiptStatusSuccessful { + return fmt.Errorf("failed") + } + return nil +} + +func positiveParseLogsAliasWithoutStatus(w *Watcher, ctx context.Context, tx gethtypes.Hash) error { + receipt, err := w.ethConn.TransactionReceipt(ctx, tx) + if err != nil { + return err + } + logs := receipt.Logs + for _, log := range logs { + _, err := w.ethConn.ParseLogMessagePublished(*log) + if err != nil { + return err + } + } + return nil +} + +func positiveParseIndexedLogsWithoutStatus(w *Watcher, ctx context.Context, tx gethtypes.Hash) error { + receipt, err := w.ethConn.TransactionReceipt(ctx, tx) + if err != nil { + return err + } + logs := receipt.Logs + for i := range logs { + _, err := w.ethConn.ParseLogMessagePublished(*logs[i]) + if err != nil { + return err + } + } + return nil +} + +func positiveParseDirectIndexedLogsWithoutStatus(w *Watcher, ctx context.Context, tx gethtypes.Hash) error { + receipt, err := w.ethConn.TransactionReceipt(ctx, tx) + if err != nil { + return err + } + for i := range receipt.Logs { + _, err := w.ethConn.ParseLogMessagePublished(*receipt.Logs[i]) + if err != nil { + return err + } + } + return nil +} + +func parseOneLog(c Connector, log gethtypes.Log) error { + _, err := c.ParseLogMessagePublished(log) + return err +} + +func positiveHelperParameterParseWithoutStatus(w *Watcher, ctx context.Context, tx gethtypes.Hash) error { + receipt, err := w.ethConn.TransactionReceipt(ctx, tx) + if err != nil { + return err + } + for _, log := range receipt.Logs { + if err := parseOneLog(w.ethConn, *log); err != nil { + return err + } + } + return nil +} + +func positiveMsgDerivedFromUncheckedReceipt(w *Watcher, ctx context.Context, tx gethtypes.Hash) error { + receipt, err := w.ethConn.TransactionReceipt(ctx, tx) + if err != nil { + return err + } + for _, log := range receipt.Logs { + event, err := w.ethConn.ParseLogMessagePublished(*log) + if err != nil { + return err + } + msg := &common.MessagePublication{} + _ = event + return w.verifyAndPublish(msg, ctx, tx, receipt) + } + return nil +} + +func positiveCheckedLogsButDifferentPublishReceipt(w *Watcher, ctx context.Context, msg *common.MessagePublication, tx gethtypes.Hash) error { + checked, err := w.ethConn.TransactionReceipt(ctx, tx) + if err != nil { + return err + } + if checked.Status != gethtypes.ReceiptStatusSuccessful { + return fmt.Errorf("failed") + } + logs := checked.Logs + for i := range logs { + _, err := w.ethConn.ParseLogMessagePublished(*logs[i]) + if err != nil { + return err + } + } + other, err := w.ethConn.TransactionReceipt(ctx, tx) + if err != nil { + return err + } + return w.verifyAndPublish(msg, ctx, tx, other) +} + +func positiveLogsAliasFromReassignedReceipt(w *Watcher, ctx context.Context, tx gethtypes.Hash) error { + receipt, err := w.ethConn.TransactionReceipt(ctx, tx) + if err != nil { + return err + } + logs := receipt.Logs + receipt, err = w.ethConn.TransactionReceipt(ctx, tx) + if err != nil { + return err + } + if receipt.Status != gethtypes.ReceiptStatusSuccessful { + return fmt.Errorf("failed") + } + for _, log := range logs { + if _, err := w.ethConn.ParseLogMessagePublished(*log); err != nil { + return err + } + } + return nil +} + +func positiveMessageEventsUncheckedError(w *Watcher, ctx context.Context, tx gethtypes.Hash) error { + receipt, _, msgs, _ := MessageEventsForTransaction(ctx, w.ethConn, w.contract, tx) + for _, msg := range msgs { + if err := w.verifyAndPublish(msg, ctx, tx, receipt); err != nil { + return err + } + } + return nil +} + +func positiveMessageEventsErrorOverwrittenBeforeCheck(w *Watcher, ctx context.Context, tx gethtypes.Hash) error { + receipt, _, msgs, err := MessageEventsForTransaction(ctx, w.ethConn, w.contract, tx) + err = nil + if err != nil { + return err + } + for _, msg := range msgs { + if err := w.verifyAndPublish(msg, ctx, tx, receipt); err != nil { + return err + } + } + return nil +} + +func positiveMessageEventsMixedTuples(w *Watcher, ctx context.Context, tx gethtypes.Hash) error { + receipt, _, _, receiptErr := MessageEventsForTransaction(ctx, w.ethConn, w.contract, tx) + if receiptErr != nil { + return receiptErr + } + _, _, msgs, messagesErr := MessageEventsForTransaction(ctx, w.ethConn, w.contract, tx) + if messagesErr != nil { + return messagesErr + } + for _, msg := range msgs { + if err := w.verifyAndPublish(msg, ctx, tx, receipt); err != nil { + return err + } + } + return nil +} + +func positiveMessageEventsReceiptReassigned(w *Watcher, ctx context.Context, tx gethtypes.Hash) error { + receipt, _, msgs, err := MessageEventsForTransaction(ctx, w.ethConn, w.contract, tx) + if err != nil { + return err + } + receipt, err = w.ethConn.TransactionReceipt(ctx, tx) + if err != nil { + return err + } + for _, msg := range msgs { + if err := w.verifyAndPublish(msg, ctx, tx, receipt); err != nil { + return err + } + } + return nil +} + +func positiveMessageEventsMessagesReassigned(w *Watcher, ctx context.Context, tx gethtypes.Hash) error { + receipt, _, msgs, err := MessageEventsForTransaction(ctx, w.ethConn, w.contract, tx) + if err != nil { + return err + } + msgs = []*common.MessagePublication{{}} + for _, msg := range msgs { + if err := w.verifyAndPublish(msg, ctx, tx, receipt); err != nil { + return err + } + } + return nil +} + +func positiveMessageEventsRangeValueReassigned(w *Watcher, ctx context.Context, tx gethtypes.Hash) error { + receipt, _, msgs, err := MessageEventsForTransaction(ctx, w.ethConn, w.contract, tx) + if err != nil { + return err + } + for _, msg := range msgs { + msg = &common.MessagePublication{} + if err := w.verifyAndPublish(msg, ctx, tx, receipt); err != nil { + return err + } + } + return nil +} + +func positiveMessageEventsSliceElementReassigned(w *Watcher, ctx context.Context, tx gethtypes.Hash) error { + receipt, _, msgs, err := MessageEventsForTransaction(ctx, w.ethConn, w.contract, tx) + if err != nil { + return err + } + msgs[0] = &common.MessagePublication{} + if err := w.verifyAndPublish(msgs[0], ctx, tx, receipt); err != nil { + return err + } + return nil +} diff --git a/codeql/test/evm-require-successful-receipt-before-observation/node/pkg/watchers/evm/stubs.go b/codeql/test/evm-require-successful-receipt-before-observation/node/pkg/watchers/evm/stubs.go new file mode 100644 index 0000000000..1a7d51d855 --- /dev/null +++ b/codeql/test/evm-require-successful-receipt-before-observation/node/pkg/watchers/evm/stubs.go @@ -0,0 +1,57 @@ +package evm + +import ( + "context" + "fmt" + + "codeql/evmrequiresuccessfulreceipt/gethtypes" + "codeql/evmrequiresuccessfulreceipt/node/pkg/common" +) + +type Connector struct{} + +type Watcher struct { + ethConn Connector + contract string +} + +type Event struct { + Raw struct { + TxHash gethtypes.Hash + } +} + +func (c Connector) TransactionReceipt(ctx context.Context, tx gethtypes.Hash) (*gethtypes.Receipt, error) { + return &gethtypes.Receipt{}, nil +} + +func (c Connector) ParseLogMessagePublished(log gethtypes.Log) (Event, error) { + return Event{}, nil +} + +func (w *Watcher) verifyAndPublish(msg *common.MessagePublication, ctx context.Context, txHash gethtypes.Hash, receipt *gethtypes.Receipt) error { + return nil +} + +func MessageEventsForTransaction(ctx context.Context, ethConn Connector, contract string, tx gethtypes.Hash) (*gethtypes.Receipt, uint64, []*common.MessagePublication, error) { + receipt, err := ethConn.TransactionReceipt(ctx, tx) + if receipt == nil || err != nil { + return nil, 0, nil, fmt.Errorf("receipt: %w", err) + } + if receipt.Status != gethtypes.ReceiptStatusSuccessful { + return nil, 0, nil, fmt.Errorf("failed receipt") + } + msgs := make([]*common.MessagePublication, 0, len(receipt.Logs)) + for _, log := range receipt.Logs { + if log == nil { + continue + } + event, err := ethConn.ParseLogMessagePublished(*log) + if err != nil { + return nil, 0, nil, err + } + _ = event + msgs = append(msgs, &common.MessagePublication{}) + } + return receipt, 1, msgs, nil +} diff --git a/codeql/test/evm-require-successful-receipt-before-observation/node/pkg/watchers/evm/unsafe/positive_unsafe_helper.go b/codeql/test/evm-require-successful-receipt-before-observation/node/pkg/watchers/evm/unsafe/positive_unsafe_helper.go new file mode 100644 index 0000000000..1db42b1fca --- /dev/null +++ b/codeql/test/evm-require-successful-receipt-before-observation/node/pkg/watchers/evm/unsafe/positive_unsafe_helper.go @@ -0,0 +1,56 @@ +package unsafe + +import ( + "context" + + "codeql/evmrequiresuccessfulreceipt/gethtypes" + "codeql/evmrequiresuccessfulreceipt/node/pkg/common" +) + +type Connector struct{} + +type Watcher struct { + ethConn Connector + contract string +} + +func (c Connector) TransactionReceipt(ctx context.Context, tx gethtypes.Hash) (*gethtypes.Receipt, error) { + return &gethtypes.Receipt{}, nil +} + +func (c Connector) ParseLogMessagePublished(log gethtypes.Log) (*common.MessagePublication, error) { + return &common.MessagePublication{}, nil +} + +func (w *Watcher) verifyAndPublish(msg *common.MessagePublication, ctx context.Context, txHash gethtypes.Hash, receipt *gethtypes.Receipt) error { + return nil +} + +func MessageEventsForTransaction(ctx context.Context, ethConn Connector, contract string, tx gethtypes.Hash) (*gethtypes.Receipt, []*common.MessagePublication, error) { + receipt, err := ethConn.TransactionReceipt(ctx, tx) + if err != nil { + return nil, nil, err + } + msgs := make([]*common.MessagePublication, 0, len(receipt.Logs)) + for _, log := range receipt.Logs { + msg, err := ethConn.ParseLogMessagePublished(*log) + if err != nil { + return nil, nil, err + } + msgs = append(msgs, msg) + } + return receipt, msgs, nil +} + +func positiveUnsafeSameNamedHelper(w *Watcher, ctx context.Context, tx gethtypes.Hash) error { + receipt, msgs, err := MessageEventsForTransaction(ctx, w.ethConn, w.contract, tx) + if err != nil { + return err + } + for _, msg := range msgs { + if err := w.verifyAndPublish(msg, ctx, tx, receipt); err != nil { + return err + } + } + return nil +} diff --git a/codeql/test/evm-require-successful-receipt-before-observation/node/pkg/watchers/solana/non_evm.go b/codeql/test/evm-require-successful-receipt-before-observation/node/pkg/watchers/solana/non_evm.go new file mode 100644 index 0000000000..dc93197413 --- /dev/null +++ b/codeql/test/evm-require-successful-receipt-before-observation/node/pkg/watchers/solana/non_evm.go @@ -0,0 +1,18 @@ +package solana + +import ( + "context" + + "codeql/evmrequiresuccessfulreceipt/gethtypes" + "codeql/evmrequiresuccessfulreceipt/node/pkg/common" +) + +type Watcher struct{} + +func (w *Watcher) verifyAndPublish(msg *common.MessagePublication, ctx context.Context, txHash gethtypes.Hash, receipt *gethtypes.Receipt) error { + return nil +} + +func nonEvm(w *Watcher, ctx context.Context, msg *common.MessagePublication) error { + return w.verifyAndPublish(msg, ctx, gethtypes.Hash{}, &gethtypes.Receipt{}) +} diff --git a/codeql/test/evm-verify-and-publish-gate/evm-verify-and-publish-gate.expected b/codeql/test/evm-verify-and-publish-gate/evm-verify-and-publish-gate.expected new file mode 100644 index 0000000000..d1a93caaf4 --- /dev/null +++ b/codeql/test/evm-verify-and-publish-gate/evm-verify-and-publish-gate.expected @@ -0,0 +1,5 @@ +| node/pkg/watchers/evm/positive.go:6:2:6:14 | send statement | EVM watcher publications must go through (*Watcher).verifyAndPublish; this send writes to the watcher's msgC field directly and can bypass transfer-verifier state updates. | +| node/pkg/watchers/evm/positive.go:11:2:11:11 | send statement | EVM watcher publications must go through (*Watcher).verifyAndPublish; this send writes through a local alias of the watcher's msgC channel and can bypass transfer-verifier state updates. | +| node/pkg/watchers/evm/positive.go:19:2:19:11 | send statement | EVM watcher publications must go through (*Watcher).verifyAndPublish; this thin helper send is reachable from a call that passes the watcher's msgC channel and can bypass transfer-verifier state updates. | +| node/pkg/watchers/evm/positive.go:25:2:25:14 | send statement | EVM watcher publications must go through (*Watcher).verifyAndPublish; this send writes through a local alias of the watcher's msgC channel and can bypass transfer-verifier state updates. | +| node/pkg/watchers/evm/positive.go:34:2:34:11 | send statement | EVM watcher publications must go through (*Watcher).verifyAndPublish; this send writes through a local alias of the watcher's msgC channel and can bypass transfer-verifier state updates. | diff --git a/codeql/test/evm-verify-and-publish-gate/evm-verify-and-publish-gate.qlref b/codeql/test/evm-verify-and-publish-gate/evm-verify-and-publish-gate.qlref new file mode 100644 index 0000000000..94fb4b88d2 --- /dev/null +++ b/codeql/test/evm-verify-and-publish-gate/evm-verify-and-publish-gate.qlref @@ -0,0 +1 @@ +../../src/evm-verify-and-publish-gate.ql diff --git a/codeql/test/evm-verify-and-publish-gate/go.mod b/codeql/test/evm-verify-and-publish-gate/go.mod new file mode 100644 index 0000000000..ea58ba01ec --- /dev/null +++ b/codeql/test/evm-verify-and-publish-gate/go.mod @@ -0,0 +1,3 @@ +module github.com/certusone/wormhole + +go 1.22 diff --git a/codeql/test/evm-verify-and-publish-gate/node/pkg/common/message_publication.go b/codeql/test/evm-verify-and-publish-gate/node/pkg/common/message_publication.go new file mode 100644 index 0000000000..a41803c0da --- /dev/null +++ b/codeql/test/evm-verify-and-publish-gate/node/pkg/common/message_publication.go @@ -0,0 +1,5 @@ +package common + +type MessagePublication struct { + TxID []byte +} diff --git a/codeql/test/evm-verify-and-publish-gate/node/pkg/watchers/evm/evm_test.go b/codeql/test/evm-verify-and-publish-gate/node/pkg/watchers/evm/evm_test.go new file mode 100644 index 0000000000..f130227019 --- /dev/null +++ b/codeql/test/evm-verify-and-publish-gate/node/pkg/watchers/evm/evm_test.go @@ -0,0 +1,10 @@ +package evm + +import "github.com/certusone/wormhole/node/pkg/common" + +func TestDirectSendFixtureIsExcluded(t interface{ Fatal(args ...any) }) { + msgC := make(chan *common.MessagePublication, 1) + w := NewWatcher(msgC) + w.msgC <- &common.MessagePublication{} + <-msgC +} diff --git a/codeql/test/evm-verify-and-publish-gate/node/pkg/watchers/evm/negative.go b/codeql/test/evm-verify-and-publish-gate/node/pkg/watchers/evm/negative.go new file mode 100644 index 0000000000..686dd2dc93 --- /dev/null +++ b/codeql/test/evm-verify-and-publish-gate/node/pkg/watchers/evm/negative.go @@ -0,0 +1,74 @@ +package evm + +import ( + "context" + + "github.com/certusone/wormhole/node/pkg/common" +) + +func negativeApprovedHelperCall(w *Watcher, msg *common.MessagePublication) error { + return w.verifyAndPublish(msg, context.Background(), Hash{}, &Receipt{}) +} + +func negativeConstructorWiring(msgC chan<- *common.MessagePublication) *Watcher { + return NewWatcher(msgC) +} + +func negativeUnrelatedChannel(msg *common.MessagePublication) { + internal := make(chan *common.MessagePublication, 1) + internal <- msg +} + +func negativeMessageOnlyHelper() *common.MessagePublication { + return makeMessage() +} + +func negativeChannelRead(in <-chan *common.MessagePublication) *common.MessagePublication { + return <-in +} + +func negativeSendBeforeProtectedAssignment(w *Watcher, msg *common.MessagePublication) { + var out chan<- *common.MessagePublication = make(chan *common.MessagePublication, 1) + out <- msg + out = w.msgC +} + +func negativeSendAfterUnrelatedReassignment(w *Watcher, msg *common.MessagePublication) { + out := w.msgC + out = make(chan *common.MessagePublication, 1) + out <- msg +} + +func negativeUnsupportedClosureCapture(w *Watcher, msg *common.MessagePublication) { + out := w.msgC + publish := func() { + out <- msg + } + publish() +} + +func negativeUnsupportedClosureOverwriteBeforeCall(w *Watcher, msg *common.MessagePublication) { + out := w.msgC + publish := func() { + out <- msg + } + out = make(chan *common.MessagePublication, 1) + publish() +} + +type wrapperPublisher struct { + out chan<- *common.MessagePublication +} + +func newWrapperPublisher(w *Watcher) wrapperPublisher { + return wrapperPublisher{out: w.msgC} +} + +func (p wrapperPublisher) Publish(msg *common.MessagePublication) { + p.out <- msg +} + +func negativeUnsupportedWrapperBoundary(w *Watcher, msg *common.MessagePublication) { + publisher := newWrapperPublisher(w) + publisher.Publish(msg) +} diff --git a/codeql/test/evm-verify-and-publish-gate/node/pkg/watchers/evm/positive.go b/codeql/test/evm-verify-and-publish-gate/node/pkg/watchers/evm/positive.go new file mode 100644 index 0000000000..8618145280 --- /dev/null +++ b/codeql/test/evm-verify-and-publish-gate/node/pkg/watchers/evm/positive.go @@ -0,0 +1,35 @@ +package evm + +import "github.com/certusone/wormhole/node/pkg/common" + +func positiveDirectSend(w *Watcher, msg *common.MessagePublication) { + w.msgC <- msg +} + +func positiveAliasSend(w *Watcher, msg *common.MessagePublication) { + out := w.msgC + out <- msg +} + +func positiveThinHelper(w *Watcher, msg *common.MessagePublication) { + publishDirect(w.msgC, msg) +} + +func publishDirect(out chan<- *common.MessagePublication, msg *common.MessagePublication) { + out <- msg +} + +func positiveAliasChain(w *Watcher, msg *common.MessagePublication) { + first := w.msgC + second := first + second <- msg +} + +func positiveClosureOverwriteDoesNotAffectOuterAlias(w *Watcher, msg *common.MessagePublication) { + out := w.msgC + unused := func() { + out = make(chan *common.MessagePublication, 1) + } + _ = unused + out <- msg +} diff --git a/codeql/test/evm-verify-and-publish-gate/node/pkg/watchers/evm/stubs.go b/codeql/test/evm-verify-and-publish-gate/node/pkg/watchers/evm/stubs.go new file mode 100644 index 0000000000..2ce937e036 --- /dev/null +++ b/codeql/test/evm-verify-and-publish-gate/node/pkg/watchers/evm/stubs.go @@ -0,0 +1,28 @@ +package evm + +import ( + "context" + + "github.com/certusone/wormhole/node/pkg/common" +) + +type Hash [32]byte + +type Receipt struct{} + +type Watcher struct { + msgC chan<- *common.MessagePublication +} + +func NewWatcher(msgC chan<- *common.MessagePublication) *Watcher { + return &Watcher{msgC: msgC} +} + +func (w *Watcher) verifyAndPublish(msg *common.MessagePublication, ctx context.Context, txHash Hash, receipt *Receipt) error { + w.msgC <- msg + return nil +} + +func makeMessage() *common.MessagePublication { + return &common.MessagePublication{} +} diff --git a/codeql/test/evm-verify-and-publish-gate/node/pkg/watchers/solana/non_evm.go b/codeql/test/evm-verify-and-publish-gate/node/pkg/watchers/solana/non_evm.go new file mode 100644 index 0000000000..fdc5ca729b --- /dev/null +++ b/codeql/test/evm-verify-and-publish-gate/node/pkg/watchers/solana/non_evm.go @@ -0,0 +1,11 @@ +package solana + +import "github.com/certusone/wormhole/node/pkg/common" + +type Watcher struct { + msgC chan<- *common.MessagePublication +} + +func NonEvmDirectSend(w *Watcher, msg *common.MessagePublication) { + w.msgC <- msg +} diff --git a/codeql/test/message-publication-canonical-timestamp/go.mod b/codeql/test/message-publication-canonical-timestamp/go.mod new file mode 100644 index 0000000000..ffc9534a9b --- /dev/null +++ b/codeql/test/message-publication-canonical-timestamp/go.mod @@ -0,0 +1,3 @@ +module codeql/messagepublicationcanonicaltimestamp + +go 1.22 diff --git a/codeql/test/message-publication-canonical-timestamp/message-publication-canonical-timestamp.expected b/codeql/test/message-publication-canonical-timestamp/message-publication-canonical-timestamp.expected new file mode 100644 index 0000000000..a354a917b7 --- /dev/null +++ b/codeql/test/message-publication-canonical-timestamp/message-publication-canonical-timestamp.expected @@ -0,0 +1,7 @@ +| node/pkg/watchers/evm/positive.go:13:14:13:43 | call to Unix | MessagePublication timestamp from chain-derived Unix seconds must be validated with vaa.TimeFromUnix and must not publish on conversion error; this timestamp appears to bypass the VAA uint32 wire-format check. | +| node/pkg/watchers/evm/positive.go:19:14:19:40 | call to Unix | MessagePublication timestamp from chain-derived Unix seconds must be validated with vaa.TimeFromUnix and must not publish on conversion error; this timestamp appears to bypass the VAA uint32 wire-format check. | +| node/pkg/watchers/evm/positive.go:24:15:24:44 | call to Unix | MessagePublication timestamp from chain-derived Unix seconds must be validated with vaa.TimeFromUnix and must not publish on conversion error; this timestamp appears to bypass the VAA uint32 wire-format check. | +| node/pkg/watchers/evm/positive.go:31:18:31:44 | call to TimeFromUnix | MessagePublication timestamp from chain-derived Unix seconds must be validated with vaa.TimeFromUnix and must not publish on conversion error; this timestamp appears to bypass the VAA uint32 wire-format check. | +| node/pkg/watchers/evm/positive.go:38:20:38:46 | call to TimeFromUnix | MessagePublication timestamp from chain-derived Unix seconds must be validated with vaa.TimeFromUnix and must not publish on conversion error; this timestamp appears to bypass the VAA uint32 wire-format check. | +| node/pkg/watchers/evm/positive.go:48:20:48:46 | call to TimeFromUnix | MessagePublication timestamp from chain-derived Unix seconds must be validated with vaa.TimeFromUnix and must not publish on conversion error; this timestamp appears to bypass the VAA uint32 wire-format check. | +| node/pkg/watchers/evm/positive.go:64:9:64:38 | call to Unix | MessagePublication timestamp from chain-derived Unix seconds must be validated with vaa.TimeFromUnix and must not publish on conversion error; this timestamp appears to bypass the VAA uint32 wire-format check. | diff --git a/codeql/test/message-publication-canonical-timestamp/message-publication-canonical-timestamp.qlref b/codeql/test/message-publication-canonical-timestamp/message-publication-canonical-timestamp.qlref new file mode 100644 index 0000000000..00db768b17 --- /dev/null +++ b/codeql/test/message-publication-canonical-timestamp/message-publication-canonical-timestamp.qlref @@ -0,0 +1 @@ +../../src/message-publication-canonical-timestamp.ql diff --git a/codeql/test/message-publication-canonical-timestamp/node/pkg/common/message.go b/codeql/test/message-publication-canonical-timestamp/node/pkg/common/message.go new file mode 100644 index 0000000000..d77f4a6742 --- /dev/null +++ b/codeql/test/message-publication-canonical-timestamp/node/pkg/common/message.go @@ -0,0 +1,7 @@ +package common + +import "time" + +type MessagePublication struct { + Timestamp time.Time +} diff --git a/codeql/test/message-publication-canonical-timestamp/node/pkg/watchers/evm/fakecommon/message.go b/codeql/test/message-publication-canonical-timestamp/node/pkg/watchers/evm/fakecommon/message.go new file mode 100644 index 0000000000..4ab0e9c794 --- /dev/null +++ b/codeql/test/message-publication-canonical-timestamp/node/pkg/watchers/evm/fakecommon/message.go @@ -0,0 +1,7 @@ +package fakecommon + +import "time" + +type MessagePublication struct { + Timestamp time.Time +} diff --git a/codeql/test/message-publication-canonical-timestamp/node/pkg/watchers/evm/negative.go b/codeql/test/message-publication-canonical-timestamp/node/pkg/watchers/evm/negative.go new file mode 100644 index 0000000000..6ec227136a --- /dev/null +++ b/codeql/test/message-publication-canonical-timestamp/node/pkg/watchers/evm/negative.go @@ -0,0 +1,61 @@ +package evm + +import ( + "time" + + "codeql/messagepublicationcanonicaltimestamp/node/pkg/common" + fakecommon "codeql/messagepublicationcanonicaltimestamp/node/pkg/watchers/evm/fakecommon" + "codeql/messagepublicationcanonicaltimestamp/sdk/vaa" +) + +func checkedTimeFromUnix(blockTime uint64) (common.MessagePublication, error) { + timestamp, err := vaa.TimeFromUnix(blockTime) + if err != nil { + return common.MessagePublication{}, err + } + return common.MessagePublication{ + Timestamp: timestamp, + }, nil +} + +func checkedTimeFromUnixNilGuard(blockTime uint64) (common.MessagePublication, error) { + timestamp, err := vaa.TimeFromUnix(blockTime) + if err == nil { + return common.MessagePublication{ + Timestamp: timestamp, + }, nil + } + return common.MessagePublication{}, err +} + +func localWallClock() common.MessagePublication { + return common.MessagePublication{ + Timestamp: time.Unix(time.Now().Unix(), 0), + } +} + +func typedTimeFromParser(timestamp time.Time) common.MessagePublication { + return common.MessagePublication{ + Timestamp: timestamp, + } +} + +func checkedWrapper(blockTime uint64) (common.MessagePublication, error) { + timestamp, err := canonicalPublicationTime(blockTime) + if err != nil { + return common.MessagePublication{}, err + } + return common.MessagePublication{ + Timestamp: timestamp, + }, nil +} + +func canonicalPublicationTime(blockTime uint64) (time.Time, error) { + return vaa.TimeFromUnix(blockTime) +} + +func unrelatedMessagePublicationType(blockTime uint64) fakecommon.MessagePublication { + return fakecommon.MessagePublication{ + Timestamp: time.Unix(int64(blockTime), 0), + } +} diff --git a/codeql/test/message-publication-canonical-timestamp/node/pkg/watchers/evm/positive.go b/codeql/test/message-publication-canonical-timestamp/node/pkg/watchers/evm/positive.go new file mode 100644 index 0000000000..54fc69e478 --- /dev/null +++ b/codeql/test/message-publication-canonical-timestamp/node/pkg/watchers/evm/positive.go @@ -0,0 +1,65 @@ +package evm + +import ( + t "time" + "time" + + "codeql/messagepublicationcanonicaltimestamp/node/pkg/common" + "codeql/messagepublicationcanonicaltimestamp/sdk/vaa" +) + +func directUnix(blockTime uint64) common.MessagePublication { + return common.MessagePublication{ + Timestamp: time.Unix(int64(blockTime), 0), + } +} + +func aliasedUnix(blockTime uint64) common.MessagePublication { + return common.MessagePublication{ + Timestamp: t.Unix(int64(blockTime), 0), + } +} + +func localUnix(blockTime uint64) common.MessagePublication { + timestamp := time.Unix(int64(blockTime), 0) + return common.MessagePublication{ + Timestamp: timestamp, + } +} + +func ignoredTimeFromUnixError(blockTime uint64) common.MessagePublication { + timestamp, _ := vaa.TimeFromUnix(blockTime) + return common.MessagePublication{ + Timestamp: timestamp, + } +} + +func fallbackAfterTimeFromUnixError(blockTime uint64) common.MessagePublication { + timestamp, err := vaa.TimeFromUnix(blockTime) + if err != nil { + timestamp = time.Time{} + } + return common.MessagePublication{ + Timestamp: timestamp, + } +} + +func publishInErrorBranch(blockTime uint64) (common.MessagePublication, error) { + timestamp, err := vaa.TimeFromUnix(blockTime) + if err != nil { + return common.MessagePublication{ + Timestamp: timestamp, + }, nil + } + return common.MessagePublication{}, err +} + +func unixWrapper(blockTime uint64) common.MessagePublication { + return common.MessagePublication{ + Timestamp: publicationTime(blockTime), + } +} + +func publicationTime(blockTime uint64) time.Time { + return time.Unix(int64(blockTime), 0) +} diff --git a/codeql/test/message-publication-canonical-timestamp/node/pkg/watchers/evm/positive_test.go b/codeql/test/message-publication-canonical-timestamp/node/pkg/watchers/evm/positive_test.go new file mode 100644 index 0000000000..f4cffe4c32 --- /dev/null +++ b/codeql/test/message-publication-canonical-timestamp/node/pkg/watchers/evm/positive_test.go @@ -0,0 +1,13 @@ +package evm + +import ( + "time" + + "codeql/messagepublicationcanonicaltimestamp/node/pkg/common" +) + +func testFixtureUnix() common.MessagePublication { + return common.MessagePublication{ + Timestamp: time.Unix(1, 0), + } +} diff --git a/codeql/test/message-publication-canonical-timestamp/node/pkg/watchers/evm/unsupported_boundaries.go b/codeql/test/message-publication-canonical-timestamp/node/pkg/watchers/evm/unsupported_boundaries.go new file mode 100644 index 0000000000..7d6ac5a7c3 --- /dev/null +++ b/codeql/test/message-publication-canonical-timestamp/node/pkg/watchers/evm/unsupported_boundaries.go @@ -0,0 +1,20 @@ +package evm + +import ( + "time" + + "codeql/messagepublicationcanonicaltimestamp/node/pkg/common" +) + +func unsupportedFieldAssignment(blockTime uint64) common.MessagePublication { + publication := common.MessagePublication{} + publication.Timestamp = time.Unix(int64(blockTime), 0) + return publication +} + +func unsupportedFunctionValue(blockTime uint64) common.MessagePublication { + converter := time.Unix + return common.MessagePublication{ + Timestamp: converter(int64(blockTime), 0), + } +} diff --git a/codeql/test/message-publication-canonical-timestamp/sdk/vaa/structs.go b/codeql/test/message-publication-canonical-timestamp/sdk/vaa/structs.go new file mode 100644 index 0000000000..8fc6a0a59b --- /dev/null +++ b/codeql/test/message-publication-canonical-timestamp/sdk/vaa/structs.go @@ -0,0 +1,14 @@ +package vaa + +import ( + "errors" + "math" + "time" +) + +func TimeFromUnix(timestamp uint64) (time.Time, error) { + if timestamp > math.MaxUint32 { + return time.Time{}, errors.New("timestamp exceeds VAA wire precision") + } + return time.Unix(int64(timestamp), 0), nil +} diff --git a/codeql/test/message-publication-safe-serialization/go.mod b/codeql/test/message-publication-safe-serialization/go.mod new file mode 100644 index 0000000000..3c951d95ef --- /dev/null +++ b/codeql/test/message-publication-safe-serialization/go.mod @@ -0,0 +1,3 @@ +module github.com/wormhole-foundation/wormhole/codeqltest/messagepublication + +go 1.22 diff --git a/codeql/test/message-publication-safe-serialization/message-publication-safe-serialization.expected b/codeql/test/message-publication-safe-serialization/message-publication-safe-serialization.expected new file mode 100644 index 0000000000..d3bc8a58d1 --- /dev/null +++ b/codeql/test/message-publication-safe-serialization/message-publication-safe-serialization.expected @@ -0,0 +1,12 @@ +| node/pkg/db/governor.go:10:9:10:23 | call to Marshal | MessagePublication current-format serialization must use MarshalBinary; deprecated Marshal omits Unreliable and verificationState and is allowed only for explicit old Governor migration reads. | +| node/pkg/db/governor.go:22:9:22:47 | call to UnmarshalMessagePublication | MessagePublication current-format serialization must use UnmarshalBinary; deprecated UnmarshalMessagePublication omits Unreliable and verificationState and is allowed only for explicit old Governor migration reads. | +| node/pkg/db/governor.go:32:14:32:29 | call to Marshal | MessagePublication current-format serialization must use MarshalBinary; deprecated Marshal omits Unreliable and verificationState and is allowed only for explicit old Governor migration reads. | +| node/pkg/db/governor.go:49:14:49:52 | call to UnmarshalMessagePublication | MessagePublication current-format serialization must use UnmarshalBinary; deprecated UnmarshalMessagePublication omits Unreliable and verificationState and is allowed only for explicit old Governor migration reads. | +| node/pkg/notary/notary.go:11:26:11:65 | selection of Marshal | MessagePublication current-format serialization must use MarshalBinary; deprecated Marshal omits Unreliable and verificationState and is allowed only for explicit old Governor migration reads. | +| node/pkg/notary/notary.go:12:28:12:65 | selection of UnmarshalMessagePublication | MessagePublication current-format serialization must use UnmarshalBinary; deprecated UnmarshalMessagePublication omits Unreliable and verificationState and is allowed only for explicit old Governor migration reads. | +| node/pkg/notary/notary.go:15:9:15:51 | call to UnmarshalMessagePublication | MessagePublication current-format serialization must use UnmarshalBinary; deprecated UnmarshalMessagePublication omits Unreliable and verificationState and is allowed only for explicit old Governor migration reads. | +| node/pkg/notary/notary.go:19:9:19:21 | call to Marshal | MessagePublication current-format serialization must use MarshalBinary; deprecated Marshal omits Unreliable and verificationState and is allowed only for explicit old Governor migration reads. | +| node/pkg/notary/notary.go:23:9:23:44 | call to Marshal | MessagePublication current-format serialization must use MarshalBinary; deprecated Marshal omits Unreliable and verificationState and is allowed only for explicit old Governor migration reads. | +| node/pkg/notary/notary.go:27:9:27:25 | call to Marshal | MessagePublication current-format serialization must use MarshalBinary; deprecated Marshal omits Unreliable and verificationState and is allowed only for explicit old Governor migration reads. | +| node/pkg/notary/notary.go:55:13:55:23 | selection of Marshal | MessagePublication current-format serialization must use MarshalBinary; deprecated Marshal omits Unreliable and verificationState and is allowed only for explicit old Governor migration reads. | +| node/pkg/notary/notary.go:60:9:60:53 | call to UnmarshalMessagePublication | MessagePublication current-format serialization must use UnmarshalBinary; deprecated UnmarshalMessagePublication omits Unreliable and verificationState and is allowed only for explicit old Governor migration reads. | diff --git a/codeql/test/message-publication-safe-serialization/message-publication-safe-serialization.qlref b/codeql/test/message-publication-safe-serialization/message-publication-safe-serialization.qlref new file mode 100644 index 0000000000..42ec39666a --- /dev/null +++ b/codeql/test/message-publication-safe-serialization/message-publication-safe-serialization.qlref @@ -0,0 +1 @@ +../../src/message-publication-safe-serialization.ql diff --git a/codeql/test/message-publication-safe-serialization/node/pkg/common/chainlock.go b/codeql/test/message-publication-safe-serialization/node/pkg/common/chainlock.go new file mode 100644 index 0000000000..3ac12fbe4f --- /dev/null +++ b/codeql/test/message-publication-safe-serialization/node/pkg/common/chainlock.go @@ -0,0 +1,32 @@ +package common + +type MessagePublication struct { + Payload []byte +} + +func (m *MessagePublication) Marshal() ([]byte, error) { + return m.Payload, nil +} + +func UnmarshalMessagePublication(data []byte) (*MessagePublication, error) { + return &MessagePublication{Payload: data}, nil +} + +func (m *MessagePublication) MarshalBinary() ([]byte, error) { + return m.Payload, nil +} + +func (m *MessagePublication) UnmarshalBinary(data []byte) error { + m.Payload = data + return nil +} + +func (m *MessagePublication) MarshalJSON() ([]byte, error) { + return m.Payload, nil +} + +type OtherMessage struct{} + +func (m *OtherMessage) Marshal() ([]byte, error) { + return nil, nil +} diff --git a/codeql/test/message-publication-safe-serialization/node/pkg/common/chainlock_test.go b/codeql/test/message-publication-safe-serialization/node/pkg/common/chainlock_test.go new file mode 100644 index 0000000000..94dfec6ab1 --- /dev/null +++ b/codeql/test/message-publication-safe-serialization/node/pkg/common/chainlock_test.go @@ -0,0 +1,7 @@ +package common + +func TestDeprecatedCompatibilityHelpersAreOutOfProductionScope() { + msg := &MessagePublication{} + _, _ = msg.Marshal() + _, _ = UnmarshalMessagePublication(nil) +} diff --git a/codeql/test/message-publication-safe-serialization/node/pkg/db/governor.go b/codeql/test/message-publication-safe-serialization/node/pkg/db/governor.go new file mode 100644 index 0000000000..58f51f6520 --- /dev/null +++ b/codeql/test/message-publication-safe-serialization/node/pkg/db/governor.go @@ -0,0 +1,59 @@ +package db + +import "github.com/wormhole-foundation/wormhole/codeqltest/messagepublication/node/pkg/common" + +type PendingTransfer struct { + Msg *common.MessagePublication +} + +func DeprecatedCurrentWrite(p *PendingTransfer) ([]byte, error) { + return p.Msg.Marshal() +} + +func CurrentWrite(p *PendingTransfer) ([]byte, error) { + bz, err := p.Msg.MarshalBinary() + if err != nil { + return nil, err + } + return bz, nil +} + +func DeprecatedCurrentRead(buf []byte) (*common.MessagePublication, error) { + return common.UnmarshalMessagePublication(buf) +} + +func UnmarshalPendingTransfer(buf []byte, isOld bool) (*PendingTransfer, error) { + if isOld { + oldMsg, err := common.UnmarshalMessagePublication(buf) + if err != nil { + return nil, err + } + if len(buf) == 0 { + _, err := oldMsg.Marshal() + if err != nil { + return nil, err + } + } + return &PendingTransfer{Msg: oldMsg}, nil + } + if isOld == true { + oldMsg, err := common.UnmarshalMessagePublication(buf) + if err != nil { + return nil, err + } + return &PendingTransfer{Msg: oldMsg}, nil + } + if len(buf) > 0 { + isOld := true + if isOld { + _, err := common.UnmarshalMessagePublication(buf) + return nil, err + } + } + + msg := &common.MessagePublication{} + if err := msg.UnmarshalBinary(buf); err != nil { + return nil, err + } + return &PendingTransfer{Msg: msg}, nil +} diff --git a/codeql/test/message-publication-safe-serialization/node/pkg/notary/notary.go b/codeql/test/message-publication-safe-serialization/node/pkg/notary/notary.go new file mode 100644 index 0000000000..6e8a2e5a32 --- /dev/null +++ b/codeql/test/message-publication-safe-serialization/node/pkg/notary/notary.go @@ -0,0 +1,61 @@ +package notary + +import wormcommon "github.com/wormhole-foundation/wormhole/codeqltest/messagepublication/node/pkg/common" + +type MP = wormcommon.MessagePublication + +type EmbeddedPublication struct { + wormcommon.MessagePublication +} + +var marshalPublication = (*wormcommon.MessagePublication).Marshal +var unmarshalPublication = wormcommon.UnmarshalMessagePublication + +func ImportAliasDeprecatedRead(buf []byte) (*wormcommon.MessagePublication, error) { + return wormcommon.UnmarshalMessagePublication(buf) +} + +func TypeAliasDeprecatedWrite(msg *MP) ([]byte, error) { + return msg.Marshal() +} + +func EmbeddedExplicitDeprecatedWrite(wrapper *EmbeddedPublication) ([]byte, error) { + return wrapper.MessagePublication.Marshal() +} + +func EmbeddedPromotedDeprecatedWrite(wrapper *EmbeddedPublication) ([]byte, error) { + return wrapper.Marshal() +} + +func CurrentBinaryAPIs(msg *wormcommon.MessagePublication, buf []byte) ([]byte, error) { + bz, err := msg.MarshalBinary() + if err != nil { + return nil, err + } + if err := msg.UnmarshalBinary(buf); err != nil { + return nil, err + } + return bz, nil +} + +func OutOfScopeMarshalJSON(msg *wormcommon.MessagePublication) ([]byte, error) { + return msg.MarshalJSON() +} + +func OutOfScopeOtherMarshal(msg *wormcommon.OtherMessage) ([]byte, error) { + return msg.Marshal() +} + +func UseCapturedHelpers(msg *wormcommon.MessagePublication, buf []byte) { + _, _ = marshalPublication(msg) + _, _ = unmarshalPublication(buf) +} + +func BoundMethodValue(msg *wormcommon.MessagePublication) { + marshal := msg.Marshal + _, _ = marshal() +} + +func ParenthesizedDeprecatedRead(buf []byte) (*wormcommon.MessagePublication, error) { + return (wormcommon.UnmarshalMessagePublication)(buf) +} diff --git a/codeql/test/message-publication-safe-serialization/tools/non_node.go b/codeql/test/message-publication-safe-serialization/tools/non_node.go new file mode 100644 index 0000000000..38b3caaeec --- /dev/null +++ b/codeql/test/message-publication-safe-serialization/tools/non_node.go @@ -0,0 +1,8 @@ +package tools + +import "github.com/wormhole-foundation/wormhole/codeqltest/messagepublication/node/pkg/common" + +func DeprecatedHelpersOutsideNodeAreOutOfScope(msg *common.MessagePublication, buf []byte) { + _, _ = msg.Marshal() + _, _ = common.UnmarshalMessagePublication(buf) +} diff --git a/codeql/test/near-finalized-receipt-outcome-before-publication/go.mod b/codeql/test/near-finalized-receipt-outcome-before-publication/go.mod new file mode 100644 index 0000000000..ea58ba01ec --- /dev/null +++ b/codeql/test/near-finalized-receipt-outcome-before-publication/go.mod @@ -0,0 +1,3 @@ +module github.com/certusone/wormhole + +go 1.22 diff --git a/codeql/test/near-finalized-receipt-outcome-before-publication/near-finalized-receipt-outcome-before-publication.expected b/codeql/test/near-finalized-receipt-outcome-before-publication/near-finalized-receipt-outcome-before-publication.expected new file mode 100644 index 0000000000..3cb938ecf3 --- /dev/null +++ b/codeql/test/near-finalized-receipt-outcome-before-publication/near-finalized-receipt-outcome-before-publication.expected @@ -0,0 +1,15 @@ +| node/pkg/watchers/near/near_miss.go:13:10:13:66 | call to processWormholeLog | NEAR receipt_outcome logs must not be published before proving that the same receipt_outcome.block_hash is finalized. | +| node/pkg/watchers/near/near_miss.go:24:10:24:66 | call to processWormholeLog | NEAR receipt_outcome logs must not be published before proving that the same receipt_outcome.block_hash is finalized. | +| node/pkg/watchers/near/positive.go:9:10:9:66 | call to processWormholeLog | NEAR receipt_outcome logs must not be published before proving that the same receipt_outcome.block_hash is finalized. | +| node/pkg/watchers/near/positive.go:18:13:18:69 | call to processWormholeLog | NEAR receipt_outcome logs must not be published before proving that the same receipt_outcome.block_hash is finalized. | +| node/pkg/watchers/near/positive.go:39:10:39:66 | call to processWormholeLog | NEAR receipt_outcome logs must not be published before proving that the same receipt_outcome.block_hash is finalized. | +| node/pkg/watchers/near/positive.go:52:10:52:66 | call to processWormholeLog | NEAR receipt_outcome logs must not be published before proving that the same receipt_outcome.block_hash is finalized. | +| node/pkg/watchers/near/positive.go:63:10:63:66 | call to processWormholeLog | NEAR receipt_outcome logs must not be published before proving that the same receipt_outcome.block_hash is finalized. | +| node/pkg/watchers/near/positive.go:78:10:78:66 | call to processWormholeLog | NEAR receipt_outcome logs must not be published before proving that the same receipt_outcome.block_hash is finalized. | +| node/pkg/watchers/near/positive.go:92:10:92:66 | call to processWormholeLog | NEAR receipt_outcome logs must not be published before proving that the same receipt_outcome.block_hash is finalized. | +| node/pkg/watchers/near/positive.go:107:10:107:69 | call to processWormholeLog | NEAR receipt_outcome logs must not be published before proving that the same receipt_outcome.block_hash is finalized. | +| node/pkg/watchers/near/positive.go:122:10:122:69 | call to processWormholeLog | NEAR receipt_outcome logs must not be published before proving that the same receipt_outcome.block_hash is finalized. | +| node/pkg/watchers/near/positive.go:136:10:136:69 | call to processWormholeLog | NEAR receipt_outcome logs must not be published before proving that the same receipt_outcome.block_hash is finalized. | +| node/pkg/watchers/near/positive.go:150:10:150:69 | call to processWormholeLog | NEAR receipt_outcome logs must not be published before proving that the same receipt_outcome.block_hash is finalized. | +| node/pkg/watchers/near/positive.go:164:10:164:69 | call to processWormholeLog | NEAR receipt_outcome logs must not be published before proving that the same receipt_outcome.block_hash is finalized. | +| node/pkg/watchers/near/positive.go:178:10:178:69 | call to processWormholeLog | NEAR receipt_outcome logs must not be published before proving that the same receipt_outcome.block_hash is finalized. | diff --git a/codeql/test/near-finalized-receipt-outcome-before-publication/near-finalized-receipt-outcome-before-publication.qlref b/codeql/test/near-finalized-receipt-outcome-before-publication/near-finalized-receipt-outcome-before-publication.qlref new file mode 100644 index 0000000000..292c307af4 --- /dev/null +++ b/codeql/test/near-finalized-receipt-outcome-before-publication/near-finalized-receipt-outcome-before-publication.qlref @@ -0,0 +1 @@ +../../src/near-finalized-receipt-outcome-before-publication.ql diff --git a/codeql/test/near-finalized-receipt-outcome-before-publication/node/pkg/watchers/near/near_miss.go b/codeql/test/near-finalized-receipt-outcome-before-publication/node/pkg/watchers/near/near_miss.go new file mode 100644 index 0000000000..29c24000cb --- /dev/null +++ b/codeql/test/near-finalized-receipt-outcome-before-publication/node/pkg/watchers/near/near_miss.go @@ -0,0 +1,27 @@ +package near + +import "context" + +func nearMissExistsOnly(e *Watcher, logger *Logger, ctx context.Context, job *Job, receiptOutcome Result) error { + outcomeBlockHash := receiptOutcome.Get("block_hash") + if !outcomeBlockHash.Exists() { + return errNotFinalized() + } + outcome := receiptOutcome.Get("outcome") + logs := outcome.Get("logs") + for _, log := range logs.Array() { + return e.processWormholeLog(logger, ctx, job, Header{}, "", log) + } + return nil +} + +func nearMissCacheMutationOnly(e *Watcher, logger *Logger, ctx context.Context, job *Job, receiptOutcome Result) error { + outcomeBlockHash := receiptOutcome.Get("block_hash") + e.finalizer.setFinalized(outcomeBlockHash.String()) + outcome := receiptOutcome.Get("outcome") + logs := outcome.Get("logs") + for _, log := range logs.Array() { + return e.processWormholeLog(logger, ctx, job, Header{}, "", log) + } + return nil +} diff --git a/codeql/test/near-finalized-receipt-outcome-before-publication/node/pkg/watchers/near/negative.go b/codeql/test/near-finalized-receipt-outcome-before-publication/node/pkg/watchers/near/negative.go new file mode 100644 index 0000000000..295eeb7558 --- /dev/null +++ b/codeql/test/near-finalized-receipt-outcome-before-publication/node/pkg/watchers/near/negative.go @@ -0,0 +1,53 @@ +package near + +import "context" + +func errNotFinalized() error { return nil } + +func negativeSameOutcomeFinality(e *Watcher, logger *Logger, ctx context.Context, job *Job, receiptOutcome Result) error { + outcome := receiptOutcome.Get("outcome") + outcomeBlockHash := receiptOutcome.Get("block_hash") + logs := outcome.Get("logs") + blockHeader, isFinalized := e.finalizer.isFinalized(logger, ctx, outcomeBlockHash.String()) + if !isFinalized { + return errNotFinalized() + } + for _, log := range logs.Array() { + return e.processWormholeLog(logger, ctx, job, blockHeader, "", log) + } + return nil +} + +func negativeTrueBranch(e *Watcher, logger *Logger, ctx context.Context, job *Job, receiptOutcome Result) error { + outcome := receiptOutcome.Get("outcome") + outcomeBlockHash := receiptOutcome.Get("block_hash") + logs := outcome.Get("logs") + blockHeader, isFinalized := e.finalizer.isFinalized(logger, ctx, outcomeBlockHash.String()) + if isFinalized { + for _, log := range logs.Array() { + return e.processWormholeLog(logger, ctx, job, blockHeader, "", log) + } + } + return nil +} + +func negativeDelayedFinalityRetry(e *Watcher, logger *Logger, ctx context.Context, job *Job, receiptOutcome Result) error { + outcome := receiptOutcome.Get("outcome") + outcomeBlockHash := receiptOutcome.Get("block_hash") + logs := outcome.Get("logs") + blockHeader, isFinalized := e.finalizer.isFinalized(logger, ctx, outcomeBlockHash.String()) + if !isFinalized { + return errNotFinalized() + } + for _, log := range logs.Array() { + if err := e.processWormholeLog(logger, ctx, job, blockHeader, "", log); err != nil { + return err + } + } + return nil +} + +func negativeReobservationSamePath(e *Watcher, logger *Logger, ctx context.Context, job *Job, receiptOutcome Result) error { + job.isReobservation = true + return negativeSameOutcomeFinality(e, logger, ctx, job, receiptOutcome) +} diff --git a/codeql/test/near-finalized-receipt-outcome-before-publication/node/pkg/watchers/near/positive.go b/codeql/test/near-finalized-receipt-outcome-before-publication/node/pkg/watchers/near/positive.go new file mode 100644 index 0000000000..ad9d27ac1d --- /dev/null +++ b/codeql/test/near-finalized-receipt-outcome-before-publication/node/pkg/watchers/near/positive.go @@ -0,0 +1,181 @@ +package near + +import "context" + +func positiveNoFinality(e *Watcher, logger *Logger, ctx context.Context, job *Job, receiptOutcome Result) error { + outcome := receiptOutcome.Get("outcome") + logs := outcome.Get("logs") + for _, log := range logs.Array() { + return e.processWormholeLog(logger, ctx, job, Header{}, "", log) + } + return nil +} + +func positiveFinalityAfterPublication(e *Watcher, logger *Logger, ctx context.Context, job *Job, receiptOutcome Result) error { + outcome := receiptOutcome.Get("outcome") + logs := outcome.Get("logs") + for _, log := range logs.Array() { + if err := e.processWormholeLog(logger, ctx, job, Header{}, "", log); err != nil { + return err + } + } + outcomeBlockHash := receiptOutcome.Get("block_hash") + _, isFinalized := e.finalizer.isFinalized(logger, ctx, outcomeBlockHash.String()) + if !isFinalized { + return errNotFinalized() + } + return nil +} + +func positiveWrongReceiptFinality(e *Watcher, logger *Logger, ctx context.Context, job *Job, receiptOutcome Result, otherReceiptOutcome Result) error { + outcomeBlockHash := otherReceiptOutcome.Get("block_hash") + _, isFinalized := e.finalizer.isFinalized(logger, ctx, outcomeBlockHash.String()) + if !isFinalized { + return errNotFinalized() + } + outcome := receiptOutcome.Get("outcome") + logs := outcome.Get("logs") + for _, log := range logs.Array() { + return e.processWormholeLog(logger, ctx, job, Header{}, "", log) + } + return nil +} + +func positiveTxBlockFinalityOnly(e *Watcher, logger *Logger, ctx context.Context, job *Job, receiptOutcome Result) error { + _, isFinalized := e.finalizer.isFinalized(logger, ctx, job.blockHash) + if !isFinalized { + return errNotFinalized() + } + outcome := receiptOutcome.Get("outcome") + logs := outcome.Get("logs") + for _, log := range logs.Array() { + return e.processWormholeLog(logger, ctx, job, Header{}, "", log) + } + return nil +} + +func positiveIgnoredBoolean(e *Watcher, logger *Logger, ctx context.Context, job *Job, receiptOutcome Result) error { + outcomeBlockHash := receiptOutcome.Get("block_hash") + e.finalizer.isFinalized(logger, ctx, outcomeBlockHash.String()) + outcome := receiptOutcome.Get("outcome") + logs := outcome.Get("logs") + for _, log := range logs.Array() { + return e.processWormholeLog(logger, ctx, job, Header{}, "", log) + } + return nil +} + +func positiveReceiptReassignedAfterProof(e *Watcher, logger *Logger, ctx context.Context, job *Job, receiptOutcome Result, replacement Result) error { + outcomeBlockHash := receiptOutcome.Get("block_hash") + _, isFinalized := e.finalizer.isFinalized(logger, ctx, outcomeBlockHash.String()) + if !isFinalized { + return errNotFinalized() + } + receiptOutcome = replacement + outcome := receiptOutcome.Get("outcome") + logs := outcome.Get("logs") + for _, log := range logs.Array() { + return e.processWormholeLog(logger, ctx, job, Header{}, "", log) + } + return nil +} + +func positiveRightBooleanWrongHeader(e *Watcher, logger *Logger, ctx context.Context, job *Job, receiptOutcome Result) error { + outcomeBlockHash := receiptOutcome.Get("block_hash") + _, isFinalized := e.finalizer.isFinalized(logger, ctx, outcomeBlockHash.String()) + if !isFinalized { + return errNotFinalized() + } + outcome := receiptOutcome.Get("outcome") + logs := outcome.Get("logs") + for _, log := range logs.Array() { + return e.processWormholeLog(logger, ctx, job, Header{}, "", log) + } + return nil +} + +func positiveHeaderReassignedAfterProof(e *Watcher, logger *Logger, ctx context.Context, job *Job, receiptOutcome Result) error { + outcomeBlockHash := receiptOutcome.Get("block_hash") + blockHeader, isFinalized := e.finalizer.isFinalized(logger, ctx, outcomeBlockHash.String()) + if !isFinalized { + return errNotFinalized() + } + blockHeader = Header{} + outcome := receiptOutcome.Get("outcome") + logs := outcome.Get("logs") + for _, log := range logs.Array() { + return e.processWormholeLog(logger, ctx, job, blockHeader, "", log) + } + return nil +} + +func positiveSeparatelyFetchedHeader(e *Watcher, logger *Logger, ctx context.Context, job *Job, receiptOutcome Result) error { + outcomeBlockHash := receiptOutcome.Get("block_hash") + _, isFinalized := e.finalizer.isFinalized(logger, ctx, outcomeBlockHash.String()) + if !isFinalized { + return errNotFinalized() + } + blockHeader, _ := e.finalizer.isFinalized(logger, ctx, outcomeBlockHash.String()) + outcome := receiptOutcome.Get("outcome") + logs := outcome.Get("logs") + for _, log := range logs.Array() { + return e.processWormholeLog(logger, ctx, job, blockHeader, "", log) + } + return nil +} + +func positiveInvertedFinalityGuard(e *Watcher, logger *Logger, ctx context.Context, job *Job, receiptOutcome Result) error { + outcome := receiptOutcome.Get("outcome") + outcomeBlockHash := receiptOutcome.Get("block_hash") + logs := outcome.Get("logs") + blockHeader, isFinalized := e.finalizer.isFinalized(logger, ctx, outcomeBlockHash.String()) + if isFinalized { + return nil + } + for _, log := range logs.Array() { + return e.processWormholeLog(logger, ctx, job, blockHeader, "", log) + } + return nil +} + +func positiveUnrelatedSameNameFinalizer(e *Watcher, other OtherFinalizer, logger *Logger, ctx context.Context, job *Job, receiptOutcome Result) error { + outcome := receiptOutcome.Get("outcome") + outcomeBlockHash := receiptOutcome.Get("block_hash") + logs := outcome.Get("logs") + blockHeader, isFinalized := other.isFinalized(logger, ctx, outcomeBlockHash.String()) + if !isFinalized { + return errNotFinalized() + } + for _, log := range logs.Array() { + return e.processWormholeLog(logger, ctx, job, blockHeader, "", log) + } + return nil +} + +func positiveDifferentWatcherFinalizer(e *Watcher, other *Watcher, logger *Logger, ctx context.Context, job *Job, receiptOutcome Result) error { + outcome := receiptOutcome.Get("outcome") + outcomeBlockHash := receiptOutcome.Get("block_hash") + logs := outcome.Get("logs") + blockHeader, isFinalized := other.finalizer.isFinalized(logger, ctx, outcomeBlockHash.String()) + if !isFinalized { + return errNotFinalized() + } + for _, log := range logs.Array() { + return e.processWormholeLog(logger, ctx, job, blockHeader, "", log) + } + return nil +} + +func positiveDifferentFinalizerValue(e *Watcher, finalizer Finalizer, logger *Logger, ctx context.Context, job *Job, receiptOutcome Result) error { + outcome := receiptOutcome.Get("outcome") + outcomeBlockHash := receiptOutcome.Get("block_hash") + logs := outcome.Get("logs") + blockHeader, isFinalized := finalizer.isFinalized(logger, ctx, outcomeBlockHash.String()) + if !isFinalized { + return errNotFinalized() + } + for _, log := range logs.Array() { + return e.processWormholeLog(logger, ctx, job, blockHeader, "", log) + } + return nil +} diff --git a/codeql/test/near-finalized-receipt-outcome-before-publication/node/pkg/watchers/near/stubs.go b/codeql/test/near-finalized-receipt-outcome-before-publication/node/pkg/watchers/near/stubs.go new file mode 100644 index 0000000000..f392685fb4 --- /dev/null +++ b/codeql/test/near-finalized-receipt-outcome-before-publication/node/pkg/watchers/near/stubs.go @@ -0,0 +1,37 @@ +package near + +import "context" + +type Result struct{} + +func (Result) Get(string) Result { return Result{} } +func (Result) Array() []Result { return nil } +func (Result) Exists() bool { return true } +func (Result) String() string { return "" } + +type Header struct{} +type Logger struct{} +type Job struct { + blockHash string + isReobservation bool +} +type Finalizer struct{} +type OtherFinalizer struct{} +type MessagePublication struct{} + +func (Finalizer) isFinalized(*Logger, context.Context, string) (Header, bool) { return Header{}, true } +func (Finalizer) setFinalized(string) {} +func (OtherFinalizer) isFinalized(*Logger, context.Context, string) (Header, bool) { return Header{}, true } + +type Watcher struct { + finalizer Finalizer + msgC chan *MessagePublication +} + +func parseReceipts() []Result { return nil } + +func (e *Watcher) processWormholeLog(_ *Logger, _ context.Context, _ *Job, _ Header, _ string, _ Result) error { + observation := &MessagePublication{} + e.msgC <- observation + return nil +} diff --git a/codeql/test/run-with-scissors-error-return-certusone-module/go.mod b/codeql/test/run-with-scissors-error-return-certusone-module/go.mod new file mode 100644 index 0000000000..ea58ba01ec --- /dev/null +++ b/codeql/test/run-with-scissors-error-return-certusone-module/go.mod @@ -0,0 +1,3 @@ +module github.com/certusone/wormhole + +go 1.22 diff --git a/codeql/test/run-with-scissors-error-return-certusone-module/node/pkg/common/scissors.go b/codeql/test/run-with-scissors-error-return-certusone-module/node/pkg/common/scissors.go new file mode 100644 index 0000000000..d106b4a9c2 --- /dev/null +++ b/codeql/test/run-with-scissors-error-return-certusone-module/node/pkg/common/scissors.go @@ -0,0 +1,14 @@ +package common + +import "context" + +func RunWithScissors(ctx context.Context, errC chan error, name string, runnable func() error) { + if err := runnable(); err != nil { + select { + case errC <- err: + default: + } + } + _ = ctx + _ = name +} diff --git a/codeql/test/run-with-scissors-error-return-certusone-module/node/pkg/watchers/scissors/certusone_positive.go b/codeql/test/run-with-scissors-error-return-certusone-module/node/pkg/watchers/scissors/certusone_positive.go new file mode 100644 index 0000000000..72667d55bd --- /dev/null +++ b/codeql/test/run-with-scissors-error-return-certusone-module/node/pkg/watchers/scissors/certusone_positive.go @@ -0,0 +1,16 @@ +package scissors + +import ( + "context" + "errors" + + "github.com/certusone/wormhole/node/pkg/common" +) + +func legacyModulePathInlineDirectSend(ctx context.Context, errC chan error) { + common.RunWithScissors(ctx, errC, "legacy-inline", func() error { + err := errors.New("legacy inline failed") + errC <- err + return nil + }) +} diff --git a/codeql/test/run-with-scissors-error-return-certusone-module/run-with-scissors-error-return-certusone-module.expected b/codeql/test/run-with-scissors-error-return-certusone-module/run-with-scissors-error-return-certusone-module.expected new file mode 100644 index 0000000000..adfb13a9ea --- /dev/null +++ b/codeql/test/run-with-scissors-error-return-certusone-module/run-with-scissors-error-return-certusone-module.expected @@ -0,0 +1 @@ +| node/pkg/watchers/scissors/certusone_positive.go:13:3:13:13 | send statement | Return this runnable error instead of sending directly to the RunWithScissors error channel; RunWithScissors forwards returned errors without blocking. | diff --git a/codeql/test/run-with-scissors-error-return-certusone-module/run-with-scissors-error-return-certusone-module.qlref b/codeql/test/run-with-scissors-error-return-certusone-module/run-with-scissors-error-return-certusone-module.qlref new file mode 100644 index 0000000000..8341c4b2fb --- /dev/null +++ b/codeql/test/run-with-scissors-error-return-certusone-module/run-with-scissors-error-return-certusone-module.qlref @@ -0,0 +1 @@ +../../src/run-with-scissors-error-return.ql diff --git a/codeql/test/run-with-scissors-error-return/go.mod b/codeql/test/run-with-scissors-error-return/go.mod new file mode 100644 index 0000000000..6b5170c363 --- /dev/null +++ b/codeql/test/run-with-scissors-error-return/go.mod @@ -0,0 +1,3 @@ +module github.com/wormhole-foundation/wormhole + +go 1.22 diff --git a/codeql/test/run-with-scissors-error-return/node/pkg/common/scissors.go b/codeql/test/run-with-scissors-error-return/node/pkg/common/scissors.go new file mode 100644 index 0000000000..c076c63731 --- /dev/null +++ b/codeql/test/run-with-scissors-error-return/node/pkg/common/scissors.go @@ -0,0 +1,23 @@ +package common + +import "context" + +func RunWithScissors(ctx context.Context, errC chan error, name string, runnable func() error) { + if err := runnable(); err != nil { + select { + case errC <- err: + default: + } + } + _ = ctx + _ = name +} + +func StartRunnable(errC chan error, runnable func() error) { + if err := runnable(); err != nil { + select { + case errC <- err: + default: + } + } +} diff --git a/codeql/test/run-with-scissors-error-return/node/pkg/watchers/scissors/negative.go b/codeql/test/run-with-scissors-error-return/node/pkg/watchers/scissors/negative.go new file mode 100644 index 0000000000..c3c2e7de52 --- /dev/null +++ b/codeql/test/run-with-scissors-error-return/node/pkg/watchers/scissors/negative.go @@ -0,0 +1,98 @@ +package scissors + +import ( + "context" + "errors" + "fmt" + + "github.com/wormhole-foundation/wormhole/node/pkg/common" +) + +func returnedErrorOnly(ctx context.Context, errC chan error) { + common.RunWithScissors(ctx, errC, "return", func() error { + return fmt.Errorf("return failed: %w", errors.New("boom")) + }) +} + +func siblingReadOnly(ctx context.Context, errC chan error) { + common.RunWithScissors(ctx, errC, "read", func() error { + select { + case err := <-errC: + return fmt.Errorf("sibling failed: %w", err) + default: + return nil + } + }) +} + +func unrelatedLocalChannel(ctx context.Context, errC chan error) { + otherErrC := make(chan error, 1) + common.RunWithScissors(ctx, errC, "unrelated-local", func() error { + otherErrC <- errors.New("not wrapper channel") + return nil + }) +} + +func unrelatedReceiverField(ctx context.Context, w *watcher) { + common.RunWithScissors(ctx, w.errC, "unrelated-field", func() error { + w.metricsErrC <- errors.New("metrics only") + return nil + }) +} + +func exactReceiverOnly(ctx context.Context, owner *watcher, other *watcher) { + common.RunWithScissors(ctx, owner.errC, "exact-receiver", other.run) +} + +func unrelatedHelperArgument(ctx context.Context, errC chan error, otherErrC chan error) { + common.RunWithScissors(ctx, errC, "unrelated-helper", func() error { + return reportOnChannel(otherErrC, errors.New("other helper channel")) + }) +} + +func wrapperOwnedForwarding(errC chan error, runnable func() error) { + if err := runnable(); err != nil { + select { + case errC <- err: + default: + } + } +} + +func thinWrapperNoSend(ctx context.Context, errC chan error, name string, runnable func() error) { + common.RunWithScissors(ctx, errC, name, runnable) +} + +func callerThroughUnsupportedThinWrapper(ctx context.Context, errC chan error) { + thinWrapperNoSend(ctx, errC, "unsupported-wrapper", func() error { + errC <- errors.New("currently unsupported wrapper boundary") + return nil + }) +} + +func localFunctionValueAssignedAfterRunCall(ctx context.Context, errC chan error) { + var run func() error + common.RunWithScissors(ctx, errC, "assigned-after-call", run) + run = func() error { + errC <- errors.New("assignment happens after RunWithScissors") + return nil + } +} + +func localFunctionValueOverwrittenBeforeRunCall(ctx context.Context, errC chan error) { + run := func() error { + errC <- errors.New("overwritten before RunWithScissors") + return nil + } + run = func() error { + return errors.New("current runnable returns through RunWithScissors") + } + common.RunWithScissors(ctx, errC, "overwritten-before-call", run) +} + +func goLaunchedHelperOutOfScope(ctx context.Context, errC chan error) { + common.RunWithScissors(ctx, errC, "go-helper", func() error { + go reportOnChannel(errC, errors.New("async helper is out of scope")) + return nil + }) +} diff --git a/codeql/test/run-with-scissors-error-return/node/pkg/watchers/scissors/positive.go b/codeql/test/run-with-scissors-error-return/node/pkg/watchers/scissors/positive.go new file mode 100644 index 0000000000..27615c3b8b --- /dev/null +++ b/codeql/test/run-with-scissors-error-return/node/pkg/watchers/scissors/positive.go @@ -0,0 +1,70 @@ +package scissors + +import ( + "context" + "errors" + + "github.com/wormhole-foundation/wormhole/node/pkg/common" +) + +type watcher struct { + errC chan error + metricsErrC chan error +} + +func inlineDirectSend(ctx context.Context, errC chan error) { + common.RunWithScissors(ctx, errC, "inline", func() error { + err := errors.New("inline failed") + errC <- err + return nil + }) +} + +func inlineReceiverFieldSend(ctx context.Context, w *watcher) { + common.RunWithScissors(ctx, w.errC, "receiver-field", func() error { + err := errors.New("receiver failed") + w.errC <- err + return err + }) +} + +func localFunctionValueSend(ctx context.Context, errC chan error) { + run := func() error { + err := errors.New("local function failed") + errC <- err + return nil + } + common.RunWithScissors(ctx, errC, "local", run) +} + +func methodValueSend(ctx context.Context, w *watcher) { + common.RunWithScissors(ctx, w.errC, "method", w.run) +} + +func (w *watcher) run() error { + err := errors.New("method failed") + w.errC <- err + return nil +} + +func helperChannelSend(ctx context.Context, errC chan error) { + common.RunWithScissors(ctx, errC, "helper-channel", func() error { + return reportOnChannel(errC, errors.New("helper failed")) + }) +} + +func reportOnChannel(ch chan error, err error) error { + ch <- err + return nil +} + +func helperReceiverSend(ctx context.Context, w *watcher) { + common.RunWithScissors(ctx, w.errC, "helper-receiver", func() error { + return w.reportOnReceiver(errors.New("receiver helper failed")) + }) +} + +func (w *watcher) reportOnReceiver(err error) error { + w.errC <- err + return nil +} diff --git a/codeql/test/run-with-scissors-error-return/node/pkg/watchers/scissors/unsupported.go b/codeql/test/run-with-scissors-error-return/node/pkg/watchers/scissors/unsupported.go new file mode 100644 index 0000000000..9774f12603 --- /dev/null +++ b/codeql/test/run-with-scissors-error-return/node/pkg/watchers/scissors/unsupported.go @@ -0,0 +1,40 @@ +package scissors + +import ( + "context" + "errors" + + "github.com/wormhole-foundation/wormhole/node/pkg/common" +) + +func nonRunWithScissorsGoroutine(errC chan error) { + go func() { + errC <- errors.New("plain goroutine") + }() +} + +func unsupportedTwoHopHelper(ctx context.Context, errC chan error) { + common.RunWithScissors(ctx, errC, "two-hop", func() error { + return outerHelper(errC, errors.New("two hop")) + }) +} + +func outerHelper(errC chan error, err error) error { + return reportOnChannel(errC, err) +} + +func unsupportedSameScopeLocalAlias(ctx context.Context, errC chan error) { + common.RunWithScissors(ctx, errC, "local-alias", func() error { + ch := errC + ch <- errors.New("local alias currently unsupported") + return nil + }) +} + +func unsupportedSameScopeReceiverFieldAlias(ctx context.Context, w *watcher) { + common.RunWithScissors(ctx, w.errC, "receiver-alias", func() error { + ch := w.errC + ch <- errors.New("receiver alias currently unsupported") + return nil + }) +} diff --git a/codeql/test/run-with-scissors-error-return/run-with-scissors-error-return.expected b/codeql/test/run-with-scissors-error-return/run-with-scissors-error-return.expected new file mode 100644 index 0000000000..8be7404515 --- /dev/null +++ b/codeql/test/run-with-scissors-error-return/run-with-scissors-error-return.expected @@ -0,0 +1,6 @@ +| node/pkg/watchers/scissors/positive.go:18:3:18:13 | send statement | Return this runnable error instead of sending directly to the RunWithScissors error channel; RunWithScissors forwards returned errors without blocking. | +| node/pkg/watchers/scissors/positive.go:26:3:26:15 | send statement | Return this runnable error instead of sending directly to the RunWithScissors error channel; RunWithScissors forwards returned errors without blocking. | +| node/pkg/watchers/scissors/positive.go:34:3:34:13 | send statement | Return this runnable error instead of sending directly to the RunWithScissors error channel; RunWithScissors forwards returned errors without blocking. | +| node/pkg/watchers/scissors/positive.go:46:2:46:14 | send statement | Return this runnable error instead of sending directly to the RunWithScissors error channel; RunWithScissors forwards returned errors without blocking. | +| node/pkg/watchers/scissors/positive.go:57:2:57:10 | send statement | Return this runnable error instead of sending directly to the RunWithScissors error channel; RunWithScissors forwards returned errors without blocking. | +| node/pkg/watchers/scissors/positive.go:68:2:68:14 | send statement | Return this runnable error instead of sending directly to the RunWithScissors error channel; RunWithScissors forwards returned errors without blocking. | diff --git a/codeql/test/run-with-scissors-error-return/run-with-scissors-error-return.qlref b/codeql/test/run-with-scissors-error-return/run-with-scissors-error-return.qlref new file mode 100644 index 0000000000..8341c4b2fb --- /dev/null +++ b/codeql/test/run-with-scissors-error-return/run-with-scissors-error-return.qlref @@ -0,0 +1 @@ +../../src/run-with-scissors-error-return.ql diff --git a/codeql/test/solana-commitment-match-before-publication/go.mod b/codeql/test/solana-commitment-match-before-publication/go.mod new file mode 100644 index 0000000000..ea58ba01ec --- /dev/null +++ b/codeql/test/solana-commitment-match-before-publication/go.mod @@ -0,0 +1,3 @@ +module github.com/certusone/wormhole + +go 1.22 diff --git a/codeql/test/solana-commitment-match-before-publication/node/pkg/watchers/solana/near_miss.go b/codeql/test/solana-commitment-match-before-publication/node/pkg/watchers/solana/near_miss.go new file mode 100644 index 0000000000..c2e34ad0f9 --- /dev/null +++ b/codeql/test/solana-commitment-match-before-publication/node/pkg/watchers/solana/near_miss.go @@ -0,0 +1,25 @@ +package solana + +func nearMissNonFinalizedReobservationStillNeedsCheck(s *SolanaWatcher, proposal *MessagePublicationAccount) { + isReobservation := true + _ = isReobservation + s.commitment = CommitmentConfirmed + commitment, err := accountConsistencyLevelToCommitment(proposal.ConsistencyLevel) + if err != nil { + return + } + _ = commitment + s.msgC <- &MessagePublication{} +} + +func nearMissPythnetCarveoutIsNotCommitmentCarveout(s *SolanaWatcher, proposal *MessagePublicationAccount) { + commitment, err := accountConsistencyLevelToCommitment(proposal.ConsistencyLevel) + if err != nil { + return + } + _ = commitment + // Simulates the unrelated Pythnet finalized-account-field carve-out. It must not suppress commitment checking. + if true { + s.msgC <- &MessagePublication{} + } +} diff --git a/codeql/test/solana-commitment-match-before-publication/node/pkg/watchers/solana/negative.go b/codeql/test/solana-commitment-match-before-publication/node/pkg/watchers/solana/negative.go new file mode 100644 index 0000000000..4ed7589719 --- /dev/null +++ b/codeql/test/solana-commitment-match-before-publication/node/pkg/watchers/solana/negative.go @@ -0,0 +1,115 @@ +package solana + +func negativeAccountChecked(s *SolanaWatcher, messageAccountData MessageAccountData, isReobservation bool) { + proposal, err := ParseMessagePublicationAccount(messageAccountData) + if err != nil { + return + } + commitment, err := accountConsistencyLevelToCommitment(proposal.ConsistencyLevel) + if err != nil { + return + } + if !s.checkCommitment(commitment, isReobservation) { + return + } + s.msgC <- &MessagePublication{} +} + +func negativeShimChecked(s *SolanaWatcher, postMessage *ShimPostMessageData, isReobservation bool) { + commitment, err := postMessage.ConsistencyLevel.Commitment() + if err != nil { + return + } + if !s.checkCommitment(commitment, isReobservation) { + return + } + s.msgC <- &MessagePublication{} +} + +func negativeInstructionScheduleChecked(s *SolanaWatcher, ctx Context, rpcClient *RPCClient, acc PublicKey, signature Signature, raw []byte, isReobservation bool) { + data, err := deserializePostMessage(raw) + if err != nil { + return + } + commitment, err := data.ConsistencyLevel.Commitment() + if err != nil { + return + } + if !s.checkCommitment(commitment, isReobservation) { + return + } + RunWithScissors(ctx, nil, "retryFetchMessageAccount", func(ctx Context) error { + s.retryFetchMessageAccount(ctx, rpcClient, acc, 0, 0, isReobservation, signature) + return nil + }) +} + +func negativeFinalizedWatcherReobservationStillUsesCheck(s *SolanaWatcher, proposal *MessagePublicationAccount) { + isReobservation := true + s.commitment = CommitmentFinalized + commitment, err := accountConsistencyLevelToCommitment(proposal.ConsistencyLevel) + if err != nil { + return + } + if !s.checkCommitment(commitment, isReobservation) { + return + } + s.msgC <- &MessagePublication{} +} + +func negativePythnetLikeConfirmedAccountSubscriptionStillChecked(s *SolanaWatcher, proposal *MessagePublicationAccount) { + isReobservation := false + s.commitment = CommitmentConfirmed + commitment, err := accountConsistencyLevelToCommitment(proposal.ConsistencyLevel) + if err != nil { + return + } + if !s.checkCommitment(commitment, isReobservation) { + return + } + s.msgC <- &MessagePublication{} +} + +func negativeUnrelatedRunWithScissorsAfterConversion(s *SolanaWatcher, ctx Context, postMessage *ShimPostMessageData, isReobservation bool) { + commitment, err := postMessage.ConsistencyLevel.Commitment() + if err != nil { + return + } + _ = commitment + RunWithScissors(ctx, nil, "metrics", func(ctx Context) error { + return nil + }) +} + +func negativeDirectRetryFetchChecked(s *SolanaWatcher, ctx Context, rpcClient *RPCClient, acc PublicKey, signature Signature, raw []byte, isReobservation bool) { + data, err := deserializePostMessage(raw) + if err != nil { + return + } + commitment, err := data.ConsistencyLevel.Commitment() + if err != nil { + return + } + if !s.checkCommitment(commitment, isReobservation) { + return + } + s.retryFetchMessageAccount(ctx, rpcClient, acc, 0, 0, isReobservation, signature) +} + +func negativeCloseEventDelegatesThroughAccountProof(s *SolanaWatcher, raw []byte, acc PublicKey, signature Signature) bool { + accountData, err := NewMessageAccountData(raw) + if err != nil { + return false + } + return s.processMessageAccount(nil, accountData, acc, true, signature, true) > 0 +} + +func negativeTrueBranchPublication(s *SolanaWatcher, proposal *MessagePublicationAccount, isReobservation bool) { + commitment, err := accountConsistencyLevelToCommitment(proposal.ConsistencyLevel) + if err != nil { + return + } + if s.checkCommitment(commitment, isReobservation) { + s.msgC <- &MessagePublication{} + } +} diff --git a/codeql/test/solana-commitment-match-before-publication/node/pkg/watchers/solana/positive.go b/codeql/test/solana-commitment-match-before-publication/node/pkg/watchers/solana/positive.go new file mode 100644 index 0000000000..6c3e7d73f2 --- /dev/null +++ b/codeql/test/solana-commitment-match-before-publication/node/pkg/watchers/solana/positive.go @@ -0,0 +1,237 @@ +package solana + +func positiveAccountNoCheck(s *SolanaWatcher, messageAccountData MessageAccountData, isReobservation bool) { + proposal, err := ParseMessagePublicationAccount(messageAccountData) + if err != nil { + return + } + commitment, err := accountConsistencyLevelToCommitment(proposal.ConsistencyLevel) + if err != nil { + return + } + _ = commitment + observation := &MessagePublication{} + s.msgC <- observation +} + +func positiveAccountCheckAfterSend(s *SolanaWatcher, proposal *MessagePublicationAccount, isReobservation bool) { + commitment, err := accountConsistencyLevelToCommitment(proposal.ConsistencyLevel) + if err != nil { + return + } + observation := &MessagePublication{} + s.msgC <- observation + if !s.checkCommitment(commitment, isReobservation) { + return + } +} + +func positiveAccountWrongCommitmentChecked(s *SolanaWatcher, proposal *MessagePublicationAccount, other *ShimPostMessageData, isReobservation bool) { + commitment, err := accountConsistencyLevelToCommitment(proposal.ConsistencyLevel) + if err != nil { + return + } + otherCommitment, err := other.ConsistencyLevel.Commitment() + if err != nil { + return + } + if !s.checkCommitment(otherCommitment, isReobservation) { + return + } + _ = commitment + s.msgC <- &MessagePublication{} +} + +func positiveIgnoredCheckResult(s *SolanaWatcher, proposal *MessagePublicationAccount, isReobservation bool) { + commitment, err := accountConsistencyLevelToCommitment(proposal.ConsistencyLevel) + if err != nil { + return + } + _ = s.checkCommitment(commitment, isReobservation) + s.msgC <- &MessagePublication{} +} + +func positiveNonDominatingCheck(s *SolanaWatcher, proposal *MessagePublicationAccount, isReobservation bool, maybe bool) { + commitment, err := accountConsistencyLevelToCommitment(proposal.ConsistencyLevel) + if err != nil { + return + } + if maybe { + if !s.checkCommitment(commitment, isReobservation) { + return + } + } + s.msgC <- &MessagePublication{} +} + +func positiveShimNoCheck(s *SolanaWatcher, postMessage *ShimPostMessageData, isReobservation bool) { + commitment, err := postMessage.ConsistencyLevel.Commitment() + if err != nil { + return + } + _ = commitment + s.msgC <- &MessagePublication{} +} + +func positiveInstructionScheduleNoCheck(s *SolanaWatcher, ctx Context, rpcClient *RPCClient, acc PublicKey, signature Signature, raw []byte, isReobservation bool) { + data, err := deserializePostMessage(raw) + if err != nil { + return + } + commitment, err := data.ConsistencyLevel.Commitment() + if err != nil { + return + } + _ = commitment + RunWithScissors(ctx, nil, "retryFetchMessageAccount", func(ctx Context) error { + s.retryFetchMessageAccount(ctx, rpcClient, acc, 0, 0, isReobservation, signature) + return nil + }) +} + +func positiveConversionErrorIgnored(s *SolanaWatcher, proposal *MessagePublicationAccount, isReobservation bool) { + commitment, _ := accountConsistencyLevelToCommitment(proposal.ConsistencyLevel) + if !s.checkCommitment(commitment, isReobservation) { + return + } + s.msgC <- &MessagePublication{} +} + +func positiveCommitmentReassignedAfterCheck(s *SolanaWatcher, proposal *MessagePublicationAccount, other *ShimPostMessageData, isReobservation bool) { + commitment, err := accountConsistencyLevelToCommitment(proposal.ConsistencyLevel) + if err != nil { + return + } + if !s.checkCommitment(commitment, isReobservation) { + return + } + commitment, err = other.ConsistencyLevel.Commitment() + if err != nil { + return + } + _ = commitment + s.msgC <- &MessagePublication{} +} + +func positiveInstructionScheduleNoLocalCheckEvenWhenAccountSideChecked(s *SolanaWatcher, ctx Context, rpcClient *RPCClient, acc PublicKey, signature Signature, raw []byte, proposal *MessagePublicationAccount, isReobservation bool) { + data, err := deserializePostMessage(raw) + if err != nil { + return + } + commitment, err := data.ConsistencyLevel.Commitment() + if err != nil { + return + } + _ = commitment + RunWithScissors(ctx, nil, "retryFetchMessageAccount", func(ctx Context) error { + s.retryFetchMessageAccount(ctx, rpcClient, acc, 0, 0, isReobservation, signature) + return nil + }) + + accountCommitment, err := accountConsistencyLevelToCommitment(proposal.ConsistencyLevel) + if err != nil { + return + } + if !s.checkCommitment(accountCommitment, isReobservation) { + return + } + s.msgC <- &MessagePublication{} +} + +func positiveDirectRetryFetchNoLocalCheck(s *SolanaWatcher, ctx Context, rpcClient *RPCClient, acc PublicKey, signature Signature, raw []byte, isReobservation bool) { + data, err := deserializePostMessage(raw) + if err != nil { + return + } + commitment, err := data.ConsistencyLevel.Commitment() + if err != nil { + return + } + _ = commitment + s.retryFetchMessageAccount(ctx, rpcClient, acc, 0, 0, isReobservation, signature) +} + +func positiveConfirmedReobservationStillNeedsCanonicalCheck(s *SolanaWatcher, proposal *MessagePublicationAccount) { + isReobservation := true + s.commitment = CommitmentConfirmed + commitment, err := accountConsistencyLevelToCommitment(proposal.ConsistencyLevel) + if err != nil { + return + } + if commitment != CommitmentConfirmed { + return + } + _ = isReobservation + s.msgC <- &MessagePublication{} +} + +func positiveInvertedGuardReturnsOnCheckSuccess(s *SolanaWatcher, proposal *MessagePublicationAccount, isReobservation bool) { + commitment, err := accountConsistencyLevelToCommitment(proposal.ConsistencyLevel) + if err != nil { + return + } + if s.checkCommitment(commitment, isReobservation) { + return + } + s.msgC <- &MessagePublication{} +} + +func positiveOtherReceiverSameNameHelper(s *SolanaWatcher, helper *CommitmentHelper, proposal *MessagePublicationAccount, isReobservation bool) { + commitment, err := accountConsistencyLevelToCommitment(proposal.ConsistencyLevel) + if err != nil { + return + } + if !helper.checkCommitment(commitment, isReobservation) { + return + } + s.msgC <- &MessagePublication{} +} + +func positiveDifferentWatcherChecked(s *SolanaWatcher, other *SolanaWatcher, proposal *MessagePublicationAccount, isReobservation bool) { + commitment, err := accountConsistencyLevelToCommitment(proposal.ConsistencyLevel) + if err != nil { + return + } + if !other.checkCommitment(commitment, isReobservation) { + return + } + s.msgC <- &MessagePublication{} +} + +func positiveNestedConditionalReturnInCommitmentFailureBranch(s *SolanaWatcher, proposal *MessagePublicationAccount, isReobservation bool, maybe bool) { + commitment, err := accountConsistencyLevelToCommitment(proposal.ConsistencyLevel) + if err != nil { + return + } + if !s.checkCommitment(commitment, isReobservation) { + if maybe { + return + } + } + s.msgC <- &MessagePublication{} +} + +func positiveNestedConditionalReturnInConversionErrorBranch(s *SolanaWatcher, proposal *MessagePublicationAccount, isReobservation bool, maybe bool) { + commitment, err := accountConsistencyLevelToCommitment(proposal.ConsistencyLevel) + if err != nil { + if maybe { + return + } + } + if !s.checkCommitment(commitment, isReobservation) { + return + } + s.msgC <- &MessagePublication{} +} + +func positiveStalePreConversionErrGuard(s *SolanaWatcher, messageAccountData MessageAccountData, isReobservation bool) { + proposal, err := ParseMessagePublicationAccount(messageAccountData) + if err != nil { + return + } + commitment, err := accountConsistencyLevelToCommitment(proposal.ConsistencyLevel) + if !s.checkCommitment(commitment, isReobservation) { + return + } + s.msgC <- &MessagePublication{} +} diff --git a/codeql/test/solana-commitment-match-before-publication/node/pkg/watchers/solana/stubs.go b/codeql/test/solana-commitment-match-before-publication/node/pkg/watchers/solana/stubs.go new file mode 100644 index 0000000000..a022d001f7 --- /dev/null +++ b/codeql/test/solana-commitment-match-before-publication/node/pkg/watchers/solana/stubs.go @@ -0,0 +1,89 @@ +package solana + +type CommitmentType string + +const ( + CommitmentConfirmed CommitmentType = "confirmed" + CommitmentFinalized CommitmentType = "finalized" +) + +type ConsistencyLevel uint8 + +func (c ConsistencyLevel) Commitment() (CommitmentType, error) { return CommitmentType(""), nil } + +func accountConsistencyLevelToCommitment(c uint8) (CommitmentType, error) { return CommitmentType(""), nil } + +type MessagePublicationAccount struct { + ConsistencyLevel uint8 +} + +type MessageAccountData struct{} + +func NewMessageAccountData(data []byte) (MessageAccountData, error) { return MessageAccountData{}, nil } + +func ParseMessagePublicationAccount(messageAccountData MessageAccountData) (*MessagePublicationAccount, error) { + return nil, nil +} + +type ShimPostMessageData struct { + ConsistencyLevel ConsistencyLevel + Nonce uint32 + Payload []byte +} + +type PostMessageData struct { + ConsistencyLevel ConsistencyLevel +} + +type MessagePublication struct{} + +type SolanaWatcher struct { + commitment CommitmentType + msgC chan<- *MessagePublication +} + +type CommitmentHelper struct{} + +func (h *CommitmentHelper) checkCommitment(commitment CommitmentType, isReobservation bool) bool { + return true +} + +func (s *SolanaWatcher) checkCommitment(commitment CommitmentType, isReobservation bool) bool { + if commitment == s.commitment { + return true + } + return isReobservation && s.commitment == CommitmentFinalized +} + +type PublicKey struct{} + +type Signature struct{} + +type Transaction struct{} + +type RPCClient struct{} + +type Context struct{} + +func RunWithScissors(ctx Context, errC chan error, name string, runnable func(Context) error) {} + +func (s *SolanaWatcher) retryFetchMessageAccount(ctx Context, rpcClient *RPCClient, acc PublicKey, slot uint64, retry uint, isReobservation bool, signature Signature) { +} + +func (s *SolanaWatcher) processMessageAccount(logger any, messageAccountData MessageAccountData, acc PublicKey, isReobservation bool, signature Signature, useSignatureAsTxID bool) uint32 { + proposal, err := ParseMessagePublicationAccount(messageAccountData) + if err != nil { + return 0 + } + commitment, err := accountConsistencyLevelToCommitment(proposal.ConsistencyLevel) + if err != nil { + return 0 + } + if !s.checkCommitment(commitment, isReobservation) { + return 0 + } + s.msgC <- &MessagePublication{} + return 1 +} + +func deserializePostMessage(data []byte) (PostMessageData, error) { return PostMessageData{}, nil } diff --git a/codeql/test/solana-commitment-match-before-publication/solana-commitment-match-before-publication.expected b/codeql/test/solana-commitment-match-before-publication/solana-commitment-match-before-publication.expected new file mode 100644 index 0000000000..bfabc2a606 --- /dev/null +++ b/codeql/test/solana-commitment-match-before-publication/solana-commitment-match-before-publication.expected @@ -0,0 +1,20 @@ +| node/pkg/watchers/solana/near_miss.go:12:2:12:32 | send statement | Solana watcher must prove the decoded message commitment matches the watcher commitment before scheduling or publishing the observation. | +| node/pkg/watchers/solana/near_miss.go:23:3:23:33 | send statement | Solana watcher must prove the decoded message commitment matches the watcher commitment before scheduling or publishing the observation. | +| node/pkg/watchers/solana/positive.go:14:2:14:22 | send statement | Solana watcher must prove the decoded message commitment matches the watcher commitment before scheduling or publishing the observation. | +| node/pkg/watchers/solana/positive.go:23:2:23:22 | send statement | Solana watcher must prove the decoded message commitment matches the watcher commitment before scheduling or publishing the observation. | +| node/pkg/watchers/solana/positive.go:42:2:42:32 | send statement | Solana watcher must prove the decoded message commitment matches the watcher commitment before scheduling or publishing the observation. | +| node/pkg/watchers/solana/positive.go:51:2:51:32 | send statement | Solana watcher must prove the decoded message commitment matches the watcher commitment before scheduling or publishing the observation. | +| node/pkg/watchers/solana/positive.go:64:2:64:32 | send statement | Solana watcher must prove the decoded message commitment matches the watcher commitment before scheduling or publishing the observation. | +| node/pkg/watchers/solana/positive.go:73:2:73:32 | send statement | Solana watcher must prove the decoded message commitment matches the watcher commitment before scheduling or publishing the observation. | +| node/pkg/watchers/solana/positive.go:86:2:89:3 | call to RunWithScissors | Solana watcher must prove the decoded message commitment matches the watcher commitment before scheduling or publishing the observation. | +| node/pkg/watchers/solana/positive.go:97:2:97:32 | send statement | Solana watcher must prove the decoded message commitment matches the watcher commitment before scheduling or publishing the observation. | +| node/pkg/watchers/solana/positive.go:113:2:113:32 | send statement | Solana watcher must prove the decoded message commitment matches the watcher commitment before scheduling or publishing the observation. | +| node/pkg/watchers/solana/positive.go:126:2:129:3 | call to RunWithScissors | Solana watcher must prove the decoded message commitment matches the watcher commitment before scheduling or publishing the observation. | +| node/pkg/watchers/solana/positive.go:151:2:151:82 | call to retryFetchMessageAccount | Solana watcher must prove the decoded message commitment matches the watcher commitment before scheduling or publishing the observation. | +| node/pkg/watchers/solana/positive.go:165:2:165:32 | send statement | Solana watcher must prove the decoded message commitment matches the watcher commitment before scheduling or publishing the observation. | +| node/pkg/watchers/solana/positive.go:176:2:176:32 | send statement | Solana watcher must prove the decoded message commitment matches the watcher commitment before scheduling or publishing the observation. | +| node/pkg/watchers/solana/positive.go:187:2:187:32 | send statement | Solana watcher must prove the decoded message commitment matches the watcher commitment before scheduling or publishing the observation. | +| node/pkg/watchers/solana/positive.go:198:2:198:32 | send statement | Solana watcher must prove the decoded message commitment matches the watcher commitment before scheduling or publishing the observation. | +| node/pkg/watchers/solana/positive.go:211:2:211:32 | send statement | Solana watcher must prove the decoded message commitment matches the watcher commitment before scheduling or publishing the observation. | +| node/pkg/watchers/solana/positive.go:224:2:224:32 | send statement | Solana watcher must prove the decoded message commitment matches the watcher commitment before scheduling or publishing the observation. | +| node/pkg/watchers/solana/positive.go:236:2:236:32 | send statement | Solana watcher must prove the decoded message commitment matches the watcher commitment before scheduling or publishing the observation. | diff --git a/codeql/test/solana-commitment-match-before-publication/solana-commitment-match-before-publication.qlref b/codeql/test/solana-commitment-match-before-publication/solana-commitment-match-before-publication.qlref new file mode 100644 index 0000000000..501751894c --- /dev/null +++ b/codeql/test/solana-commitment-match-before-publication/solana-commitment-match-before-publication.qlref @@ -0,0 +1 @@ +../../src/solana-commitment-match-before-publication.ql diff --git a/codeql/test/solana-message-account-validation/go.mod b/codeql/test/solana-message-account-validation/go.mod new file mode 100644 index 0000000000..37c2d13993 --- /dev/null +++ b/codeql/test/solana-message-account-validation/go.mod @@ -0,0 +1,3 @@ +module solana-message-account-validation-test + +go 1.22 diff --git a/codeql/test/solana-message-account-validation/node/pkg/watchers/solana/generated.pb.go b/codeql/test/solana-message-account-validation/node/pkg/watchers/solana/generated.pb.go new file mode 100644 index 0000000000..759fc57723 --- /dev/null +++ b/codeql/test/solana-message-account-validation/node/pkg/watchers/solana/generated.pb.go @@ -0,0 +1,6 @@ +// Code generated by a fixture generator. DO NOT EDIT. +package solana + +func generatedUnsafe(raw []byte) { + ParseMessagePublicationAccount(MessageAccountData{Data: raw}) +} diff --git a/codeql/test/solana-message-account-validation/node/pkg/watchers/solana/near_miss.go b/codeql/test/solana-message-account-validation/node/pkg/watchers/solana/near_miss.go new file mode 100644 index 0000000000..03200ffbbb --- /dev/null +++ b/codeql/test/solana-message-account-validation/node/pkg/watchers/solana/near_miss.go @@ -0,0 +1,50 @@ +package solana + +func mixedFactory(raw []byte, useManual bool) (MessageAccountData, error) { + if useManual { + return MessageAccountData{Data: raw}, nil + } + data, err := NewMessageAccountData(raw) + if err != nil { + return MessageAccountData{}, err + } + return data, nil +} + +func checkedMixedFactoryParse(raw []byte, useManual bool) error { + data, err := mixedFactory(raw, useManual) + if err != nil { + return err + } + ParseMessagePublicationAccount(data) + return nil +} + +func localPrefixCheckButCtor(raw []byte) error { + if len(raw) < 3 || string(raw[:3]) != "msg" { + return nil + } + data, err := NewMessageAccountData(raw) + if err != nil { + return err + } + ParseMessagePublicationAccount(data) + return nil +} + +func nestedMixedFactory(raw []byte, useManual bool) (MessageAccountData, error) { + data, err := mixedFactory(raw, useManual) + if err != nil { + return MessageAccountData{}, err + } + return data, nil +} + +func checkedNestedMixedFactoryParse(raw []byte, useManual bool) error { + data, err := nestedMixedFactory(raw, useManual) + if err != nil { + return err + } + ParseMessagePublicationAccount(data) + return nil +} diff --git a/codeql/test/solana-message-account-validation/node/pkg/watchers/solana/negative.go b/codeql/test/solana-message-account-validation/node/pkg/watchers/solana/negative.go new file mode 100644 index 0000000000..3e05a362e7 --- /dev/null +++ b/codeql/test/solana-message-account-validation/node/pkg/watchers/solana/negative.go @@ -0,0 +1,67 @@ +package solana + +import "context" + +func directConstructorParse(raw []byte) error { + data, err := NewMessageAccountData(raw) + if err != nil { + return err + } + alias := data + ParseMessagePublicationAccount(alias) + return nil +} + +func safeFactory(raw []byte) (MessageAccountData, error) { + data, err := NewMessageAccountData(raw) + if err != nil { + return MessageAccountData{}, err + } + return data, nil +} + +func checkedSafeFactoryProcess(ctx context.Context, watcher *SolanaWatcher, raw []byte) error { + data, err := safeFactory(raw) + if err != nil { + return err + } + watcher.processMessageAccount(ctx, data) + return nil +} + +func pointerValueRoundTrip(raw []byte) error { + data, err := NewMessageAccountData(raw) + if err != nil { + return err + } + ptr := &data + ParseMessagePublicationAccount(*ptr) + return nil +} + +func nestedSafeFactory(raw []byte) (MessageAccountData, error) { + data, err := safeFactory(raw) + if err != nil { + return MessageAccountData{}, err + } + return data, nil +} + +func checkedNestedSafeFactoryParse(raw []byte) error { + data, err := nestedSafeFactory(raw) + if err != nil { + return err + } + ParseMessagePublicationAccount(data) + return nil +} + +func constructorErrorOverwrittenAfterGuard(raw []byte) error { + data, err := NewMessageAccountData(raw) + if err != nil { + return err + } + err = nil + ParseMessagePublicationAccount(data) + return err +} diff --git a/codeql/test/solana-message-account-validation/node/pkg/watchers/solana/positive.go b/codeql/test/solana-message-account-validation/node/pkg/watchers/solana/positive.go new file mode 100644 index 0000000000..612f612d20 --- /dev/null +++ b/codeql/test/solana-message-account-validation/node/pkg/watchers/solana/positive.go @@ -0,0 +1,84 @@ +package solana + +import ( + "context" + "errors" +) + +type MessageAccountData struct { + Data []byte +} + +type SolanaWatcher struct{} + +func NewMessageAccountData(data []byte) (MessageAccountData, error) { + if len(data) == 0 { + return MessageAccountData{}, errors.New("empty") + } + return MessageAccountData{Data: data}, nil +} + +func ParseMessagePublicationAccount(data MessageAccountData) {} + +func (w *SolanaWatcher) processMessageAccount(ctx context.Context, data MessageAccountData) {} + +func uncheckedLiteralParse(raw []byte) { + ParseMessagePublicationAccount(MessageAccountData{Data: raw}) +} + +func manualFactory(raw []byte) (MessageAccountData, error) { + if len(raw) == 0 { + return MessageAccountData{}, errors.New("empty") + } + return MessageAccountData{Data: raw}, nil +} + +func checkedManualFactoryParse(raw []byte) error { + data, err := manualFactory(raw) + if err != nil { + return err + } + ParseMessagePublicationAccount(data) + return nil +} + +func rawPrefixCheckThenProcess(ctx context.Context, watcher *SolanaWatcher, raw []byte) { + if len(raw) < 3 || string(raw[:3]) != "msg" { + return + } + data := MessageAccountData{Data: raw} + watcher.processMessageAccount(ctx, data) +} + +func constructorErrorNotRejected(raw []byte) { + data, _ := NewMessageAccountData(raw) + ParseMessagePublicationAccount(data) +} + +func constructorErrorOverwrittenBeforeGuard(raw []byte) error { + data, err := NewMessageAccountData(raw) + err = errors.New("replacement") + if err != nil { + return err + } + ParseMessagePublicationAccount(data) + return nil +} + +func priorErrorGuardDoesNotValidateConstructor(raw []byte) error { + _, err := manualFactory(raw) + if err != nil { + return err + } + data, err := NewMessageAccountData(raw) + ParseMessagePublicationAccount(data) + return err +} + +func sameLineConstructorErrorOverwrite(raw []byte) error { + data, err := NewMessageAccountData(raw); err = errors.New("replacement"); if err != nil { + return err + } + ParseMessagePublicationAccount(data) + return nil +} diff --git a/codeql/test/solana-message-account-validation/node/pkg/watchers/solana/solana_test.go b/codeql/test/solana-message-account-validation/node/pkg/watchers/solana/solana_test.go new file mode 100644 index 0000000000..23e4886523 --- /dev/null +++ b/codeql/test/solana-message-account-validation/node/pkg/watchers/solana/solana_test.go @@ -0,0 +1,5 @@ +package solana + +func testUnsafe(raw []byte) { + ParseMessagePublicationAccount(MessageAccountData{Data: raw}) +} diff --git a/codeql/test/solana-message-account-validation/solana-message-account-validation.expected b/codeql/test/solana-message-account-validation/solana-message-account-validation.expected new file mode 100644 index 0000000000..4d9294ed10 --- /dev/null +++ b/codeql/test/solana-message-account-validation/solana-message-account-validation.expected @@ -0,0 +1,9 @@ +| node/pkg/watchers/solana/near_miss.go:19:2:19:37 | call to ParseMessagePublicationAccount | Solana message account data must be created by NewMessageAccountData before parsing or processing. | +| node/pkg/watchers/solana/near_miss.go:48:2:48:37 | call to ParseMessagePublicationAccount | Solana message account data must be created by NewMessageAccountData before parsing or processing. | +| node/pkg/watchers/solana/positive.go:26:2:26:62 | call to ParseMessagePublicationAccount | Solana message account data must be created by NewMessageAccountData before parsing or processing. | +| node/pkg/watchers/solana/positive.go:41:2:41:37 | call to ParseMessagePublicationAccount | Solana message account data must be created by NewMessageAccountData before parsing or processing. | +| node/pkg/watchers/solana/positive.go:50:2:50:41 | call to processMessageAccount | Solana message account data must be created by NewMessageAccountData before parsing or processing. | +| node/pkg/watchers/solana/positive.go:55:2:55:37 | call to ParseMessagePublicationAccount | Solana message account data must be created by NewMessageAccountData before parsing or processing. | +| node/pkg/watchers/solana/positive.go:64:2:64:37 | call to ParseMessagePublicationAccount | Solana message account data must be created by NewMessageAccountData before parsing or processing. | +| node/pkg/watchers/solana/positive.go:74:2:74:37 | call to ParseMessagePublicationAccount | Solana message account data must be created by NewMessageAccountData before parsing or processing. | +| node/pkg/watchers/solana/positive.go:82:2:82:37 | call to ParseMessagePublicationAccount | Solana message account data must be created by NewMessageAccountData before parsing or processing. | diff --git a/codeql/test/solana-message-account-validation/solana-message-account-validation.qlref b/codeql/test/solana-message-account-validation/solana-message-account-validation.qlref new file mode 100644 index 0000000000..44d3c0bf0e --- /dev/null +++ b/codeql/test/solana-message-account-validation/solana-message-account-validation.qlref @@ -0,0 +1 @@ +../../src/solana-message-account-validation.ql diff --git a/codeql/test/solana-require-successful-transaction-meta/go.mod b/codeql/test/solana-require-successful-transaction-meta/go.mod new file mode 100644 index 0000000000..6b5170c363 --- /dev/null +++ b/codeql/test/solana-require-successful-transaction-meta/go.mod @@ -0,0 +1,3 @@ +module github.com/wormhole-foundation/wormhole + +go 1.22 diff --git a/codeql/test/solana-require-successful-transaction-meta/node/pkg/watchers/solana/negative.go b/codeql/test/solana-require-successful-transaction-meta/node/pkg/watchers/solana/negative.go new file mode 100644 index 0000000000..9f94968dd3 --- /dev/null +++ b/codeql/test/solana-require-successful-transaction-meta/node/pkg/watchers/solana/negative.go @@ -0,0 +1,27 @@ +package solana + +func negativeValidatorGuard(s *SolanaWatcher, tx *Transaction, meta *TransactionMeta) { + if err := validateTransactionMeta(meta); err != nil { + return + } + s.processTransaction(tx, meta) +} + +func negativeDirectEquivalentGuard(s *SolanaWatcher, tx *Transaction, meta *TransactionMeta) { + if meta != nil && meta.Err == nil { + s.processTransaction(tx, meta) + } +} + +func negativeValidatedLogAndExtraction(result TransactionResult) { + if metadataErr := validateTransactionMeta(result.Meta); metadataErr != nil { + return + } + _ = result.Meta.LogMessages + result.Transaction.GetTransaction() + result.GetTransaction() +} + +func negativeWrapperAccountException(account string) { + processAccountSubscriptionData(account) +} diff --git a/codeql/test/solana-require-successful-transaction-meta/node/pkg/watchers/solana/positive.go b/codeql/test/solana-require-successful-transaction-meta/node/pkg/watchers/solana/positive.go new file mode 100644 index 0000000000..e6bc0731ab --- /dev/null +++ b/codeql/test/solana-require-successful-transaction-meta/node/pkg/watchers/solana/positive.go @@ -0,0 +1,79 @@ +package solana + +func positiveProcessNoValidation(s *SolanaWatcher, tx *Transaction, meta *TransactionMeta) { + s.processTransaction(tx, meta) +} + +func positiveLogUseNoValidation(result TransactionResult) { + _ = result.Meta.LogMessages +} + +func positiveExtractionNoValidation(result TransactionResult) { + result.Transaction.GetTransaction() +} + +func positiveResponseExtractionNoValidation(result TransactionResult) { + result.GetTransaction() +} + +func positiveDifferentMeta(s *SolanaWatcher, tx *Transaction, checked *TransactionMeta, used *TransactionMeta) { + if err := validateTransactionMeta(checked); err != nil { + return + } + s.processTransaction(tx, used) +} + +func positiveValidationAfterSink(s *SolanaWatcher, tx *Transaction, meta *TransactionMeta) { + s.processTransaction(tx, meta) + if err := validateTransactionMeta(meta); err != nil { + return + } +} + +func positiveReassignedAfterValidation(s *SolanaWatcher, tx *Transaction, meta *TransactionMeta, replacement *TransactionMeta) { + if err := validateTransactionMeta(meta); err != nil { + return + } + meta = replacement + s.processTransaction(tx, meta) +} + +func positiveIgnoredValidatorError(s *SolanaWatcher, tx *Transaction, meta *TransactionMeta) { + _ = validateTransactionMeta(meta) + s.processTransaction(tx, meta) +} + +func positiveErrorCheckedAfterSink(s *SolanaWatcher, tx *Transaction, meta *TransactionMeta) { + err := validateTransactionMeta(meta) + s.processTransaction(tx, meta) + if err != nil { + return + } +} + +func positivePartialNestedReturn(s *SolanaWatcher, tx *Transaction, meta *TransactionMeta, shouldReturn bool) { + if err := validateTransactionMeta(meta); err != nil { + if shouldReturn { + return + } + } + s.processTransaction(tx, meta) +} + +func positiveValidatorErrReassignedToNil(s *SolanaWatcher, tx *Transaction, meta *TransactionMeta) { + err := validateTransactionMeta(meta) + err = nil + if err != nil { + return + } + s.processTransaction(tx, meta) +} + +func positiveDirectProofReassigned(s *SolanaWatcher, tx *Transaction, meta *TransactionMeta, replacement *TransactionMeta) { + if meta != nil { + meta = replacement + if meta.Err == nil { + s.processTransaction(tx, meta) + } + } +} diff --git a/codeql/test/solana-require-successful-transaction-meta/node/pkg/watchers/solana/solana_meta_test.go b/codeql/test/solana-require-successful-transaction-meta/node/pkg/watchers/solana/solana_meta_test.go new file mode 100644 index 0000000000..f1fc8b3551 --- /dev/null +++ b/codeql/test/solana-require-successful-transaction-meta/node/pkg/watchers/solana/solana_meta_test.go @@ -0,0 +1,8 @@ +package solana + +import "testing" + +func TestExcludedTransactionMetaUse(t *testing.T) { + meta := &TransactionMeta{} + _ = meta.LogMessages +} diff --git a/codeql/test/solana-require-successful-transaction-meta/node/pkg/watchers/solana/stubs.go b/codeql/test/solana-require-successful-transaction-meta/node/pkg/watchers/solana/stubs.go new file mode 100644 index 0000000000..e8d01c5053 --- /dev/null +++ b/codeql/test/solana-require-successful-transaction-meta/node/pkg/watchers/solana/stubs.go @@ -0,0 +1,33 @@ +package solana + +type TransactionMeta struct { + Err error + LogMessages []string +} + +type TransactionResult struct { + Meta *TransactionMeta + Transaction EncodedTransaction +} + +type EncodedTransaction struct{} + +type Transaction struct{} + +type SolanaWatcher struct{} + +func (EncodedTransaction) GetTransaction() (*Transaction, error) { return nil, nil } + +func (TransactionResult) GetTransaction() (*Transaction, error) { return nil, nil } + +func validateTransactionMeta(meta *TransactionMeta) error { return nil } + +func (s *SolanaWatcher) processTransaction(tx *Transaction, meta *TransactionMeta) uint32 { + if metadataErr := validateTransactionMeta(meta); metadataErr != nil { + return 0 + } + _ = meta.LogMessages + return 1 +} + +func processAccountSubscriptionData(account string) {} diff --git a/codeql/test/solana-require-successful-transaction-meta/solana-require-successful-transaction-meta.expected b/codeql/test/solana-require-successful-transaction-meta/solana-require-successful-transaction-meta.expected new file mode 100644 index 0000000000..9a0c0f5f38 --- /dev/null +++ b/codeql/test/solana-require-successful-transaction-meta/solana-require-successful-transaction-meta.expected @@ -0,0 +1,12 @@ +| node/pkg/watchers/solana/positive.go:4:2:4:31 | call to processTransaction | Solana transaction metadata must be validated successful before transaction parsing or metadata use. | +| node/pkg/watchers/solana/positive.go:8:6:8:28 | selection of LogMessages | Solana transaction metadata must be validated successful before transaction parsing or metadata use. | +| node/pkg/watchers/solana/positive.go:12:2:12:36 | call to GetTransaction | Solana transaction metadata must be validated successful before transaction parsing or metadata use. | +| node/pkg/watchers/solana/positive.go:16:2:16:24 | call to GetTransaction | Solana transaction metadata must be validated successful before transaction parsing or metadata use. | +| node/pkg/watchers/solana/positive.go:23:2:23:31 | call to processTransaction | Solana transaction metadata must be validated successful before transaction parsing or metadata use. | +| node/pkg/watchers/solana/positive.go:27:2:27:31 | call to processTransaction | Solana transaction metadata must be validated successful before transaction parsing or metadata use. | +| node/pkg/watchers/solana/positive.go:38:2:38:31 | call to processTransaction | Solana transaction metadata must be validated successful before transaction parsing or metadata use. | +| node/pkg/watchers/solana/positive.go:43:2:43:31 | call to processTransaction | Solana transaction metadata must be validated successful before transaction parsing or metadata use. | +| node/pkg/watchers/solana/positive.go:48:2:48:31 | call to processTransaction | Solana transaction metadata must be validated successful before transaction parsing or metadata use. | +| node/pkg/watchers/solana/positive.go:60:2:60:31 | call to processTransaction | Solana transaction metadata must be validated successful before transaction parsing or metadata use. | +| node/pkg/watchers/solana/positive.go:69:2:69:31 | call to processTransaction | Solana transaction metadata must be validated successful before transaction parsing or metadata use. | +| node/pkg/watchers/solana/positive.go:76:4:76:33 | call to processTransaction | Solana transaction metadata must be validated successful before transaction parsing or metadata use. | diff --git a/codeql/test/solana-require-successful-transaction-meta/solana-require-successful-transaction-meta.qlref b/codeql/test/solana-require-successful-transaction-meta/solana-require-successful-transaction-meta.qlref new file mode 100644 index 0000000000..94ca503060 --- /dev/null +++ b/codeql/test/solana-require-successful-transaction-meta/solana-require-successful-transaction-meta.qlref @@ -0,0 +1 @@ +../../src/solana-require-successful-transaction-meta.ql diff --git a/codeql/test/xrpl-derived-generated-emitter/ethcrypto/crypto.go b/codeql/test/xrpl-derived-generated-emitter/ethcrypto/crypto.go new file mode 100644 index 0000000000..9e49cc8eb1 --- /dev/null +++ b/codeql/test/xrpl-derived-generated-emitter/ethcrypto/crypto.go @@ -0,0 +1,7 @@ +package ethcrypto + +func Keccak256(data []byte) []byte { + out := make([]byte, 32) + copy(out, data) + return out +} diff --git a/codeql/test/xrpl-derived-generated-emitter/go.mod b/codeql/test/xrpl-derived-generated-emitter/go.mod new file mode 100644 index 0000000000..19ffef8085 --- /dev/null +++ b/codeql/test/xrpl-derived-generated-emitter/go.mod @@ -0,0 +1,3 @@ +module codeql/xrplderivedgeneratedemitter + +go 1.22 diff --git a/codeql/test/xrpl-derived-generated-emitter/node/pkg/watchers/xrpl/badlayout/positive.go b/codeql/test/xrpl-derived-generated-emitter/node/pkg/watchers/xrpl/badlayout/positive.go new file mode 100644 index 0000000000..9a8a888995 --- /dev/null +++ b/codeql/test/xrpl-derived-generated-emitter/node/pkg/watchers/xrpl/badlayout/positive.go @@ -0,0 +1,43 @@ +package badlayout + +type ChainID int + +type chainIDs struct{ ChainIDXRPL ChainID } + +var vaa chainIDs + +type Address [20]byte +type Hash [32]byte + +type MessagePublication struct { + EmitterChain ChainID + EmitterAddress Hash + Payload []byte +} + +var xackPrefix = []byte("XACK") +var generatedEmitterPrefix = []byte("XRPL") + +func addressToEmitter(account Address) Hash { + var out Hash + copy(out[12:], account[:]) + return out +} + +func calculateGeneratedEmitterAddress(account Address) Hash { + out := addressToEmitter(account) + copy(out[:4], generatedEmitterPrefix) + out[4] = 1 + return out +} + +func positiveXACKBadGeneratedLayout(account Address) MessagePublication { + payload := make([]byte, 8) + copy(payload, xackPrefix) + emitterAddress := calculateGeneratedEmitterAddress(account) + return MessagePublication{ + EmitterChain: vaa.ChainIDXRPL, + EmitterAddress: emitterAddress, + Payload: payload, + } +} diff --git a/codeql/test/xrpl-derived-generated-emitter/node/pkg/watchers/xrpl/boundary.go b/codeql/test/xrpl-derived-generated-emitter/node/pkg/watchers/xrpl/boundary.go new file mode 100644 index 0000000000..9d2e2bb9b4 --- /dev/null +++ b/codeql/test/xrpl-derived-generated-emitter/node/pkg/watchers/xrpl/boundary.go @@ -0,0 +1,21 @@ +package xrpl + +func boundaryPayloadOnlyXTCF() []byte { + payload := make([]byte, 8) + copy(payload, xtcfPrefix) + return payload +} + +func boundaryNonXRPLChain(account Address) MessagePublication { + payload := make([]byte, 8) + copy(payload, xtcfPrefix) + return MessagePublication{ + EmitterChain: vaa.ChainIDSolana, + EmitterAddress: addressToEmitter(account), + Payload: payload, + } +} + +func boundaryRoutingOnly(publication MessagePublication) MessagePublication { + return publication +} diff --git a/codeql/test/xrpl-derived-generated-emitter/node/pkg/watchers/xrpl/generated.go b/codeql/test/xrpl-derived-generated-emitter/node/pkg/watchers/xrpl/generated.go new file mode 100644 index 0000000000..d9cab78fd1 --- /dev/null +++ b/codeql/test/xrpl-derived-generated-emitter/node/pkg/watchers/xrpl/generated.go @@ -0,0 +1,14 @@ +// Code generated by fixture generator; DO NOT EDIT. + +package xrpl + +func generatedBadXTCF(account Address) MessagePublication { + payload := make([]byte, 8) + copy(payload, xtcfPrefix) + emitterAddress := addressToEmitter(account) + return MessagePublication{ + EmitterChain: vaa.ChainIDXRPL, + EmitterAddress: emitterAddress, + Payload: payload, + } +} diff --git a/codeql/test/xrpl-derived-generated-emitter/node/pkg/watchers/xrpl/near_miss.go b/codeql/test/xrpl-derived-generated-emitter/node/pkg/watchers/xrpl/near_miss.go new file mode 100644 index 0000000000..772dba2d37 --- /dev/null +++ b/codeql/test/xrpl-derived-generated-emitter/node/pkg/watchers/xrpl/near_miss.go @@ -0,0 +1,30 @@ +package xrpl + +func nearMissXACKHelperNamedGeneratedButNoOverlay(account Address) MessagePublication { + payload := make([]byte, 8) + copy(payload, xackPrefix) + emitterAddress := calculateGeneratedEmitterAddressNoOverlay(account) + return MessagePublication{ + EmitterChain: vaa.ChainIDXRPL, + EmitterAddress: emitterAddress, + Payload: payload, + } +} + +func calculateGeneratedEmitterAddressNoOverlay(account Address) Hash { + return addressToEmitter(account) +} + +func nearMissUnreachableBadXTCF(account Address) *MessagePublication { + payload := make([]byte, 8) + copy(payload, xtcfPrefix) + if false { + emitterAddress := addressToEmitter(account) + return &MessagePublication{ + EmitterChain: vaa.ChainIDXRPL, + EmitterAddress: emitterAddress, + Payload: payload, + } + } + return nil +} diff --git a/codeql/test/xrpl-derived-generated-emitter/node/pkg/watchers/xrpl/negative.go b/codeql/test/xrpl-derived-generated-emitter/node/pkg/watchers/xrpl/negative.go new file mode 100644 index 0000000000..0f99b4a0e7 --- /dev/null +++ b/codeql/test/xrpl-derived-generated-emitter/node/pkg/watchers/xrpl/negative.go @@ -0,0 +1,32 @@ +package xrpl + +func negativeXTCFGeneratedEmitter(account Address) MessagePublication { + payload := make([]byte, 8) + copy(payload, xtcfPrefix) + emitterAddress := calculateGeneratedEmitterAddress(account) + return MessagePublication{ + EmitterChain: vaa.ChainIDXRPL, + EmitterAddress: emitterAddress, + Payload: payload, + } +} + +func negativeXACKGeneratedEmitter(account Address) MessagePublication { + payload := make([]byte, 8) + copy(payload, xackPrefix) + emitterAddress := calculateGeneratedEmitterAddress(account) + return MessagePublication{ + EmitterChain: vaa.ChainIDXRPL, + EmitterAddress: emitterAddress, + Payload: payload, + } +} + +func negativeCoreRawEmitterException(account Address, memo []byte) MessagePublication { + emitterAddress := addressToEmitter(account) + return MessagePublication{ + EmitterChain: vaa.ChainIDXRPL, + EmitterAddress: emitterAddress, + Payload: memo, + } +} diff --git a/codeql/test/xrpl-derived-generated-emitter/node/pkg/watchers/xrpl/nttignored/positive.go b/codeql/test/xrpl-derived-generated-emitter/node/pkg/watchers/xrpl/nttignored/positive.go new file mode 100644 index 0000000000..81eba6e3dd --- /dev/null +++ b/codeql/test/xrpl-derived-generated-emitter/node/pkg/watchers/xrpl/nttignored/positive.go @@ -0,0 +1,48 @@ +package nttignored + +type ChainID int +type chainIDs struct{ ChainIDXRPL ChainID } + +var vaa chainIDs + +type Address [20]byte +type Hash [32]byte + +type MessagePublication struct { + EmitterChain ChainID + EmitterAddress Hash + Payload []byte +} + +func addressToEmitter(account Address) Hash { + var out Hash + copy(out[12:], account[:]) + return out +} + +func Keccak256(data []byte) Hash { + var out Hash + copy(out[:], data) + return out +} + +func buildNTTPayload(sourceToken Hash) []byte { return sourceToken[:] } + +func calculateEmitterAddress(sourceNTTManager Hash, sourceToken Hash) Hash { + buf := make([]byte, 67) + copy(buf[:3], "ntt") + copy(buf[3:35], sourceNTTManager[:]) + copy(buf[35:], sourceToken[:]) + _ = Keccak256(buf) + return sourceNTTManager +} + +func parseNttTransaction(destination Address, sourceToken Hash) MessagePublication { + sourceNTTManager := addressToEmitter(destination) + payload := buildNTTPayload(sourceToken) + return MessagePublication{ + EmitterChain: vaa.ChainIDXRPL, + EmitterAddress: calculateEmitterAddress(sourceNTTManager, sourceToken), + Payload: payload, + } +} diff --git a/codeql/test/xrpl-derived-generated-emitter/node/pkg/watchers/xrpl/nttwrong/near_miss.go b/codeql/test/xrpl-derived-generated-emitter/node/pkg/watchers/xrpl/nttwrong/near_miss.go new file mode 100644 index 0000000000..0f0f465a0f --- /dev/null +++ b/codeql/test/xrpl-derived-generated-emitter/node/pkg/watchers/xrpl/nttwrong/near_miss.go @@ -0,0 +1,51 @@ +package nttwrong + +type ChainID int + +type chainIDs struct{ ChainIDXRPL ChainID } + +var vaa chainIDs + +type Address [20]byte +type Hash [32]byte + +type MessagePublication struct { + EmitterChain ChainID + EmitterAddress Hash + Payload []byte +} + +func addressToEmitter(account Address) Hash { + var out Hash + copy(out[12:], account[:]) + return out +} + +func Keccak256(data []byte) Hash { + var out Hash + copy(out[:], data) + return out +} + +func buildNTTPayload(sourceToken Hash) []byte { + return sourceToken[:] +} + +func calculateEmitterAddress(sourceNTTManager Hash, sourceToken Hash) Hash { + buf := make([]byte, 67) + copy(buf[:32], sourceToken[:]) + copy(buf[32:64], sourceNTTManager[:]) + copy(buf[64:67], "ntt") + return Keccak256(buf) +} + +func parseNttTransaction(destination Address, sourceToken Hash) MessagePublication { + sourceNTTManager := addressToEmitter(destination) + payload := buildNTTPayload(sourceToken) + emitterAddress := calculateEmitterAddress(sourceNTTManager, sourceToken) + return MessagePublication{ + EmitterChain: vaa.ChainIDXRPL, + EmitterAddress: emitterAddress, + Payload: payload, + } +} diff --git a/codeql/test/xrpl-derived-generated-emitter/node/pkg/watchers/xrpl/overwrite/positive.go b/codeql/test/xrpl-derived-generated-emitter/node/pkg/watchers/xrpl/overwrite/positive.go new file mode 100644 index 0000000000..35e4717e5e --- /dev/null +++ b/codeql/test/xrpl-derived-generated-emitter/node/pkg/watchers/xrpl/overwrite/positive.go @@ -0,0 +1,49 @@ +package overwrite + +type ChainID int +type chainIDs struct{ ChainIDXRPL ChainID } + +var vaa chainIDs + +type Address [20]byte +type Hash [32]byte + +type MessagePublication struct { + EmitterChain ChainID + EmitterAddress Hash + Payload []byte +} + +func addressToEmitter(account Address) Hash { + var out Hash + copy(out[12:], account[:]) + return out +} + +func Keccak256(data []byte) Hash { + var out Hash + copy(out[:], data) + return out +} + +func buildNTTPayload(sourceToken Hash) []byte { return sourceToken[:] } + +func calculateEmitterAddress(sourceNTTManager Hash, sourceToken Hash) Hash { + buf := make([]byte, 67) + copy(buf[:3], "ntt") + copy(buf[3:35], sourceNTTManager[:]) + copy(buf[35:], sourceToken[:]) + return Keccak256(buf) +} + +func parseNttTransaction(destination Address, sourceToken Hash) MessagePublication { + sourceNTTManager := addressToEmitter(destination) + payload := buildNTTPayload(sourceToken) + emitterAddress := calculateEmitterAddress(sourceNTTManager, sourceToken) + emitterAddress = sourceNTTManager + return MessagePublication{ + EmitterChain: vaa.ChainIDXRPL, + EmitterAddress: emitterAddress, + Payload: payload, + } +} diff --git a/codeql/test/xrpl-derived-generated-emitter/node/pkg/watchers/xrpl/positive.go b/codeql/test/xrpl-derived-generated-emitter/node/pkg/watchers/xrpl/positive.go new file mode 100644 index 0000000000..cf3e5f0c1e --- /dev/null +++ b/codeql/test/xrpl-derived-generated-emitter/node/pkg/watchers/xrpl/positive.go @@ -0,0 +1,83 @@ +package xrpl + +func positiveXTCFRawEmitter(account Address) MessagePublication { + payload := make([]byte, 8) + copy(payload, xtcfPrefix) + emitterAddress := addressToEmitter(account) + return MessagePublication{ + EmitterChain: vaa.ChainIDXRPL, + EmitterAddress: emitterAddress, + Payload: payload, + } +} + +func positiveXACKRawEmitterDirect(account Address) MessagePublication { + payload := make([]byte, 8) + copy(payload, xackPrefix) + return MessagePublication{ + EmitterChain: vaa.ChainIDXRPL, + EmitterAddress: addressToEmitter(account), + Payload: payload, + } +} + +func positiveXTCFUndomainSeparatedWrapper(account Address) MessagePublication { + payload := make([]byte, 8) + copy(payload, xtcfPrefix) + emitterAddress := rawAccountEmitterWrapper(account) + return MessagePublication{ + EmitterChain: vaa.ChainIDXRPL, + EmitterAddress: emitterAddress, + Payload: payload, + } +} + +func rawAccountEmitterWrapper(account Address) Hash { + return addressToEmitter(account) +} + +func parseNttTransaction(destination Address, sourceToken Hash) []MessagePublication { + sourceNTTManager := addressToEmitter(destination) + payload := buildNTTPayload(sourceToken) + rawManagerEmitter := addressToEmitter(destination) + noPrefixEmitter := calculateEmitterAddressWithoutPrefix(sourceNTTManager, sourceToken) + wrongHashEmitter := calculateEmitterAddressWrongHash(sourceNTTManager, sourceToken) + + return []MessagePublication{ + { + EmitterChain: vaa.ChainIDXRPL, + EmitterAddress: rawManagerEmitter, + Payload: payload, + }, + { + EmitterChain: vaa.ChainIDXRPL, + EmitterAddress: noPrefixEmitter, + Payload: payload, + }, + { + EmitterChain: vaa.ChainIDXRPL, + EmitterAddress: wrongHashEmitter, + Payload: payload, + }, + { + EmitterChain: vaa.ChainIDXRPL, + EmitterAddress: calculateEmitterAddress(sourceNTTManager, sourceToken), + Payload: payload, + }, + } +} + +func calculateEmitterAddressWithoutPrefix(sourceNTTManager Hash, sourceToken Hash) Hash { + buf := make([]byte, 64) + copy(buf[:32], sourceNTTManager[:]) + copy(buf[32:64], sourceToken[:]) + return Keccak256(buf) +} + +func calculateEmitterAddressWrongHash(sourceNTTManager Hash, sourceToken Hash) Hash { + buf := make([]byte, 67) + copy(buf[:3], "ntt") + copy(buf[3:35], sourceNTTManager[:]) + copy(buf[35:67], sourceToken[:]) + return sha256Hash(buf) +} diff --git a/codeql/test/xrpl-derived-generated-emitter/node/pkg/watchers/xrpl/production_regression.go b/codeql/test/xrpl-derived-generated-emitter/node/pkg/watchers/xrpl/production_regression.go new file mode 100644 index 0000000000..acababba2f --- /dev/null +++ b/codeql/test/xrpl-derived-generated-emitter/node/pkg/watchers/xrpl/production_regression.go @@ -0,0 +1,37 @@ +package xrpl + +import "codeql/xrplderivedgeneratedemitter/ethcrypto" + +const nttEmitterDomainLen = 3 + +type Parser struct{} + +func (p *Parser) parseNttTransaction(destination Address, tokenInfo nttTokenInfo) MessagePublication { + sourceNTTManager := addressToEmitter(destination) + payload := buildNTTPayload(tokenInfo.sourceToken) + + // Calculate emitter address: keccak256("ntt" + source_ntt_manager + source_token) + emitterAddress := p.calculateEmitterAddress(sourceNTTManager, tokenInfo.sourceToken) + + return MessagePublication{ + EmitterChain: vaa.ChainIDXRPL, + EmitterAddress: emitterAddress, + Payload: payload, + } +} + +type nttTokenInfo struct { + sourceToken Hash +} + +func (p *Parser) calculateEmitterAddress(sourceNTTManager, sourceToken Hash) Hash { + const addrLen = len(sourceNTTManager) + data := make([]byte, nttEmitterDomainLen+2*addrLen) + copy(data[:nttEmitterDomainLen], "ntt") + copy(data[nttEmitterDomainLen:nttEmitterDomainLen+addrLen], sourceNTTManager[:]) + copy(data[nttEmitterDomainLen+addrLen:], sourceToken[:]) + hash := ethcrypto.Keccak256(data) + var emitter Hash + copy(emitter[:], hash) + return emitter +} diff --git a/codeql/test/xrpl-derived-generated-emitter/node/pkg/watchers/xrpl/regression_test.go b/codeql/test/xrpl-derived-generated-emitter/node/pkg/watchers/xrpl/regression_test.go new file mode 100644 index 0000000000..8b7c3b4ba1 --- /dev/null +++ b/codeql/test/xrpl-derived-generated-emitter/node/pkg/watchers/xrpl/regression_test.go @@ -0,0 +1,12 @@ +package xrpl + +func testFixtureBadXACK(account Address) MessagePublication { + payload := make([]byte, 8) + copy(payload, xackPrefix) + emitterAddress := addressToEmitter(account) + return MessagePublication{ + EmitterChain: vaa.ChainIDXRPL, + EmitterAddress: emitterAddress, + Payload: payload, + } +} diff --git a/codeql/test/xrpl-derived-generated-emitter/node/pkg/watchers/xrpl/stubs.go b/codeql/test/xrpl-derived-generated-emitter/node/pkg/watchers/xrpl/stubs.go new file mode 100644 index 0000000000..3c8bcd9950 --- /dev/null +++ b/codeql/test/xrpl-derived-generated-emitter/node/pkg/watchers/xrpl/stubs.go @@ -0,0 +1,59 @@ +package xrpl + +type ChainID int + +type chainIDs struct { + ChainIDXRPL ChainID + ChainIDSolana ChainID +} + +var vaa chainIDs + +type Address [20]byte +type Hash [32]byte + +type MessagePublication struct { + EmitterChain ChainID + EmitterAddress Hash + Payload []byte +} + +var xtcfPrefix = []byte("XTCF") +var xackPrefix = []byte("XACK") +var generatedEmitterPrefix = []byte("XRPL") + +func addressToEmitter(account Address) Hash { + var out Hash + copy(out[12:], account[:]) + return out +} + +func Keccak256(data []byte) Hash { + var out Hash + copy(out[:], data) + return out +} + +func calculateGeneratedEmitterAddress(account Address) Hash { + out := addressToEmitter(account) + copy(out[:4], generatedEmitterPrefix) + return out +} + +func calculateEmitterAddress(sourceNTTManager Hash, sourceToken Hash) Hash { + buf := make([]byte, 67) + copy(buf[:3], "ntt") + copy(buf[3:35], sourceNTTManager[:]) + copy(buf[35:67], sourceToken[:]) + return Keccak256(buf) +} + +func buildNTTPayload(sourceToken Hash) []byte { + return sourceToken[:] +} + +func sha256Hash(data []byte) Hash { + var out Hash + copy(out[:], data) + return out +} diff --git a/codeql/test/xrpl-derived-generated-emitter/xrpl-derived-generated-emitter.expected b/codeql/test/xrpl-derived-generated-emitter/xrpl-derived-generated-emitter.expected new file mode 100644 index 0000000000..d7ebf87624 --- /dev/null +++ b/codeql/test/xrpl-derived-generated-emitter/xrpl-derived-generated-emitter.expected @@ -0,0 +1,12 @@ +| node/pkg/watchers/xrpl/badlayout/positive.go:37:2:37:60 | ... := ... | XRPL generated XACK publication must use the domain-separated emitter (the "XRPL" generated account emitter); this expression appears to use a non-approved emitter derivation. | +| node/pkg/watchers/xrpl/near_miss.go:6:2:6:69 | ... := ... | XRPL generated XACK publication must use the domain-separated emitter (the "XRPL" generated account emitter); this expression appears to use a non-approved emitter derivation. | +| node/pkg/watchers/xrpl/near_miss.go:22:3:22:45 | ... := ... | XRPL generated XTCF publication must use the domain-separated emitter (the "XRPL" generated account emitter); this expression appears to use a raw account emitter. | +| node/pkg/watchers/xrpl/nttignored/positive.go:45:19:45:72 | call to calculateEmitterAddress | XRPL generated NTT publication must use the domain-separated emitter (keccak256("ntt" + source manager + source token)); this expression appears to use a non-approved emitter derivation. | +| node/pkg/watchers/xrpl/nttwrong/near_miss.go:45:2:45:73 | ... := ... | XRPL generated NTT publication must use the domain-separated emitter (keccak256("ntt" + source manager + source token)); this expression appears to use a non-approved emitter derivation. | +| node/pkg/watchers/xrpl/overwrite/positive.go:46:19:46:32 | emitterAddress | XRPL generated NTT publication must use the domain-separated emitter (keccak256("ntt" + source manager + source token)); this expression appears to use a non-approved emitter derivation. | +| node/pkg/watchers/xrpl/positive.go:6:2:6:44 | ... := ... | XRPL generated XTCF publication must use the domain-separated emitter (the "XRPL" generated account emitter); this expression appears to use a raw account emitter. | +| node/pkg/watchers/xrpl/positive.go:19:19:19:43 | call to addressToEmitter | XRPL generated XACK publication must use the domain-separated emitter (the "XRPL" generated account emitter); this expression appears to use a raw account emitter. | +| node/pkg/watchers/xrpl/positive.go:27:2:27:52 | ... := ... | XRPL generated XTCF publication must use the domain-separated emitter (the "XRPL" generated account emitter); this expression appears to use a non-approved emitter derivation. | +| node/pkg/watchers/xrpl/positive.go:42:2:42:51 | ... := ... | XRPL generated NTT publication must use the domain-separated emitter (keccak256("ntt" + source manager + source token)); this expression appears to use a raw account emitter. | +| node/pkg/watchers/xrpl/positive.go:43:2:43:87 | ... := ... | XRPL generated NTT publication must use the domain-separated emitter (keccak256("ntt" + source manager + source token)); this expression appears to use a non-approved emitter derivation. | +| node/pkg/watchers/xrpl/positive.go:44:2:44:84 | ... := ... | XRPL generated NTT publication must use the domain-separated emitter (keccak256("ntt" + source manager + source token)); this expression appears to use a non-approved emitter derivation. | diff --git a/codeql/test/xrpl-derived-generated-emitter/xrpl-derived-generated-emitter.qlref b/codeql/test/xrpl-derived-generated-emitter/xrpl-derived-generated-emitter.qlref new file mode 100644 index 0000000000..1313889efd --- /dev/null +++ b/codeql/test/xrpl-derived-generated-emitter/xrpl-derived-generated-emitter.qlref @@ -0,0 +1 @@ +../../src/xrpl-derived-generated-emitter.ql diff --git a/codeql/test/xrpl-first-memo-only/go.mod b/codeql/test/xrpl-first-memo-only/go.mod new file mode 100644 index 0000000000..9cc54ccc6a --- /dev/null +++ b/codeql/test/xrpl-first-memo-only/go.mod @@ -0,0 +1,3 @@ +module codeql/xrplfirstmemoonly + +go 1.22 diff --git a/codeql/test/xrpl-first-memo-only/node/pkg/watchers/xrpl/near_miss.go b/codeql/test/xrpl-first-memo-only/node/pkg/watchers/xrpl/near_miss.go new file mode 100644 index 0000000000..6542bc58c0 --- /dev/null +++ b/codeql/test/xrpl-first-memo-only/node/pkg/watchers/xrpl/near_miss.go @@ -0,0 +1,107 @@ +package xrpl + +const unrelatedMemoFormat = "application/x-not-wormhole" + +type XackMessage struct { + Memos []Memo +} + +type XackMap map[string]any + +type XtcfMap map[string]any + +func ParseUnrelatedMemoScanner(tx Transaction) bool { + for _, memo := range tx.Memos { + if memo.MemoFormat == unrelatedMemoFormat { + return true + } + } + return false +} + +func ParseCoreSelectedMemoDataBytes(tx Transaction) bool { + if len(tx.Memos) == 0 { + return false + } + memo := tx.Memos[0] + if memo.MemoFormat != coreMemoFormat { + return false + } + for _, b := range memo.MemoData { + if b == 0xff { + return false + } + } + return true +} + +func ParseXackMemoScanner(msg XackMessage) bool { + for _, memo := range msg.Memos { + if memo.MemoFormat == coreMemoFormat { + return true + } + } + return false +} + +func ParseLocalMemoArray() bool { + memos := []Memo{{MemoFormat: coreMemoFormat}, {MemoFormat: nttMemoFormat}} + return memos[0].MemoFormat == coreMemoFormat +} + +func ParseXackRenamedAlias(msg XackMessage) bool { + memoList := msg.Memos + for _, memo := range memoList { + if memo.MemoFormat == nttMemoFormat { + return true + } + } + return false +} + +func ParseCoreUnrelatedHelperCalls(tx Transaction) bool { + _, foundUnrelated := findMemoWithFormat(tx.Memos, unrelatedMemoFormat) + selected := onlyFirstMemoHasFormat(tx.Memos, coreMemoFormat) + return foundUnrelated && selected +} + +func onlyFirstMemoHasFormat(memos []Memo, format string) bool { + if len(memos) == 0 { + return false + } + return memos[0].MemoFormat == format +} + +func ParseMapShapedXack(xack XackMap) bool { + memosRaw := xack["Memos"] + memos, ok := memosRaw.([]Memo) + if !ok { + return false + } + for _, memo := range memos { + if memo.MemoFormat == coreMemoFormat { + return true + } + } + return false +} + +func ParseMapShapedXtcf(xtcf XtcfMap) bool { + memosRaw := xtcf["Memos"] + memos, ok := memosRaw.([]Memo) + if !ok { + return false + } + for _, memo := range memos { + if memo.MemoFormat == nttMemoFormat { + return true + } + } + return false +} + +func ParseCoreDivergentWrapperCalls(tx Transaction) bool { + local := []Memo{{MemoFormat: coreMemoFormat}} + return scanMemoWrapper(tx.Memos, unrelatedMemoFormat) || + onlyFirstMemoHasFormat(local, coreMemoFormat) +} diff --git a/codeql/test/xrpl-first-memo-only/node/pkg/watchers/xrpl/negative.go b/codeql/test/xrpl-first-memo-only/node/pkg/watchers/xrpl/negative.go new file mode 100644 index 0000000000..fc66ec81fa --- /dev/null +++ b/codeql/test/xrpl-first-memo-only/node/pkg/watchers/xrpl/negative.go @@ -0,0 +1,34 @@ +package xrpl + +func ParseCoreFirstMemoOnly(tx Transaction) bool { + if len(tx.Memos) == 0 { + return false + } + memo := tx.Memos[0] + if memo.MemoFormat != coreMemoFormat { + return false + } + return len(memo.MemoData) > 0 +} + +func ParseNttFirstMemoOnly(tx Transaction) bool { + if len(tx.Memos) == 0 { + return false + } + memo := tx.Memos[0] + return memo.MemoFormat == nttMemoFormat +} + +func ParseCoreNoFallbackAfterMalformedFirst(tx Transaction) bool { + if len(tx.Memos) == 0 { + return false + } + memo := tx.Memos[0] + if memo.MemoFormat != coreMemoFormat { + return false + } + if len(memo.MemoData) < 4 { + return false + } + return true +} diff --git a/codeql/test/xrpl-first-memo-only/node/pkg/watchers/xrpl/positive.go b/codeql/test/xrpl-first-memo-only/node/pkg/watchers/xrpl/positive.go new file mode 100644 index 0000000000..fe4e152faf --- /dev/null +++ b/codeql/test/xrpl-first-memo-only/node/pkg/watchers/xrpl/positive.go @@ -0,0 +1,104 @@ +package xrpl + +const coreMemoFormat = "application/x-wormhole-publish" +const nttMemoFormat = "application/x-ntt-transfer" + +type Memo struct { + MemoFormat string + MemoData []byte +} + +type Transaction struct { + Memos []Memo +} + +type FlatTransaction map[string]any + +func ParseCoreWithRange(tx Transaction) bool { + memos := tx.Memos + for _, memo := range memos { + if memo.MemoFormat == coreMemoFormat { + return true + } + } + return false +} + +func ParseNttWithDynamicIndex(tx Transaction, i int) bool { + if len(tx.Memos) == 0 { + return false + } + memos := tx.Memos + memo := memos[i] + return memo.MemoFormat == nttMemoFormat +} + +func ParseCoreViaScanningHelper(tx Transaction) bool { + memo, ok := findMemoWithFormat(tx.Memos, coreMemoFormat) + return ok && memo.MemoFormat == coreMemoFormat +} + +func findMemoWithFormat(memos []Memo, format string) (Memo, bool) { + for _, memo := range memos { + if memo.MemoFormat == format { + return memo, true + } + } + return Memo{}, false +} + +func ParseNttLastMemo(tx Transaction) bool { + if len(tx.Memos) == 0 { + return false + } + memos := tx.Memos + memo := memos[len(memos)-1] + return memo.MemoFormat == nttMemoFormat +} + +func ParseCoreWithDirectFieldRange(tx Transaction) bool { + for _, memo := range tx.Memos { + if memo.MemoFormat == coreMemoFormat { + return true + } + } + return false +} + +func ParseCoreWithRenamedAlias(tx Transaction) bool { + memoList := tx.Memos + for _, memo := range memoList { + if memo.MemoFormat == coreMemoFormat { + return true + } + } + return false +} + +func ParseNttViaFormatHelper(tx Transaction) bool { + _, ok := findMemoWithFormat(tx.Memos, nttMemoFormat) + return ok +} + +func ParseCoreFlatMapRange(tx FlatTransaction) bool { + memosRaw := tx["Memos"] + memos, ok := memosRaw.([]Memo) + if !ok { + return false + } + for _, memo := range memos { + if memo.MemoFormat == coreMemoFormat { + return true + } + } + return false +} + +func ParseCoreViaScanningWrapper(tx Transaction) bool { + return scanMemoWrapper(tx.Memos, coreMemoFormat) +} + +func scanMemoWrapper(memos []Memo, format string) bool { + _, ok := findMemoWithFormat(memos, format) + return ok +} diff --git a/codeql/test/xrpl-first-memo-only/xrpl-first-memo-only.expected b/codeql/test/xrpl-first-memo-only/xrpl-first-memo-only.expected new file mode 100644 index 0000000000..616db8e02d --- /dev/null +++ b/codeql/test/xrpl-first-memo-only/xrpl-first-memo-only.expected @@ -0,0 +1,10 @@ +| node/pkg/watchers/xrpl/positive.go:19:2:23:2 | range statement | XRPL Wormhole Core/NTT parsing must inspect only Memos[0]. | +| node/pkg/watchers/xrpl/positive.go:32:10:32:17 | index expression | XRPL Wormhole Core/NTT parsing must inspect only Memos[0]. | +| node/pkg/watchers/xrpl/positive.go:37:14:37:57 | call to findMemoWithFormat | XRPL Wormhole Core/NTT parsing must inspect only Memos[0]. | +| node/pkg/watchers/xrpl/positive.go:42:2:46:2 | range statement | XRPL Wormhole Core/NTT parsing must inspect only Memos[0]. | +| node/pkg/watchers/xrpl/positive.go:55:10:55:28 | index expression | XRPL Wormhole Core/NTT parsing must inspect only Memos[0]. | +| node/pkg/watchers/xrpl/positive.go:60:2:64:2 | range statement | XRPL Wormhole Core/NTT parsing must inspect only Memos[0]. | +| node/pkg/watchers/xrpl/positive.go:70:2:74:2 | range statement | XRPL Wormhole Core/NTT parsing must inspect only Memos[0]. | +| node/pkg/watchers/xrpl/positive.go:79:11:79:53 | call to findMemoWithFormat | XRPL Wormhole Core/NTT parsing must inspect only Memos[0]. | +| node/pkg/watchers/xrpl/positive.go:89:2:93:2 | range statement | XRPL Wormhole Core/NTT parsing must inspect only Memos[0]. | +| node/pkg/watchers/xrpl/positive.go:98:9:98:49 | call to scanMemoWrapper | XRPL Wormhole Core/NTT parsing must inspect only Memos[0]. | diff --git a/codeql/test/xrpl-first-memo-only/xrpl-first-memo-only.qlref b/codeql/test/xrpl-first-memo-only/xrpl-first-memo-only.qlref new file mode 100644 index 0000000000..33725feb4f --- /dev/null +++ b/codeql/test/xrpl-first-memo-only/xrpl-first-memo-only.qlref @@ -0,0 +1 @@ +../../src/xrpl-first-memo-only.ql diff --git a/codeql/test/xrpl-require-validated-transaction/go.mod b/codeql/test/xrpl-require-validated-transaction/go.mod new file mode 100644 index 0000000000..71df5a5a9b --- /dev/null +++ b/codeql/test/xrpl-require-validated-transaction/go.mod @@ -0,0 +1,3 @@ +module github.com/wormhole-foundation/wormhole/codeqltest + +go 1.22 diff --git a/codeql/test/xrpl-require-validated-transaction/node/pkg/watchers/xrpl/near_miss.go b/codeql/test/xrpl-require-validated-transaction/node/pkg/watchers/xrpl/near_miss.go new file mode 100644 index 0000000000..541cff0818 --- /dev/null +++ b/codeql/test/xrpl-require-validated-transaction/node/pkg/watchers/xrpl/near_miss.go @@ -0,0 +1,31 @@ +package xrpl + +func nearMissPositiveBranchWithoutExit(parser *Parser, tx TxResponse) { + if tx.Validated { + logInvalid(tx) + } + parser.ParseTxResponse(&txResponseV2{TxResponse: tx}) +} + +func nearMissBareValidatedRead(parser *Parser, tx TxResponse) { + _ = tx.Validated + parser.ParseTxResponse(&txResponseV2{TxResponse: tx}) +} + +func nearMissAddressWrapper(parser *Parser, tx TxResponse) { + if !tx.Validated { + return + } + wrapper := &txResponseV2{TxResponse: tx} + parser.ParseTxResponse(wrapper) +} + +func nearMissStalePointerAlias(parser *Parser, tx TxResponse, replacement TxResponse) { + if !tx.Validated { + return + } + pointer := &tx + pointer = &replacement + pointer.Validated = false + parser.ParseTxResponse(&txResponseV2{TxResponse: tx}) +} diff --git a/codeql/test/xrpl-require-validated-transaction/node/pkg/watchers/xrpl/negative.go b/codeql/test/xrpl-require-validated-transaction/node/pkg/watchers/xrpl/negative.go new file mode 100644 index 0000000000..fe651e128e --- /dev/null +++ b/codeql/test/xrpl-require-validated-transaction/node/pkg/watchers/xrpl/negative.go @@ -0,0 +1,50 @@ +package xrpl + +func negativeRejectFalse(parser *Parser, tx TxResponse) { + if !tx.Validated { + return + } + parser.ParseTxResponse(&txResponseV2{TxResponse: tx}) +} + +func negativeTrueBranch(parser *Parser, tx TxResponse) { + if tx.Validated { + parser.ParseTxResponse(&txResponseV2{TxResponse: tx}) + } +} + +func negativeBooleanAlias(parser *Parser, tx TxResponse) { + validated := tx.Validated + if !validated { + return + } + parser.ParseTxResponse(&txResponseV2{TxResponse: tx}) +} + +func negativeLoopContinue(parser *Parser, txs []TxResponse) { + for _, tx := range txs { + if !tx.Validated { + continue + } + parser.ParseTxResponse(&txResponseV2{TxResponse: tx}) + } +} + +func negativeStreamRejectFalse(parser *Parser, tx StreamTransaction) { + if !tx.Validated { + return + } + parser.ParseTransactionStream(tx) +} + +func negativeUnrelatedMethodNameInOtherDirectory(parser *Parser, tx TxResponse) { + if !tx.Validated { + return + } + parser.ParseTxResponse(&txResponseV2{TxResponse: tx}) +} + +func negativeUnrelatedSameNameMethods(other *OtherParser, tx TxResponse, stream StreamTransaction) { + other.ParseTxResponse(&txResponseV2{TxResponse: tx}) + other.ParseTransactionStream(stream) +} diff --git a/codeql/test/xrpl-require-validated-transaction/node/pkg/watchers/xrpl/parser_stubs.go b/codeql/test/xrpl-require-validated-transaction/node/pkg/watchers/xrpl/parser_stubs.go new file mode 100644 index 0000000000..297709b4ff --- /dev/null +++ b/codeql/test/xrpl-require-validated-transaction/node/pkg/watchers/xrpl/parser_stubs.go @@ -0,0 +1,33 @@ +package xrpl + +type Parser struct{} + +type OtherParser struct{} + +type TxResponse struct { + Validated bool + Result string +} + +type txResponseV2 struct { + TxResponse +} + +type StreamTransaction struct { + Validated bool + Hash string +} + +func (p *Parser) ParseTransactionStream(tx StreamTransaction) {} + +func (p *Parser) ParseTxResponse(tx *txResponseV2) {} + +func (p *OtherParser) ParseTransactionStream(tx StreamTransaction) {} + +func (p *OtherParser) ParseTxResponse(tx *txResponseV2) {} + +func logInvalid(any) {} + +func shouldSkip(tx TxResponse) bool { + return !tx.Validated +} diff --git a/codeql/test/xrpl-require-validated-transaction/node/pkg/watchers/xrpl/positive.go b/codeql/test/xrpl-require-validated-transaction/node/pkg/watchers/xrpl/positive.go new file mode 100644 index 0000000000..d10297b9ea --- /dev/null +++ b/codeql/test/xrpl-require-validated-transaction/node/pkg/watchers/xrpl/positive.go @@ -0,0 +1,112 @@ +package xrpl + +func positiveNoGuard(parser *Parser, tx TxResponse) { + parser.ParseTxResponse(&txResponseV2{TxResponse: tx}) +} + +func positiveStreamNoGuard(parser *Parser, tx StreamTransaction) { + parser.ParseTransactionStream(tx) +} + +func positiveAfterCallGuard(parser *Parser, tx TxResponse) { + parser.ParseTxResponse(&txResponseV2{TxResponse: tx}) + if !tx.Validated { + return + } +} + +func positiveNonTerminatingFalseBranch(parser *Parser, tx TxResponse) { + if !tx.Validated { + logInvalid(tx) + } + parser.ParseTxResponse(&txResponseV2{TxResponse: tx}) +} + +func positiveDifferentValueGuard(parser *Parser, guarded TxResponse, parsed TxResponse) { + if !guarded.Validated { + return + } + parser.ParseTxResponse(&txResponseV2{TxResponse: parsed}) +} + +func positiveReassignedAfterGuard(parser *Parser, tx TxResponse, replacement TxResponse) { + if !tx.Validated { + return + } + tx = replacement + parser.ParseTxResponse(&txResponseV2{TxResponse: tx}) +} + +func positiveResultOnly(parser *Parser, tx TxResponse) { + if tx.Result != "tesSUCCESS" { + return + } + parser.ParseTxResponse(&txResponseV2{TxResponse: tx}) +} + +func positiveValidatedCheckOnlyInHelper(parser *Parser, tx TxResponse) { + if shouldSkip(tx) { + return + } + parser.ParseTxResponse(&txResponseV2{TxResponse: tx}) +} + +func positiveValidatedFieldMutatedAfterGuard(parser *Parser, tx TxResponse) { + if !tx.Validated { + return + } + tx.Validated = false + parser.ParseTxResponse(&txResponseV2{TxResponse: tx}) +} + +func positiveWrappedTxResponseReplacedAfterGuard(parser *Parser, tx TxResponse, replacement TxResponse) { + if !tx.Validated { + return + } + wrapper := &txResponseV2{TxResponse: tx} + wrapper.TxResponse = replacement + parser.ParseTxResponse(wrapper) +} + +func positiveWrappedValidatedFieldMutatedAfterGuard(parser *Parser, tx TxResponse) { + if !tx.Validated { + return + } + wrapper := &txResponseV2{TxResponse: tx} + wrapper.TxResponse.Validated = false + parser.ParseTxResponse(wrapper) +} + +func positivePromotedValidatedFieldMutatedAfterGuard(parser *Parser, tx TxResponse) { + if !tx.Validated { + return + } + wrapper := &txResponseV2{TxResponse: tx} + wrapper.Validated = false + parser.ParseTxResponse(wrapper) +} + +func positiveMutationThenLaterValidatedRead(parser *Parser, tx TxResponse) { + if !tx.Validated { + return + } + tx.Validated = false + _ = tx.Validated + parser.ParseTxResponse(&txResponseV2{TxResponse: tx}) +} + +func positiveSameLineMutationAfterGuard(parser *Parser, tx TxResponse) { + if !tx.Validated { + return + } + tx.Validated = false; parser.ParseTxResponse(&txResponseV2{TxResponse: tx}) +} + +func positivePointerAliasMutationAfterGuard(parser *Parser, tx TxResponse) { + if !tx.Validated { + return + } + pointer := &tx + pointer.Validated = false + parser.ParseTxResponse(&txResponseV2{TxResponse: tx}) +} diff --git a/codeql/test/xrpl-require-validated-transaction/xrpl-require-validated-transaction.expected b/codeql/test/xrpl-require-validated-transaction/xrpl-require-validated-transaction.expected new file mode 100644 index 0000000000..2c5b9f66d0 --- /dev/null +++ b/codeql/test/xrpl-require-validated-transaction/xrpl-require-validated-transaction.expected @@ -0,0 +1,18 @@ +| node/pkg/watchers/xrpl/near_miss.go:7:2:7:54 | call to ParseTxResponse | XRPL transaction is parsed before proving Validated == true. | +| node/pkg/watchers/xrpl/near_miss.go:12:2:12:54 | call to ParseTxResponse | XRPL transaction is parsed before proving Validated == true. | +| node/pkg/watchers/xrpl/near_miss.go:30:2:30:54 | call to ParseTxResponse | XRPL transaction is parsed before proving Validated == true. | +| node/pkg/watchers/xrpl/positive.go:4:2:4:54 | call to ParseTxResponse | XRPL transaction is parsed before proving Validated == true. | +| node/pkg/watchers/xrpl/positive.go:8:2:8:34 | call to ParseTransactionStream | XRPL transaction is parsed before proving Validated == true. | +| node/pkg/watchers/xrpl/positive.go:12:2:12:54 | call to ParseTxResponse | XRPL transaction is parsed before proving Validated == true. | +| node/pkg/watchers/xrpl/positive.go:22:2:22:54 | call to ParseTxResponse | XRPL transaction is parsed before proving Validated == true. | +| node/pkg/watchers/xrpl/positive.go:29:2:29:58 | call to ParseTxResponse | XRPL transaction is parsed before proving Validated == true. | +| node/pkg/watchers/xrpl/positive.go:37:2:37:54 | call to ParseTxResponse | XRPL transaction is parsed before proving Validated == true. | +| node/pkg/watchers/xrpl/positive.go:44:2:44:54 | call to ParseTxResponse | XRPL transaction is parsed before proving Validated == true. | +| node/pkg/watchers/xrpl/positive.go:51:2:51:54 | call to ParseTxResponse | XRPL transaction is parsed before proving Validated == true. | +| node/pkg/watchers/xrpl/positive.go:59:2:59:54 | call to ParseTxResponse | XRPL transaction is parsed before proving Validated == true. | +| node/pkg/watchers/xrpl/positive.go:68:2:68:32 | call to ParseTxResponse | XRPL transaction is parsed before proving Validated == true. | +| node/pkg/watchers/xrpl/positive.go:77:2:77:32 | call to ParseTxResponse | XRPL transaction is parsed before proving Validated == true. | +| node/pkg/watchers/xrpl/positive.go:86:2:86:32 | call to ParseTxResponse | XRPL transaction is parsed before proving Validated == true. | +| node/pkg/watchers/xrpl/positive.go:95:2:95:54 | call to ParseTxResponse | XRPL transaction is parsed before proving Validated == true. | +| node/pkg/watchers/xrpl/positive.go:102:24:102:76 | call to ParseTxResponse | XRPL transaction is parsed before proving Validated == true. | +| node/pkg/watchers/xrpl/positive.go:111:2:111:54 | call to ParseTxResponse | XRPL transaction is parsed before proving Validated == true. | diff --git a/codeql/test/xrpl-require-validated-transaction/xrpl-require-validated-transaction.qlref b/codeql/test/xrpl-require-validated-transaction/xrpl-require-validated-transaction.qlref new file mode 100644 index 0000000000..917256eba7 --- /dev/null +++ b/codeql/test/xrpl-require-validated-transaction/xrpl-require-validated-transaction.qlref @@ -0,0 +1 @@ +../../src/xrpl-require-validated-transaction.ql diff --git a/cspell-custom-words.txt b/cspell-custom-words.txt index 39a9dae69f..8c8f5c1a4d 100644 --- a/cspell-custom-words.txt +++ b/cspell-custom-words.txt @@ -38,6 +38,7 @@ certusone chainid ChainID Chainlink +chainlock channelcheck channellint Coinspect @@ -78,9 +79,11 @@ forgeable FQTs frontends funder +gofmt gogoproto goimports golangci +goroutines gossipv guardiand GUARDIAND @@ -95,6 +98,7 @@ Holesky horcrux ICCO idls +idxrpl incentivized incentivizing initialisation @@ -104,9 +108,11 @@ injective Injective inotify intcblock +interprocedural ints Ints IPFS +itob journalctl karura Karura @@ -149,6 +155,7 @@ multichecker Neodyme nhooyr nolint +nonblocking obsv Obsv OP_CHECKMULTISIG @@ -157,6 +164,7 @@ OP_EQUALVERIFY optimisation optin Optin +overlength P2WPKH parachain pdas @@ -166,6 +174,7 @@ permissionlessly Polkachu Polkadot Positionals +postcondition postconditions prefunded promauto @@ -185,17 +194,22 @@ pythnet QUIC ramfs readyz +refetched regen reinit +relock reobservation Reobservation reobservations Reobservations +reobserve reobserved +reobserver repoint RLUSD runtimes rustup +sarif satoshi satoshis SECG @@ -216,6 +230,7 @@ spydk Starport statesync stdlib +strconv struct structs subdenom @@ -239,29 +254,33 @@ txverifier uatom uluna unbond -Unbuffered unbuffered +Unbuffered Uncompromised undelegated undercollateralization underflows +unfinalized unforgeable unichain Unichain unmarshal unmarshals +unmodeled unnormalize unpausable unpauser unrepresentable Unrepresentable untampered +unvalidated upserted usize utest UTXO uusd uvarint +vaaid varint varints vimdiff @@ -279,9 +298,11 @@ wormchaind Wormholescan wormscan wormscanurl +xack XFER xlayer xpla XPLA XRPL +xtcf Zellic