diff --git a/.agents/skills/playwright-best-practices/frameworks/nextjs.md b/.agents/skills/playwright-best-practices/frameworks/nextjs.md index c16a9e9df..2dc7fe3d2 100644 --- a/.agents/skills/playwright-best-practices/frameworks/nextjs.md +++ b/.agents/skills/playwright-best-practices/frameworks/nextjs.md @@ -68,7 +68,7 @@ NEXT_PUBLIC_API_URL=http://localhost:3000/api DATABASE_URL=postgresql://localhost:5432/test_db # .env.test.local (gitignored) -NEXTAUTH_SECRET=test-secret-local +NEXTAUTH_SECRET=test-secret-local # security.sh:allow (doc example placeholder) ``` ## App Router Patterns diff --git a/.github/skills/security-review/SKILL.md b/.github/skills/security-review/SKILL.md new file mode 100644 index 000000000..2f50a3a1e --- /dev/null +++ b/.github/skills/security-review/SKILL.md @@ -0,0 +1,82 @@ +--- +name: security-review +description: > + Security review rules for any RozoAI pull request. Use when reviewing a PR, + before merging, or auditing a diff — especially for changes that touch build + configs, dependencies, .env files, CI, or payment/treasury code. Encodes the + lessons from the 2026-06 supply-chain incidents (tailwind dead-drop loader + + the atob/eval C2 loader). Iterate these rules here in rozo-security; repos + copy what they need. +--- + +# Security Review — RozoAI + +Run `security.sh scan` first (it's mechanical). Then a human/agent applies the +judgment rules below. The scanner catches known shapes; review catches intent. + +## 1. Build configs are executable code — review them like code + +Any file that runs at build/dev/CI time can exfiltrate the build env. Treat +these as high-scrutiny on every diff: + +`*tailwind.config.*` · `metro.config.*` · `app.config.*` · `babel.config.*` · +`next.config.*` · `vite.config.*` · `postcss.config.*` · `react-router.config.*` · +`vercel.json` · `.npmrc` · `package.json` (scripts) · any `*.config.{js,ts,mjs,cjs}` + +**Red flags in these files (block the PR):** +- `eval(...)`, `new Function(...)`, `child_process` / `spawn` / `exec` +- `atob(...)` / `Buffer.from(x,'base64')` followed by execution or a network call +- `fetch` / `node-fetch` / `import(...)` to any non-obvious domain +- **Code that runs at module load** — an IIFE `(async () => {...})()`, or any + statement *before* or *after* the legitimate `export default` / `module.exports`. + A clean config only *exports* an object; it doesn't *do* anything. +- A config that suddenly `import 'dotenv/config'` to read an env var it never needed. + +## 2. Both incidents hid the same way — know the patterns + +| Campaign | Where | Tell | +|---|---|---| +| tailwind dead-drop | `tailwind.config.js` tail | code AFTER `module.exports`; beacon `9-0037-2`; reads Tron/BSC chains for stage-2 | +| atob/eval C2 | `react-router.config.ts` / `database.types.ts` head | IIFE BEFORE `export default`; `atob(process.env.X)` → `node-fetch` → `eval`; C2 URL base64'd in a tracked `.env` | + +Both: authored by a **compromised developer account**, **back-dated commits** to +look old, and **woven into an existing commit** (no obvious "new suspicious +commit" on top). So: **don't trust commit dates**, and **diff the actual file +content** against a known-clean baseline — not just "what changed recently." + +## 3. .env / secrets + +- A **tracked `.env`** is a red flag by itself — it should be gitignored. In the + 2026-06 incident a tracked `.env` was the *carrier* for the C2 URL (base64). +- Never approve a hard-coded private key, mnemonic, or `service_role` key. + A public Supabase **anon** key or a Firebase client key is OK (RLS/public by + design) but should be annotated `// security.sh:allow`. +- Secret values must never be pasted into the PR description, review comments, + or CI logs. + +## 4. Dependencies & supply chain + +- New dependency? Check it's the real package (typosquat?), pinned, and that its + `postinstall`/`prepare` scripts don't fetch+run remote code. +- A lockfile change with no corresponding `package.json` change is suspicious. + +## 5. Permissions & merge hygiene (the structural lesson) + +The 2026-06 malware survived ~6 months because an **admin self-merged without +review**, bypassing branch protection. So: +- PRs to `main` need a **different** person's approval. Authors don't self-approve. +- Keep the "require PR + approval" ruleset; don't put broad admin bypass on it. +- Payment/treasury/migration changes are **high risk**: require owner approval + + an independent review pass before merge. + +## 6. The review checklist (paste into the PR) + +``` +[ ] security.sh scan passes (or every finding is justified) +[ ] no executable code in build configs (no eval/spawn/atob/fetch/IIFE; only exports) +[ ] no code before/after the config's export statement +[ ] no tracked .env; no hard-coded private key / mnemonic / service_role +[ ] new deps are real, pinned, no remote-fetching install scripts +[ ] diff reviewed against content, not trusting commit dates +[ ] not self-approved; high-risk (money/migration) has owner sign-off +``` diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml new file mode 100644 index 000000000..e3f73dedf --- /dev/null +++ b/.github/workflows/security-scan.yml @@ -0,0 +1,39 @@ +name: Security scan + +# Pre-merge security gate. Runs scripts/security.sh (one file: code-injection + +# secret-leak checks) on every push and PR. See scripts/SECURITY.md. +# +# A finding fails the job. If/when you want to soften it while cleaning up known +# pre-existing findings, add `continue-on-error: true` to the job. + +on: + push: + branches: ['**'] + pull_request: + branches: ['**'] + +permissions: + contents: read + +jobs: + security-scan: + name: Security scan + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run security gate + run: | + chmod +x scripts/security.sh + bash scripts/security.sh scan + + - name: On failure, explain + if: failure() + run: | + echo "::error::scripts/security.sh found a code-injection or secret-leak issue." + echo "Matched secret values are intentionally hidden — inspect the reported file:line." + echo "Real secret → remove + rotate + use a secret manager. Build-config injection → see docs/incident-tailwind-malware-2026-06-18.md." + echo "Confirmed false positive → add a '# security.sh:allow' comment on that line. See scripts/SECURITY.md." diff --git a/scripts/SECURITY.md b/scripts/SECURITY.md new file mode 100644 index 000000000..9e7896e60 --- /dev/null +++ b/scripts/SECURITY.md @@ -0,0 +1,67 @@ +# Pre-commit Security Gate — SOP + +One file does everything: **`scripts/security.sh`**. Copy that single file into +any repo to get the same protection — nothing else required. + +It runs two checks on the files you're about to commit: + +| Check | Catches | +|---|---| +| **Code injection** | Malicious code in build configs — the 2026-06 tailwind dead-drop loader class (`eval` / `child_process` / network calls / code after `module.exports`). Covers tailwind, metro, app.config, babel, vite, next, vercel.json, .npmrc, package.json scripts. | +| **Leaked secrets** | Credentials about to be committed: sensitive filenames (`.env`, `*.pem`, `*.key`, keystores, `.netrc`, service-account json) **and** secret shapes in content (JWT, AWS/GitHub/Stripe/Google/Slack tokens, mnemonics, EVM private keys). | + +> **Secret matches never print the value** — only `file:line + rule name` — so +> secrets can't leak into CI logs, terminal scrollback, or AI transcripts. + +## Use it + +```bash +bash scripts/security.sh scan # scan the whole repo (what CI runs) +bash scripts/security.sh hook # scan only staged files (pre-commit) +bash scripts/security.sh install # install the git pre-commit hook (once per clone) +bash scripts/security.sh help +``` + +`install` writes a `.git/hooks/pre-commit` that calls `security.sh hook`, so +every commit is checked locally. CI runs `security.sh scan` via +`.github/workflows/ioc-scan.yml`. + +## When it fires + +- **Code injection** → a build config has suspicious code. Serious — do not + bypass without confirming it's benign. See + `docs/incident-tailwind-malware-2026-06-18.md`. +- **Leaked secret** → + - **Real secret**: remove it, **rotate it**, load it from env / a secret + manager. (git history keeps it alive until rotated.) + - **Placeholder/example**: mark it (``, `your-key-here`, `***`) or move + it into a `*.env.example` — those are allow-listed. + - **Reviewed & known-benign** (e.g. a public Supabase *anon* key): add a + `# security.sh:allow` comment on that line to silence it. + +## Bypass (last resort) + +```bash +git commit --no-verify # skips the local hook; use only for a confirmed false positive +``` + +CI still runs, so a real problem is caught there anyway. + +## Copy to another repo + +```bash +cp scripts/security.sh /scripts/ +cp .github/workflows/ioc-scan.yml /.github/workflows/security-scan.yml +cd && bash scripts/security.sh install +``` + +No repo-specific values are hard-coded. To tune: edit the `CONTENT_RULES` / +`CONFIG_GLOBS` / `PUBLIC_BY_DESIGN_RE` arrays near the top of `security.sh`. + +## Known pre-existing findings + +`packages/shared/core/src/rozo-intent/index.ts:777,867` hold hard-coded Supabase +**anon** JWTs. The anon key is public by design (protected by RLS, not secrecy), +so this is a code-tidiness issue, not a leak — but the scanner flags it. Either +add `# security.sh:allow` on those lines, or move them to env, to get a green +scan. diff --git a/scripts/security.sh b/scripts/security.sh new file mode 100755 index 000000000..6a5457c96 --- /dev/null +++ b/scripts/security.sh @@ -0,0 +1,225 @@ +#!/usr/bin/env bash +# ============================================================================= +# security.sh — one-file pre-commit security gate. Copy this single file into +# any repo to get the same protection. No other files required. +# +# Gate 1 code injection — malicious code in build configs (the 2026-06 +# tailwind dead-drop loader: eval / child_process / +# network / code after module.exports). Covers +# tailwind, metro, app.config, babel, vite, next, +# vercel.json, .npmrc, package.json scripts. +# Gate 2 leaked secrets — credentials about to be committed: sensitive +# filenames (.env, *.pem, *.key, keystores, netrc, +# service-account json) AND secret shapes in content +# (JWT, AWS/GitHub/Stripe/Google/Slack tokens, +# mnemonics, EVM private keys). +# +# The secret gate NEVER prints a matched value — only ": rule" — so +# secrets can't leak into CI logs / scrollback / AI transcripts. +# +# USAGE +# bash scripts/security.sh scan # scan whole repo (CI / manual) +# bash scripts/security.sh hook # scan staged files (pre-commit) +# bash scripts/security.sh install # install the git pre-commit hook +# bash scripts/security.sh scan --files a b c +# bash scripts/security.sh help +# +# Exit 0 = clean, 1 = findings, 2 = usage error. +# ============================================================================= +# Source of truth: github.com/RozoAI/rozo-security — iterate the rules there. +# Repos may run older copies; that's fine. Bump this when you change the rules. +SECURITY_SH_VERSION="1.0.1" +# ============================================================================= +set -uo pipefail + +REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +cd "$REPO_ROOT" || exit 2 +SELF="scripts/security.sh" + +# ---------------------------------------------------------------------------- +# Shared config +# ---------------------------------------------------------------------------- +IOC_STRINGS=( + "global['!']='9-0037-2'" "9-0037-2" + "TMfKQEd7TJJa5xNZJZ2Lep838vrzrs7mAP" "TXfxHUet9pJVU1BgVkBAbrES4YUc1nGzcG" + "2[gWfGj;<:-93Z^C" "m6:tTh^D)cBz?NM]" +) +CONFIG_GLOBS=( + '*tailwind.config.js' '*tailwind.config.ts' '*metro.config.js' '*metro.config.ts' + '*app.config.js' '*app.config.ts' '*babel.config.js' '*babel.config.ts' + '*next.config.js' '*next.config.mjs' '*next.config.ts' + '*vite.config.js' '*vite.config.ts' '*vite.config.mjs' '*vercel.json' '*.npmrc' +) +DANGER_RE='child_process|require\(["'\'']child_process|\bspawn\(|\bexec(Sync|File)?\(|\beval\(|new[[:space:]]+Function|process\.binding|\bnode[[:space:]]+-e\b|atob\(|Buffer\.from\([^,]*,[[:space:]]*["'\'']base64|fetch\(["'\'']https?://(api\.trongrid|bsc-|fullnode\.mainnet\.aptos)' + +SENSITIVE_NAME_RE='(^|/)\.env($|\.[^/]*$)|\.pem$|\.p12$|\.pfx$|\.jks$|\.keystore$|(^|/)id_rsa($|\.)|(^|/)id_ed25519($|\.)|\.mobileprovision$|(^|/)serviceaccount[^/]*\.json$|(^|/)[^/]*credentials[^/]*\.json$|(^|/)\.netrc$' +ALLOW_NAME_RE='\.env\.(example|sample|template|dist)$|\.example$|\.sample$|\.template$' +NPMRC_AUTH_RE='_authToken=|_password=|_auth=|//.*:_authToken' +PUBLIC_BY_DESIGN_RE='google-services\.json$|GoogleService-Info\.plist$|firebase[^/]*\.json$' +PLACEHOLDER_RE='<[^>]*>|YOUR[_-]|EXAMPLE|PLACEHOLDER|CHANGE[_-]?ME|xxxx|XXXX|\*\*\*|\.\.\.|dummy|sample|REPLACE' +# type/schema declarations are not secrets even though they mention KEY/SECRET +# (e.g. `SESSION_SECRET: z.string().min(32)`, `apiKey: string`, interface fields). +# Also: reading a secret FROM env is safe — `const X_PRIVATE_KEY = firstEnv(...)`, +# `process.env.X`, `Deno.env.get(...)`, `os.environ[...]` — the string there is a +# VARIABLE NAME, not a value. And shell `export X=$VAR` / `${VAR}` references. +SCHEMA_DECL_RE='z\.(string|enum|number|object|boolean|optional|coerce)|:[[:space:]]*(string|number|boolean)\b|\.string\(\)|Schema|interface |type [A-Z]|process\.env|import\.meta\.env|Deno\.env|os\.environ|getenv|firstEnv|requireEnv|\benv\(|=[[:space:]]*"?\$\{?[A-Za-z_]' +CONTENT_RULES=( + "private_key_assignment::(PRIVATE_KEY|SECRET_KEY|SECRET|MNEMONIC|SEED_PHRASE|SEED)[\"'\` ]*[:=][\"'\` ]*[^[:space:]\"'\`]{8,}" + "jwt_token::eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{6,}" + "aws_access_key::AKIA[0-9A-Z]{16}" + "github_token::gh[posru]_[A-Za-z0-9]{30,}" + "stripe_live_key::sk_live_[0-9a-zA-Z]{20,}" + "rozo_live_key::rz_(live|test)_[0-9a-zA-Z]{16,}" + "google_api_key::AIza[0-9A-Za-z_-]{30,}" + "slack_token::xox[baprs]-[0-9A-Za-z-]{10,}" + "openai_key::sk-[A-Za-z0-9]{20,}" +) +EVM_PK_RE='(^|[^0-9a-fA-Fx])0x[a-fA-F0-9]{64}([^0-9a-fA-F]|$)' +EVM_PK_CONTEXT_RE='priv(ate)?[_-]?key|privkey|signerkey|wallet[_-]?key|secret[_-]?key|deployer[_-]?key|PRIVATE_KEY' +EVM_PK_EXCLUDE_RE='hash|txid|signature|\bsig\b|proof|merkle|root|digest|commitment|blockhash' +# inline allow-list marker: put `security.sh:allow` in a comment on a line you +# have reviewed and confirmed benign (e.g. a public anon key) to silence it. +ALLOW_MARKER='security\.sh:allow' + +findings=0 +hit() { findings=$((findings+1)); printf ' ✖ %s\n' "$*"; } + +is_config_file() { local f="$1" g; for g in "${CONFIG_GLOBS[@]}"; do [[ "$f" == $g ]] && return 0; done; return 1; } + +check_post_export_code() { # IOC structural: executable code after terminating export + awk ' + /^[[:space:]]*module\.exports[[:space:]]*=/ { le=NR } /^[[:space:]]*export[[:space:]]+default/ { le=NR } + { L[NR]=$0 } END { + if (le==0) exit 0; d=0 + for (i=le;i<=NR;i++){ l=L[i]; o=gsub(/[\(\{\[]/,"&",l); c=gsub(/[\)\}\]]/,"&",l) + if (i>le && d<=0){ s=L[i]; gsub(/^[[:space:]]+|[[:space:]]+$/,"",s) + if(s!="" && s!~/^\/\// && s!~/^\*/ && s!~/^\/\*/ && s!~/^[\)\}\];,]+;?$/) + if(s~/(^|[^A-Za-z])(function|var|let|const|require|eval|global|_0x|!function|\(function|void 0|process\.|child_process|spawn|atob)/){print i": "substr(L[i],1,80); bad=1}} + d+=o-c } if(bad) exit 7 }' "$1" +} + +# ---------------------------------------------------------------------------- +# Scanners (operate on a newline-separated file list passed on stdin) +# ---------------------------------------------------------------------------- +scan_files() { # reads file list on stdin + local checked=0 + while IFS= read -r f; do + [ -z "$f" ] && continue; [ -f "$f" ] || continue + [ "$(basename "$f")" = "security.sh" ] && continue + # the security-review skill legitimately quotes IOC strings as samples; + # SECURITY.md documents the gate. Don't flag our own docs. + checked=$((checked+1)) + + # ---- Gate 1: code injection ---- + # Markdown is documentation, not executable code — security docs legitimately + # quote IOC strings as samples. Skip IOC matching for .md (secret checks below + # still run on .md, since docs can accidentally contain a real token). + case "$f" in + *.md) : ;; + *) for ioc in "${IOC_STRINGS[@]}"; do + grep -qF -- "$ioc" "$f" 2>/dev/null && hit "$f — code injection IOC: $ioc" + done ;; + esac + if is_config_file "$f"; then + local d; d=$(grep -nE "$DANGER_RE" "$f" 2>/dev/null | head -3) + [ -n "$d" ] && hit "$f — dangerous primitive in build config: $(echo "$d" | tr '\n' ';')" + if [[ "$f" == *.js || "$f" == *.ts || "$f" == *.mjs ]]; then + local p; if ! p=$(check_post_export_code "$f"); then + hit "$f — executable code after module.exports/export default: $(echo "$p" | tr '\n' ';')" + fi + fi + fi + if [ "$(basename "$f")" = "package.json" ]; then + local s; s=$(grep -nE '"(pre|post)?(install|prepare)"[[:space:]]*:' "$f" 2>/dev/null | grep -E 'curl|wget|node[[:space:]]+-e|child_process|\beval\b|base64 -d|\| *sh' | head -3) + [ -n "$s" ] && hit "$f — suspicious package.json lifecycle script: $(echo "$s" | tr '\n' ';')" + fi + + # ---- Gate 2: leaked secrets ---- + if echo "$f" | grep -qE "$ALLOW_NAME_RE"; then : + elif echo "$f" | grep -qiE "$SENSITIVE_NAME_RE"; then + hit "$f — sensitive filename (don't commit; .gitignore it or use a secret manager)" + fi + [ "$(basename "$f")" = ".npmrc" ] && while IFS= read -r ln; do + [ -n "$ln" ] && hit "$f:${ln%%:*} — npmrc auth token/password" + done < <(grep -nE "$NPMRC_AUTH_RE" "$f" 2>/dev/null) + + echo "$f" | grep -qE "$PUBLIC_BY_DESIGN_RE" && continue + if file "$f" 2>/dev/null | grep -qiE 'text|json|xml|empty|ASCII|Unicode'; then + local rule name re ln lineno linetext + for rule in "${CONTENT_RULES[@]}"; do + name="${rule%%::*}"; re="${rule#*::}" + while IFS= read -r ln; do + [ -z "$ln" ] && continue; lineno="${ln%%:*}"; linetext="${ln#*:}" + echo "$linetext" | grep -qiE "$PLACEHOLDER_RE" && continue + echo "$linetext" | grep -qE "$ALLOW_MARKER" && continue + echo "$linetext" | grep -qE "$SCHEMA_DECL_RE" && continue # type/schema decl, not a value + hit "$f:$lineno — secret shape: $name" + done < <(grep -nE "$re" "$f" 2>/dev/null) + done + while IFS= read -r ln; do + [ -z "$ln" ] && continue; lineno="${ln%%:*}"; linetext="${ln#*:}" + echo "$linetext" | grep -qiE "$PLACEHOLDER_RE" && continue + echo "$linetext" | grep -qE "$ALLOW_MARKER" && continue + if echo "$linetext" | grep -qiE "$EVM_PK_CONTEXT_RE" && ! echo "$linetext" | grep -qiE "$EVM_PK_EXCLUDE_RE"; then + hit "$f:$lineno — secret shape: evm_private_key" + fi + done < <(grep -nE "$EVM_PK_RE" "$f" 2>/dev/null) + fi + done + echo " ($checked file(s) checked)" +} + +# ---------------------------------------------------------------------------- +# Commands +# ---------------------------------------------------------------------------- +cmd_scan() { + local list + case "${1:-}" in + --files) shift; list="$(printf '%s\n' "$@")" ;; + --staged) list="$(git diff --cached --name-only --diff-filter=ACM)" ;; + *) list="$(git ls-files)" ;; + esac + echo "=== security.sh scan ===" + # NOTE: use process substitution, NOT a pipe. `... | scan_files` runs + # scan_files in a subshell, so its `findings++` is lost and the exit code is + # always 0 — silently disabling the gate. `< <(...)` keeps it in this shell. + scan_files < <(echo "$list" | sort -u) + if [ "$findings" -gt 0 ]; then + echo "RESULT: 🔴 $findings finding(s). (secret values are not shown — inspect file:line.)" + return 1 + fi + echo "RESULT: ✅ clean"; return 0 +} + +cmd_hook() { # pre-commit: scan only staged + cmd_scan --staged && return 0 + echo "" + echo "🚫 commit blocked by scripts/security.sh (see above)." + echo " real issue → fix it (rotate any real secret). false positive → add a" + echo " '# security.sh:allow' comment on that line, or 'git commit --no-verify'." + return 1 +} + +cmd_install() { + local hook="$REPO_ROOT/.git/hooks/pre-commit" + cat > "$hook" < $hook" + echo " it runs: bash $SELF hook" +} + +cmd_help() { + sed -n '2,40p' "$REPO_ROOT/$SELF" | sed 's/^# \{0,1\}//' +} + +case "${1:-help}" in + scan) shift; cmd_scan "$@" ;; + hook) cmd_hook ;; + install) cmd_install ;; + version|--version|-v) echo "security.sh v$SECURITY_SH_VERSION (source: github.com/RozoAI/rozo-security)" ;; + help|-h|--help) cmd_help ;; + *) echo "unknown command: $1" >&2; echo "try: bash $SELF help" >&2; exit 2 ;; +esac