Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions rules/optimization/memory/detect-expensive-memory-copies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
export interface ExpensiveMemoryCopy {
type: string;
line: number;
description: string;
recommendation: string;
}

export interface ExpensiveMemoryCopiesResult {
detected: boolean;
copies: ExpensiveMemoryCopy[];
message: string;
suggestion: string;
}

interface MemoryCopyPattern {
type: string;
pattern: RegExp;
description: string;
recommendation: string;
}

const MEMORY_COPY_PATTERNS: MemoryCopyPattern[] = [
{
type: "unnecessary-clone",
pattern: /\.clone\(\)/g,
description:
"`.clone()` creates a deep copy of the data, incurring gas cost for large or complex types.",
recommendation:
"Use references (&T) where possible instead of cloning. Clone only when an owned copy is strictly necessary.",
},
{
type: "string-to-string-copy",
pattern: /String::from\(\s*\w+\s*\)|\.to_string\(\)|\.into\(\)/g,
description:
"String conversion or allocation creates a new heap allocation, which is expensive in Soroban contracts.",
recommendation:
"Use &str references or Symbol where possible. Avoid unnecessary String allocations in hot paths.",
},
{
type: "vec-copy",
pattern: /\.to_vec\(\)|\.iter\(\).*\.collect\(\)/g,
description:
"Copying an entire Vec or similar collection creates a deep copy of all elements.",
recommendation:
"Use slice references (&[T]) instead of copying the entire Vec when read-only access is sufficient.",
},
{
type: "large-struct-copy",
pattern:
/fn\s+\w+\s*\([^)]*(?:&self|self|&mut self)[^)]*\)\s*->\s*\w+\s*\{[^}]*\b(?:clone|to_owned)\b/g,
description:
"Functions returning owned copies of large structs force unnecessary memory duplication.",
recommendation:
"Return references where the lifetime allows, or use Cow (clone-on-write) for conditional copying.",
},
];

function lineNumber(code: string, index: number): number {
let line = 1;
for (let i = 0; i < index; i++) {
if (code[i] === "\n") line++;
}
return line;
}

export function detectExpensiveMemoryCopies(
code: string,
): ExpensiveMemoryCopiesResult {
const copies: ExpensiveMemoryCopy[] = [];

for (const {
type,
pattern,
description,
recommendation,
} of MEMORY_COPY_PATTERNS) {
const re = new RegExp(pattern.source, "g");
let match: RegExpExecArray | null;
while ((match = re.exec(code)) !== null) {
copies.push({
type,
line: lineNumber(code, match.index),
description,
recommendation,
});
}
}

if (copies.length === 0) {
return {
detected: false,
copies: [],
message: "No expensive memory copies detected.",
suggestion: "",
};
}

return {
detected: true,
copies,
message: `Detected ${copies.length} expensive memory copy operation(s).`,
suggestion:
"Use references (&T) instead of cloning, prefer &str over String, and avoid copying large collections.",
};
}
93 changes: 93 additions & 0 deletions rules/optimization/storage/detect-expensive-sload-operations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
export interface ExpensiveSLOAD {
type: string;
line: number;
description: string;
recommendation: string;
}

export interface ExpensiveSLOADResult {
detected: boolean;
sloads: ExpensiveSLOAD[];
message: string;
suggestion: string;
}

interface SLOADPattern {
type: string;
pattern: RegExp;
description: string;
recommendation: string;
}

const SLOAD_PATTERNS: SLOADPattern[] = [
{
type: "repeated-sload",
pattern: /\.get\s*\([^)]+\)\s*;\s*(?:.|\n)*?\.get\s*\(/g,
description:
"Repeated SLOAD operations for the same storage key within a function. Each SLOAD costs gas.",
recommendation:
"Cache storage reads in a local variable at the start of the function to avoid repeated SLOAD costs.",
},
{
type: "sload-in-loop",
pattern:
/(?:for|while)\s*[^{]*\{[^}]*?(?:storage|env\.storage)\(\)\.(?:instance|persistent|temporary)\(\)\.get\s*\(/g,
description:
"SLOAD operation inside a loop. Each iteration reads from storage, significantly increasing gas costs.",
recommendation:
"Read storage values into a local variable before the loop and reference the cached value inside the loop body.",
},
{
type: "unbatched-sload",
pattern:
/(?:storage|env\.storage)\(\)\.(?:instance|persistent|temporary)\(\)\.get\s*\([^)]+\)\s*;\s*(?:\s*\/\/[^\n]*\n)*\s*(?:storage|env\.storage)\(\)\.(?:instance|persistent|temporary)\(\)\.get/g,
description:
"Multiple unbatched SLOAD operations. Each access incurs individual gas cost.",
recommendation:
"Batch related storage reads or cache them in local variables to reduce gas consumption.",
},
];

function lineNumber(code: string, index: number): number {
let line = 1;
for (let i = 0; i < index; i++) {
if (code[i] === "\n") line++;
}
return line;
}

export function detectExpensiveSLOADOperations(
code: string,
): ExpensiveSLOADResult {
const sloads: ExpensiveSLOAD[] = [];

for (const { type, pattern, description, recommendation } of SLOAD_PATTERNS) {
const re = new RegExp(pattern.source, "g");
let match: RegExpExecArray | null;
while ((match = re.exec(code)) !== null) {
sloads.push({
type,
line: lineNumber(code, match.index),
description,
recommendation,
});
}
}

if (sloads.length === 0) {
return {
detected: false,
sloads: [],
message: "No expensive SLOAD operations detected.",
suggestion: "",
};
}

return {
detected: true,
sloads,
message: `Detected ${sloads.length} expensive SLOAD pattern(s).`,
suggestion:
"Cache storage reads in local variables and avoid SLOAD operations inside loops to reduce gas costs.",
};
}
96 changes: 96 additions & 0 deletions rules/security/signatures/detect-unsafe-signature-recovery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
export interface UnsafeSignatureRecovery {
type: string;
line: number;
description: string;
recommendation: string;
}

export interface UnsafeSignatureRecoveryResult {
detected: boolean;
violations: UnsafeSignatureRecovery[];
message: string;
suggestion: string;
}

interface SignatureRecoveryPattern {
type: string;
pattern: RegExp;
description: string;
recommendation: string;
}

const SIGNATURE_PATTERNS: SignatureRecoveryPattern[] = [
{
type: "ecrecover-usage",
pattern: /\becrecover\s*\(/g,
description:
"ecrecover() without proper signature malleability protections may allow signature forgery.",
recommendation:
"Use OpenZeppelin ECDSA library which handles malleability (v/r/s validation, EIP-2).",
},
{
type: "no-malleability-check",
pattern: /ecrecover\s*\([^)]*\)\s*(?![^;]*\brequire\b)/g,
description:
"ecrecover() result used without checking for signature malleability or invalid signature values.",
recommendation:
"Validate the s value is in the lower half of the secp256k1 curve and v is 27 or 28 (EIP-2).",
},
{
type: "unchecked-recovery-result",
pattern: /address\s+\w+\s*=\s*ecrecover\s*\([^)]*\)\s*;/g,
description:
"ecrecover() returns address(0) for invalid signatures, but the result is not validated.",
recommendation:
"Always check that the recovered address is not address(0) and matches the expected signer.",
},
];

function lineNumber(code: string, index: number): number {
let line = 1;
for (let i = 0; i < index; i++) {
if (code[i] === "\n") line++;
}
return line;
}

export function detectUnsafeSignatureRecovery(
code: string,
): UnsafeSignatureRecoveryResult {
const violations: UnsafeSignatureRecovery[] = [];

for (const {
type,
pattern,
description,
recommendation,
} of SIGNATURE_PATTERNS) {
const re = new RegExp(pattern.source, "g");
let match: RegExpExecArray | null;
while ((match = re.exec(code)) !== null) {
violations.push({
type,
line: lineNumber(code, match.index),
description,
recommendation,
});
}
}

if (violations.length === 0) {
return {
detected: false,
violations: [],
message: "No unsafe signature recovery patterns detected.",
suggestion: "",
};
}

return {
detected: true,
violations,
message: `Detected ${violations.length} unsafe signature recovery pattern(s).`,
suggestion:
"Use OpenZeppelin ECDSA library for safe signature recovery with malleability protections.",
};
}
6 changes: 6 additions & 0 deletions src/analysis/stellar/storage-layout/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export { SorobanStorageLayoutAnalyzer } from "./storage-layout-analyzer";
export type {
StorageLayoutEntry,
StorageLayoutWarning,
StorageLayoutReport,
} from "./storage-layout-analyzer";
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, expect, it } from "@jest/globals";
import { SorobanStorageLayoutAnalyzer } from "./storage-layout-analyzer";

describe("SorobanStorageLayoutAnalyzer", () => {
it("builds storage layout from set operations", () => {
const source = `
use soroban_sdk::{contract, contractimpl, Env, Symbol};

#[contract]
pub struct Counter;

#[contractimpl]
impl Counter {
pub fn init(env: Env) {
env.storage().instance().set(&Symbol::new(&env, "count"), &0u32);
env.storage().instance().set(&Symbol::new(&env, "owner"), &env.current_contract_address());
}
}
`;

const analyzer = new SorobanStorageLayoutAnalyzer(source, "counter.rs");
const report = analyzer.analyze();

expect(report.contractName).toBe("Counter");
expect(report.entries.length).toBeGreaterThanOrEqual(2);
expect(report.summary).toContain("storage layout");
});

it("warns on duplicate storage keys", () => {
const source = `
use soroban_sdk::{contract, contractimpl, Env, Symbol};

#[contract]
pub struct Dup;

#[contractimpl]
impl Dup {
pub fn set_a(env: Env) {
env.storage().instance().set(&Symbol::new(&env, "key"), &1u32);
env.storage().instance().set(&Symbol::new(&env, "key"), &2u32);
}
}
`;

const analyzer = new SorobanStorageLayoutAnalyzer(source, "dup.rs");
const report = analyzer.analyze();

expect(report.warnings.some((w) => w.severity === "high")).toBe(true);
});

it("returns warning when no storage entries found", () => {
const source = `
use soroban_sdk::{contract, contractimpl, Env};

#[contract]
pub struct Empty;

#[contractimpl]
impl Empty {
pub fn noop(env: Env) {}
}
`;

const analyzer = new SorobanStorageLayoutAnalyzer(source, "empty.rs");
const report = analyzer.analyze();

expect(
report.warnings.some((w) => w.message.includes("No storage entries")),
).toBe(true);
});
});
Loading
Loading