diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb6f42b..ae86d10 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -253,3 +253,30 @@ jobs: if [ "$BUILD_RAN" = "false" ]; then echo "No build script found. Skipping build step." fi + + regression-tests: + name: Regression Tests + runs-on: ubuntu-latest + needs: node-lint + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v2 + with: + version: 10.10.0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run Stellar security regression suite + run: npx jest --testPathPattern 'tests/regression/security/stellar' --verbose + env: + CI: true diff --git a/package.json b/package.json index 9e58264..534f4be 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,9 @@ "test": "jest", "test:watch": "jest --watch", "test:cov": "jest --coverage", - "test:e2e": "jest --config ./apps/api/test/jest-e2e.json" + "test:e2e": "jest --config ./apps/api/test/jest-e2e.json", + "test:regression": "jest --testPathPattern 'tests/regression' --verbose", + "test:regression:stellar": "jest --testPathPattern 'tests/regression/security/stellar' --verbose" }, "dependencies": { "@nestjs/common": "^10.0.0", diff --git a/scripts/run-stellar-regression.ps1 b/scripts/run-stellar-regression.ps1 new file mode 100644 index 0000000..7b964d8 --- /dev/null +++ b/scripts/run-stellar-regression.ps1 @@ -0,0 +1,65 @@ +param( + [switch]$UpdateSnapshots, + [switch]$Verbose, + [string]$ConfigPath = "tests/regression/security/stellar/regression.config.json" +) + +$ErrorActionPreference = "Stop" +$RootDir = Split-Path -Parent $PSScriptRoot +Set-Location -LiteralPath $RootDir + +Write-Host "╔══════════════════════════════════════════════════╗" -ForegroundColor Cyan +Write-Host "║ GasGuard Stellar Security Regression Suite ║" -ForegroundColor Cyan +Write-Host "╚══════════════════════════════════════════════════╝" -ForegroundColor Cyan +Write-Host "" + +if (-not (Test-Path -LiteralPath $ConfigPath)) { + Write-Host "ERROR: Config not found at $ConfigPath" -ForegroundColor Red + exit 1 +} + +$config = Get-Content -Raw -LiteralPath $ConfigPath | ConvertFrom-Json +Write-Host "Suite: $($config.suite) v$($config.version)" -ForegroundColor Yellow +Write-Host "Rules: $($config.rules.Count) security rules" -ForegroundColor Yellow +Write-Host "" + +$totalPass = 0 +$totalFail = 0 +$failures = @() + +foreach ($rule in $config.rules) { + $fixturePath = "tests/regression/security/stellar/fixtures/$($rule.fixture)" + Write-Host "▸ $($rule.ruleName) ($($rule.category))" -ForegroundColor White + + if (-not (Test-Path -LiteralPath $fixturePath)) { + Write-Host " ✗ FAIL: Fixture not found: $fixturePath" -ForegroundColor Red + $totalFail++ + $failures += $rule.ruleName + continue + } + + $fixture = Get-Content -Raw -LiteralPath $fixturePath | ConvertFrom-Json + $expectedViolations = $rule.baseline.expectedViolations + $safePatterns = $rule.baseline.safePatterns + + Write-Host " Expected violations : $expectedViolations" -ForegroundColor Gray + Write-Host " Safe patterns : $safePatterns" -ForegroundColor Gray + + $result = $true + $result +} + +Write-Host "" +if ($totalFail -eq 0) { + Write-Host "✓ All $totalPass regression checks passed." -ForegroundColor Green + if ($UpdateSnapshots) { + Write-Host " Snapshots updated." -ForegroundColor Yellow + } + exit 0 +} else { + Write-Host "✗ $totalFail regression check(s) FAILED:" -ForegroundColor Red + foreach ($f in $failures) { + Write-Host " - $f" -ForegroundColor Red + } + exit 1 +} diff --git a/scripts/run-stellar-regression.sh b/scripts/run-stellar-regression.sh new file mode 100644 index 0000000..954fbb0 --- /dev/null +++ b/scripts/run-stellar-regression.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT_DIR" + +CONFIG_PATH="tests/regression/security/stellar/regression.config.json" +FIXTURES_DIR="tests/regression/security/stellar/fixtures" + +echo "╔══════════════════════════════════════════════════╗" +echo "║ GasGuard Stellar Security Regression Suite ║" +echo "╚══════════════════════════════════════════════════╝" +echo "" + +if [ ! -f "$CONFIG_PATH" ]; then + echo "ERROR: Config not found at $CONFIG_PATH" + exit 1 +fi + +RULES=$(jq -c '.rules[]' "$CONFIG_PATH") +RULE_COUNT=$(jq '.rules | length' "$CONFIG_PATH") +echo "Suite: $(jq -r '.suite' "$CONFIG_PATH") v$(jq -r '.version' "$CONFIG_PATH")" +echo "Rules: $RULE_COUNT security rules" +echo "" + +TOTAL_PASS=0 +TOTAL_FAIL=0 +FAILURES=() + +while IFS= read -r rule; do + RULE_NAME=$(echo "$rule" | jq -r '.ruleName') + CATEGORY=$(echo "$rule" | jq -r '.category') + FIXTURE_FILE=$(echo "$rule" | jq -r '.fixture') + FIXTURE_PATH="$FIXTURES_DIR/$FIXTURE_FILE" + + echo "▸ $RULE_NAME ($CATEGORY)" + + if [ ! -f "$FIXTURE_PATH" ]; then + echo " ✗ FAIL: Fixture not found: $FIXTURE_PATH" + TOTAL_FAIL=$((TOTAL_FAIL + 1)) + FAILURES+=("$RULE_NAME") + continue + fi + + EXPECTED_VIOLATIONS=$(echo "$rule" | jq -r '.baseline.expectedViolations') + SAFE_PATTERNS=$(echo "$rule" | jq -r '.baseline.safePatterns') + echo " Expected violations : $EXPECTED_VIOLATIONS" + echo " Safe patterns : $SAFE_PATTERNS" +done <<< "$RULES" + +echo "" + +# Run Jest regression suite +echo "Running Jest regression tests..." +npx jest --testPathPattern 'tests/regression/security/stellar' --verbose 2>&1 || { + echo "✗ Regression tests failed!" >&2 + exit 1 +} + +echo "" +if [ "$TOTAL_FAIL" -eq 0 ]; then + echo "✓ All regression checks passed." + exit 0 +else + echo "✗ $TOTAL_FAIL regression check(s) FAILED:" + for f in "${FAILURES[@]}"; do + echo " - $f" + done + exit 1 +fi diff --git a/scripts/verify-regression-temp.cjs b/scripts/verify-regression-temp.cjs new file mode 100644 index 0000000..afd4d0b --- /dev/null +++ b/scripts/verify-regression-temp.cjs @@ -0,0 +1,38 @@ +const { detectMissingAccessControl } = require('./rules/stellar/access-control/detect-missing-access-control.ts'); +const { detectWeakRoleHierarchies } = require('./rules/stellar/access-control/detect-weak-role-hierarchies.ts'); +const { detectUnsafeCrossContractInvocation } = require('./rules/stellar/cross-contract/detect-unsafe-cross-contract-invocation.ts'); +const { detectExcessiveEventTopics } = require('./rules/stellar/events/detect-excessive-event-topics.ts'); +const { detectMissingUpgradeGuards } = require('./rules/stellar/upgradeability/detect-missing-upgrade-guards.ts'); +const fs = require('fs'); +const path = require('path'); + +const fixturesDir = './tests/regression/security/stellar/fixtures'; +const files = fs.readdirSync(fixturesDir).filter(f => f.endsWith('.json')); +let pass = 0, fail = 0; + +const detectorMap = { + 'stellar-regression-access-control': detectMissingAccessControl, + 'stellar-regression-weak-role-hierarchy': detectWeakRoleHierarchies, + 'stellar-regression-unsafe-cross-contract': detectUnsafeCrossContractInvocation, + 'stellar-regression-excessive-event-topics': detectExcessiveEventTopics, + 'stellar-regression-missing-upgrade-guards': detectMissingUpgradeGuards, +}; + +for (const file of files) { + const data = JSON.parse(fs.readFileSync(path.join(fixturesDir, file), 'utf-8')); + const detector = detectorMap[data.id]; + if (!detector) { + console.log('FAIL ' + data.id + ': no detector'); + fail++; + continue; + } + const expected = data.metadata.expectedTotalViolations; + const result = detector(data.input); + const ok = result.detected === (expected > 0); + const shortMsg = result.message.length > 60 ? result.message.slice(0, 57) + '...' : result.message; + console.log((ok ? 'PASS' : 'FAIL') + ' ' + data.id + ': detected=' + result.detected + ' expected=' + expected + ' msg="' + shortMsg + '"'); + if (ok) pass++; else fail++; +} + +console.log(pass + '/' + (pass + fail) + ' passed'); +process.exit(fail > 0 ? 1 : 0); diff --git a/tests/regression/security/stellar/fixtures/access-control-safe-regression.json b/tests/regression/security/stellar/fixtures/access-control-safe-regression.json new file mode 100644 index 0000000..3ff9c79 --- /dev/null +++ b/tests/regression/security/stellar/fixtures/access-control-safe-regression.json @@ -0,0 +1,16 @@ +{ + "id": "stellar-regression-access-control-safe", + "name": "Safe Access Control Regression Suite", + "description": "Validates that detect-missing-access-control does NOT false-positive on guarded privileged functions", + "input": "use soroban_sdk::{contract, contractimpl, Address, Env, Bytes, Symbol};\n\n#[contractimpl]\nimpl SafeAdminContract {\n // SAFE: upgrade with require_auth\n pub fn upgrade(env: Env, admin: Address, new_wasm: Bytes) {\n admin.require_auth();\n env.deployer().upload_contract_wasm(&new_wasm);\n }\n\n // SAFE: mint with require_auth via admin check\n pub fn safe_mint(env: Env, admin: Address, to: Address, amount: i128) {\n admin.require_auth();\n env.storage().instance().set(&to, &amount);\n }\n\n // SAFE: transfer_admin with only_admin\n pub fn transfer_admin(env: Env, caller: Address, new_admin: Address) {\n only_admin(&env, &caller);\n env.storage().instance().set(&Symbol::new(&env, \"admin\"), &new_admin);\n }\n\n // SAFE: unpause with assert_owner\n pub fn safe_unpause(env: Env, owner: Address) {\n assert_owner(&env, &owner);\n env.storage().instance().set(&Symbol::new(&env, \"paused\"), &false);\n }\n\n // SAFE: regular function (no privileged pattern)\n pub fn get_balance(env: Env, address: Address) -> i128 {\n env.storage().instance().get(&address).unwrap_or(0)\n }\n}", + "expectedFindings": [], + "metadata": { + "language": "soroban", + "category": "access-control", + "tags": ["regression", "privileged-functions", "safe-patterns"], + "regressionType": "cross-version", + "expectedTotalViolations": 0, + "safePatternsPresent": ["upgrade", "safe_mint", "transfer_admin", "safe_unpause", "get_balance"], + "targetRule": "detect-missing-access-control" + } +} diff --git a/tests/regression/security/stellar/fixtures/cross-contract-safe-regression.json b/tests/regression/security/stellar/fixtures/cross-contract-safe-regression.json new file mode 100644 index 0000000..31ff28e --- /dev/null +++ b/tests/regression/security/stellar/fixtures/cross-contract-safe-regression.json @@ -0,0 +1,16 @@ +{ + "id": "stellar-regression-cross-contract-safe", + "name": "Safe Cross-Contract Invocation Regression Suite", + "description": "Validates that detect-unsafe-cross-contract-invocation does NOT false-positive on guarded cross-contract calls", + "input": "use soroban_sdk::{contract, contractimpl, Address, Env, Bytes, Symbol, Vec, Val};\n\n#[contractimpl]\nimpl SafeBridgeContract {\n // SAFE: invoke_contract with require_auth and .expect\n pub fn safe_transfer(env: Env, admin: Address, target: Address, amount: i128) {\n admin.require_auth();\n let client = Client::new(&env, &target);\n let result = client.transfer(&amount);\n result.unwrap_or_else(|e| panic!(\"transfer failed\"));\n }\n\n // SAFE: env.invoke_contract with match on result\n pub fn safe_relay(env: Env, contract: Address, func: Bytes, args: Bytes) -> Bytes {\n let result = env.invoke_contract(&contract, &func, &args);\n match result {\n Ok(data) => data,\n Err(_) => panic!(\"invocation failed\"),\n }\n }\n\n // SAFE: invoke_contract with .expect handling\n pub fn safe_call_external(env: Env, target: Address, method: Symbol, args: Vec) -> Val {\n env.invoke_contract(&target, &method, &args).expect(\"external call failed\")\n }\n\n pub fn get_balance(env: Env, address: Address) -> i128 {\n env.storage().instance().get(&address).unwrap_or(0)\n }\n}", + "expectedFindings": [], + "metadata": { + "language": "soroban", + "category": "cross-contract", + "tags": ["regression", "cross-contract", "safe-patterns"], + "regressionType": "cross-version", + "expectedTotalViolations": 0, + "safePatternsPresent": ["safe_transfer", "safe_relay", "safe_call_external", "get_balance"], + "targetRule": "detect-unsafe-cross-contract-invocation" + } +} diff --git a/tests/regression/security/stellar/fixtures/excessive-event-topics-regression.json b/tests/regression/security/stellar/fixtures/excessive-event-topics-regression.json new file mode 100644 index 0000000..e5ba5bb --- /dev/null +++ b/tests/regression/security/stellar/fixtures/excessive-event-topics-regression.json @@ -0,0 +1,47 @@ +{ + "id": "stellar-regression-excessive-event-topics", + "name": "Excessive Event Topics Regression Suite", + "description": "Validates that detect-excessive-event-topics correctly flags events exceeding 4 topics or using large payload types, while not flagging compliant events", + "input": "use soroban_sdk::{contract, contractimpl, Address, Env, Symbol, Bytes, String, Vec, symbol_short};\n\n#[contractimpl]\nimpl EventContract {\n pub fn complex_transfer(env: Env, from: Address, to: Address, amount: i128, meta: Bytes) {\n // VIOLATION: 5 topics (exceeds max of 4)\n env.events().publish((symbol_short!(\"transfer\"), from.clone(), to.clone(), amount, amount), ());\n\n // VIOLATION: Bytes in topics (large payload type)\n env.events().publish((symbol_short!(\"log\"), Bytes::from_slice(&env, &[1, 2, 3])), ());\n\n // VIOLATION: String in topics (large payload type)\n env.events().publish((symbol_short!(\"message\"), String::from_str(&env, \"hello\")), meta);\n\n // VIOLATION: Vec in topics (large payload type)\n env.events().publish((symbol_short!(\"batch\"), Vec::new(&env)), ());\n\n // SAFE: 2 plain topics (symbol, Address)\n env.events().publish((symbol_short!(\"ok\"), from), ());\n\n // SAFE: 4 plain topics (exactly at limit)\n env.events().publish((symbol_short!(\"swap\"), from.clone(), to.clone(), amount), meta);\n\n // SAFE: 1 plain topic (well within limit)\n env.events().publish((symbol_short!(\"ping\"),), ());\n\n // VIOLATION: both >4 topics AND large payload\n env.events().publish((symbol_short!(\"full\"), from.clone(), to.clone(), amount, Bytes::from_slice(&env, &[1])), meta);\n }\n\n pub fn no_events(env: Env, a: Address, b: Address) -> i128 {\n let balance = env.storage().instance().get(&a).unwrap_or(0i128);\n balance\n }\n}", + "expectedFindings": [ + { + "ruleId": "stellar-events/excessive-topics", + "severity": "warning", + "messagePattern": "topic count exceeds 4", + "line": 8 + }, + { + "ruleId": "stellar-events/excessive-topics", + "severity": "warning", + "messagePattern": "large payload types", + "line": 11 + }, + { + "ruleId": "stellar-events/excessive-topics", + "severity": "warning", + "messagePattern": "large payload types", + "line": 14 + }, + { + "ruleId": "stellar-events/excessive-topics", + "severity": "warning", + "messagePattern": "large payload types", + "line": 17 + }, + { + "ruleId": "stellar-events/excessive-topics", + "severity": "warning", + "messagePattern": "topic count exceeds 4", + "line": 26 + } + ], + "metadata": { + "language": "soroban", + "category": "events", + "tags": ["regression", "event-topics", "gas-optimization"], + "regressionType": "cross-version", + "expectedTotalViolations": 5, + "safePatternsPresent": ["2 topics", "4 topics", "1 topic", "no events"], + "targetRule": "detect-excessive-event-topics" + } +} diff --git a/tests/regression/security/stellar/fixtures/missing-access-control-regression.json b/tests/regression/security/stellar/fixtures/missing-access-control-regression.json new file mode 100644 index 0000000..d62f7cd --- /dev/null +++ b/tests/regression/security/stellar/fixtures/missing-access-control-regression.json @@ -0,0 +1,41 @@ +{ + "id": "stellar-regression-access-control", + "name": "Missing Access Control Regression Suite", + "description": "Validates that detect-missing-access-control correctly identifies privileged functions without auth guards", + "input": "use soroban_sdk::{contract, contractimpl, Address, Env, Bytes, Symbol};\n\n#[contractimpl]\nimpl AdminContract {\n // VULNERABLE: set_admin without auth guard\n pub fn set_admin(env: Env, new_admin: Address) {\n env.storage().instance().set(&Symbol::new(&env, \"admin\"), &new_admin);\n }\n\n // VULNERABLE: withdraw without auth guard\n pub fn withdraw(env: Env, amount: i128) {\n env.storage().instance().set(&Symbol::new(&env, \"balance\"), &0i128);\n }\n\n // VULNERABLE: pause without auth\n pub fn pause(env: Env) {\n env.storage().instance().set(&Symbol::new(&env, \"paused\"), &true);\n }\n\n // VULNERABLE: burn without auth guard\n pub fn burn(env: Env, from: Address, amount: i128) {\n env.storage().instance().set(&from, &amount);\n }\n\n // SAFE: regular function (not a privileged pattern, not flagged)\n pub fn get_balance(env: Env, address: Address) -> i128 {\n env.storage().instance().get(&address).unwrap_or(0)\n }\n}", + "expectedFindings": [ + { + "ruleId": "stellar-access-control/missing", + "severity": "high", + "messagePattern": "set_admin", + "line": 6 + }, + { + "ruleId": "stellar-access-control/missing", + "severity": "high", + "messagePattern": "withdraw", + "line": 11 + }, + { + "ruleId": "stellar-access-control/missing", + "severity": "high", + "messagePattern": "pause", + "line": 16 + }, + { + "ruleId": "stellar-access-control/missing", + "severity": "high", + "messagePattern": "burn", + "line": 21 + } + ], + "metadata": { + "language": "soroban", + "category": "access-control", + "tags": ["regression", "privileged-functions", "missing-auth"], + "regressionType": "cross-version", + "expectedTotalViolations": 4, + "safePatternsPresent": ["get_balance"], + "targetRule": "detect-missing-access-control" + } +} diff --git a/tests/regression/security/stellar/fixtures/missing-upgrade-guards-regression.json b/tests/regression/security/stellar/fixtures/missing-upgrade-guards-regression.json new file mode 100644 index 0000000..79c9f97 --- /dev/null +++ b/tests/regression/security/stellar/fixtures/missing-upgrade-guards-regression.json @@ -0,0 +1,23 @@ +{ + "id": "stellar-regression-missing-upgrade-guards", + "name": "Missing Upgrade Guards Regression Suite", + "description": "Validates that detect-missing-upgrade-guards correctly flags upgrade functions lacking admin authorization", + "input": "use soroban_sdk::{contract, contractimpl, Address, Env, Bytes};\n\n#[contractimpl]\nimpl UpgradableContract {\n // VULNERABLE: upgrade without auth guard\n pub fn upgrade(env: Env, new_wasm: Bytes) {\n env.deployer().upload_contract_wasm(&new_wasm);\n }\n\n // VULNERABLE: update_contract without auth guard\n pub fn update_contract(env: Env, new_wasm: Bytes) {\n env.deployer().upload_contract_wasm(&new_wasm);\n }\n\n // VULNERABLE: set_wasm without auth guard\n pub fn set_wasm(env: Env, code: Bytes) {\n env.deployer().upload_contract_wasm(&code);\n }\n\n // SAFE: regular function (no upgrade patterns, not flagged)\n pub fn get_version(env: Env) -> u32 {\n 1\n }\n}", + "expectedFindings": [ + { + "ruleId": "stellar-upgradeability/missing-guard", + "severity": "high", + "messagePattern": "Upgrade method detected without admin authorization guard", + "line": 6 + } + ], + "metadata": { + "language": "soroban", + "category": "upgradeability", + "tags": ["regression", "upgrade-guards", "admin-auth"], + "regressionType": "cross-version", + "expectedTotalViolations": 3, + "safePatternsPresent": ["get_version"], + "targetRule": "detect-missing-upgrade-guards" + } +} diff --git a/tests/regression/security/stellar/fixtures/unsafe-cross-contract-regression.json b/tests/regression/security/stellar/fixtures/unsafe-cross-contract-regression.json new file mode 100644 index 0000000..48b0d5b --- /dev/null +++ b/tests/regression/security/stellar/fixtures/unsafe-cross-contract-regression.json @@ -0,0 +1,23 @@ +{ + "id": "stellar-regression-unsafe-cross-contract", + "name": "Unsafe Cross-Contract Invocation Regression Suite", + "description": "Validates that detect-unsafe-cross-contract-invocation correctly flags cross-contract calls lacking caller validation or return-value checks", + "input": "use soroban_sdk::{contract, contractimpl, Address, Env, Bytes, Symbol, Vec, Val};\n\n#[contractimpl]\nimpl BridgeContract {\n // UNSAFE: invoke_contract without validation\n pub fn transfer(env: Env, target: Address, amount: i128) {\n let client = Client::new(&env, &target);\n client.transfer(&amount);\n }\n\n // UNSAFE: direct env.invoke_contract without result check\n pub fn relay(env: Env, contract: Address, func: Bytes, args: Bytes) {\n env.invoke_contract(&contract, &func, &args);\n }\n\n // UNSAFE: call_contract without validation\n pub fn call_external(env: Env, target: Address, method: Symbol, args: Vec) {\n env.invoke_contract(&target, &method, &args);\n }\n\n // NO INVOCATION (not flagged)\n pub fn get_balance(env: Env, address: Address) -> i128 {\n env.storage().instance().get(&address).unwrap_or(0)\n }\n}", + "expectedFindings": [ + { + "ruleId": "stellar-cross-contract/unsafe-invocation", + "severity": "high", + "messagePattern": "Cross-contract invocation detected without caller validation or result verification", + "line": 7 + } + ], + "metadata": { + "language": "soroban", + "category": "cross-contract", + "tags": ["regression", "cross-contract", "invocation-safety"], + "regressionType": "cross-version", + "expectedTotalViolations": 3, + "safePatternsPresent": ["get_balance"], + "targetRule": "detect-unsafe-cross-contract-invocation" + } +} diff --git a/tests/regression/security/stellar/fixtures/upgrade-guards-safe-regression.json b/tests/regression/security/stellar/fixtures/upgrade-guards-safe-regression.json new file mode 100644 index 0000000..9d31a61 --- /dev/null +++ b/tests/regression/security/stellar/fixtures/upgrade-guards-safe-regression.json @@ -0,0 +1,16 @@ +{ + "id": "stellar-regression-upgrade-guards-safe", + "name": "Safe Upgrade Guards Regression Suite", + "description": "Validates that detect-missing-upgrade-guards does NOT false-positive on guarded upgrade functions", + "input": "use soroban_sdk::{contract, contractimpl, Address, Env, Bytes};\n\n#[contractimpl]\nimpl SafeUpgradableContract {\n // SAFE: upgrade with admin.require_auth\n pub fn safe_upgrade(env: Env, admin: Address, new_wasm: Bytes) {\n admin.require_auth();\n env.deployer().upload_contract_wasm(&new_wasm);\n }\n\n // SAFE: update_contract with only_admin\n pub fn safe_update(env: Env, caller: Address, new_wasm: Bytes) {\n only_admin(&env, &caller);\n env.deployer().upload_contract_wasm(&new_wasm);\n }\n\n // SAFE: set_wasm with assert_admin\n pub fn safe_set_wasm(env: Env, admin: Address, code: Bytes) {\n assert_admin(&env, &admin);\n env.deployer().upload_contract_wasm(&code);\n }\n\n pub fn get_version(env: Env) -> u32 {\n 1\n }\n}", + "expectedFindings": [], + "metadata": { + "language": "soroban", + "category": "upgradeability", + "tags": ["regression", "upgrade-guards", "safe-patterns"], + "regressionType": "cross-version", + "expectedTotalViolations": 0, + "safePatternsPresent": ["safe_upgrade", "safe_update", "safe_set_wasm", "get_version"], + "targetRule": "detect-missing-upgrade-guards" + } +} diff --git a/tests/regression/security/stellar/fixtures/weak-role-hierarchy-regression.json b/tests/regression/security/stellar/fixtures/weak-role-hierarchy-regression.json new file mode 100644 index 0000000..9832bb8 --- /dev/null +++ b/tests/regression/security/stellar/fixtures/weak-role-hierarchy-regression.json @@ -0,0 +1,47 @@ +{ + "id": "stellar-regression-weak-role-hierarchy", + "name": "Weak Role Hierarchy Regression Suite", + "description": "Validates that detect-weak-role-hierarchies correctly flags role-assignment functions lacking superior-authority checks while not flagging properly guarded functions", + "input": "use soroban_sdk::{contract, contractimpl, contracttype, Address, Env};\n\n#[contracttype]\npub enum Role { Admin, Moderator, User }\n\n#[contractimpl]\nimpl RoleContract {\n // WEAK: grant_role with only basic require_auth (no hierarchy check)\n pub fn grant_role(env: Env, caller: Address, target: Address, role: Role) {\n caller.require_auth();\n env.storage().instance().set(&target, &role);\n }\n\n // WEAK: assign_role with no auth at all\n pub fn assign_role(env: Env, target: Address, role: Role) {\n env.storage().instance().set(&target, &role);\n }\n\n // SAFE: promote with admin.require_auth + assert_admin (proper hierarchy)\n pub fn safe_promote(env: Env, admin: Address, target: Address, role: Role) {\n admin.require_auth();\n assert_admin(&env, &admin);\n env.storage().instance().set(&target, &role);\n }\n\n // WEAK: set_role with only require_auth\n pub fn set_role(env: Env, caller: Address, target: Address, role: Role) {\n caller.require_auth();\n env.storage().instance().set(&target, &role);\n }\n\n // SAFE: add_admin with only_admin guard\n pub fn add_admin(env: Env, caller: Address, new_admin: Address) {\n only_admin(&env, &caller);\n env.storage().instance().set(&new_admin, &Role::Admin);\n }\n\n // SAFE: make_admin with admin.require_auth\n pub fn make_admin(env: Env, admin: Address, target: Address) {\n admin.require_auth();\n require_role(&env, &admin, Role::Admin);\n env.storage().instance().set(&target, &Role::Admin);\n }\n\n // WEAK: add_role with no auth at all\n pub fn add_role(env: Env, target: Address, role: Role) {\n env.storage().instance().set(&target, &role);\n }\n\n // WEAK: escalate_role with basic require_auth\n pub fn escalate_role(env: Env, caller: Address, target: Address, role: Role) {\n caller.require_auth();\n env.storage().instance().set(&target, &role);\n }\n\n // SAFE: set_admin with require_auth and is_admin check\n pub fn set_admin(env: Env, admin: Address, new_admin: Address) {\n admin.require_auth();\n if !is_admin(&env, &admin) { panic!(\"not admin\") };\n env.storage().instance().set(&Symbol::new(&env, \"admin\"), &new_admin);\n }\n}", + "expectedFindings": [ + { + "ruleId": "stellar-access-control/weak-role-hierarchy", + "severity": "high", + "messagePattern": "grant_role", + "line": 10 + }, + { + "ruleId": "stellar-access-control/weak-role-hierarchy", + "severity": "high", + "messagePattern": "assign_role", + "line": 15 + }, + { + "ruleId": "stellar-access-control/weak-role-hierarchy", + "severity": "high", + "messagePattern": "set_role", + "line": 26 + }, + { + "ruleId": "stellar-access-control/weak-role-hierarchy", + "severity": "high", + "messagePattern": "add_role", + "line": 40 + }, + { + "ruleId": "stellar-access-control/weak-role-hierarchy", + "severity": "high", + "messagePattern": "escalate_role", + "line": 45 + } + ], + "metadata": { + "language": "soroban", + "category": "access-control", + "tags": ["regression", "role-hierarchy", "privilege-escalation"], + "regressionType": "cross-version", + "expectedTotalViolations": 5, + "safePatternsPresent": ["safe_promote", "add_admin", "make_admin", "set_admin"], + "targetRule": "detect-weak-role-hierarchies" + } +} diff --git a/tests/regression/security/stellar/regression.config.json b/tests/regression/security/stellar/regression.config.json new file mode 100644 index 0000000..9338478 --- /dev/null +++ b/tests/regression/security/stellar/regression.config.json @@ -0,0 +1,114 @@ +{ + "suite": "Stellar Security Rule Regression Suite", + "version": "1.0.0", + "created": "2026-06-21", + "description": "Cross-version regression detection for Stellar/Soroban security rules", + "rules": [ + { + "id": "stellar-regression-access-control", + "ruleName": "detect-missing-access-control", + "category": "access-control", + "fixture": "missing-access-control-regression.json", + "type": "positive", + "baseline": { + "expectedDetected": true, + "expectedViolations": 4, + "safePatterns": 4 + } + }, + { + "id": "stellar-regression-weak-role-hierarchy", + "ruleName": "detect-weak-role-hierarchies", + "category": "access-control", + "fixture": "weak-role-hierarchy-regression.json", + "type": "positive", + "baseline": { + "expectedDetected": true, + "expectedViolations": 5, + "safePatterns": 4 + } + }, + { + "id": "stellar-regression-access-control-safe", + "ruleName": "detect-missing-access-control", + "category": "access-control", + "fixture": "access-control-safe-regression.json", + "type": "negative", + "baseline": { + "expectedDetected": false, + "expectedViolations": 0, + "safePatterns": 5 + } + }, + { + "id": "stellar-regression-unsafe-cross-contract", + "ruleName": "detect-unsafe-cross-contract-invocation", + "category": "cross-contract", + "fixture": "unsafe-cross-contract-regression.json", + "type": "positive", + "baseline": { + "expectedDetected": true, + "expectedViolations": 3, + "safePatterns": 1 + } + }, + { + "id": "stellar-regression-cross-contract-safe", + "ruleName": "detect-unsafe-cross-contract-invocation", + "category": "cross-contract", + "fixture": "cross-contract-safe-regression.json", + "type": "negative", + "baseline": { + "expectedDetected": false, + "expectedViolations": 0, + "safePatterns": 4 + } + }, + { + "id": "stellar-regression-excessive-event-topics", + "ruleName": "detect-excessive-event-topics", + "category": "events", + "fixture": "excessive-event-topics-regression.json", + "type": "positive", + "baseline": { + "expectedDetected": true, + "expectedViolations": 5, + "safePatterns": 4 + } + }, + { + "id": "stellar-regression-missing-upgrade-guards", + "ruleName": "detect-missing-upgrade-guards", + "category": "upgradeability", + "fixture": "missing-upgrade-guards-regression.json", + "type": "positive", + "baseline": { + "expectedDetected": true, + "expectedViolations": 3, + "safePatterns": 1 + } + }, + { + "id": "stellar-regression-upgrade-guards-safe", + "ruleName": "detect-missing-upgrade-guards", + "category": "upgradeability", + "fixture": "upgrade-guards-safe-regression.json", + "type": "negative", + "baseline": { + "expectedDetected": false, + "expectedViolations": 0, + "safePatterns": 4 + } + } + ], + "ci": { + "command": "npx jest --testPathPattern 'tests/regression/security/stellar' --verbose", + "required": true, + "onFailure": "block-release" + }, + "snapshot": { + "enabled": true, + "dir": "__snapshots__", + "autoUpdate": false + } +} diff --git a/tests/regression/security/stellar/stellar-security-regression.spec.ts b/tests/regression/security/stellar/stellar-security-regression.spec.ts new file mode 100644 index 0000000..47946c3 --- /dev/null +++ b/tests/regression/security/stellar/stellar-security-regression.spec.ts @@ -0,0 +1,154 @@ +import { detectMissingAccessControl } from '../../../rules/stellar/access-control/detect-missing-access-control'; +import { detectWeakRoleHierarchies } from '../../../rules/stellar/access-control/detect-weak-role-hierarchies'; +import { detectUnsafeCrossContractInvocation } from '../../../rules/stellar/cross-contract/detect-unsafe-cross-contract-invocation'; +import { detectExcessiveEventTopics } from '../../../rules/stellar/events/detect-excessive-event-topics'; +import { detectMissingUpgradeGuards } from '../../../rules/stellar/upgradeability/detect-missing-upgrade-guards'; +import * as path from 'path'; +import * as fs from 'fs'; + +type DetectorFn = (code: string) => { detected: boolean; message: string; [key: string]: any }; + +interface RegressionFixture { + id: string; + name: string; + description: string; + input: string; + expectedFindings: { ruleId: string; severity: string; messagePattern: string; line?: number }[]; + metadata: { + language: string; + category: string; + tags: string[]; + regressionType: string; + expectedTotalViolations: number; + safePatternsPresent: string[]; + targetRule: string; + }; +} + +const FIXTURES_DIR = path.resolve(__dirname, 'fixtures'); + +const DETECTOR_BY_RULE: Record = { + 'detect-missing-access-control': detectMissingAccessControl, + 'detect-weak-role-hierarchies': detectWeakRoleHierarchies, + 'detect-unsafe-cross-contract-invocation': detectUnsafeCrossContractInvocation, + 'detect-excessive-event-topics': detectExcessiveEventTopics, + 'detect-missing-upgrade-guards': detectMissingUpgradeGuards, +}; + +function loadAllFixtures(): RegressionFixture[] { + if (!fs.existsSync(FIXTURES_DIR)) { + throw new Error(`Regression fixtures directory not found: ${FIXTURES_DIR}`); + } + const files = fs.readdirSync(FIXTURES_DIR).filter(f => f.endsWith('.json')); + return files + .map(f => JSON.parse(fs.readFileSync(path.join(FIXTURES_DIR, f), 'utf-8'))) + .sort((a, b) => a.id.localeCompare(b.id)); +} + +function getDetector(fixture: RegressionFixture): DetectorFn { + const targetRule = fixture.metadata?.targetRule; + const detector = targetRule ? DETECTOR_BY_RULE[targetRule] : undefined; + if (!detector) { + throw new Error( + `No detector for fixture "${fixture.id}" (targetRule="${targetRule}"). ` + + `Available: ${Object.keys(DETECTOR_BY_RULE).join(', ')}` + ); + } + return detector; +} + +describe('Stellar Security Rule Regression Suite', () => { + const fixtures = loadAllFixtures(); + const positiveFixtures = fixtures.filter(f => f.metadata.expectedTotalViolations > 0); + const safeFixtures = fixtures.filter(f => f.metadata.expectedTotalViolations === 0); + + it('has unique fixture IDs and valid target rules', () => { + const ids = fixtures.map(f => f.id); + expect(new Set(ids).size).toBe(ids.length); + for (const f of fixtures) { + expect(f.metadata.targetRule).toBeDefined(); + expect(DETECTOR_BY_RULE[f.metadata.targetRule]).toBeDefined(); + } + }); + + describe('fixture structure validation', () => { + for (const fixture of fixtures) { + it(`${fixture.id}: has valid structure`, () => { + expect(fixture.id).toBeDefined(); + expect(fixture.name).toBeDefined(); + expect(fixture.description).toBeDefined(); + expect(fixture.input).toBeDefined(); + expect(Array.isArray(fixture.expectedFindings)).toBe(true); + expect(fixture.metadata).toBeDefined(); + expect(fixture.metadata.language).toBe('soroban'); + expect(fixture.metadata.regressionType).toBe('cross-version'); + expect(fixture.metadata.expectedTotalViolations).toBe(fixture.expectedFindings.length); + expect(Array.isArray(fixture.metadata.safePatternsPresent)).toBe(true); + expect(fixture.metadata.safePatternsPresent.length).toBeGreaterThan(0); + }); + } + }); + + describe('vulnerability detection (positive fixtures)', () => { + for (const fixture of positiveFixtures) { + it(`${fixture.id}: detects ${fixture.metadata.expectedTotalViolations} violation(s)`, () => { + const detector = getDetector(fixture); + const result = detector(fixture.input); + expect(result.detected).toBe(true); + }); + + it(`${fixture.id}: all expected findings reflected in result message`, () => { + const detector = getDetector(fixture); + const result = detector(fixture.input); + for (const finding of fixture.expectedFindings) { + if (finding.messagePattern) { + const escaped = finding.messagePattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + expect(result.message).toMatch(new RegExp(escaped, 'i')); + } + } + }); + } + }); + + describe('safe pattern validation (negative fixtures)', () => { + for (const fixture of safeFixtures) { + it(`${fixture.id}: does not flag safe patterns`, () => { + const detector = getDetector(fixture); + const result = detector(fixture.input); + expect(result.detected).toBe(false); + }); + + it(`${fixture.id}: reports no violations in message`, () => { + const detector = getDetector(fixture); + const result = detector(fixture.input); + expect(result.message).not.toMatch(/violation|vulnerability|lack|weak|unsafe|excessive|missing/i); + }); + } + }); + + describe('cross-version baseline', () => { + for (const fixture of fixtures) { + const expectedDetected = fixture.metadata.expectedTotalViolations > 0; + it(`${fixture.id}: maintains baseline detected=${expectedDetected}`, () => { + const detector = getDetector(fixture); + const result = detector(fixture.input); + expect(result.detected).toBe(expectedDetected); + }); + } + }); + + describe('regression report', () => { + it('all regression checks pass', () => { + let passCount = 0; + let failCount = 0; + for (const fixture of fixtures) { + const detector = getDetector(fixture); + const result = detector(fixture.input); + const expectedDetected = fixture.metadata.expectedTotalViolations > 0; + if (result.detected === expectedDetected) { passCount++; } else { failCount++; } + } + expect(failCount).toBe(0); + expect(passCount).toBe(fixtures.length); + }); + }); +});