| Audited | solana-foundation/program-examples at commit de090195e4de698e150572c4f886c333323a64a5 |
|---|---|
| Date | 11 October 2026 |
| How it ran | cloud session, full audit, Standard review |
| Verdict after review | Fail (rule: Fail if a High finding remains after review, otherwise Pass with notes) |
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. 32 findings were first rated Medium or High; 8 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.
High after review (2)
From the report
/// CHECK: This account is neither written to nor read from.
pub new_leaf_owner: UncheckedAccount<'info>,
Withdraw and WithdrawTwo have no Signer and no stored authority. The vault PDA signs the Bubblegum transfer for every caller, and the caller picks the recipient, so any wallet can take every cNFT in the vault.
Store an owner or admin key at setup and require that signer (has_one or address). Pin bubblegum_program to the Bubblegum program ID.
Review: Withdraw and WithdrawTwo (lib.rs:269-330) contain no Signer and no stored authority; leaf_owner is only the cNFT-vault PDA, which invoke_signed at lines 141-145 and 225/262 signs for whoever calls. new_leaf_owner is an arbitrary UncheckedAccount ("neither written to nor read from"), so the caller picks the recipient. Textbook missing authorization on a program-signed asset transfer; the example's whole point is a vault, so a copy inherits it.
Upstream: No matching upstream report found (11 October 2026).
From the report
self.vault.amount >= self.fundraiser.amount_to_raise,
...
transfer(cpi_ctx, self.vault.amount)?;
The goal check uses the vault's token balance, which anyone can raise by sending tokens straight to it. The maker can top up the vault temporarily, pass the check and sweep everything, including the contributions of a campaign that never reached its target. Contributors then cannot get a refund. The refund check in refund.rs:66 also reads vault.amount.
Compare fundraiser.current_amount, which only contribute and refund change, against the target in both places.
Review: checker.rs:53-56 requires vault.amount >= amount_to_raise, then sweeps vault.amount (line 80) and closes fundraiser (close = maker, line 28). The vault is a plain ATA, so anyone, including the maker inside the same transaction, can top it up, pass the check, and take the entire vault; the top-up comes back in the sweep, so the maker risks nothing. After fundraiser is closed contributors cannot refund. refund.rs:66 reads vault.amount too, so a donation can also block refunds. current_amount exists and is the right counter. Upstream: no match found (related: #725, #674 are different problems).
Upstream: No exact upstream report; related items: #725 (issue, open), #685 (PR, merged).
Medium after review (6)
From the report
let [target_account] = accounts else { ... };
target_account.resize(account_span)?;
target_account_data.copy_from_slice(data);
There is no signer, no ownership relation and no record-type tag. Anyone can pass another user's account and replace its contents with a different record type, which destroys that user's data. Two of the three record types are 25 bytes long, so size alone cannot tell them apart.
Start every record with a one-byte type tag and reject any type other than the one expected. Before resizing or writing, require an authority signature or a PDA derived from the owner.
Review: Pinocchio reallocate_zero_init (reallocate.rs:31-44) and native (37-48) take one account, no signer, no owner field, and resize/overwrite it; true. But there is no stored authority concept anywhere in this example: the Anchor sibling (Update in basics/realloc/anchor) only requires a payer signer and no relation between payer and account, so it is equally open, and the records are address/work demo data with no funds. Not HIGH. The report's type-confusion remark is accurate for same-size records. Upstream: no match found.
Upstream: No matching upstream report found (11 October 2026).
From the report
let target_account = next_account_info(accounts_iter)?;
target_account.resize(account_span)?;
data.serialize(&mut &mut target_account.data.borrow_mut()[..])?;
reallocate_zero_init overwrites any account the program owns. reallocate_without_zero_init (line 18) parses any 25-byte account as an address record, including a work record.
As in finding 9, add a type tag, verify it, and require an owner or authority before any change.
Review: Pinocchio reallocate_zero_init (reallocate.rs:31-44) and native (37-48) take one account, no signer, no owner field, and resize/overwrite it; true. But there is no stored authority concept anywhere in this example: the Anchor sibling (Update in basics/realloc/anchor) only requires a payer signer and no relation between payer and account, so it is equally open, and the records are address/work demo data with no funds. Not HIGH. The report's type-confusion remark is accurate for same-size records. Upstream: no match found.
Upstream: No matching upstream report found (11 October 2026).
From the report
**rent_vault.lamports.borrow_mut() -= lamports_required_for_rent;
**new_account.lamports.borrow_mut() += lamports_required_for_rent;
There is no signer check and no account is ever created. Each call moves the rent minimum from the vault to any account the caller names, so repeated calls empty the vault.
Require new_account to sign and create it with a system create_account CPI signed by the vault PDA, as the Anchor variant does. Use checked arithmetic, and return an error instead of assert!.
Review: Native lines 25-26 and Pinocchio 29-30 move lamports out of the rent_vault PDA to any account with no signer check; true. But the Anchor sibling is also open: new_account there is any fresh keypair that signs, and the CPI signed by the vault funds it, so any caller can still drain rent_vault (the report's fix "as the Anchor variant does" would not close it). The drain is bounded to funds above the vault's rent minimum (the runtime rejects an under-funded program-owned account). Upstream: issue #671 (open, "create_new_account has no caller check - worth a note for people copying it?") and PR #732 (closed, docs caution note) show maintainers know and treat it as a documentation matter.
Upstream: Already reported upstream: #671 (issue, open), #732 (PR, closed unmerged).
From the report
rent_vault.set_lamports(rent_vault.lamports() - lamports_required_for_rent);
new_account.set_lamports(new_account.lamports() + lamports_required_for_rent);
Same as finding 29. In addition, instruction_data[0] at line 18 panics on empty input.
Same as finding 29. Also check the instruction data length.
Review: Native lines 25-26 and Pinocchio 29-30 move lamports out of the rent_vault PDA to any account with no signer check; true. But the Anchor sibling is also open: new_account there is any fresh keypair that signs, and the CPI signed by the vault funds it, so any caller can still drain rent_vault (the report's fix "as the Anchor variant does" would not close it). The drain is bounded to funds above the vault's rent minimum (the runtime rejects an under-funded program-owned account). Upstream: issue #671 (open, "create_new_account has no caller check - worth a note for people copying it?") and PR #732 (closed, docs caution note) show maintainers know and treat it as a documentation matter.
Upstream: Already reported upstream: #671 (issue, open), #732 (PR, closed unmerged).
From the report
let mut liquidity = (amount_a as u128)
.checked_mul(amount_b as u128)
.unwrap()
LP tokens are computed as sqrt(a·b) of each deposit, not as a share of the existing reserves. Once fees have grown the pool, new depositors are over-credited at the expense of existing LPs.
After the first deposit, mint min(a·supply/pool_a, b·supply/pool_b).
Review: deposit_liquidity.rs:68-71 mints isqrt(amount_a*amount_b) for every deposit, not a share of the existing LP supply. swap_exact_tokens_for_tokens.rs:31-36 takes a fee that stays in the pool, so sqrt(k) grows above supply, and later depositors are over-credited at the expense of existing LPs. Real economic bug in a copyable AMM, no direct theft, hence MEDIUM. Upstream: no match found.
Upstream: No exact upstream report; related items: #567 (PR, closed unmerged), #690 (PR, merged).
From the report
pub authority: Signer<'info>,
Any signer is accepted. The program's PDA then verifies any metadata account into its collection, so anyone can add a foreign NFT to the collection.
Constrain authority to a stored admin, and constrain metadata to NFTs this program minted.
Review: Any signer can have the program verify any NFT that merely claims this collection. A verified-collection mark is the trust signal, so MEDIUM.
Upstream: Already reported upstream: #693 (PR, open).
Low after review (43)
From the report
**offer_info.lamports.borrow_mut() -= lamports;
**payer.lamports.borrow_mut() += lamports;
payer is never checked. It does not have to sign and does not have to match whoever funded the offer. A taker can pass their own wallet and collect the rent reserve the maker paid.
Send the rent back to the maker, as the Anchor version does with close = maker. Alternatively, store the funder in the Offer at creation and require payer.key == offer.payer.
Review: payer unchecked (native take_offer.rs:200-204; Pinocchio :144-148, whose doc comment says [signer] but the code never checks it); loss is one offer account's rent (~0.002 SOL), and the taker already receives the vault rent. 3: chop_tree.rs:87-89 has a misleading CHECK comment and no mint validation, but the write is fixed to the key "wood" with the caller's own count and only works on mints whose update authority is this program's PDA; cosmetic NFT metadata griefing. 23: eight prepare.mjs run solana config set -um from postinstall (confirmed, incl. the one safe copy in create-token/pinocchio); a real unannounced global-config side effect but deploying to mainnet still needs a funded wallet. 33: 1_u64.pow(n) is always 1 (contribute.rs:61), functional bug, no loss. 34: vault never closed (rent stranded, re-initialize blocked); upstream #725/#731 cover this. 36: one-sided donation to an empty pool leaves pool_a == 0 so checked_div(..).unwrap() panics (deposit_liquidity.rs:46-50); a pool-seeding DoS, cheap but limited to unseeded pools. 37/38: native/Pinocchio mint have no admin check (Anchor mint.rs has mint_config.admin), so a front-runner can receive another user's NFT between create and mint; low value.
From the report
let new_payer_lamports = payer.lamports() + offer_account.lamports();
payer.set_lamports(new_payer_lamports);
offer_account.close()?;
As in finding 1, the taker picks which account receives the closed offer's lamports.
Refund the maker, or check the account against a funder recorded in the offer.
Review: payer unchecked (native take_offer.rs:200-204; Pinocchio :144-148, whose doc comment says [signer] but the code never checks it); loss is one offer account's rent (~0.002 SOL), and the taker already receives the vault rent. 3: chop_tree.rs:87-89 has a misleading CHECK comment and no mint validation, but the write is fixed to the key "wood" with the caller's own count and only works on mints whose update authority is this program's PDA; cosmetic NFT metadata griefing. 23: eight prepare.mjs run solana config set -um from postinstall (confirmed, incl. the one safe copy in create-token/pinocchio); a real unannounced global-config side effect but deploying to mainnet still needs a funded wallet. 33: 1_u64.pow(n) is always 1 (contribute.rs:61), functional bug, no loss. 34: vault never closed (rent stranded, re-initialize blocked); upstream #725/#731 cover this. 36: one-sided donation to an empty pool leaves pool_a == 0 so checked_div(..).unwrap() panics (deposit_liquidity.rs:46-50); a pool-seeding DoS, cheap but limited to unseeded pools. 37/38: native/Pinocchio mint have no admin check (Anchor mint.rs has mint_config.admin), so a front-runner can receive another user's NFT between create and mint; low value.
chop_tree accepts any mint and updates its metadata with the program's authorityFrom the report
/// CHECK: Make sure the ata to the mint is actually owned by the signer
#[account(mut)]
pub mint: AccountInfo<'info>,
The comment promises an ownership check, but no such check exists. The program's PDA signs a Token-2022 update_field on whatever mint the caller passes. Any player can therefore overwrite the "wood" field of any NFT this program controls, not just their own.
Type the account as InterfaceAccount<Mint> constrained to the Token-2022 program. Bind it to the player, either with a stored mint plus has_one, or by requiring the signer's token account for this mint to hold 1. Check the metadata-pointer and update-authority values too.
Upstream: No matching upstream report found (11 October 2026).
From the report
/// CHECK:
pub mint: UncheckedAccount<'info>,
The hook does not check that the mint belongs to Token-2022, that a transfer is in progress, or that the list accounts are the expected PDAs owned by this program. The allow/deny decision rests on accounts the caller supplies.
Add the transferring-flag check that the account-data-as-seed example already uses. Check the mint's owner. Constrain the list accounts with seeds, a bump and an owner check.
tokens/token-2022/transfer-hook/block-list/pinocchio/program/src/instructions/tx_hook.rs:17From the report
/// 5- given all the above we can skip a lot of type and owner checks
let owner = unsafe { &*(source_data[32..64].as_ptr() as *const Address) };
The code assumes Token-2022 has already validated the inputs. A direct call with short or foreign accounts can make it misread data or panic.
Before slicing, check that the source and destination accounts are owned by Token-2022 and are long enough. Check that the block accounts are owned by this program.
From the report
for (const [address, signature] of Object.entries(signed.signatures)) {
if (signature) {
signedTransaction.addSignature(new PublicKey(address), Buffer.from(signature));
Missing signatures are skipped silently. The failure then surfaces later as an opaque send error instead of a clear message, for example when the user rejected the request.
After copying, check that every required signer, at least the fee payer, has a signature. Throw a clear error if one is missing.
latest in one package and ^6.9.0 in its siblingsFrom the report
"@solana/kit": "latest"
The next install can pull a new major version into the scripts while the web app and API stay on 6.x.
Use the same pinned range in every sub-package.
games/world-cup/pinocchio/webapp/src/components/solana/solana-provider.tsx:80 and games/gacha/pinocchio/webapp/src/components/wallet-button.tsx:100From the report
await connect(connectorId);
We searched both apps for isSecureContext and location.protocol and found neither. Served over plain HTTP from a host other than localhost, common browser wallets refuse to connect, and the user sees nothing.
Check the origin on load and show an explicit message. Deploy over HTTPS.
sleep() test helper never waitstokens/token-swap/anchor/tests/utils.ts:10 (copy in tokens/escrow/native/tests/utils.ts:27)From the report
export async function sleep(seconds: number) {
new Promise(resolve => setTimeout(resolve, seconds * 1000));
The promise is neither returned nor awaited, so the function returns at once. Anyone who relies on it for timing gets no delay.
Write return new Promise(...), or delete the helper.
getProgramAccounts in a tight loopFrom the report
for (;;) {
try { await crankOnce(client, operatorSigner, operatorSeed, pool);
Every cycle runs a full account scan, which is costly on RPC quotas and adds latency.
Drive the crank from an account or log subscription. Keep a slow poll only to catch missed events.
From the report
} catch (err) {
console.error(` ✗ failed to settle ${pubkey}:`, err);
}
A pull that cannot be settled is rebuilt and resent forever, which wastes RPC calls and fees and floods the logs.
Track failures per pull and apply exponential backoff or a retry cap.
games/gacha/pinocchio/scripts/operator-settle.ts:57 (also burst-randomness.ts:112-113, webapp/src/lib/gacha.ts:8)From the report
const PULL_ACCOUNT_SIZE = 220n;
const PULL_POOL_OFFSET = 4n;
These values match today, but nothing ties them to the program, so a layout change will silently break the account filters.
Export the size and offsets from the generated client, or add a test that compares them with the program's layout.
From the report
let ext_bytes = &acc_data_bytes[EXTENSION_DATA_OFFSET..];
let ext_len = unsafe { &*(ext_bytes[ext_len_idx..].as_ptr() as *const u16) };
A truncated entry panics or reads outside the intended range. The u16 read is unaligned, and the result is cast to an address without a length check.
Read lengths with from_le_bytes on checked slices and verify idx + len <= data.len(). Check that the returned slice holds at least 32 bytes. Cap the number of entries the loop walks.
From the report
let surfpoolProcess: ChildProcess | null = null;
let startingValidator = false;
let deployingProgram = false;
Validator process state, the config store, airdrops and deploy planning share one router, so every change touches everything.
Split it into validator, config-store, airdrop and deploy modules behind a route table.
From the report
async function handleCreateMockUsdc(): Promise<Response> { ... const child = spawn('spl-token', ['create-token', ...]);
A double click or retry creates several mints, and the concurrent read-modify-write of config.json keeps only the last write.
Keep one in-flight promise for this operation and serialize config writes.
From the report
resolve(jsonResponse({ success: true, pid: child.pid }));
If surfpool is missing or fails to start, the client has already been told it succeeded.
Resolve on the spawn event or on the first passing health check, and return the error from the error handler.
RPC_URL is read from the environment but most calls use a hard-coded endpointFrom the report
const healthRes = await fetch('http://127.0.0.1:8899', {
Setting RPC_URL points only some handlers at the new endpoint, so the server ends up talking to two nodes.
Use the RPC_URL constant everywhere, and read the port from the environment too.
From the report
const PULL_STATUS_PENDING = 0;
const PULL_STATUS_SETTLED = 1;
The generated client already exports PullStatus, and these copies will drift if the program's enum changes.
Import PullStatus here, in operator-settle.ts and in burst-randomness.ts.
games/world-cup/pinocchio/webapp/api/server.ts:27 (also src/config/networks.ts:15, scripts/init-test-environment.ts:10)From the report
const PROGRAM_ADDRESS = 'wCupoZtR1g1NXRRVELe5KqFgayyEteVKKxEerxugvxA';
A redeploy to a new address must be edited in three places, and missing one silently breaks that path.
Export the address once, from the generated client or one config module, and import it everywhere.
From the report
if (!buyInstruction || !data || data.length !== 33 || data[0] !== 1) {
The values match today, but if the program's instruction layout changes, validation will reject every valid purchase or accept the wrong ones.
Take the discriminator and length from the generated client.
tokens/nft-operations/pinocchio/prepare.mjs:25 (same in create-token/{native,anchor}, pda-mint-authority/{native,anchor}, transfer-tokens/{native,anchor}, nft-operations/anchor)From the report
execSync('solana config set -um', { stdio: 'inherit' });
After pnpm install, the developer's CLI stays pointed at mainnet-beta. A later solana program deploy (for example from cicd.sh) then spends real SOL on mainnet. tokens/create-token/pinocchio/prepare.mjs already uses the safe form.
In every copy, use solana program dump -um <id> <file> and remove config set.
saveKeypairToFile deletes an existing key file without askingcompression/cutils/anchor/tests/utils/helpers.ts:132 (same in compression/cnft-burn/anchor/tests/utils/helpers.ts:132)From the report
// remove the current file, if it already exists
if (fs.existsSync(fileName)) fs.unlinkSync(fileName);
A caller that reuses a name loses that secret key for good.
Fail when the file exists, or write with { flag: 'wx' }.
savePublicKeyToFile wipes stored addresses after a read error it swallowedFrom the report
let data: any = loadPublicKeysFromFile(absPath);
The loader returns {} on any error, such as a corrupt file or one bad key. The save then writes only the new entry over the whole file.
Tell "file missing" apart from "file unreadable", and abort the save in the second case.
getInstructionData is copied three times within the cNFT vault examplecompression/cnft-vault/anchor/tests/tests.ts:122 (also tests/scripts/withdrawTwo.ts:69, tests/scripts/withdrawWithLookup.ts:109)From the report
function getInstructionData(asset: any, proof: any): [number[], number[], number[], anchor.BN, number, number] {
Three copies of the proof-decoding logic have to be fixed in step.
Move it into the example's existing tests/utils.ts.
.github/workflows/anchor.yml:238From the report
failed_projects=$(cat $GITHUB_WORKSPACE/failed_projects.txt | jq -R -s -c 'split("\n")[:-1]')
The other three workflows guard this read with [ -f ... ]. solana-asm.yml lacks the Node 24 environment flag. The anchor.yml path filter omits Cargo.toml and Cargo.lock.
Move the shared jobs into a reusable workflow or composite action parameterised by framework.
From the report
amount >= 1_u64.pow(self.mint_to_raise.decimals as u32),
1^n is always 1, so the intended minimum of one whole token is never enforced.
Use 10_u64.pow(decimals), or a named minimum scaled by the decimals.
From the report
close = maker,
The fundraiser account is closed but its vault is not. The vault's rent stays locked, and a second initialize for the same maker and mint fails because the vault already exists.
After the transfer, close the vault with a close_account CPI signed by the fundraiser PDA.
From the report
let amount_b_required = amount_a_u128.checked_mul(pool_b_u128).unwrap()
.checked_div(pool_a_u128)
.unwrap();
If someone sends token B to an empty pool, pool_a stays 0 and the division panics. The pool can then never be seeded.
Treat "either reserve is zero" as pool creation, and replace the unwrap() calls with returned errors.
From the report
invoke_signed(
&token_instruction::mint_to(
token_program.key, mint_account.key, associated_token_account.key,
The PDA signs mint_to for any caller. Whoever calls first receives the token from a mint someone else created.
Store a creator or admin key and require it to sign, as the Anchor variant does.
From the report
MintTo { mint: mint_account, account: associated_token_account, mint_authority, amount: 1,
.invoke_signed(&signers)?;
Same as finding 37.
Same as finding 37.
tokens/token-2022/transfer-hook/account-data-as-seed/anchor/programs/transfer-hook/src/lib.rs:63 (same in transfer-hook/counter/anchor/programs/transfer-hook/src/lib.rs:65)From the report
if amount > 50 {
msg!("The amount is too big: {}", amount);
//return err!(TransferError::AmountTooBig);
Oversized transfers go through. Only a log line records them.
Re-enable the error, or remove the limit and the unused error variant.
tokens/token-2022/transfer-hook/account-data-as-seed/anchor/programs/transfer-hook/src/lib.rs:69 (same in the counter example)From the report
let count = ctx
.accounts
.counter_account
The incremented value is only logged. The account is not mutable and is never written, so the count is always reported as 1.
Mark counter_account as mut and assign the new count.
From the report
const DAS_URL = process.env.NEXT_PUBLIC_DAS_RPC;
Next.js inlines NEXT_PUBLIC_* variables into client code, so every visitor can read the provider credential.
Proxy DAS calls through a server route that reads a variable without the public prefix. Rotate any key already exposed.
From the report
if TournamentState::try_from(config.state)? != TournamentState::Finalized {
return Err(WorldCupError::InvalidState.into());
}
Only the admin can advance the state. We searched the program for refund, cancel or timeout logic and found none. If the admin key is lost or the admin withholds results, every entry fee stays in the vault.
Add a refund anyone can trigger after a deadline, or a cancel state.
From the report
Config::init(&mut config_data, config_bump, accounts.admin.address(), lock_ts, ENTRY_FEE)?;
The config PDA is global and any signer can initialize it. Someone who front-runs the deployer controls results and payouts.
Restrict initialization to the upgrade authority or a fixed deployer key, or seed the PDAs by admin.
Upstream: No exact upstream report; related items: #743.
From the report
const allowedOrigin = origin && ALLOWED_ORIGINS.includes(origin) ? origin : ALLOWED_ORIGINS[0];
Requests from unknown origins are still processed, and the body is parsed whatever its Content-Type. Any site the developer visits can call airdrop, start-validator, create-mock-usdc and save-config, and save-config can rewrite the program address.
Reject origins that are not on the allow list, validate Host, require Content-Type: application/json, and add a per-run token.
From the report
if (typeof val !== baseType) throw new Error(`Field ${key} must be ${baseType}, got ${typeof val}`);
extractFields throws, and neither the request handler nor the server callback catches the error. A body such as {"recipient":5} ends the process, and because of finding 45 a cross-site request can send it.
Wrap request handling in try/catch and return 400.
From the report
if (body.tokens) { prev.tokens = body.tokens; }
if (body.programAddress) { prev.programAddress = body.programAddress; }
Anything that is an object passes as tokens, and programAddress and network are not checked at all. Later handlers that call tokens.find(...) throw, and the injected address feeds the deploy plan.
Validate the network against an allow list, and check token entries and the address against base58 rules.
declare_id!tokens/token-swap/anchor/Anchor.toml:10 and tokens/token-2022/transfer-hook/allow-block-list-token/anchor/Anchor.toml:11From the report
swap_example = "AsGVFxWqEn8icRBFQApxJe68x3r9zvfSbmiEzYFATGYn"
abl-token = "EYBRvArz4kb5YLtzjD4TW6DbWhS8qjcMYqBU4wHLW3qj"
lib.rs declares UPxp2moQ… and 3ku1ZEGv…. A deploy using the Anchor.toml key produces a program that rejects its own instructions. CI hides this because it regenerates the keys.
Run anchor keys sync and regenerate the IDL and client from one source.
expectRevert test helper can never failtokens/token-swap/anchor/tests/utils.ts:20 (same in tokens/token-2022/transfer-hook/transfer-switch/anchor/tests/litesvm.test.ts:26)From the report
await promise;
throw new Error('Expected a revert');
} catch {
The "expected a revert" error is caught by its own catch, so every negative-path test passes whether or not the program rejects the input.
Set a flag in the catch and throw after the try block, as tokens/escrow/pinocchio/tests/utils.ts does. Then re-run the affected tests.
basics/account-data/native/cicd.sh:7 (and the other cicd.sh scripts)From the report
cargo build-sbf --manifest-path=./program/Cargo.toml --sbf-out-dir=./program/target/so
solana program deploy ./program/target/so/program.so
There is no set -e. A failed build still deploys whatever .so is left over from an earlier build.
Add set -euo pipefail after the shebang. pipefail matters for the script that pipes deploy output into grep.
scripts/lib/command-update.ts:21From the report
const npmVersion = execSync(`npm view ${pkgName} version`).toString().trim();
Package names come from the dependency keys of every package.json in the tree. A contributed key containing shell syntax runs on the maintainer's machine during an update.
Use execFileSync('npm', ['view', pkgName, 'version']) and validate the name first.
.github/workflows/anchor.yml:52 (and the native, Pinocchio and asm workflows)From the report
workflow:
- added|modified: '.github/workflows/anchor.yml'
Edits to .github/actions/setup/** or .github/.ghaignore match no filter, so no projects are built and the breakage first shows up on main.
Add those paths to the workflow filter in all four workflows.
Info after review (3)
basics/favorites/pinocchio/cicd.sh:7From the report
cargo build-sbf --manifest-path=./program/Cargo.toml --bpf-out-dir=./program/target/so
Copying scripts into each example is a deliberate design choice. Still, seven Pinocchio scripts use --bpf-out-dir while the rest use --sbf-out-dir, and some prepare.mjs headers still mention bankrun, which has been removed. A periodic sync check would keep the copies aligned.
is_token_2022_mint reads the account data before checking its lengthFrom the report
let mint_type_byte = data[TYPE_BYTE_OFFSET];
data.len() > TYPE_BYTE_OFFSET && mint_type_byte == MINT_TYPE_BYTE && mint.owned_by(&TOKEN_2022_PROGRAM_ID)
A classic 82-byte mint or an empty account panics instead of returning false and producing the intended InvalidMint error, so the length check never does its job.
Check the owner and length first, or use data.get(TYPE_BYTE_OFFSET).
basics/transfer-sol/pinocchio/program/src/lib.rs:25 (also processing-instructions/pinocchio/program/src/lib.rs:7, pda-rent-payer/pinocchio/.../init_rent_vault.rs:20, favorites/pinocchio/program/src/processor.rs:11, close-account/pinocchio/program/src/lib.rs:47, realloc/pinocchio/.../reallocate.rs:26, program-derived-addresses/pinocchio/.../create.rs:24)From the report
let amount_bytes: [u8; 8] = instruction_data[0..8].try_into().map_err(|_| ProgramError::InvalidInstructionData)?;
Slicing with [a..b], indexing, split_first().unwrap() and copy_from_slice all panic on bad input, so the map_err handling never runs and callers get an opaque failure. These examples are what people copy.
Use instruction_data.get(..n).ok_or(ProgramError::InvalidInstructionData)?, and validate exact lengths before copy_from_slice.