Sample audits · Open-source projects and documentation, eight more

Anchor (otter-sec/anchor)

The Solana program framework. On GitHub at otter-sec/anchor; solana-foundation/anchor redirects there.

Auditedotter-sec/anchor at commit 50c68b9b9b57ce7a6f6014e707febef9abb33401
Date11 October 2026
How it rancloud session, full audit, Standard review
Verdict after reviewPass with notes (rule: Fail if a High finding remains after review, otherwise Pass with notes)
0
High after review
3
Medium after review
35
Low after review
1
Info after review
0
excluded on review
How to read this page

Each finding keeps the number it has in the audit report. The rating shown first is the one after review; the first automated rating is listed with it. 21 findings were first rated Medium or High; 3 of them are Medium or High after review. Text marked "From the report" is quoted from the audit report; fixes are suggestions and were not tested. Code locations link to the file and line at the audited commit.

Medium after review (3)

5. Medium The Rust client's event subscription ends silently on one bad log or a dropped socket
First rating: Medium · Reviewed rating: Medium · Review: confirmed
Location: client/src/lib.rs:414 (also lines 368-377 and 277)
From the report
Evidence
let events = parse_logs(&logs.value.logs, &program_id_str)?;
for e in events {
    f(&ctx, e);
Why it matters

Any parse error ends the subscription task through ?. This includes a log line that carries a known event prefix but does not decode, which a program echoing user text can produce. The error is then discarded by let _ = self.handle.await. The PubsubClient is cached in a OnceCell forever, even after its websocket has died. Callers stop receiving events with no signal.

Suggested fix, not tested

Log the error and continue per message. Report task termination to the caller through a channel or the unsubscribe result. Clear or rebuild the cached pubsub client when its stream ends.

Review: on_internal returns from the spawned task with ? on any parse_logs error (let events = parse_logs(...)?;, lib.rs:414) and when the notification stream ends the while let simply finishes with Ok(()). unsubscribe_internal throws the result away (let _ = self.handle.await;, lib.rs:274-277), and the PubsubClient stays in the OnceCell (get_or_try_init, never reset), so there is no reconnect. A long-running listener therefore goes deaf with no signal to the caller, and a dropped websocket is a normal production event. The "bad log" trigger is narrower (needs an 8-byte event discriminator prefix followed by a body that fails borsh decode), but handle_program_log does .transpose()? on exactly that. Shipped crate (anchor-client). Upstream: two searches (event subscription websocket parse_logs, PubsubClient reconnect), no match found.

Upstream: No matching upstream report found (11 October 2026).

21. Medium anchor new can delete an existing lib.rs without --force
First rating: Medium · Reviewed rating: Medium · Review: confirmed
From the report
Evidence
// Remove the stub version
fs::remove_file(&lib_rs_path)?;
Why it matters

create_files skips paths that already exist, but the stub lib.rs is then removed unconditionally and replaced by the template. anchor new refuses only when the name is registered for the current cluster (cli/src/lib.rs:2093). If programs/<name>/src/lib.rs exists but is registered only under another cluster, or not at all, the user's program source is deleted and overwritten.

Suggested fix, not tested

Refuse to proceed when programs/<name> already exists and --force is not given. At minimum, delete the stub only if this run created it.

Review: create_program writes an empty stub lib.rs through create_files (which skips existing paths), then calls fs::remove_file(&lib_rs_path)? unconditionally (template.rs:78) and re-creates it from the template. The only guard is programs.contains_key(&name) against cfg.programs[cluster] in new (lib.rs:2093). A program directory that exists but is absent from the current cluster's [programs.*] table is silently emptied and replaced. The trigger is narrow (name collision plus unregistered), and the source is usually in version control, but it is an unprompted destruction of user source with a trivial fix. Shipped CLI. Upstream: two searches (anchor new overwrite lib.rs existing program, anchor new force programs already exists), no match found.

Upstream: No matching upstream report found (11 October 2026).

35. Medium IDL history accepts upload chunks from any transaction that mentions the IDL account
First rating: Medium · Reviewed rating: Medium · Review: confirmed
From the report
Evidence
.filter_map(|instruction| extract_compressed_chunk(&instruction.data).ok().flatten())
Why it matters

Chunks are recognised by their data prefix alone. The code never checks that the instruction targets the program or that its first account is the IDL account. Anyone can list the IDL account in their own transaction and inject fake "historical" IDLs, which anchor idl fetch --history then writes to disk as that program's history.

Suggested fix, not tested

Resolve each instruction's program id and accounts against the message keys, and accept only instructions to the target program whose first account is the IDL account, as the metadata path already does.

Review: extract_from_raw_instructions / extract_from_parsed_instructions (chunks.rs:72) accept any instruction whose data starts with the 8-byte IDL tag plus variant 0x02; the program id and account list are never checked. Signatures come from get_signatures_for_address(<IDL account>) (rpc.rs:51), so any successful transaction that merely lists that address read-only (with the forged bytes sent to any program that ignores or tolerates its data) enters the history, and a forged zlib session of JSON is written out as a historical IDL. Failed transactions are filtered (sig.err, rpc.rs:135), which does not stop a crafted successful one. Impact is forged provenance in a forensic/history feature, not a change to the live IDL, so this is borderline MEDIUM/LOW. Shipped CLI (anchor idl fetch --history). Upstream: two searches (idl fetch history chunks, idl history fake OR forged OR spoof) returned only the feature's own PR #3992 and issue #3941, not this defect; no match found.

Upstream: No matching upstream report found (11 October 2026).

Low after review (35)

1. Low The associated token address helper ignores Token-2022 mints
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
[owner.toBuffer(), TOKEN_PROGRAM_ID.toBuffer(), mint.toBuffer()],
  ASSOCIATED_PROGRAM_ID
Why it matters

associatedAddress always derives the address with the legacy token program id, and the package has no Token-2022 program id (searched ts/packages/anchor/src). For a Token-2022 mint it returns an address that is not that mint's real associated token account, so transfers or lookups built on it fail or go to the wrong account.

Suggested fix, not tested

Add an optional tokenProgramId parameter that defaults to the legacy id, and export the Token-2022 id. Callers can then read the mint's owner program and pass the matching id.

Review: Real limitation (Token-2022 not supported, TOKEN_PROGRAM_ID hard-coded at token.ts:18), but it is a feature gap in an optional helper that nothing else in the package calls; callers hit a wrong address only if they use it with Token-2022 mints.

2. Low The wallet's signing result is not validated before sending
First rating: Low · Reviewed rating: Low · Review: location checked, kept as rated
From the report
Evidence
const signedTxs = await this.wallet.signAllTransactions(txs);
...
const tx = signedTxs[k];
Why it matters

sendAll assumes the wallet returned one signed transaction for each input. If a wallet returns fewer, tx becomes undefined and the loop throws after earlier transactions have already been sent, leaving a partly executed batch and an unclear error.

Suggested fix, not tested

Before sending anything, check that signedTxs.length === txs.length and that each transaction carries the fee payer's signature. If not, fail with an explicit "wallet rejected or returned incomplete signatures" error.

3. Low Removing the last event listener races with adding a new one
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
await this._provider!.connection.removeOnLogsListener(
  this._onLogsSubscriptionId
);
this._onLogsSubscriptionId = undefined;
Why it matters

The subscription id is cleared only after the await. An addEventListener call made during that window sees the old id at line 95, skips creating a new onLogs subscription, and then the id is wiped. The new listener never receives events, and nothing reports it.

Suggested fix, not tested

Take the id into a local variable and clear the field before awaiting the removal, or serialise add and remove through one promise chain.

Review: The race is real (_onLogsSubscriptionId = undefined after await removeOnLogsListener, event.ts:157; add at line 95 returns early on the stale id), but it needs an add during the single RPC round trip of a teardown. Narrow window.

4. Low Account subscriptions are shared across all clients and keyed only by address
First rating: Medium · Reviewed rating: Low · Review: rated too high
Location: ts/packages/anchor/src/program/namespace/account.ts:292 (registry declared at line 397)
From the report
Evidence
const sub = subscriptions.get(address.toString());
if (sub) {
  return sub.ee;
Why it matters

The registry is module-global. If two AccountClients (different account types, programs, providers or commitments) subscribe to the same address, the second one gets the first one's emitter, and the data is decoded with the first client's layout and connection. unsubscribe then removes the listener on the wrong connection.

Suggested fix, not tested

Keep the registry per client instance, or key it by connection, program id, account name and commitment. Store the owner in each entry and refuse a mismatched reuse.

Review: The registry is module-global and keyed by address only (account.ts:292, 397), but one address holds one account type at a time, so the realistic harm is only multi-connection or multi-commitment apps.

6. Low The debugger re-resolves source paths and re-reads files on every redraw
First rating: Low · Reviewed rating: Low · Review: location checked, kept as rated
Location: cli/src/debugger/tui.rs:732 (file read at line 1052)
From the report
Evidence
let resolved_path = resolve_src_path(
Why it matters

The resolution and the file read are recomputed on every frame, even though their inputs do not change during a session. This wastes CPU and disk I/O while stepping.

Suggested fix, not tested

Memoise the resolved path per (file, root) next to the existing file cache, and reuse the cached lines.

7. Low The GDB server's accept loop busy-polls every 20 ms
First rating: Low · Reviewed rating: Low · Review: location checked, kept as rated
From the report
Evidence
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
    thread::sleep(Duration::from_millis(20));
}
Why it matters

This causes about 50 wake-ups per second for the whole session even when nothing happens.

Suggested fix, not tested

Use a blocking accept(). On shutdown, unblock it by connecting to the socket once after setting the stop flag, or use poll with a wake pipe.

8. Low The nightly update cache records one version for two separately installed binaries
First rating: Low · Reviewed rating: Low · Review: location checked, kept as rated
From the report
Evidence
let needs_download =
    cached_version.as_deref() != Some(manifest.version.as_str()) || !nightly_binaries_exist();
Why it matters

If the second of the two installs fails, the binaries end up at different versions, while the cache still says one version is current. The cached path may then be taken and the mismatch kept.

Suggested fix, not tested

Record the version per artifact, or write the cache only after both installs succeed and clear it before starting.

9. Low The "current version" file has several writers and is rewritten in place
First rating: Low · Reviewed rating: Low · Review: location checked, kept as rated
Location: avm/src/lib.rs:613 (same pattern at line 295)
From the report
Evidence
let mut current_version_file = fs::File::create(current_version_file_path())?;
current_version_file.write_all(version.to_string().as_bytes())?;
Why it matters

The file is truncated and then written from several places. An interrupted write leaves it empty or garbled, and every later avm and anchor call then misreads the active version.

Suggested fix, not tested

Add one write_current_version helper that writes a temp file and renames it (the code already has an atomic install helper), and use it everywhere.

10. Low The process working directory is changed and restored only on success
First rating: Low · Reviewed rating: Low · Review: location checked, kept as rated
Location: cli/src/lib.rs:3557 (same pattern near lines 2089, 2655, 3288)
From the report
Evidence
std::env::set_current_dir(program_path)?;
let idl = generate_idl(&cfg, skip_lint, no_docs, &cargo_args)?;
std::env::set_current_dir(current_dir)?;
Why it matters

If generate_idl fails, the CLI stays in the program directory, and any later code in the same process resolves relative paths against the wrong place.

Suggested fix, not tested

Use an RAII guard that restores the directory on drop, or pass the directory to subprocesses with Command::current_dir.

11. Low The IDL stream decompressor has a per-stream cap but no total budget
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
let consumed = decoder.total_in() as usize;
if is_complete_idl_json(&out) {
    streams.push(out);
Why it matters

Each stream is capped at 16 MiB, but every stream that parses as JSON is kept. Highly compressible streams of about 16 KB each can make about 1 MB of crafted on-chain data hold more than 1 GiB in memory during anchor idl fetch.

Suggested fix, not tested

Apply one shared budget for total decompressed bytes and stream count, and fail with a clear error once it is exceeded.

Review: Verified in code: decompress.rs keeps every JSON-valid 16 MiB stream, legacy_idl.rs:249 uses read_to_end with no take, legacy_idl.rs:248 slices account.data[44..44 + compressed_len] (panic), pmp.rs:921 bs58::decode has no size cap, idl.ts:475-477 ungzip/inflate unbounded, lib.rs:4260 Vec::with_capacity(size) from an untrusted u32. All real, but the attacker must be the writer of the IDL/metadata/account that the developer chooses to fetch, the failure is a loud abort/hang/OOM of a developer CLI or script, nothing is corrupted, and the legacy path (#12, #13) sits in a deprecated legacy-idl subcommand while the new fetcher already has bounded readers (pmp.rs:953-969).

12. Low Legacy IDL fetch inflates account data without any output limit
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
let mut z = ZlibDecoder::new(compressed_bytes);
let mut s = Vec::new();
z.read_to_end(&mut s)?;
Why it matters

The account contents are controlled by whoever wrote them. A compression bomb in an IDL account can exhaust memory on the machine running the fetch. The newer fetch module already guards against this with a take(MAX + 1) reader.

Suggested fix, not tested

Reuse the same bounded reader and limit here, and fail when the limit is exceeded.

Review: Verified in code: decompress.rs keeps every JSON-valid 16 MiB stream, legacy_idl.rs:249 uses read_to_end with no take, legacy_idl.rs:248 slices account.data[44..44 + compressed_len] (panic), pmp.rs:921 bs58::decode has no size cap, idl.ts:475-477 ungzip/inflate unbounded, lib.rs:4260 Vec::with_capacity(size) from an untrusted u32. All real, but the attacker must be the writer of the IDL/metadata/account that the developer chooses to fetch, the failure is a loud abort/hang/OOM of a developer CLI or script, nothing is corrupted, and the legacy path (#12, #13) sits in a deprecated legacy-idl subcommand while the new fetcher already has bounded readers (pmp.rs:953-969).

13. Low Legacy IDL fetch slices with an unchecked length from the account
First rating: Medium · Reviewed rating: Low · Review: rated too high
Location: cli/src/legacy_idl.rs:248 (also lines 244 and 247)
From the report
Evidence
let compressed_len: usize = idl_account.data_len.try_into().unwrap();
let compressed_bytes = &account.data[44..44 + compressed_len];
Why it matters

data_len comes from the account. If it claims more bytes than the account holds, or the account is too short, the CLI panics and gives no useful error.

Suggested fix, not tested

Use account.data.get(44..44usize.checked_add(len)?) and return a descriptive error. Check the minimum length before cutting off the discriminator.

Review: Verified in code: decompress.rs keeps every JSON-valid 16 MiB stream, legacy_idl.rs:249 uses read_to_end with no take, legacy_idl.rs:248 slices account.data[44..44 + compressed_len] (panic), pmp.rs:921 bs58::decode has no size cap, idl.ts:475-477 ungzip/inflate unbounded, lib.rs:4260 Vec::with_capacity(size) from an untrusted u32. All real, but the attacker must be the writer of the IDL/metadata/account that the developer chooses to fetch, the failure is a loud abort/hang/OOM of a developer CLI or script, nothing is corrupted, and the legacy path (#12, #13) sits in a deprecated legacy-idl subcommand while the new fetcher already has bounded readers (pmp.rs:953-969).

14. Low Base58 metadata is decoded with no work limit
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
MetadataEncoding::Base58 => bs58::decode(bytes).into_vec().map_err(|err| {
Why it matters

Base58 decoding takes time roughly quadratic in input size, and the uploader of the metadata chooses the encoding. A payload near the 16 MiB byte cap can keep the CLI busy for a very long time.

Suggested fix, not tested

Apply a much smaller size limit to Base58 payloads (for example 256 KiB), and reject anything larger before decoding.

Review: Verified in code: decompress.rs keeps every JSON-valid 16 MiB stream, legacy_idl.rs:249 uses read_to_end with no take, legacy_idl.rs:248 slices account.data[44..44 + compressed_len] (panic), pmp.rs:921 bs58::decode has no size cap, idl.ts:475-477 ungzip/inflate unbounded, lib.rs:4260 Vec::with_capacity(size) from an untrusted u32. All real, but the attacker must be the writer of the IDL/metadata/account that the developer chooses to fetch, the failure is a loud abort/hang/OOM of a developer CLI or script, nothing is corrupted, and the legacy path (#12, #13) sits in a deprecated legacy-idl subcommand while the new fetcher already has bounded readers (pmp.rs:953-969).

15. Low The TypeScript client decompresses and re-encodes on-chain metadata with no limits
First rating: Medium · Reviewed rating: Low · Review: rated too high
Location: ts/packages/anchor/src/idl.ts:475 (also line 477 and line 496)
From the report
Evidence
return Buffer.from(ungzip(data));
case MetadataCompression.Zlib:
return Buffer.from(inflate(data));
Why it matters

Metadata comes from on-chain accounts, and the account resolver can fetch it for programs the developer never named. A gzip or zlib bomb can crash a Node process or a browser tab. The bs58.encode call at line 496 is quadratic and blocks the event loop on large payloads.

Suggested fix, not tested

Use pako's streaming inflater and abort once the output passes a fixed limit, matching the 16 MiB cap in the Rust fetcher. Reject Base58 payloads above a small size.

Review: Verified in code: decompress.rs keeps every JSON-valid 16 MiB stream, legacy_idl.rs:249 uses read_to_end with no take, legacy_idl.rs:248 slices account.data[44..44 + compressed_len] (panic), pmp.rs:921 bs58::decode has no size cap, idl.ts:475-477 ungzip/inflate unbounded, lib.rs:4260 Vec::with_capacity(size) from an untrusted u32. All real, but the attacker must be the writer of the IDL/metadata/account that the developer chooses to fetch, the failure is a loud abort/hang/OOM of a developer CLI or script, nothing is corrupted, and the legacy path (#12, #13) sits in a deprecated legacy-idl subcommand while the new fetcher already has bounded readers (pmp.rs:953-969).

16. Low anchor account trusts a 32-bit element count from account data
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
let mut vec_data: Vec<JsonValue> = Vec::with_capacity(size);

for _ in 0..size {
Why it matters

size is read directly from the account bytes and can be up to about 4.29 billion. with_capacity then requests up to about 137 GB, which aborts the process. With zero-sized element types the loop can run billions of times.

Suggested fix, not tested

Reject a size larger than the remaining bytes, do not preallocate from untrusted counts (or clamp the preallocation), and add an iteration budget. Apply the same rule to fixed-length arrays.

Review: Verified in code: decompress.rs keeps every JSON-valid 16 MiB stream, legacy_idl.rs:249 uses read_to_end with no take, legacy_idl.rs:248 slices account.data[44..44 + compressed_len] (panic), pmp.rs:921 bs58::decode has no size cap, idl.ts:475-477 ungzip/inflate unbounded, lib.rs:4260 Vec::with_capacity(size) from an untrusted u32. All real, but the attacker must be the writer of the IDL/metadata/account that the developer chooses to fetch, the failure is a loud abort/hang/OOM of a developer CLI or script, nothing is corrupted, and the legacy path (#12, #13) sits in a deprecated legacy-idl subcommand while the new fetcher already has bounded readers (pmp.rs:953-969).

17. Low Account-resolution data in the IDL depends on an exact-case environment value
First rating: Low · Reviewed rating: Low · Review: location checked, kept as rated
From the report
Evidence
let resolution = option_env!("ANCHOR_IDL_BUILD_RESOLUTION")
    .map(|val| val == "TRUE")
    .unwrap_or_default();
Why it matters

When the variable is unset or written as true, resolution information is dropped from the generated IDL without a warning. The IDL builder, by contrast, defaults to enabling resolution.

Suggested fix, not tested

Make the unset default match the builder default. Parse the value case-insensitively in one shared helper used by every IDL generator.

18. Low cli/src/lib.rs has grown to about 7,600 lines of mixed concerns
First rating: Low · Reviewed rating: Low · Review: location checked, kept as rated
From the report
Evidence
struct TestValidatorPlan {
Why it matters

Docker builds, IDL commands, the account decoder, validator and surfpool orchestration, and key management all live in one file. That makes review and change hard and invites coupling.

Suggested fix, not tested

Move each cluster into its own module, starting with validator orchestration and the account decoder.

19. Low cli/src/program.rs and cli/src/config.rs are also very large
First rating: Low · Reviewed rating: Low · Review: location checked, kept as rated
From the report
Evidence
#[cfg(test)]
Why it matters

These files hold about 2,985 and 1,960 lines of non-test code, each mixing several independent concerns: deploy and buffer handling, authority management and inspection in one; config parsing, workspace discovery, validator defaults and keypair handling in the other.

Suggested fix, not tested

Split them along those lines into sub-modules.

20. Low The account resolver does not coalesce concurrent fetches of the same account
First rating: Low · Reviewed rating: Low · Review: location checked, kept as rated
From the report
Evidence
if (!this._cache.has(address)) {
  const accountInfo = await this._provider.connection.getAccountInfo(
Why it matters

The cache is filled only after the await. Seeds resolved in parallel that read the same account each send their own RPC request, which can include repeated IDL fetches and decompression.

Suggested fix, not tested

Cache the in-flight promise and remove it if it rejects.

22. Low anchor init --force overwrites existing project files without listing them
First rating: Low · Reviewed rating: Low · Review: location checked, kept as rated
From the report
Evidence
let toml = cfg.to_string();
fs::write("Anchor.toml", toml)?;
Why it matters

With --force in an existing directory, Anchor.toml, package.json, tsconfig.json, ignore files, migrations and tests are truncated and rewritten. Custom content is lost and the user is not told which files were replaced.

Suggested fix, not tested

Print the list of files that will be replaced and ask for confirmation, or keep .bak copies.

23. Low solana-verify is downloaded and made executable without verification
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
let bin_path = get_bin_dir_path().join("solana-verify");
fs::write(&bin_path, res.bytes()?)?;
Why it matters

The binary is written straight into ~/.avm/bin and set to mode 0775 with no checksum or provenance check, while Anchor binaries in the same file are verified. A tampered release asset or an intercepting proxy would get code execution on the developer's machine. The non-atomic write can also leave a truncated executable that later runs.

Suggested fix, not tested

Pin the expected SHA-256 for each platform asset, or verify a build attestation, and install through the existing atomic install helper.

Review: #23: solana-verify is fetched over HTTPS from a version-pinned GitHub release URL with no hash (lib.rs:662); a real hardening gap, but the exposure is a compromised upstream release, which is the same trust as most download-and-run tooling. #24: predictable std::env::temp_dir()/solana-install-init-{target}-{version} is written and executed (solana.rs:485-500) without create_new, a genuine CWE-377 pattern, but it is a fallback used only when the primary Solana installer fails (solana.rs:385, 424, 436), and it needs a hostile local user on a shared Linux /tmp (macOS temp dirs are per-user).

24. Low The legacy Solana installer is written to a predictable shared temp path and run unverified
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
let installer = std::env::temp_dir().join(format!("solana-install-init-{target}-{version}"));
let bytes = download_installer_bytes(&url)
Why it matters

The path in the shared temp directory is predictable. Another local user can pre-create it as a symlink, so fs::write and chmod hit a file of the attacker's choosing, or can swap the content before it runs. The downloaded bytes are executed without any hash check.

Suggested fix, not tested

Download into a private tempfile::tempdir() and create the file with create_new and mode 0700. Verify a pinned checksum before executing.

Review: #23: solana-verify is fetched over HTTPS from a version-pinned GitHub release URL with no hash (lib.rs:662); a real hardening gap, but the exposure is a compromised upstream release, which is the same trust as most download-and-run tooling. #24: predictable std::env::temp_dir()/solana-install-init-{target}-{version} is written and executed (solana.rs:485-500) without create_new, a genuine CWE-377 pattern, but it is a fallback used only when the primary Solana installer fails (solana.rs:385, 424, 436), and it needs a hostile local user on a shared Linux /tmp (macOS temp dirs are per-user).

25. Low The shell installer takes its expected hashes from the same source as the archives
First rating: Low · Reviewed rating: Low · Review: location checked, kept as rated
Location: avm/install:303
From the report
Evidence
[ "$actual_sha" = "$expected_sha" ] || fail "checksum mismatch for $tool: expected $expected_sha, got $actual_sha"
Why it matters

The manifest that holds the expected hashes comes from the same bucket as the archives (line 293), so the check catches corruption but not a compromised bucket. The Rust updater already verifies attestations.

Suggested fix, not tested

Verify the attestation when tooling is available, or print a clear notice that provenance was not verified.

26. Low The default keypair path is rebuilt in seven places with different failure behaviour
First rating: Low · Reviewed rating: Low · Review: location checked, kept as rated
From the report
Evidence
path.push(".config");
path.push("solana");
path.push("id.json");
Why it matters

The same path is built four times in keygen.rs, once in config.rs and twice in lib.rs. When HOME is unset, one copy panics and another falls back to a literal ~ path, so the copies already behave differently.

Suggested fix, not tested

Add one default_keypair_path() -> Result<PathBuf> in a shared module, use it everywhere, and return an error instead of panicking.

27. Low Prerelease and dist-tag logic is copied across release scripts
First rating: Low · Reviewed rating: Low · Review: location checked, kept as rated
From the report
Evidence
if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+-.+ ]]; then
  publish_args+=(--tag next)
Why it matters

The same regex and tag selection appear in bump-version.sh and twice in release.yaml. Any change to the versioning scheme has to be made in four places, or the release channels drift apart.

Suggested fix, not tested

Move this logic into one sourced helper script and call it from all of them.

28. Low Host target detection is duplicated in avm
First rating: Low · Reviewed rating: Low · Review: location checked, kept as rated
From the report
Evidence
let output = Command::new("rustc").arg("-vV").output()?;
Why it matters

The same parsing already exists as rustc_host_target() (line 1242), so a fix to one copy can miss the other.

Suggested fix, not tested

Call the existing function.

29. Low The CLI crate root and its template module import from each other
First rating: Low · Reviewed rating: Low · Review: location checked, kept as rated
From the report
Evidence
crate::{
    compat::solana_pubkey, config::ProgramWorkspace, create_files, override_or_create_files,
    AbsolutePath, Files, PackageManager, VERSION,
Why it matters

lib.rs depends on template.rs, and template.rs imports helpers back from lib.rs. This tight cycle makes both harder to change or test in isolation.

Suggested fix, not tested

Move the file-creation helpers and shared types into a small leaf module that both depend on.

30. Low npm publish does not check that the bundled binary matches the package version
First rating: Low · Reviewed rating: Low · Review: location checked, kept as rated
Location: .github/workflows/release.yaml:389 (line corrected on review; the report cites :410)
From the report
Evidence
cp "$DIST"/anchor-*-x86_64-unknown-linux-gnu/anchor-*-x86_64-unknown-linux-gnu cli/npm-package/anchor
Why it matters

The binary's version comes from the git tag, and the package version comes from package.json. A mismatch would ship a package whose wrapper rejects its own binary, and a published version cannot be republished.

Suggested fix, not tested

Before npm publish, assert that ./anchor --version equals the package version, and fail the job otherwise.

32. Low The program deploy keypair is created with default file permissions
First rating: Medium · Reviewed rating: Low · Review: rated too high
Location: cli/src/config.rs:1822 (line corrected on review; the report cites :1823)
From the report
Evidence
let mut file = File::create(&path)
    .with_context(|| format!("Error creating file with path: {}", path.display()))?;
file.write_all(format!("{:?}", program_kp.to_bytes()).as_bytes())?;
Why it matters

The secret key in target/deploy/<name>-keypair.json gets the umask default (usually 0644), so other local users can read it before the first deploy. The exists()-then-create sequence also lets a concurrent run truncate an existing key.

Suggested fix, not tested

Reuse the existing secure keypair writer (create_new plus mode 0600).

Review: The program-id keypair is written with File::create default mode (config.rs:1822). Real, but this key only matters for the first deploy of that program id, upgrade authority is the wallet, and the file sits under the project's target/ in the user's own directory.

33. Low Recursive deletes rely on weak path checks
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
if !ledger_path.is_relative() {
...
fs::remove_dir_all(&ledger_path).with_context(|| {
Why it matters

The ledger path from Anchor.toml is only required to be relative, so "." or "../.." passes and is deleted with remove_dir_all. A cloned project with a hostile Anchor.toml can wipe the workspace or its parent directories on anchor test. In the same way, anchor coverage --trace-dir . deletes the whole workspace (line 4766).

Suggested fix, not tested

Canonicalise the path, reject .. and .-only forms, and require it to be strictly inside the workspace and not equal to it before deleting.

Review: ledger = "." or ".." passes is_relative() and is removed (lib.rs:6268), and --trace-dir . deletes the workspace trace path (lib.rs:4766). Real footguns, but both are values the user wrote, and a hostile Anchor.toml already controls [scripts] (config.rs:363), which anchor test runs, so the "cloned hostile repo" framing adds nothing.

34. Low The wait for surfpool runbooks has no timeout and leaks the validator on error
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
loop {
    let resp = client
        .send::<RpcResponse<SurfnetInfoResponse>>(
Why it matters

If a runbook never completes, anchor test hangs forever. If the RPC call fails, ? returns and the spawned surfpool process keeps running.

Suggested fix, not tested

Bound the loop with a timeout and check try_wait() on each pass. Kill and wait for the child on any error or timeout.

Review: Unbounded loop without a timeout and ? that leaves the spawned surfpool running (lib.rs:6080-6095). Real; the effect is a hung anchor test that Ctrl+C ends and a stray local process.

36. Low Decoding inside websocket callbacks is not guarded
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
const account = this._coder.accounts.decode(
  this._idlAccount.name,
  acc.data
Why it matters

When a subscribed account is closed or reassigned, decode throws "Invalid account discriminator" inside the notification handler, and no event reaches the subscriber. In the event coder, only the base64 step is guarded. A malformed body behind a known discriminator, which can come from user-controlled log text, throws inside the logs callback.

Suggested fix, not tested

Wrap the decodes in try/catch, skip empty account data, and emit an error event (or return null) instead of throwing.

Review: #36: unguarded decode in the callbacks (account.ts:302, event.ts:62) throws inside a notification handler; loud, per-event, no data harm. #37: the doc comment says "Accounts not found or with wrong discriminator are returned as null", but decode throws "Invalid account discriminator" (accounts.ts:63), so one foreign account fails the batch; a real contract bug but visible at once and easy to work around. #38: Buffer.alloc(1000) with a TODO in all three encoders, RangeError on larger payloads; loud failure, and instruction data is bounded by the 1232-byte transaction limit anyway.

37. Low fetchMultiple throws where its documentation promises null
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
// Decode accounts where discriminator is correct, null otherwise
...
data: this._coder.accounts.decode(this._idlAccount.name, account.data),
Why it matters

The documentation says accounts with the wrong discriminator come back as null, but decode throws, so one foreign account fails the whole batch.

Suggested fix, not tested

Check the discriminator, or catch per entry, and return null for mismatches as documented.

Review: #36: unguarded decode in the callbacks (account.ts:302, event.ts:62) throws inside a notification handler; loud, per-event, no data harm. #37: the doc comment says "Accounts not found or with wrong discriminator are returned as null", but decode throws "Invalid account discriminator" (accounts.ts:63), so one foreign account fails the batch; a real contract bug but visible at once and easy to work around. #38: Buffer.alloc(1000) with a TODO in all three encoders, RangeError on larger payloads; loud failure, and instruction data is bounded by the 1232-byte transaction limit anyway.

38. Low The account, type and instruction encoders use a fixed 1000-byte buffer
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
const buffer = Buffer.alloc(1000); // TODO: use a tighter buffer.
Why it matters

Any account, type or instruction payload that serialises to more than 1000 bytes (long strings, vectors, larger structs) fails to encode with a range error. This breaks client-side account construction, test fixtures and large instructions.

Suggested fix, not tested

Compute the size from the layout first, or grow the buffer on overflow.

Review: #36: unguarded decode in the callbacks (account.ts:302, event.ts:62) throws inside a notification handler; loud, per-event, no data harm. #37: the doc comment says "Accounts not found or with wrong discriminator are returned as null", but decode throws "Invalid account discriminator" (accounts.ts:63), so one foreign account fails the batch; a real contract bug but visible at once and easy to work around. #38: Buffer.alloc(1000) with a TODO in all three encoders, RangeError on larger payloads; loud failure, and instruction data is bounded by the 1232-byte transaction limit anyway.

39. Low The manual make publish target omits two required crates
First rating: Medium · Reviewed rating: Low · Review: rated too high
Location: Makefile:36
From the report
Evidence
cd lang/ && cargo publish && cd ../
sleep 25
cd spl/ && cargo publish && cd ../
Why it matters

anchor-lang-error (lang/error) and anchor-cli-macros (cli/cli-macros) are publishable, required dependencies of anchor-lang and anchor-cli, but they are missing from the list. Running the target publishes about ten crates and then fails, leaving a partial release that cannot be undone.

Suggested fix, not tested

Remove the target, or make it call the same workspace-wide release command the CI release uses, so the crate list cannot drift.

Review: Makefile publish target (Makefile:36) lacks lang/error and cli/cli-macros (both publish = true), so it would fail partway. It is manual maintainer tooling, not shipped, and the CI release workflow is the real path.

Info after review (1)

31. Info The npm wrapper prints the same two warnings on every run on unsupported platforms
First rating: Info · Reviewed rating: Info · Review: location checked, kept as rated
From the report
Evidence
console.error(`Only x86_64 / Linux distributed in NPM package right now.`);
Why it matters

This is a permanent condition rather than an event, so it adds the same noise to every CI log and script output.

Suggested fix, not tested

Fall back to the global binary quietly, and warn only when it is missing or its version differs.

Want this for your code?

Upload a ZIP or link a public GitHub repo, pick the areas and the depth, and get findings by severity with fixes.