diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..690400a --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,118 @@ +name: Security + +on: + pull_request: + types: [opened, synchronize, reopened] + push: + branches: [main, dev, stg] + +permissions: + contents: read + security-events: write # CodeQL needs this to upload SARIF results + +jobs: + + # ── 1. Dependency audit ──────────────────────────────────────────────────── + # Blocks on HIGH/CRITICAL in production dependencies (what ships to users). + # Dev-only vulns (vitest, esbuild) are reported but do not fail the build. + audit: + name: Dependency Audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-node@v5 + with: + node-version: 22 + cache: 'npm' + + - run: npm ci + + - name: Audit production dependencies (blocking) + run: npm audit --omit=dev --audit-level=high + + - name: Audit all dependencies (informational) + run: npm audit --audit-level=high || true + + # ── 2. Dependency review on PRs ─────────────────────────────────────────── + # Blocks PRs that introduce new vulnerable packages. + # Uses GITHUB_TOKEN automatically — no external API key needed. + dependency-review: + name: Dependency Review + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + steps: + - uses: actions/checkout@v5 + - uses: actions/dependency-review-action@v4 + with: + fail-on-severity: high + + # ── 3. CodeQL static analysis ───────────────────────────────────────────── + # Catches classes of bugs the linter/typechecker miss: + # CWE-22 path traversal (F-003, F-009) + # CWE-918 SSRF (F-002) + # CWE-73 external file path control (F-008) + # CWE-116 improper output encoding (F-004, F-006) + # Free for public repos. Uses GITHUB_TOKEN — no external key. + codeql: + name: CodeQL + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-node@v5 + with: + node-version: 22 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: javascript-typescript + queries: security-extended + + - run: npm ci && npm run build + + - name: Analyze + uses: github/codeql-action/analyze@v3 + with: + category: '/language:javascript-typescript' + + # ── 4. ESLint with security rules ───────────────────────────────────────── + # Runs eslint-plugin-security against src/ using eslint.security.config.mjs. + # Separate from the main lint job so security findings surface distinctly. + # No API key — pure local analysis. + lint-security: + name: ESLint Security + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-node@v5 + with: + node-version: 22 + cache: 'npm' + + - run: npm ci + + - name: Install security plugin + run: npm install --no-save eslint-plugin-security + + - name: Run security lint + run: npx eslint src/ --config eslint.security.config.mjs --format stylish + + # ── 5. Secret scanning ──────────────────────────────────────────────────── + # Scans git history for accidentally committed secrets (API keys, tokens). + # gitleaks is open source, no account or API key required. + secret-scan: + name: Secret Scan + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 # full history needed for git log scan + + - name: Scan for secrets with gitleaks + uses: gitleaks/gitleaks-action@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # GITLEAKS_LICENSE not set — free mode scans public repos without limit diff --git a/.gitignore b/.gitignore index 7d313bf..5872d35 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ test/dev-e2e/.env.local # Per-session Claude Code worktrees (parallel-session scratch space). Other # `.claude/` content (e.g., `skills/`) stays tracked. .claude/worktrees/ +sandbox/ diff --git a/eslint.config.mjs b/eslint.config.mjs index e415e8c..ca3a170 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -5,7 +5,7 @@ import globals from 'globals'; export default tseslint.config( { - ignores: ['dist/**', 'coverage/**', 'node_modules/**', 'perf/**', '.claude/worktrees/**'], + ignores: ['dist/**', 'coverage/**', 'node_modules/**', 'perf/**', '.claude/worktrees/**', 'sandbox/**'], }, js.configs.recommended, ...tseslint.configs.recommended, diff --git a/eslint.security.config.mjs b/eslint.security.config.mjs new file mode 100644 index 0000000..d06a439 --- /dev/null +++ b/eslint.security.config.mjs @@ -0,0 +1,39 @@ +/** + * Security-focused ESLint config used by CI security job only. + * Run: npx eslint src/ --config eslint.security.config.mjs + * + * Requires eslint-plugin-security to be installed: + * npm install --no-save eslint-plugin-security + */ +import security from 'eslint-plugin-security'; +import tseslint from 'typescript-eslint'; +import globals from 'globals'; + +export default tseslint.config( + { + ignores: ['dist/**', 'coverage/**', 'node_modules/**', 'sandbox/**'], + }, + security.configs.recommended, + { + languageOptions: { + ecmaVersion: 2022, + sourceType: 'module', + globals: { ...globals.node }, + }, + rules: { + // Block non-literal paths in fs calls — catches CWE-22, CWE-73 + 'security/detect-non-literal-fs-filename': 'error', + // Warn on object injection via bracket notation with user input + 'security/detect-object-injection': 'warn', + // Warn on timing-unsafe comparisons (token equality checks) + 'security/detect-possible-timing-attacks': 'warn', + // Error on non-literal RegExp (ReDoS) + 'security/detect-non-literal-regexp': 'warn', + // Error on child_process with non-literal args + 'security/detect-child-process': 'error', + // Disable rules that generate too much noise for a CLI codebase + 'security/detect-non-literal-require': 'off', + 'security/detect-unsafe-regex': 'warn', + }, + }, +);