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).`)
+}