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
140 changes: 140 additions & 0 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
@@ -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',
'',
'<details><summary>cargo audit</summary>',
'',
'```',
audit,
'```',
'',
'</details>',
'',
'<details><summary>Soroban pattern check</summary>',
'',
'```',
soroban,
'```',
'',
'</details>',
].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',
'',
'<details><summary>npm audit</summary>',
'',
'```',
read('/tmp/npm-audit.txt'),
'```',
'',
'</details>',
'',
'<details><summary>ESLint</summary>',
'',
'```',
read('/tmp/eslint-output.txt'),
'```',
'',
'</details>',
'',
'<details><summary>Hardcoded secrets check</summary>',
'',
'```',
read('/tmp/secrets-output.txt'),
'```',
'',
'</details>',
].join('\n')
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
})
2 changes: 2 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -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 [
{
Expand All @@ -13,6 +14,7 @@ export default [
],
},
js.configs.recommended,
security.configs.recommended,
{
plugins: {
'@next/next': nextPlugin,
Expand Down
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
65 changes: 65 additions & 0 deletions scripts/check-secrets.mjs
Original file line number Diff line number Diff line change
@@ -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)
}
160 changes: 160 additions & 0 deletions scripts/soroban-security-check.mjs
Original file line number Diff line number Diff line change
@@ -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).`)
}
Loading