@elytrasec/engine 0.4.5 → 0.4.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +185 -2
  2. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -2912,6 +2912,7 @@ var PY = [".py"];
2912
2912
  var GO = [".go"];
2913
2913
  var SOL = [".sol"];
2914
2914
  var JAVA = [".java"];
2915
+ var RUST = [".rs"];
2915
2916
  var RUBY = [".rb", ".erb"];
2916
2917
  var PHP = [".php"];
2917
2918
  var ALL = ["*"];
@@ -5229,6 +5230,179 @@ var modernDeFiRules = [
5229
5230
  languages: SOL
5230
5231
  }
5231
5232
  ];
5233
+ var solanaRules = [
5234
+ {
5235
+ id: "cp-sl-missing-signer-check",
5236
+ title: "Solana: account used as authority without signer check",
5237
+ description: "An account is passed to an instruction and used as the authority for an action (transfer, mint, close) without verifying it has signed the transaction. The function deserializes the account but never checks `is_signer` or uses the Anchor `Signer<'info>` type.",
5238
+ suggestion: "Anchor: declare the authority as `pub authority: Signer<'info>` so the runtime enforces is_signer. Raw: `require!(ctx.accounts.authority.is_signer, ErrorCode::Unauthorized);`",
5239
+ multilinePattern: /pub\s+\w+\s*:\s*AccountInfo\s*<'info>[\s\S]{0,400}?(?:transfer|mint_to|close|set_authority|burn)\s*\((?![\s\S]{0,800}?(?:is_signer|Signer\s*<|require!\s*\([^)]*is_signer|require_keys_eq!\s*\([^)]*authority))/,
5240
+ severity: "critical",
5241
+ category: "solidity",
5242
+ confidence: "medium",
5243
+ languages: RUST
5244
+ },
5245
+ {
5246
+ id: "cp-sl-missing-owner-check",
5247
+ title: "Solana: account ownership not verified before deserialization",
5248
+ description: "Raw `AccountInfo` is deserialized with `try_from_slice` or accessed via `data.borrow()` without first checking `account.owner == &expected_program_id`. An attacker can pass any account holding crafted bytes, leading to type confusion.",
5249
+ suggestion: "Anchor: use typed account wrappers (`Account<'info, MyState>`) which check the discriminator automatically. Raw: `require_keys_eq!(*account.owner, program_id, ErrorCode::WrongOwner);` before any deserialization.",
5250
+ multilinePattern: /(?:\.|::)try_from_slice\s*\(\s*(?:&|&mut\s+)\s*\w+\.(?:data|try_borrow_data)(?![\s\S]{0,800}?(?:require_keys_eq!\s*\([^)]*\.owner|require!\s*\([^)]*\.owner\s*==|assert_eq!\s*\([^)]*\.owner|Account\s*<|AccountLoader\s*<))/,
5251
+ severity: "critical",
5252
+ category: "solidity",
5253
+ confidence: "medium",
5254
+ languages: RUST
5255
+ },
5256
+ {
5257
+ id: "cp-sl-ata-confusion",
5258
+ title: "Solana: token account mint not validated (ATA confusion)",
5259
+ description: "An SPL `TokenAccount` is used in a transfer without validating its `mint` field matches the expected mint. An attacker can substitute a token account they control with the same authority but different (worthless) mint, draining the real account.",
5260
+ suggestion: "Anchor: use `#[account(mint = expected_mint, token::authority = user)]` constraints. Raw: `require_keys_eq!(token_account.mint, expected_mint);`",
5261
+ multilinePattern: /token::transfer\s*\([\s\S]{0,400}?\)(?![\s\S]{0,1500}?(?:mint\s*=\s*\w+|mint\s*==\s*\w+|require_keys_eq!\s*\([^)]*\.mint|associated_token::))/,
5262
+ severity: "high",
5263
+ category: "solidity",
5264
+ confidence: "low",
5265
+ languages: RUST
5266
+ },
5267
+ {
5268
+ id: "cp-sl-account-substitution",
5269
+ title: "Solana: account passed without has_one or constraint binding",
5270
+ description: "An account is destructured from `ctx.accounts` and read/written, but the program never checks that this account is the one expected by the user's PDA-derived state. An attacker can substitute any same-typed account.",
5271
+ suggestion: "Use Anchor's `#[account(has_one = expected_field)]` or `#[account(constraint = a.key() == state.b)]` to bind related accounts. Raw: `require_keys_eq!(ctx.accounts.target.key(), state.target);`",
5272
+ multilinePattern: /#\[derive\s*\(\s*Accounts\s*\)\][\s\S]{0,2000}?pub\s+\w+\s*:\s*Account\s*<\s*'info\s*,\s*\w+\s*>\s*,(?![\s\S]{0,2000}?(?:has_one\s*=|constraint\s*=|seeds\s*=))/,
5273
+ // Downgraded to medium: it's a "review needed" heuristic, not a confirmed exploit.
5274
+ severity: "medium",
5275
+ category: "solidity",
5276
+ confidence: "low",
5277
+ languages: RUST
5278
+ },
5279
+ {
5280
+ id: "cp-sl-pyth-staleness",
5281
+ title: "Solana: Pyth/Switchboard price used without staleness check",
5282
+ description: "Pyth `get_price_unchecked` (or Switchboard `get_result`) returns a price that may be stale \u2014 the publisher could be offline. Using this for collateral pricing without checking `publish_time` is the Solana-side equivalent of the Mango oracle exploit ($114M).",
5283
+ suggestion: "Use Pyth's `get_price_no_older_than(clock, MAX_AGE)` (or `get_ema_price_no_older_than`). For Switchboard, check `result.last_update_timestamp` against `clock.unix_timestamp`.",
5284
+ multilinePattern: /(?:get_price_unchecked|get_price_no_older|get_ema_price_unchecked|get_result\s*\(\s*\))\s*\([\s\S]{0,200}?\)(?![\s\S]{0,1200}?(?:publish_time|MAX_AGE|max_age|staleness|STALENESS|unix_timestamp\s*-\s*\w+|clock\.\s*unix_timestamp))/,
5285
+ severity: "high",
5286
+ category: "solidity",
5287
+ confidence: "medium",
5288
+ languages: RUST
5289
+ },
5290
+ {
5291
+ id: "cp-sl-cpi-reentrancy",
5292
+ title: "Solana: state mutation after cross-program invocation",
5293
+ description: "Anchor checks-effects-interactions: state should be updated BEFORE invoking another program. A CPI followed by state mutation invites cross-program reentrancy, especially with callback programs.",
5294
+ suggestion: "Move state writes to BEFORE the `invoke` / `invoke_signed` / `CpiContext::new` call. Or use Anchor's account locking + a reentrancy guard flag.",
5295
+ multilinePattern: /(?:invoke|invoke_signed|CpiContext::new(?:_with_signer)?)\s*\([\s\S]{0,400}?\)\s*\?\s*;[\s\S]{0,400}?(?:\.\s*\w+\s*=\s*\w+|state\.\s*\w+\s*=|account\.\s*\w+\s*=)/,
5296
+ severity: "high",
5297
+ category: "solidity",
5298
+ confidence: "low",
5299
+ languages: RUST
5300
+ },
5301
+ {
5302
+ id: "cp-sl-arbitrary-cpi",
5303
+ title: "Solana: program passed as account, used for arbitrary CPI",
5304
+ description: "An `AccountInfo` (or untyped account) is used as the `program_id` for an `invoke()` call. An attacker who can choose the program account effectively gets arbitrary cross-program invocation \u2014 equivalent to delegatecall in EVM.",
5305
+ suggestion: "Type the program field as `Program<'info, MyProgram>` (Anchor) so the runtime enforces the program's pubkey. Or `require_keys_eq!(program.key(), expected_program_id);` before invoke.",
5306
+ multilinePattern: /invoke(?:_signed)?\s*\(\s*&Instruction\s*\{\s*program_id\s*:\s*[*\w.()]+\s*,/,
5307
+ severity: "critical",
5308
+ category: "solidity",
5309
+ confidence: "medium",
5310
+ languages: RUST
5311
+ },
5312
+ {
5313
+ id: "cp-sl-close-account-no-lamport-check",
5314
+ title: "Solana: account closed without verifying lamport destination",
5315
+ description: "Using `#[account(close = destination)]` or manually closing an account transfers its rent-exempt SOL to the destination. If `destination` isn't validated (constrained to the rightful owner), an attacker can drain rent into their own wallet on every close.",
5316
+ suggestion: "Constrain the close destination: `#[account(close = expected_owner, has_one = expected_owner)]`. Or verify with `require_keys_eq!(destination.key(), authority.key())` before transfer.",
5317
+ // Match only when close=X appears in a #[account(...)] block that has NO has_one / constraint
5318
+ // in the same block (tempered greedy excludes those keywords inside the brackets).
5319
+ multilinePattern: /#\[account\s*\((?:(?!has_one|constraint)[^)\n]){0,400}close\s*=\s*\w+(?:(?!has_one|constraint)[^)\n]){0,400}\)\]/,
5320
+ severity: "high",
5321
+ category: "solidity",
5322
+ confidence: "low",
5323
+ languages: RUST
5324
+ },
5325
+ {
5326
+ id: "cp-sl-lamport-overflow",
5327
+ title: "Solana: unchecked lamport arithmetic (overflow / underflow)",
5328
+ description: "Direct mutation of `account.lamports.borrow_mut() += X` or `-= X` without `checked_add` / `checked_sub` can overflow on large mints or underflow on overdraft. Solana's runtime does NOT panic on integer overflow in release mode.",
5329
+ suggestion: "Use `lamports.borrow_mut().checked_add(amount).ok_or(MyError::Overflow)?` and matching `checked_sub`. Or use `**lamports = lamports.checked_add(X)?`.",
5330
+ pattern: /\*\*[\w.]+?\.lamports\s*\.\s*borrow_mut\s*\(\s*\)\s*[+\-]=/,
5331
+ severity: "high",
5332
+ category: "solidity",
5333
+ confidence: "medium",
5334
+ languages: RUST
5335
+ },
5336
+ {
5337
+ id: "cp-sl-sysvar-substitution",
5338
+ title: "Solana: sysvar accepted as AccountInfo (substitution risk)",
5339
+ description: "Accepting `Clock`, `Rent`, `EpochSchedule`, or `SlotHashes` as raw `AccountInfo` allows an attacker to substitute a forged account holding crafted bytes. Type confusion \u2192 wrong clock values \u2192 expiry bypass.",
5340
+ suggestion: "Use typed Anchor sysvar wrappers: `pub clock: Sysvar<'info, Clock>`, `pub rent: Sysvar<'info, Rent>`. The runtime enforces the correct pubkey.",
5341
+ multilinePattern: /pub\s+(?:clock|rent|epoch_schedule|slot_hashes|recent_blockhashes|instructions)\s*:\s*AccountInfo\s*<\s*'info\s*>/,
5342
+ severity: "high",
5343
+ category: "solidity",
5344
+ confidence: "high",
5345
+ languages: RUST
5346
+ },
5347
+ {
5348
+ id: "cp-sl-pda-bump-not-stored",
5349
+ title: "Solana: PDA bump derived but not stored on account",
5350
+ description: "Calling `Pubkey::find_program_address(seeds, program_id)` recomputes the canonical bump every time \u2014 that's 32+ ed25519 ops per call. More critically, if you don't STORE the bump on the account, attackers can derive alternative PDAs with non-canonical bumps and break invariants.",
5351
+ suggestion: "Anchor: declare the seed + bump in the account constraint: `#[account(seeds = [...], bump = state.bump)]`. Store the bump on first init.",
5352
+ pattern: /Pubkey::find_program_address\s*\(/,
5353
+ severity: "low",
5354
+ category: "solidity",
5355
+ confidence: "low",
5356
+ languages: RUST
5357
+ },
5358
+ {
5359
+ id: "cp-sl-multisig-threshold-check",
5360
+ title: "Solana: multisig execution without threshold validation",
5361
+ description: "A function checks individual signer authority but never validates that the count of signers meets the multisig threshold. Squads, Realms, and similar multisig programs must enforce `signers.len() >= threshold` before executing.",
5362
+ suggestion: "Add `require!(approvals.len() as u8 >= multisig.threshold, MyError::QuorumNotReached)` before the dispatch.",
5363
+ // Allow qualified field access like multisig.threshold, state.quorum, etc.
5364
+ multilinePattern: /(?:signers|approvals|approvers|signatures|members)\s*\.\s*len\s*\(\s*\)(?![\s\S]{0,500}?(?:>=|>)\s*(?:\w+\.)?(?:threshold|quorum|min_signers|required_signers|min_approvals|m_of_n))/,
5365
+ severity: "high",
5366
+ category: "solidity",
5367
+ confidence: "low",
5368
+ languages: RUST
5369
+ }
5370
+ ];
5371
+ var modernSolidityV2Rules = [
5372
+ {
5373
+ id: "cp-sol-eip1271-replay",
5374
+ title: "EIP-1271 isValidSignature without nonce / expiry",
5375
+ description: "Smart-contract signatures via `isValidSignature` (ERC-1271) that don't bind to a nonce + expiry can be replayed indefinitely. Permit2 leak-class attacks of 2024 exploited exactly this. Once a signature is leaked, it's valid forever and on every chain.",
5376
+ suggestion: "Bind every accepted signature to: (a) a per-account nonce, (b) a deadline / expiry timestamp, AND (c) `block.chainid` in the typed-data domain. Increment nonce on successful verify.",
5377
+ multilinePattern: /function\s+isValidSignature\s*\([^)]*\)\s*(?:external|public)[^{;]*\{(?![\s\S]{0,1500}?(?:nonce|deadline|expiry|chainid|usedSig|_consumeNonce|_useNonce))/,
5378
+ severity: "high",
5379
+ category: "solidity",
5380
+ confidence: "medium",
5381
+ languages: SOL
5382
+ },
5383
+ {
5384
+ id: "cp-sol-safemint-reentrancy",
5385
+ title: "ERC-721 _safeMint reentrancy via onERC721Received callback",
5386
+ description: "`_safeMint` calls the recipient's `onERC721Received` hook. If state (totalSupply, balances, mint cap) is updated AFTER the safeMint, the recipient contract can re-enter and bypass the cap. Several memecoin / NFT mints have lost their entire whitelist allocation this way.",
5387
+ suggestion: "Apply checks-effects-interactions: update mintCount, totalSupply, whitelist-spent BEFORE calling `_safeMint`. Or use `_mint` (no callback) when you don't need ERC-721-receiver compatibility, or add `nonReentrant`.",
5388
+ multilinePattern: /_safeMint\s*\([^)]*\)\s*;[\s\S]{0,300}?(?:totalSupply|_mintedCount|mintedPerUser|whitelist\s*\[[^\]]*\]\s*=|_balanceOf\s*\[[^\]]*\]\s*=|\.\s*\w+\s*\+=\s*1)/,
5389
+ severity: "high",
5390
+ category: "solidity",
5391
+ confidence: "medium",
5392
+ languages: SOL
5393
+ },
5394
+ {
5395
+ id: "cp-sol-voting-snapshot-bypass",
5396
+ title: "Voting power read from spot balanceOf (snapshot manipulation)",
5397
+ description: "A governance proposal reads voting weight from `balanceOf(voter)` at vote time. Attacker flash-borrows the governance token, votes, returns the loan in the same tx. This is distinct from the Beanstalk same-block execute bug \u2014 here even a 24h timelock won't help because the bad vote is already counted.",
5398
+ suggestion: "Use `getPriorVotes(voter, proposalSnapshotBlock)` (Compound), `ERC20Votes.getPastVotes`, or maintain a delegated-balance snapshot via Comp/OZ Votes. Never use `balanceOf` for voting weight.",
5399
+ multilinePattern: /function\s+(?:castVote|vote|castVoteWithReason|_castVote)\s*\([^)]*\)[^{;]*\{[\s\S]{0,800}?balanceOf\s*\(\s*(?:msg\.sender|voter|\w+)\s*\)(?![\s\S]{0,400}?(?:getPriorVotes|getPastVotes|getVotes|snapshot|_snapshot|checkpoints))/,
5400
+ severity: "critical",
5401
+ category: "solidity",
5402
+ confidence: "medium",
5403
+ languages: SOL
5404
+ }
5405
+ ];
5232
5406
  var ALL_RULES2 = [
5233
5407
  ...securityRules,
5234
5408
  ...solidityRules2,
@@ -5245,7 +5419,9 @@ var ALL_RULES2 = [
5245
5419
  ...tstoreRules,
5246
5420
  ...uniswapV4Rules,
5247
5421
  ...hackReplayRules,
5248
- ...modernDeFiRules
5422
+ ...modernDeFiRules,
5423
+ ...solanaRules,
5424
+ ...modernSolidityV2Rules
5249
5425
  ];
5250
5426
 
5251
5427
  // src/static/pattern-scanner.ts
@@ -5282,7 +5458,7 @@ function isDocsFile(filePath) {
5282
5458
  }
5283
5459
  function isCommentLine(line) {
5284
5460
  const trimmed = line.trim();
5285
- return /^(?:\/\/|#|\/?\*|<!--)/.test(trimmed);
5461
+ return /^(?!\*\*\w)(?:\/\/|#|\/?\*|<!--)/.test(trimmed);
5286
5462
  }
5287
5463
  function isImportLine(line) {
5288
5464
  const trimmed = line.trim();
@@ -5584,6 +5760,13 @@ function scanFile(relPath, content, rules, changedRanges) {
5584
5760
  if (rule.id === "cp-hack-wormhole-unchecked-signature-set" && /\b(?:EIP712|DOMAIN_SEPARATOR|_hashTypedDataV4|PERMIT_TYPEHASH|DELEGATION_TYPEHASH|ERC1271|EIP712Upgradeable)\b/.test(content)) continue;
5585
5761
  if (rule.id === "cp-sol-eip712-missing-chainid" && /\b(?:block\.chainid|chainId|chainid)\b/.test(content)) continue;
5586
5762
  if (rule.id === "cp-sol-bridge-missing-source-check" && /\b(?:EntryPoint|UserOperation|PackedUserOperation|IERC7579|IERC4337|executionCalldata|onlyEntryPoint)\b/.test(content)) continue;
5763
+ if (rule.id === "cp-sl-missing-signer-check" && /\bSigner\s*<\s*'info\s*>/.test(content)) continue;
5764
+ if (rule.id === "cp-sl-ata-confusion" && /(?:mint\s*=\s*\w+|require_keys_eq!\s*\([^)]*\.mint|token::mint_to)/.test(content)) continue;
5765
+ if (rule.id === "cp-sl-account-substitution" && /#\[account\s*\([^)]*(?:has_one|constraint|seeds)\s*=/.test(content)) continue;
5766
+ if (rule.id === "cp-sl-pyth-staleness" && /(?:get_price_no_older_than|publish_time|MAX_AGE|max_age|stale|clock\.unix_timestamp\s*-)/.test(content)) continue;
5767
+ if (rule.id === "cp-sl-arbitrary-cpi" && /Program\s*<\s*'info\s*,/.test(content)) continue;
5768
+ if (rule.id === "cp-sl-missing-owner-check" && /(?:require_keys_eq!\s*\([^)]*\.owner|assert_eq!\s*\([^)]*\.owner|Account\s*<\s*'info|AccountLoader\s*<\s*'info)/.test(content)) continue;
5769
+ if (rule.id === "cp-sol-voting-snapshot-bypass" && /(?:getPriorVotes|getPastVotes|getVotes|_snapshot|checkpoints|ERC20Votes|VotesUpgradeable|delegateBySig)/.test(content)) continue;
5587
5770
  rule.multilinePattern.lastIndex = 0;
5588
5771
  const isGlobal = rule.multilinePattern.flags.includes("g");
5589
5772
  let match;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@elytrasec/engine",
3
- "version": "0.4.5",
4
- "description": "Core analysis engine for Elytra \u2014 181 detection rules including 12 famous-hack patterns, 11 rug-surface checks, 5 modern-DeFi detectors (ERC-4626 inflation, EIP-712 chainId, bridge source check, permit deadline, MEV swap), static + AI scanning, scoring.",
3
+ "version": "0.4.7",
4
+ "description": "Core analysis engine for Elytra \u2014 196 detection rules across Solidity, Solana/Anchor (Rust), JS/TS, Python, Go, IaC. 12 famous-hack patterns, 11 rug-surface, 5 modern-DeFi + 3 modern-Solidity-v2, 12 Solana detectors.",
5
5
  "license": "MIT",
6
6
  "author": "ElytraSec <hello@elytrasec.io>",
7
7
  "homepage": "https://elytrasec.io",