diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..2483514 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,140 @@ +name: Security Scan + +on: + pull_request: + branches: [main] + +jobs: + contract-audit: + name: Rust / Soroban Audit + runs-on: ubuntu-latest + defaults: + run: + working-directory: contracts + + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: contracts + + - name: clippy + run: cargo clippy --all-targets -- -D warnings + + - name: cargo audit + run: | + cargo install cargo-audit --locked --quiet + cargo audit 2>&1 | tee /tmp/audit-output.txt + exit ${PIPESTATUS[0]} + + - name: Soroban pattern check + run: node ../scripts/soroban-security-check.mjs 2>&1 | tee /tmp/soroban-output.txt + + - name: Post contract audit summary + if: always() + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs') + const audit = fs.existsSync('/tmp/audit-output.txt') + ? fs.readFileSync('/tmp/audit-output.txt', 'utf8').trim() + : '(no output)' + const soroban = fs.existsSync('/tmp/soroban-output.txt') + ? fs.readFileSync('/tmp/soroban-output.txt', 'utf8').trim() + : '(no output)' + const body = [ + '## šŸ” Contract Security Scan', + '', + '
cargo audit', + '', + '```', + audit, + '```', + '', + '
', + '', + '
Soroban pattern check', + '', + '```', + soroban, + '```', + '', + '
', + ].join('\n') + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }) + + frontend-audit: + name: Frontend Audit + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - run: npm ci + + - name: npm audit + run: npm audit --audit-level=high 2>&1 | tee /tmp/npm-audit.txt; exit ${PIPESTATUS[0]} + + - name: ESLint security + run: npm run lint 2>&1 | tee /tmp/eslint-output.txt; exit ${PIPESTATUS[0]} + + - name: Check for hardcoded secrets + run: | + node scripts/check-secrets.mjs 2>&1 | tee /tmp/secrets-output.txt + exit ${PIPESTATUS[0]} + + - name: Post frontend audit summary + if: always() + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs') + const read = (p) => fs.existsSync(p) ? fs.readFileSync(p, 'utf8').trim() : '(no output)' + const body = [ + '## šŸ›”ļø Frontend Security Scan', + '', + '
npm audit', + '', + '```', + read('/tmp/npm-audit.txt'), + '```', + '', + '
', + '', + '
ESLint', + '', + '```', + read('/tmp/eslint-output.txt'), + '```', + '', + '
', + '', + '
Hardcoded secrets check', + '', + '```', + read('/tmp/secrets-output.txt'), + '```', + '', + '
', + ].join('\n') + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }) diff --git a/eslint.config.mjs b/eslint.config.mjs index 703b010..03f16f7 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,6 +1,7 @@ import globals from 'globals' import js from '@eslint/js' import nextPlugin from '@next/eslint-plugin-next' +import security from 'eslint-plugin-security' export default [ { @@ -13,6 +14,7 @@ export default [ ], }, js.configs.recommended, + security.configs.recommended, { plugins: { '@next/next': nextPlugin, diff --git a/package-lock.json b/package-lock.json index fa12b69..f2bca8a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4267,6 +4267,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, diff --git a/package.json b/package.json index 27fb90e..01d73ef 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^10.5.0", + "eslint-plugin-security": "^3.0.1", "globals": "^17.7.0", "postcss": "^8.5", "tailwindcss": "^4.2.0", diff --git a/scripts/check-secrets.mjs b/scripts/check-secrets.mjs new file mode 100644 index 0000000..dea70d9 --- /dev/null +++ b/scripts/check-secrets.mjs @@ -0,0 +1,65 @@ +#!/usr/bin/env node +// scripts/check-secrets.mjs +// Scan source files for hardcoded secrets and API keys. +// Exits 1 if any HIGH-severity pattern is found. + +import { readFileSync, readdirSync, statSync } from 'fs' +import { join, extname } from 'path' + +const ROOT = new URL('..', import.meta.url).pathname + +const IGNORED_DIRS = new Set(['.git', '.next', 'node_modules', 'target', 'public', 'test_snapshots']) +const ALLOWED_EXTS = new Set(['.ts', '.tsx', '.js', '.mjs', '.env.example', '.yml', '.yaml', '.json']) + +// Patterns that indicate a hardcoded secret value (not just a variable name). +// Note: Stellar public keys (G…/C… strkey) are intentionally excluded — they're public identifiers, not secrets. +const SECRET_PATTERNS = [ + // Real base64 secrets: must contain + or / (Stellar strkeys use only A-Z0-9, so this excludes them) + { re: /['"`][A-Za-z0-9+/]{40,}={0,2}['"`](?=.*[+/])/, label: 'possible base64 secret' }, + { re: /sk_live_[A-Za-z0-9]{24,}/, label: 'Stripe live secret key' }, + { re: /sk_test_[A-Za-z0-9]{24,}/, label: 'Stripe test secret key' }, + { re: /AKIA[0-9A-Z]{16}/, label: 'AWS access key ID' }, + { re: /ghp_[A-Za-z0-9]{36}/, label: 'GitHub personal access token' }, + { re: /-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----/, label: 'private key' }, + { re: /xoxb-[0-9A-Za-z-]{50,}/, label: 'Slack bot token' }, +] + +function walk(dir, files = []) { + for (const entry of readdirSync(dir)) { + if (IGNORED_DIRS.has(entry)) continue + const full = join(dir, entry) + const st = statSync(full) + if (st.isDirectory()) { + walk(full, files) + } else if (ALLOWED_EXTS.has(extname(entry)) || entry.startsWith('.env')) { + // Skip .env.local (contains real values) — only scan example/template files + if (entry === '.env.local') continue + files.push(full) + } + } + return files +} + +const files = walk(ROOT) +let found = 0 + +for (const file of files) { + const src = readFileSync(file, 'utf8') + src.split('\n').forEach((line, i) => { + for (const { re, label } of SECRET_PATTERNS) { + if (re.test(line)) { + const rel = file.replace(ROOT, '') + console.log(`āŒ HIGH [SECRETS-001] ${rel}:${i + 1} — ${label}: ${line.trim().slice(0, 80)}`) + found++ + } + } + }) +} + +if (found === 0) { + console.log('āœ… No hardcoded secrets found.') + process.exit(0) +} else { + console.log(`\n${found} potential secret(s) found. Remove before merging.`) + process.exit(1) +} diff --git a/scripts/soroban-security-check.mjs b/scripts/soroban-security-check.mjs new file mode 100644 index 0000000..337ff25 --- /dev/null +++ b/scripts/soroban-security-check.mjs @@ -0,0 +1,160 @@ +#!/usr/bin/env node +// scripts/soroban-security-check.mjs +// Custom Soroban/Rust security pattern checker. +// Exits 1 if any HIGH-severity issue is found; warns on LOW. + +import { readFileSync, readdirSync, statSync } from 'fs' +import { join, extname } from 'path' + +const ROOT = new URL('..', import.meta.url).pathname + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function walkRust(dir, files = []) { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry) + if (statSync(full).isDirectory()) { + walkRust(full, files) + } else if (extname(entry) === '.rs') { + files.push(full) + } + } + return files +} + +function lines(src) { + return src.split('\n') +} + +// ── Rules ───────────────────────────────────────────────────────────────────── + +// Each rule: { id, severity, description, check(src, filePath) -> [{line, col, msg}] } + +const rules = [ + { + id: 'SOROBAN-001', + severity: 'HIGH', + description: 'Unchecked arithmetic on i128/u128/u64 — use checked_add / checked_mul', + check(src) { + const findings = [] + // Flag bare `+`, `-`, `*` on i128/u128 variables. + // Heuristic: look for `withdrawn_amount +=` or similar without checked_ call on the same line. + const uncheckedOps = /\b(withdrawn_amount|deposited_amount|amount_per_second|cliff_amount|linear_amount)\s*(\+=|-=|\*=)/g + lines(src).forEach((line, i) => { + if (uncheckedOps.test(line) && !line.includes('checked_')) { + findings.push({ line: i + 1, msg: `Unchecked compound assignment: ${line.trim()}` }) + } + uncheckedOps.lastIndex = 0 + }) + return findings + }, + }, + + { + id: 'SOROBAN-002', + severity: 'HIGH', + description: 'Public write function missing require_auth()', + check(src) { + const findings = [] + // Collect all pub fn bodies; check that require_auth appears before any storage write. + const fnRegex = /pub fn (\w+)\s*\(env:\s*Env[^)]*\)/g + // Read-only patterns — skip them + const readOnly = /^(get_|bump_stream|load_stream)/ + let match + while ((match = fnRegex.exec(src)) !== null) { + const name = match[1] + if (readOnly.test(name)) continue + + // Slice from function start to next `pub fn` or end of file + const body = src.slice(match.index) + const nextFn = body.indexOf('\n pub fn ', 1) + const fnBody = nextFn === -1 ? body : body.slice(0, nextFn) + + if (!fnBody.includes('require_auth()')) { + const lineNo = src.slice(0, match.index).split('\n').length + findings.push({ line: lineNo, msg: `pub fn ${name} performs writes but has no require_auth()` }) + } + } + return findings + }, + }, + + { + id: 'SOROBAN-003', + severity: 'HIGH', + description: 'Persistent storage set() without a subsequent extend_ttl()', + check(src) { + const findings = [] + // Each .persistent().set() call should be paired with an extend_ttl in the same function body. + const fnBodies = [] + const fnRegex = /\bfn (\w+)\s*\(/g + let match + while ((match = fnRegex.exec(src)) !== null) { + const start = match.index + const after = src.indexOf('\n fn ', start + 1) + const body = after === -1 ? src.slice(start) : src.slice(start, after) + fnBodies.push({ name: match[1], body, lineNo: src.slice(0, start).split('\n').length }) + } + + for (const { name, body, lineNo } of fnBodies) { + if (body.includes('.persistent().set(') && !body.includes('extend_ttl')) { + findings.push({ line: lineNo, msg: `fn ${name} writes to persistent storage without extend_ttl()` }) + } + } + return findings + }, + }, + + { + id: 'SOROBAN-004', + severity: 'LOW', + description: 'panic!() with string message — prefer ContractError enum + panic_with_error!()', + check(src) { + const findings = [] + const panicRe = /panic!\s*\(\s*"/g + lines(src).forEach((line, i) => { + if (panicRe.test(line)) { + findings.push({ line: i + 1, msg: `panic! with string: ${line.trim()}` }) + } + panicRe.lastIndex = 0 + }) + return findings + }, + }, +] + +// ── Runner ──────────────────────────────────────────────────────────────────── + +const contractsDir = join(ROOT, 'contracts') +const rustFiles = walkRust(contractsDir).filter(f => !f.includes('/target/')) + +let totalHigh = 0 +let totalLow = 0 + +for (const file of rustFiles) { + const src = readFileSync(file, 'utf8') + const rel = file.replace(ROOT, '') + + for (const rule of rules) { + const findings = rule.check(src, file) + for (const f of findings) { + const prefix = rule.severity === 'HIGH' ? 'āŒ HIGH' : 'āš ļø LOW ' + console.log(`${prefix} [${rule.id}] ${rel}:${f.line} — ${f.msg}`) + if (rule.severity === 'HIGH') totalHigh++ + else totalLow++ + } + } +} + +if (totalHigh === 0 && totalLow === 0) { + console.log('āœ… No Soroban security issues found.') +} + +if (totalHigh > 0) { + console.log(`\n${totalHigh} HIGH severity issue(s) found. Fix before merging.`) + process.exit(1) +} + +if (totalLow > 0) { + console.log(`\n${totalLow} LOW severity issue(s) found (warnings only).`) +}