diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1c5d449..a87fbab 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,6 +42,9 @@ jobs: - name: Test run: bun test + - name: Build + run: bun run build + - name: Bump version id: version run: | diff --git a/README.md b/README.md index fbf5791..5c458f8 100644 --- a/README.md +++ b/README.md @@ -4,15 +4,17 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Powered by OSV](https://img.shields.io/badge/powered%20by-OSV-4285F4.svg)](https://osv.dev) [![Powered by Snyk](https://img.shields.io/badge/powered%20by-Snyk-4C4A73.svg)](https://snyk.io) +![CodeRabbit Pull Request Reviews](https://img.shields.io/coderabbit/prs/github/muneebs/bun-security-scanner?utm_source=oss&utm_medium=github&utm_campaign=muneebs%2Fbun-security-scanner&labelColor=171717&color=FF570A&link=https%3A%2F%2Fcoderabbit.ai&label=CodeRabbit+Reviews) A [Bun security scanner](https://bun.com/docs/pm/security-scanner-api) that checks your dependencies against vulnerability databases before they get installed. Uses [Google's OSV database](https://osv.dev) by default — no API keys required. - 🔍 **Automatic scanning**: runs transparently on every `bun install` -- ⚡ **Fast**: 24-hour per-package lockfile cache means repeat installs skip the network entirely +- ⚡ **Fast**: per-package lockfile cache (24h by default, configurable) means repeat installs skip the network entirely - 🔀 **Two backends**: OSV (free, no setup) or Snyk (commercial, broader coverage) - 🔒 **Fail-open by default**: a downed API never blocks your install -- 🎯 **CVSS fallback**: uses score-based severity when a label isn't available -- 🛠️ **Configurable**: tune behaviour via environment variables +- 🎯 **CVSS fallback**: falls back to score-based severity when a label isn't available +- 🙈 **Ignore file**: suppress false positives and accepted risks with `.bun-security-ignore` +- ⚙️ **Configurable**: tune behaviour via environment variables --- @@ -31,15 +33,6 @@ scanner = "@nebzdev/bun-security-scanner" That's it. The scanner runs automatically on the next `bun install`. -### Local development - -Point `bunfig.toml` directly at the entry file using an absolute or relative path: - -```toml -[install.security] -scanner = "../bun-osv-scanner/src/index.ts" -``` - --- ## 🔀 Backends @@ -78,10 +71,10 @@ SNYK_ORG_ID=your-org-id When `bun install` runs, Bun calls the scanner with the full list of packages to be installed. The scanner: -1. **Filters** non-resolvable versions — workspace, git, file, and path dependencies are skipped -2. **Checks the cache** — packages seen within the last 24 hours skip the network entirely -3. **Queries the backend** for any uncached packages -4. **Returns advisories** to Bun, which surfaces them as warnings or fatal errors +1. Filters non-resolvable versions — workspace, git, file, and path dependencies are skipped +2. Checks the cache — packages seen within the cache TTL (24h by default) skip the network entirely +3. Queries the backend for any uncached packages +4. Returns advisories to Bun, which surfaces them as warnings or fatal errors --- @@ -89,7 +82,7 @@ When `bun install` runs, Bun calls the scanner with the full list of packages to | Level | Trigger | Bun behaviour | |-------|---------|---------------| -| `fatal` | CRITICAL or HIGH severity; or CVSS score ≥ 7.0 | Installation halts | +| `fatal` | CRITICAL or HIGH severity; or CVSS score >= 7.0 | Installation halts | | `warn` | MODERATE or LOW severity; or CVSS score < 7.0 | User is prompted; auto-cancelled in CI | --- @@ -111,8 +104,10 @@ All options are set via environment variables — in your shell, or in a `.env` | `OSV_FAIL_CLOSED` | `false` | Throw on network error instead of failing open | | `OSV_NO_CACHE` | `false` | Always query OSV fresh, bypassing the local cache | | `OSV_CACHE_FILE` | `.osv.lock` | Path to the cache file | +| `OSV_CACHE_TTL_MS` | `86400000` | Cache TTL in milliseconds (default: 24 hours) | | `OSV_TIMEOUT_MS` | `10000` | Per-request timeout in milliseconds | | `OSV_API_BASE` | `https://api.osv.dev/v1` | OSV API base URL | +| `OSV_NO_IGNORE` | `false` | Disable `.bun-security-ignore` processing | ### Snyk backend @@ -123,6 +118,7 @@ All options are set via environment variables — in your shell, or in a `.env` | `SNYK_FAIL_CLOSED` | `false` | Throw on network error instead of failing open | | `SNYK_NO_CACHE` | `false` | Always query Snyk fresh, bypassing the local cache | | `SNYK_CACHE_FILE` | `.snyk.lock` | Path to the cache file | +| `SNYK_CACHE_TTL_MS` | `86400000` | Cache TTL in milliseconds (default: 24 hours) | | `SNYK_TIMEOUT_MS` | `10000` | Per-request timeout in milliseconds | | `SNYK_RATE_LIMIT` | `160` | Max requests per minute (hard cap: 180) | | `SNYK_CONCURRENCY` | `10` | Max concurrent connections | @@ -131,10 +127,10 @@ All options are set via environment variables — in your shell, or in a `.env` ### Fail-open vs fail-closed -By default the scanner **fails open**: if the backend is unreachable the scan is skipped and installation proceeds normally. Set `OSV_FAIL_CLOSED=true` or `SNYK_FAIL_CLOSED=true` to invert this. +By default the scanner fails open: if the backend is unreachable the scan is skipped and installation proceeds normally. Set `OSV_FAIL_CLOSED=true` or `SNYK_FAIL_CLOSED=true` to invert this. ```sh -# .env — strict mode +# .env -- strict mode OSV_FAIL_CLOSED=true ``` @@ -142,14 +138,22 @@ OSV_FAIL_CLOSED=true ## 🗄️ Cache -Results are cached per `package@version` in a lock file at the project root with a 24-hour TTL. Because a published package version is immutable, its vulnerability profile is stable within that window. +Results are cached per `package@version` in a lock file at the project root. Because a published package version is immutable, its vulnerability profile is stable within the cache window. -| Backend | Lock file | -|---------|-----------| -| OSV | `.osv.lock` | -| Snyk | `.snyk.lock` | +| Backend | Lock file | TTL env var | +|---------|-----------|-------------| +| OSV | `.osv.lock` | `OSV_CACHE_TTL_MS` | +| Snyk | `.snyk.lock` | `SNYK_CACHE_TTL_MS` | -The files are designed to be **committed to git** — similar to a lockfile, committing them means your team and CI share the cache from day one without waiting for a warm-up scan. +The default TTL is 24 hours. In CI environments where cold-start scan time is a concern, increase it: + +```sh +# .env.ci +OSV_CACHE_TTL_MS=604800000 # 7 days +SNYK_CACHE_TTL_MS=604800000 # 7 days +``` + +The lock files are designed to be committed to git. Like a lockfile, committing them means your team and CI share the cache from day one without waiting for a warm-up scan. ```sh git add .osv.lock # or .snyk.lock @@ -165,14 +169,74 @@ SNYK_NO_CACHE=true bun install --- +## 🙈 Ignore file + +Not every advisory is actionable. A vulnerability may affect a code path your project doesn't use, have no fix available yet, or be a false positive. The `.bun-security-ignore` file lets you acknowledge these cases without blocking installs permanently. + +### Format + +```toml +# .bun-security-ignore + +[[ignore]] +package = "lodash" +advisories = ["GHSA-35jh-r3h4-6jhm"] +reason = "Only affects the cloneDeep path, which we do not use." +expires = "2026-12-31" # optional -- re-surfaces automatically after this date + +[[ignore]] +package = "minimist" +advisories = ["*"] # wildcard -- suppress all advisories for this package +reason = "Transitive only, no direct usage, no fix available." +``` + +### Behaviour + +| Advisory level | Session type | Effect | +|----------------|--------------|--------| +| `fatal` matched | Interactive (no `CI=true`, stdin is a TTY) | Downgraded to `warn` — visible in output but no longer blocks the install | +| `fatal` matched | CI / non-interactive | Suppressed entirely — logged to stderr but not returned | +| `warn` matched | Any | Suppressed entirely — logged to stderr but not returned | + +All suppressions are logged to stderr so they remain visible in CI output. Ignored advisories are never silently swallowed. + +- `expires` -- entries re-activate at UTC midnight on the given date, so you're reminded when to reassess +- `advisories = ["*"]` -- wildcard suppresses all advisories for the package +- `reason` -- encouraged but not required; a notice is printed to stderr if omitted +- `OSV_NO_IGNORE=true` -- disables all ignore file processing for strict environments +- `BUN_SECURITY_IGNORE_FILE` -- override the default `.bun-security-ignore` path + +### Environment variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `BUN_SECURITY_IGNORE_FILE` | `.bun-security-ignore` | Path to the ignore file | +| `OSV_NO_IGNORE` | `false` | Disable all ignore file processing | + +### Committing the file + +The ignore file should be committed alongside your lockfile. It documents deliberate risk-acceptance decisions for your whole team and CI. + +--- + ## 🛠️ Development ### Setup ```sh -git clone https://github.com/muneebs/bun-osv-scanner.git -cd bun-osv-scanner +git clone https://github.com/muneebs/bun-security-scanner.git +cd bun-security-scanner bun install +bunx lefthook install +``` + +### Local development + +Point `bunfig.toml` directly at the entry file using an absolute or relative path: + +```toml +[install.security] +scanner = "../bun-security-scanner/src/index.ts" ``` ### Commands @@ -189,17 +253,20 @@ bun run check:write # Lint + format, auto-fix what it can ### Project structure ``` -bun-osv-scanner/ +bun-security-scanner/ ├── src/ │ ├── __tests__/ # Test suite (bun:test) │ ├── snyk/ # Snyk backend -│ ├── cache.ts # 24h lockfile cache +│ ├── cache.ts # Lockfile cache (configurable TTL) │ ├── client.ts # OSV API client │ ├── config.ts # OSV constants and env vars │ ├── display.ts # TTY progress spinner -│ ├── index.ts # Entry point — dispatches to OSV or Snyk +│ ├── ignore.ts # .bun-security-ignore loader and matcher +│ ├── index.ts # Entry point -- dispatches to OSV or Snyk │ ├── osv.ts # OSV scanner implementation +│ ├── scanner.ts # Shared scanner factory (cache + ignore orchestration) │ └── severity.ts # OSV level classification +├── dist/ # Compiled output (published to npm) ├── bunfig.toml └── package.json ``` @@ -218,7 +285,7 @@ bun-osv-scanner/ ## ⚠️ Limitations - Only scans npm packages with concrete semver versions. `workspace:`, `file:`, `git:`, and range-only specifiers are skipped. -- OSV aggregates GitHub Advisory, NVD, and other feeds — coverage may lag slightly behind a vulnerability's public disclosure. +- OSV aggregates GitHub Advisory, NVD, and other feeds, so coverage may lag slightly behind a vulnerability's public disclosure. - The OSV batch API has a hard limit of 1,000 queries per request. Larger projects are split across multiple requests automatically. - Snyk's per-package endpoint is rate-limited to 180 req/min. At that rate, a project with 2,000+ packages will take several minutes on the first scan. @@ -233,6 +300,6 @@ MIT © [Muneeb Samuels](https://github.com/muneebs) ## 🔗 Links - [📦 npm](https://www.npmjs.com/package/@nebzdev/bun-security-scanner) -- [🐛 Issue tracker](https://github.com/muneebs/bun-osv-scanner/issues) +- [🐛 Issue tracker](https://github.com/muneebs/bun-security-scanner/issues) - [🔍 OSV database](https://osv.dev) -- [📖 Bun security scanner docs](https://bun.com/docs/pm/security-scanner-api) +- [📖 Bun security scanner docs](https://bun.com/docs/pm/security-scanner-api) \ No newline at end of file diff --git a/build.ts b/build.ts new file mode 100644 index 0000000..e03900e --- /dev/null +++ b/build.ts @@ -0,0 +1,58 @@ +import { rmSync } from 'node:fs'; + +const c = { + reset: '\x1b[0m', + bold: '\x1b[1m', + dim: '\x1b[2m', + cyan: '\x1b[36m', + green: '\x1b[32m', + red: '\x1b[31m', + white: '\x1b[37m', + bgRed: '\x1b[41m', +} as const; + +const pkg = await Bun.file('./package.json').json(); +const version = pkg.version as string; +const buildTime = new Date().toISOString(); + +console.log( + `\n ${c.cyan}${c.bold}@nebzdev/bun-security-scanner${c.reset} ${c.dim}v${version}${c.reset}\n` +); + +// Clean previous output +rmSync('./dist', { recursive: true, force: true }); + +const start = performance.now(); +process.stdout.write(` ${c.cyan}▸${c.reset} Bundling...`); + +const result = await Bun.build({ + entrypoints: ['./src/index.ts'], + outdir: './dist', + naming: 'index.js', + target: 'bun', + format: 'esm', + minify: true, +}); + +const elapsed = ((performance.now() - start) / 1000).toFixed(2); + +if (!result.success) { + console.log(` ${c.red}${c.bold}FAILED${c.reset}\n`); + for (const log of result.logs) { + console.error(` ${c.red}✗${c.reset} ${log}`); + } + console.error(`\n ${c.bgRed}${c.white}${c.bold} BUILD FAILED ${c.reset}\n`); + process.exit(1); +} + +const sizeKB = (Bun.file('./dist/index.js').size / 1024).toFixed(1); + +console.log( + ` ${c.green}${c.bold}done${c.reset} ${c.dim}(${elapsed}s)${c.reset}` +); +console.log(` + ${c.green}${c.bold}✓ Build succeeded${c.reset} + + ${c.dim}Output${c.reset} ${c.white}dist/index.js${c.reset} ${c.dim}(${sizeKB} KB)${c.reset} + ${c.dim}Built at${c.reset} ${c.white}${buildTime}${c.reset} +`); diff --git a/bun.lock b/bun.lock index 24e7e23..4bcc5dd 100644 --- a/bun.lock +++ b/bun.lock @@ -7,6 +7,7 @@ "devDependencies": { "@biomejs/biome": "2.4.10", "@types/bun": "latest", + "lefthook": "^2.1.4", "typescript": "^5.0.0", }, }, @@ -36,6 +37,28 @@ "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + "lefthook": ["lefthook@2.1.4", "", { "optionalDependencies": { "lefthook-darwin-arm64": "2.1.4", "lefthook-darwin-x64": "2.1.4", "lefthook-freebsd-arm64": "2.1.4", "lefthook-freebsd-x64": "2.1.4", "lefthook-linux-arm64": "2.1.4", "lefthook-linux-x64": "2.1.4", "lefthook-openbsd-arm64": "2.1.4", "lefthook-openbsd-x64": "2.1.4", "lefthook-windows-arm64": "2.1.4", "lefthook-windows-x64": "2.1.4" }, "bin": { "lefthook": "bin/index.js" } }, "sha512-JNfJ5gAn0KADvJ1I6/xMcx70+/6TL6U9gqGkKvPw5RNMfatC7jIg0Evl97HN846xmfz959BV70l8r3QsBJk30w=="], + + "lefthook-darwin-arm64": ["lefthook-darwin-arm64@2.1.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BUAAE9+rUrjr39a+wH/1zHmGrDdwUQ2Yq/z6BQbM/yUb9qtXBRcQ5eOXxApqWW177VhGBpX31aqIlfAZ5Q7wzw=="], + + "lefthook-darwin-x64": ["lefthook-darwin-x64@2.1.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-K1ncIMEe84fe+ss1hQNO7rIvqiKy2TJvTFpkypvqFodT7mJXZn7GLKYTIXdIuyPAYthRa9DwFnx5uMoHwD2F1Q=="], + + "lefthook-freebsd-arm64": ["lefthook-freebsd-arm64@2.1.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-PVUhjOhVN71YaYsVdQyNbFZ4a2jFB2Tg5hKrrn9kaWpx64aLz/XivLjwr8sEuTaP1GRlEWBpW6Bhrcsyo39qFw=="], + + "lefthook-freebsd-x64": ["lefthook-freebsd-x64@2.1.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-ZWV9o/LeyWNEBoVO+BhLqxH3rGTba05nkm5NvMjEFSj7LbUNUDbQmupZwtHl1OMGJO66eZP0CalzRfUH6GhBxQ=="], + + "lefthook-linux-arm64": ["lefthook-linux-arm64@2.1.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-iWN0pGnTjrIvNIcSI1vQBJXUbybTqJ5CLMniPA0olabMXQfPDrdMKVQe+mgdwHK+E3/Y0H0ZNL3lnOj6Sk6szA=="], + + "lefthook-linux-x64": ["lefthook-linux-x64@2.1.4", "", { "os": "linux", "cpu": "x64" }, "sha512-96bTBE/JdYgqWYAJDh+/e/0MaxJ25XTOAk7iy/fKoZ1ugf6S0W9bEFbnCFNooXOcxNVTan5xWKfcjJmPIKtsJA=="], + + "lefthook-openbsd-arm64": ["lefthook-openbsd-arm64@2.1.4", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-oYUoK6AIJNEr9lUSpIMj6g7sWzotvtc3ryw7yoOyQM6uqmEduw73URV/qGoUcm4nqqmR93ZalZwR2r3Gd61zvw=="], + + "lefthook-openbsd-x64": ["lefthook-openbsd-x64@2.1.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/Dv9Jcm68y9cggr1PhyUhOabBGP9+hzQPoiyOhKks7y9qrJl79A8XfG6LHekSuYc2VpiSu5wdnnrE1cj2nfTg=="], + + "lefthook-windows-arm64": ["lefthook-windows-arm64@2.1.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-hSww7z+QX4YMnw2lK7DMrs3+w7NtxksuMKOkCKGyxUAC/0m1LAICo0ZbtdDtZ7agxRQQQ/SEbzFRhU5ysNcbjA=="], + + "lefthook-windows-x64": ["lefthook-windows-x64@2.1.4", "", { "os": "win32", "cpu": "x64" }, "sha512-eE68LwnogxwcPgGsbVGPGxmghyMGmU9SdGwcc+uhGnUxPz1jL89oECMWJNc36zjVK24umNeDAzB5KA3lw1MuWw=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 0000000..cf0f588 --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,44 @@ +# EXAMPLE USAGE: +# +# Refer for explanation to following link: +# https://lefthook.dev/configuration/ +# +# pre-push: +# jobs: +# - name: packages audit +# tags: +# - frontend +# - security +# run: yarn audit +# +# - name: gems audit +# tags: +# - backend +# - security +# run: bundle audit +# +pre-push: + parallel: true + jobs: + - name: packages audit + tags: + - security + run: bun audit + glob: "bun.lock" +pre-commit: + parallel: true + fail_on_changes: "always" + jobs: + - name: format + run: bunx biome format --write {staged_files} + glob: "*.{js,ts,jsx,tsx}" + stage_fixed: true + - name: lint + run: bunx biome lint {staged_files} + glob: "*.{js,ts,jsx,tsx}" + - name: type-check + run: bun type-check + glob: "*.{js,ts,jsx,tsx}" + - name: test + run: bun test + glob: "*.{js,ts,jsx,tsx}" \ No newline at end of file diff --git a/package.json b/package.json index bac243a..9e92cb9 100644 --- a/package.json +++ b/package.json @@ -6,11 +6,10 @@ "license": "MIT", "exports": { "./package.json": "./package.json", - ".": "./src/index.ts" + ".": "./dist/index.js" }, "files": [ - "src", - "!src/__tests__", + "dist", "README.md" ], "keywords": [ @@ -22,16 +21,19 @@ "vulnerability" ], "scripts": { - "publish:npm": "npm publish --access public", + "build": "bun run build.ts", + "publish:npm": "bun run build && npm publish --access public", "lint": "biome lint ./src", "format": "biome format ./src", "format:write": "biome format --write ./src", "check": "biome check ./src", - "check:write": "biome check --write ./src" + "check:write": "biome check --write ./src", + "type-check": "tsc --noEmit" }, "devDependencies": { "@biomejs/biome": "2.4.10", "@types/bun": "latest", + "lefthook": "^2.1.4", "typescript": "^5.0.0" } } diff --git a/src/__tests__/ignore.test.ts b/src/__tests__/ignore.test.ts new file mode 100644 index 0000000..a42b416 --- /dev/null +++ b/src/__tests__/ignore.test.ts @@ -0,0 +1,359 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test'; +import { applyIgnoreList, type IgnoreList, loadIgnoreList } from '../ignore'; + +// ── helpers ────────────────────────────────────────────────────────────────── + +function advisory( + pkg: string, + level: 'fatal' | 'warn', + url: string +): Bun.Security.Advisory { + return { + package: pkg, + level, + description: 'Test advisory', + url, + }; +} + +function ignoreList(...entries: IgnoreList['entries']): IgnoreList { + return { entries }; +} + +const FUTURE = '2099-12-31'; +const PAST = '2000-01-01'; + +// ── applyIgnoreList ─────────────────────────────────────────────────────────── + +describe('applyIgnoreList', () => { + test('keeps advisory when ignore list is empty', () => { + const result = applyIgnoreList( + advisory( + 'lodash', + 'fatal', + 'https://ghsa.github.com/advisories/GHSA-aaa-bbb-cccc' + ), + ignoreList() + ); + expect(result.action).toBe('keep'); + }); + + test('keeps advisory when package name does not match', () => { + const result = applyIgnoreList( + advisory( + 'lodash', + 'fatal', + 'https://ghsa.github.com/advisories/GHSA-aaa-bbb-cccc' + ), + ignoreList({ + package: 'minimist', + advisories: ['GHSA-aaa-bbb-cccc'], + reason: 'not our package', + }) + ); + expect(result.action).toBe('keep'); + }); + + test('keeps advisory when advisory ID does not match', () => { + const result = applyIgnoreList( + advisory( + 'lodash', + 'fatal', + 'https://ghsa.github.com/advisories/GHSA-aaa-bbb-cccc' + ), + ignoreList({ + package: 'lodash', + advisories: ['GHSA-xxx-yyy-zzzz'], + reason: 'different advisory', + }) + ); + expect(result.action).toBe('keep'); + }); + + test('downgrades fatal advisory to warn when matched', () => { + const result = applyIgnoreList( + advisory( + 'lodash', + 'fatal', + 'https://ghsa.github.com/advisories/GHSA-aaa-bbb-cccc' + ), + ignoreList({ + package: 'lodash', + advisories: ['GHSA-aaa-bbb-cccc'], + reason: 'only affects cloneDeep', + }) + ); + expect(result.action).toBe('downgrade'); + if (result.action === 'downgrade') { + expect(result.reason).toBe('only affects cloneDeep'); + } + }); + + test('drops warn advisory when matched', () => { + const result = applyIgnoreList( + advisory( + 'lodash', + 'warn', + 'https://ghsa.github.com/advisories/GHSA-aaa-bbb-cccc' + ), + ignoreList({ + package: 'lodash', + advisories: ['GHSA-aaa-bbb-cccc'], + reason: 'transitive only', + }) + ); + expect(result.action).toBe('drop'); + if (result.action === 'drop') { + expect(result.reason).toBe('transitive only'); + } + }); + + test('wildcard matches any advisory ID for the package', () => { + const result = applyIgnoreList( + advisory( + 'minimist', + 'fatal', + 'https://nvd.nist.gov/vuln/detail/CVE-2021-44906' + ), + ignoreList({ + package: 'minimist', + advisories: ['*'], + reason: 'transitive only', + }) + ); + expect(result.action).toBe('downgrade'); + }); + + test('matching is case-insensitive for advisory IDs', () => { + const result = applyIgnoreList( + advisory( + 'lodash', + 'fatal', + 'https://ghsa.github.com/advisories/GHSA-aaa-bbb-cccc' + ), + ignoreList({ + package: 'lodash', + advisories: ['ghsa-aaa-bbb-cccc'], + reason: 'lowercase in ignore file', + }) + ); + expect(result.action).toBe('downgrade'); + }); + + test('keeps advisory when entry has expired', () => { + const result = applyIgnoreList( + advisory( + 'lodash', + 'fatal', + 'https://ghsa.github.com/advisories/GHSA-aaa-bbb-cccc' + ), + ignoreList({ + package: 'lodash', + advisories: ['GHSA-aaa-bbb-cccc'], + reason: 'was suppressed', + expires: PAST, + }) + ); + expect(result.action).toBe('keep'); + }); + + test('matches advisory when entry has a future expiry', () => { + const result = applyIgnoreList( + advisory( + 'lodash', + 'warn', + 'https://ghsa.github.com/advisories/GHSA-aaa-bbb-cccc' + ), + ignoreList({ + package: 'lodash', + advisories: ['GHSA-aaa-bbb-cccc'], + reason: 'still active', + expires: FUTURE, + }) + ); + expect(result.action).toBe('drop'); + }); + + test('uses fallback reason when entry has no reason', () => { + const result = applyIgnoreList( + advisory( + 'lodash', + 'fatal', + 'https://ghsa.github.com/advisories/GHSA-aaa-bbb-cccc' + ), + ignoreList({ + package: 'lodash', + advisories: ['GHSA-aaa-bbb-cccc'], + }) + ); + expect(result.action).toBe('downgrade'); + if (result.action === 'downgrade') { + expect(result.reason).toBe('(no reason provided)'); + } + }); + + test('extracts advisory ID from NVD URL', () => { + const result = applyIgnoreList( + advisory( + 'pkg', + 'fatal', + 'https://nvd.nist.gov/vuln/detail/CVE-2021-44906' + ), + ignoreList({ + package: 'pkg', + advisories: ['CVE-2021-44906'], + reason: 'accepted risk', + }) + ); + expect(result.action).toBe('downgrade'); + }); + + test('uses first matching entry', () => { + const result = applyIgnoreList( + advisory( + 'lodash', + 'fatal', + 'https://ghsa.github.com/advisories/GHSA-aaa-bbb-cccc' + ), + ignoreList( + { + package: 'lodash', + advisories: ['GHSA-aaa-bbb-cccc'], + reason: 'first entry', + }, + { + package: 'lodash', + advisories: ['*'], + reason: 'second entry', + } + ) + ); + expect(result.action).toBe('downgrade'); + if (result.action === 'downgrade') { + expect(result.reason).toBe('first entry'); + } + }); +}); + +// ── loadIgnoreList ──────────────────────────────────────────────────────────── + +describe('loadIgnoreList', () => { + let fileSpy: ReturnType>; + let ignoreFileContent: string | null = null; + + beforeEach(() => { + ignoreFileContent = null; + const origBunFile = Bun.file.bind(Bun); + fileSpy = spyOn(Bun, 'file'); + fileSpy.mockImplementation(((path: unknown, opts?: BlobPropertyBag) => { + if (path === '.bun-security-ignore') { + if (ignoreFileContent === null) { + return { + text: async () => { + throw new Error('ENOENT'); + }, + } as unknown as ReturnType; + } + const content = ignoreFileContent; + return { + text: async () => content, + } as unknown as ReturnType; + } + if (typeof path === 'string') { + return { + text: async () => { + throw new Error('ENOENT'); + }, + } as unknown as ReturnType; + } + return origBunFile(path as Parameters[0], opts); + }) as typeof Bun.file); + }); + + afterEach(() => { + fileSpy.mockRestore(); + }); + + test('returns empty list when ignore file does not exist', async () => { + // ignoreFileContent stays null → ENOENT + const list = await loadIgnoreList(); + expect(list.entries).toHaveLength(0); + }); + + test('parses a valid ignore file with one entry', async () => { + ignoreFileContent = ` +[[ignore]] +package = "lodash" +advisories = ["GHSA-aaa-bbb-cccc"] +reason = "cloneDeep not used" +expires = "${FUTURE}" +`; + + const list = await loadIgnoreList(); + expect(list.entries).toHaveLength(1); + expect(list.entries[0]).toMatchObject({ + package: 'lodash', + advisories: ['GHSA-aaa-bbb-cccc'], + reason: 'cloneDeep not used', + expires: FUTURE, + }); + }); + + test('parses multiple [[ignore]] entries', async () => { + ignoreFileContent = ` +[[ignore]] +package = "lodash" +advisories = ["GHSA-aaa-bbb-cccc"] +reason = "first" + +[[ignore]] +package = "minimist" +advisories = ["*"] +reason = "second" +`; + + const list = await loadIgnoreList(); + expect(list.entries).toHaveLength(2); + expect(list.entries[0].package).toBe('lodash'); + expect(list.entries[1].package).toBe('minimist'); + }); + + test('parses wildcard advisory', async () => { + ignoreFileContent = ` +[[ignore]] +package = "minimist" +advisories = ["*"] +reason = "transitive only" +`; + + const list = await loadIgnoreList(); + expect(list.entries[0]?.advisories).toEqual(['*']); + }); + + test('ignores comment lines and blank lines', async () => { + ignoreFileContent = ` +# This is a comment + +[[ignore]] +# Another comment +package = "lodash" +advisories = ["GHSA-aaa"] +reason = "ok" +`; + + const list = await loadIgnoreList(); + expect(list.entries).toHaveLength(1); + }); + + test('still returns entry when reason is missing', async () => { + ignoreFileContent = ` +[[ignore]] +package = "lodash" +advisories = ["GHSA-aaa"] +`; + + const list = await loadIgnoreList(); + expect(list.entries).toHaveLength(1); + expect(list.entries[0]?.reason).toBeUndefined(); + }); +}); diff --git a/src/__tests__/scanner.test.ts b/src/__tests__/scanner.test.ts index 4da03c9..576c956 100644 --- a/src/__tests__/scanner.test.ts +++ b/src/__tests__/scanner.test.ts @@ -221,3 +221,158 @@ describe('scanner.scan', () => { expect(advisories).toEqual([]); }); }); + +describe('scanner.scan — ignore file integration', () => { + let fetchSpy: ReturnType>; + let fileSpy: ReturnType>; + let writeSpy: ReturnType>; + let renameSpy: ReturnType>; + + const IGNORE_TOML = ` +[[ignore]] +package = "lodash" +advisories = ["GHSA-aaa-bbb-cccc"] +reason = "cloneDeep not used" + +[[ignore]] +package = "minimist" +advisories = ["*"] +reason = "transitive only" +`; + + beforeEach(() => { + fetchSpy = spyOn(globalThis, 'fetch'); + const origBunFile = Bun.file.bind(Bun); + fileSpy = spyOn(Bun, 'file'); + fileSpy.mockImplementation(((path: unknown, opts?: BlobPropertyBag) => { + if (path === '.bun-security-ignore') { + return { text: async () => IGNORE_TOML } as unknown as ReturnType< + typeof Bun.file + >; + } + if (typeof path === 'string') { + return { + text: async () => { + throw new Error('ENOENT'); + }, + } as unknown as ReturnType; + } + return origBunFile(path as Parameters[0], opts); + }) as typeof Bun.file); + writeSpy = spyOn(Bun, 'write'); + writeSpy.mockResolvedValue(0); + renameSpy = spyOn(fsPromises, 'rename'); + renameSpy.mockResolvedValue(undefined); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + fileSpy.mockRestore(); + writeSpy.mockRestore(); + renameSpy.mockRestore(); + }); + + test('drops fatal advisory in non-interactive mode when matched by ignore file', async () => { + const origCI = process.env.CI; + process.env.CI = 'true'; + try { + mockOsvResponses( + fetchSpy, + ['GHSA-aaa-bbb-cccc'], + [makeOsvVuln('GHSA-aaa-bbb-cccc', 'HIGH', 'Prototype Pollution')] + ); + + const advisories = await scanner.scan({ + packages: [pkg('lodash', '4.17.4')], + }); + + // CI mode: ignored fatal is dropped so the install proceeds. + expect(advisories).toHaveLength(0); + } finally { + if (origCI === undefined) delete process.env.CI; + else process.env.CI = origCI; + } + }); + + test('downgrades fatal advisory to warn in interactive mode when matched by ignore file', async () => { + const origCI = process.env.CI; + const origIsTTYDescriptor = Object.getOwnPropertyDescriptor( + process.stdin, + 'isTTY' + ); + // Simulate an interactive terminal session. + process.env.CI = 'false'; + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + configurable: true, + }); + + try { + mockOsvResponses( + fetchSpy, + ['GHSA-aaa-bbb-cccc'], + [makeOsvVuln('GHSA-aaa-bbb-cccc', 'HIGH', 'Prototype Pollution')] + ); + + const advisories = await scanner.scan({ + packages: [pkg('lodash', '4.17.4')], + }); + + expect(advisories).toHaveLength(1); + expect(advisories[0]?.level).toBe('warn'); + expect(advisories[0]?.package).toBe('lodash'); + } finally { + if (origCI === undefined) delete process.env.CI; + else process.env.CI = origCI; + if (origIsTTYDescriptor) { + Object.defineProperty(process.stdin, 'isTTY', origIsTTYDescriptor); + } else { + Reflect.deleteProperty(process.stdin, 'isTTY'); + } + } + }); + + test('drops warn advisory when matched by ignore file', async () => { + mockOsvResponses( + fetchSpy, + ['GHSA-aaa-bbb-cccc'], + [makeOsvVuln('GHSA-aaa-bbb-cccc', 'MODERATE', 'Prototype Pollution')] + ); + + const advisories = await scanner.scan({ + packages: [pkg('lodash', '4.17.4')], + }); + + expect(advisories).toHaveLength(0); + }); + + test('wildcard entry drops all advisories for the package', async () => { + mockOsvResponses( + fetchSpy, + ['GHSA-xxx-yyy-zzzz'], + [makeOsvVuln('GHSA-xxx-yyy-zzzz', 'MODERATE', 'Some vuln')] + ); + + const advisories = await scanner.scan({ + packages: [pkg('minimist', '1.2.5')], + }); + + expect(advisories).toHaveLength(0); + }); + + test('ignore entry does not affect other packages', async () => { + mockOsvResponses( + fetchSpy, + ['GHSA-aaa-bbb-cccc'], + [makeOsvVuln('GHSA-aaa-bbb-cccc', 'HIGH', 'Prototype Pollution')] + ); + + const advisories = await scanner.scan({ + packages: [pkg('express', '4.18.2')], + }); + + // "express" is not in the ignore list — advisory kept as fatal + expect(advisories).toHaveLength(1); + expect(advisories[0]?.level).toBe('fatal'); + }); +}); diff --git a/src/cache.ts b/src/cache.ts index 0bd49fe..dfff307 100644 --- a/src/cache.ts +++ b/src/cache.ts @@ -43,6 +43,6 @@ export async function writeCache( } catch {} } -export function isFresh(entry: CacheEntry): boolean { - return Date.now() - entry.cachedAt < CACHE_TTL_MS; +export function isFresh(entry: CacheEntry, ttl = CACHE_TTL_MS): boolean { + return Date.now() - entry.cachedAt < ttl; } diff --git a/src/config.ts b/src/config.ts index 80c0949..a6c5d95 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,7 +4,11 @@ export const OSV_BATCH_SIZE = 1000; export const FETCH_TIMEOUT_MS = Number(Bun.env.OSV_TIMEOUT_MS) || 10_000; export const PREFERRED_REF_TYPES = ['ADVISORY', 'WEB', 'ARTICLE'] as const; export const CACHE_FILE = Bun.env.OSV_CACHE_FILE ?? '.osv.lock'; -export const CACHE_TTL_MS = 24 * 60 * 60 * 1000; +const _osvCacheTtl = Number(Bun.env.OSV_CACHE_TTL_MS); +export const CACHE_TTL_MS = + Number.isFinite(_osvCacheTtl) && _osvCacheTtl >= 0 + ? _osvCacheTtl + : 24 * 60 * 60 * 1000; // When true, network failures throw and cancel installation rather than failing open. export const FAIL_CLOSED = Bun.env.OSV_FAIL_CLOSED === 'true'; diff --git a/src/ignore.ts b/src/ignore.ts new file mode 100644 index 0000000..d5e51dc --- /dev/null +++ b/src/ignore.ts @@ -0,0 +1,176 @@ +/** + * `.bun-security-ignore` loader and matcher. + * + * File format (TOML): + * + * ```toml + * [[ignore]] + * package = "lodash" + * advisories = ["GHSA-35jh-r3h4-6jhm"] + * reason = "Only affects cloneDeep, which we do not use." + * expires = "2026-12-31" # optional ISO date + * + * [[ignore]] + * package = "minimist" + * advisories = ["*"] # wildcard — suppresses all advisories for this package + * reason = "Transitive only, no direct usage, no fix available." + * ``` + * + * Behaviour: + * - `fatal` advisories matched by an active ignore entry are handled by mode: + * - Interactive (no `CI=true` env var, stdin is a TTY): downgraded to `warn`. + * - CI / non-interactive: suppressed entirely (logged to stderr, not returned). + * - `warn` advisories matched by an active ignore entry are dropped entirely. + * - All suppressions are logged to stderr regardless of mode. + * - Entries with an `expires` date re-activate after that date (UTC midnight). + * - A missing `reason` is accepted but a notice is printed to stderr. + * - `OSV_NO_IGNORE=true` disables all ignore file processing. + */ + +export interface IgnoreEntry { + package: string; + advisories: string[]; // advisory IDs or ["*"] wildcard + reason?: string; + expires?: string; // ISO date string "YYYY-MM-DD" +} + +export interface IgnoreList { + entries: IgnoreEntry[]; +} + +const IGNORE_FILE = Bun.env.BUN_SECURITY_IGNORE_FILE ?? '.bun-security-ignore'; +export const NO_IGNORE = Bun.env.OSV_NO_IGNORE === 'true'; + +// ── Parser ──────────────────────────────────────────────────────────────────── + +/** + * Parse `.bun-security-ignore` using Bun's built-in TOML parser. + * Handles the full TOML spec (inline comments, multiline strings, dates, etc.). + */ +function parseIgnoreToml(source: string): IgnoreEntry[] { + const parsed = Bun.TOML.parse(source) as Record; + const raw = parsed.ignore; + if (!Array.isArray(raw)) return []; + + const entries: IgnoreEntry[] = []; + + for (const item of raw) { + if (typeof item !== 'object' || item === null) continue; + const row = item as Record; + + if (typeof row.package !== 'string' || !row.package) continue; + + const advisories = Array.isArray(row.advisories) + ? (row.advisories as unknown[]).filter( + (a): a is string => typeof a === 'string' + ) + : []; + + const entry: IgnoreEntry = { package: row.package, advisories }; + + if (typeof row.reason === 'string') entry.reason = row.reason; + + // `expires` may be a quoted string or a bare TOML local date (parsed as Date). + if (typeof row.expires === 'string') { + entry.expires = row.expires; + } else if (row.expires instanceof Date) { + entry.expires = row.expires.toISOString().slice(0, 10); + } + + entries.push(entry); + } + + return entries; +} + +// ── Loader ──────────────────────────────────────────────────────────────────── + +export async function loadIgnoreList(): Promise { + if (NO_IGNORE) return { entries: [] }; + + let text: string; + try { + text = await Bun.file(IGNORE_FILE).text(); + } catch { + // File doesn't exist — that's fine + return { entries: [] }; + } + + let entries: IgnoreEntry[]; + try { + entries = parseIgnoreToml(text); + } catch (err) { + process.stderr.write( + `[@nebzdev/bun-security-scanner] Warning: failed to parse "${IGNORE_FILE}" — ${err instanceof Error ? err.message : err}. All ignore entries will be skipped.\n` + ); + return { entries: [] }; + } + + for (const entry of entries) { + if (!entry.reason) { + process.stderr.write( + `[@nebzdev/bun-security-scanner] Warning: ignore entry for "${entry.package}" has no reason — consider documenting why.\n` + ); + } + } + + return { entries }; +} + +// ── Matcher ─────────────────────────────────────────────────────────────────── + +/** + * Returns true if an ignore entry is still active (not yet expired). + */ +function isActive(entry: IgnoreEntry): boolean { + if (!entry.expires) return true; + const expiryDate = new Date(`${entry.expires}T00:00:00Z`); + return Date.now() < expiryDate.getTime(); +} + +/** + * Extract a normalised advisory ID from a URL. + * Handles NVD (CVE-...) and GitHub Advisory (GHSA-...) URL patterns. + */ +function extractId(url: string | null | undefined): string { + return url?.split('/').pop()?.toUpperCase() ?? ''; +} + +export type ApplyResult = + | { action: 'keep' } + | { action: 'downgrade'; reason: string } + | { action: 'drop'; reason: string }; + +/** + * Determine what to do with an advisory given the loaded ignore list. + * + * - `keep` — advisory is not ignored; return it as-is + * - `downgrade` — `fatal` advisory matched; return it as `warn` + * - `drop` — `warn` advisory matched; suppress it entirely + */ +export function applyIgnoreList( + advisory: Bun.Security.Advisory, + ignoreList: IgnoreList +): ApplyResult { + const advId = extractId(advisory.url); + + for (const entry of ignoreList.entries) { + if (entry.package !== advisory.package) continue; + if (!isActive(entry)) continue; + + const wildcard = entry.advisories.includes('*'); + const matched = + wildcard || entry.advisories.map((a) => a.toUpperCase()).includes(advId); + + if (!matched) continue; + + const reason = entry.reason ?? '(no reason provided)'; + + if (advisory.level === 'fatal') { + return { action: 'downgrade', reason }; + } + return { action: 'drop', reason }; + } + + return { action: 'keep' }; +} diff --git a/src/osv.ts b/src/osv.ts index a794ea5..9f6ab08 100644 --- a/src/osv.ts +++ b/src/osv.ts @@ -1,12 +1,13 @@ import type { OsvVulnerability } from './client'; import { batchQuery, fetchVuln } from './client'; -import { CACHE_FILE, FAIL_CLOSED, NO_CACHE } from './config'; +import { CACHE_FILE, CACHE_TTL_MS, FAIL_CLOSED, NO_CACHE } from './config'; import { type Backend, createScanner } from './scanner'; import { advisoryUrl, severityLevel } from './severity'; const backend: Backend = { name: 'OSV', cacheFile: CACHE_FILE, + ttl: CACHE_TTL_MS, noCache: NO_CACHE, failClosed: FAIL_CLOSED, diff --git a/src/scanner.ts b/src/scanner.ts index 890ded9..85168b6 100644 --- a/src/scanner.ts +++ b/src/scanner.ts @@ -1,10 +1,12 @@ import { isFresh, readCache, writeCache } from './cache'; import { isResolvable } from './client'; import { startSpinner } from './display'; +import { applyIgnoreList, type IgnoreList, loadIgnoreList } from './ignore'; export interface Backend { readonly name: string; readonly cacheFile: string; + readonly ttl: number; readonly noCache: boolean; readonly failClosed: boolean; validateConfig?(): void; @@ -26,21 +28,27 @@ export function createScanner(backend: Backend): Bun.Security.Scanner { ); if (queryable.length === 0) return []; - const cache = backend.noCache ? {} : await readCache(backend.cacheFile); + const [cache, ignoreList] = await Promise.all([ + backend.noCache + ? Promise.resolve({} as Awaited>) + : readCache(backend.cacheFile), + loadIgnoreList(), + ]); const cachedAdvisories: Bun.Security.Advisory[] = []; const toQuery: Bun.Security.Package[] = []; for (const pkg of queryable) { const entry = cache[`${pkg.name}@${pkg.version}`]; - if (entry && isFresh(entry)) { + if (entry && isFresh(entry, backend.ttl)) { cachedAdvisories.push(...entry.advisories); } else { toQuery.push(pkg); } } - if (toQuery.length === 0) return cachedAdvisories; + if (toQuery.length === 0) + return applyIgnores(cachedAdvisories, ignoreList); const hitCount = queryable.length - toQuery.length; const spinner = startSpinner( @@ -61,7 +69,10 @@ export function createScanner(backend: Backend): Bun.Security.Scanner { } if (!backend.noCache) void writeCache(cache, backend.cacheFile); - return [...cachedAdvisories, ...[...advisoryMap.values()].flat()]; + return applyIgnores( + [...cachedAdvisories, ...[...advisoryMap.values()].flat()], + ignoreList + ); } catch (err) { spinner.stop(); @@ -74,8 +85,50 @@ export function createScanner(backend: Backend): Bun.Security.Scanner { process.stderr.write( `\n${backend.name} scan failed (${err instanceof Error ? err.message : err}), skipping.\n` ); - return cachedAdvisories; + return applyIgnores(cachedAdvisories, ignoreList); } }, }; } + +/** + * Apply the ignore list to a set of advisories: + * - `fatal` advisories that are ignored are downgraded to `warn` in + * interactive sessions so the user can make a final call. + * - In non-interactive / CI environments (process.env.CI or no stdin TTY), + * ignored `fatal` advisories are dropped entirely — downgrading to `warn` + * would still auto-cancel the install in CI, defeating the ignore file. + * - `warn` advisories that are ignored are always dropped. + * - All suppressions are logged to stderr for visibility regardless of mode. + */ +function applyIgnores( + advisories: Bun.Security.Advisory[], + ignoreList: IgnoreList +): Bun.Security.Advisory[] { + if (ignoreList.entries.length === 0) return advisories; + + const interactive = + process.env.CI !== 'true' && (process.stdin?.isTTY ?? false); + + const result: Bun.Security.Advisory[] = []; + + for (const advisory of advisories) { + const decision = applyIgnoreList(advisory, ignoreList); + + if (decision.action === 'keep') { + result.push(advisory); + } else if (decision.action === 'downgrade' && interactive) { + process.stderr.write( + `[@nebzdev/bun-security-scanner] Downgrading ${advisory.package} fatal advisory to warn (${advisory.url}) — ${decision.reason}\n` + ); + result.push({ ...advisory, level: 'warn' }); + } else { + // drop: warn advisories always, fatal advisories in CI/non-interactive + process.stderr.write( + `[@nebzdev/bun-security-scanner] Suppressing ${advisory.package} advisory (${advisory.url}) — ${decision.reason}\n` + ); + } + } + + return result; +} diff --git a/src/snyk/config.ts b/src/snyk/config.ts index 1c9b351..b83c992 100644 --- a/src/snyk/config.ts +++ b/src/snyk/config.ts @@ -12,4 +12,8 @@ export const CONCURRENCY = Number(Bun.env.SNYK_CONCURRENCY) || 10; export const RATE_LIMIT = Math.min(Number(Bun.env.SNYK_RATE_LIMIT) || 160, 180); export const CACHE_FILE = Bun.env.SNYK_CACHE_FILE ?? '.snyk.lock'; -export const CACHE_TTL_MS = 24 * 60 * 60 * 1000; +const _snykCacheTtl = Number(Bun.env.SNYK_CACHE_TTL_MS); +export const CACHE_TTL_MS = + Number.isFinite(_snykCacheTtl) && _snykCacheTtl >= 0 + ? _snykCacheTtl + : 24 * 60 * 60 * 1000; diff --git a/src/snyk/index.ts b/src/snyk/index.ts index 634f611..b7345b1 100644 --- a/src/snyk/index.ts +++ b/src/snyk/index.ts @@ -1,11 +1,12 @@ import { type Backend, createScanner } from '../scanner'; import { batchFetchIssues, validateConfig } from './client'; -import { CACHE_FILE, FAIL_CLOSED, NO_CACHE } from './config'; +import { CACHE_FILE, CACHE_TTL_MS, FAIL_CLOSED, NO_CACHE } from './config'; import { advisoryUrl, severityLevel } from './severity'; const backend: Backend = { name: 'Snyk', cacheFile: CACHE_FILE, + ttl: CACHE_TTL_MS, noCache: NO_CACHE, failClosed: FAIL_CLOSED, validateConfig,