CI security architecture and maintenance guide for FoxSchema contributors.
Pull Request opened / pushed
│
├─► dependency-security.yml
│ ├─ npm audit --audit-level=critical ←── HARD BLOCK
│ ├─ npm audit --audit-level=high ←── warn only
│ └─ ESLint security rules ←── HARD BLOCK on error
│
└─► secret-scan.yml (diff mode) ←── HARD BLOCK
Push to main / Weekly Monday 01:00 UTC
└─► secret-scan.yml (full history)
Push to main / Weekly Monday 02:00 UTC
└─► codeql.yml → GitHub Security tab
Tag push v*
├─► release-gate.yml
│ ├─ unit tests
│ ├─ ESLint security
│ ├─ npm audit critical
│ └─ Gitleaks full scan
│
├─► web-release.yml → single Docker image (linux/amd64, includes Db2)
│ → GHCR + Docker Hub (:latest / :vX.Y.Z)
│
└─► npm-publish.yml → public `foxschema` CLI package
| Check | Failure behavior | Notes |
|---|---|---|
npm audit --audit-level=critical |
Blocks PR | Production deps only (--omit=dev) |
npm audit --audit-level=high |
Warning only | devDep false positives are common |
| ESLint security rules | Blocks PR | --max-warnings 0 enforced |
Why --omit=dev for critical? Test tools (vitest, selenium-webdriver, esbuild) often carry high/critical advisories for code paths that are never reachable in production. Without --omit=dev, false positives block developer PRs for issues with no real attack surface.
Why not --omit=dev for the high check? The soft warning runs without --omit=dev so developers see the full picture in the artifact report.
Lockfile note: package-lock.json is committed (see .gitignore). The
audit job runs npm ci --ignore-scripts so it uses that graph rather than
resolving fresh. Do not delete the lockfile from the repo.
Uses Gitleaks v8, pinned to a specific version for reproducibility.
- PR runs:
--log-opts "origin/main...HEAD"— scans only commits introduced by the PR. Fast (~5 s). - Push/schedule runs: full repository history scan. Slower (~30 s).
Findings are uploaded as SARIF to the GitHub Security tab.
Allowlisted paths: apps/e2e/.env — local Docker dev credentials, explicitly labelled non-production. See .gitleaks.toml for the full allowlist with rationale.
Adding a new allowlist entry:
# .gitleaks.toml
[allowlist]
paths = [
'''path/to/known-safe-file''',
]Always document why the path is safe in the comment.
Runs on push to main and weekly on Monday at 02:00 UTC. Uses github/codeql-action/*@v4 (Node 24 runtime). Not a PR gate — CodeQL takes 5–15 minutes and would slow developer feedback loops unacceptably.
Detects:
- SQL injection (
security/sql-injection) - Path traversal (
security/path-injection) - Prototype pollution (
security/prototype-pollution) - Hardcoded credentials (
security/hardcoded-credentials) - Command injection (
security/command-injection)
Findings appear in the GitHub Security tab automatically.
To dismiss a false positive: open the finding in the Security tab → mark as "False positive" with a justification. This persists across scans.
Triggered by a tag push matching v*.
# Create and push a release tag
git tag v0.0.12
git push origin v0.0.12If all 5 gates pass, a draft GitHub Release is created automatically with auto-generated release notes. Edit the draft in the GitHub UI, then publish.
If any gate fails: fix the issue, delete the tag, re-tag.
git tag -d v0.0.12
git push origin :refs/tags/v0.0.12
# fix the issue, then re-tag
git tag v0.0.12
git push origin v0.0.12Configured in eslint.config.js at the repo root.
| Rule | Severity | Detects |
|---|---|---|
security/detect-child-process |
error | exec()/spawn() with non-literal args |
security/detect-eval-with-expression |
error | eval(expr) / new Function(expr) |
security/detect-unsafe-regex |
error | Catastrophic backtracking (ReDoS) |
security/detect-non-literal-fs-filename |
warn | fs.readFile(variable) |
security/detect-object-injection is deliberately omitted — it fires on every obj[key] access and produces an extreme false-positive rate in the dialect/registry code.
To suppress a specific line, state the reason in the directive itself, after
--. ESLint treats the text after -- as part of the directive, so it travels
with the suppression and shows up in eslint --format json:
// eslint-disable-next-line security/detect-non-literal-fs-filename -- resolvedPath is validated by validateFilePath() above
fs.readFileSync(resolvedPath);A suppression with no reason is not reviewable. eslint.config.js sets
reportUnusedDisableDirectives: 'error', so one that stops suppressing anything
fails the build rather than lingering — five had accumulated, three of them for
rules that were never installed.
Run locally:
npm run lint # everything: security rules are errors, quality rules are warnings
npm run lint:security # security rules only, zero tolerance — the gate CI requireslint:security uses eslint.security.config.js, which runs the four security
rules and nothing else. It is a separate config on purpose: the everyday config
carries quality rules as warnings, and while lint:security was defined as
eslint . --max-warnings 0 over that config it counted those too, sat at 230
warnings, and could not pass — so CI ran plain eslint . instead and this
document's claim that the two matched was not true. Both are wired into
build-gate.yml now.
- Check Gitleaks releases for a new version.
- Update
GITLEAKS_VERSIONin.github/workflows/secret-scan.ymland.github/workflows/release-gate.yml. - Test locally:
docker run --rm -v $(pwd):/repo ghcr.io/gitleaks/gitleaks:v8.21.2 detect --source /repo.
- Check if the vulnerable package is a direct or transitive dependency:
npm why <package>. - If it's a devDependency with no production attack surface, add a note and keep the
--omit=devflag. - If it's a production dependency: update via
npm update <package>or pin a safe version inpackage.json. - If no fix exists: open a GitHub advisory discussion and add a risk-accepted comment in the PR.
Forced nested pins live in the root package.json overrides (do not take npm audit fix --force for these):
dompurify@3.4.15andmonaco-editor@0.57.0— Monaco ships an older DOMPurify; the override forces the patched one. The two pins must matchapps/web/package.jsonand the lockfile, ornpm cirefuses to install (a Dependabot bump that moved only one of them broke CI on 2026-09-29).adm-zip@0.6.0—ibm_db@4still depends on^0.5.16; the suggested “fix” is a downgrade toibm_db@3.1.0.
Real secret found:
- Rotate the secret immediately — assume it is compromised.
- Remove from git history:
git filter-repo --path <file> --invert-paths(or BFG Repo Cleaner). - Force-push and notify all team members to re-clone.
False positive:
- Add to
.gitleaks.tomlallowlist with a rationale comment. - Never add a broad regex that could suppress real findings.
- Open the finding in the GitHub Security tab.
- If real: create a fix PR. Reference the CodeQL alert ID in the PR body.
- If false positive: click "Dismiss alert" → "False positive" → add justification text. CodeQL won't re-surface dismissed alerts on future scans.