Skip to content
Open
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
27 changes: 27 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
65 changes: 65 additions & 0 deletions scripts/run-stellar-regression.ps1
Original file line number Diff line number Diff line change
@@ -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
}
70 changes: 70 additions & 0 deletions scripts/run-stellar-regression.sh
Original file line number Diff line number Diff line change
@@ -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
38 changes: 38 additions & 0 deletions scripts/verify-regression-temp.cjs
Original file line number Diff line number Diff line change
@@ -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);
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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>) -> 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"
}
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading
Loading