From 2db8ce86fe1f3ff2467cd2689dfe18b695b234e7 Mon Sep 17 00:00:00 2001 From: Muneeb Date: Fri, 3 Apr 2026 20:38:48 +0200 Subject: [PATCH 01/18] feat: configurable cache TTL and .bun-security-ignore file Configurable cache TTL - Add OSV_CACHE_TTL_MS and SNYK_CACHE_TTL_MS env vars (values in ms, default 24h) - Expose ttl as a field on the Backend interface - createScanner passes backend.ttl into isFresh() instead of the hardcoded constant - isFresh() now accepts an optional ttl parameter (default 24h for backward compat) Ignore file (.bun-security-ignore) - New src/ignore.ts: TOML parser, loader, and matcher for the ignore file - Supported fields per [[ignore]] entry: package, advisories, reason, expires - advisories = ["*"] is a wildcard that suppresses all advisories for a package - Entries with an expires date (ISO "YYYY-MM-DD") automatically re-activate after that date - fatal advisories matched by an active ignore entry are downgraded to warn - warn advisories matched by an active ignore entry are dropped entirely - Both actions are logged to stderr so they remain visible in CI output - OSV_NO_IGNORE=true disables all ignore file processing (strict CI mode) - BUN_SECURITY_IGNORE_FILE overrides the default .bun-security-ignore path --- src/cache.ts | 4 +- src/config.ts | 3 +- src/ignore.ts | 169 +++++++++++++++++++++++++++++++++++++++++++++ src/osv.ts | 3 +- src/scanner.ts | 56 +++++++++++++-- src/snyk/config.ts | 3 +- src/snyk/index.ts | 3 +- 7 files changed, 230 insertions(+), 11 deletions(-) create mode 100644 src/ignore.ts 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..91acbfc 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,7 +4,8 @@ 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; +export const CACHE_TTL_MS = + Number(Bun.env.OSV_CACHE_TTL_MS) || 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..4ff579c --- /dev/null +++ b/src/ignore.ts @@ -0,0 +1,169 @@ +/** + * `.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 = "2025-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 downgraded to `warn`. + * - `warn` advisories matched by an active ignore entry are dropped entirely. + * - 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 ──────────────────────────────────────────────────────────────────── + +/** + * Minimal TOML parser for the [[ignore]] array-of-tables format. + * Only handles the subset of TOML used by `.bun-security-ignore`. + */ +function parseIgnoreToml(source: string): IgnoreEntry[] { + const entries: IgnoreEntry[] = []; + let current: Partial | null = null; + + for (const rawLine of source.split('\n')) { + const line = rawLine.trim(); + + if (line === '' || line.startsWith('#')) continue; + + if (line === '[[ignore]]') { + if (current) entries.push(current as IgnoreEntry); + current = {}; + continue; + } + + if (!current) continue; + + const eqIdx = line.indexOf('='); + if (eqIdx === -1) continue; + + const key = line.slice(0, eqIdx).trim(); + const rawVal = line.slice(eqIdx + 1).trim(); + + if (key === 'package' || key === 'reason' || key === 'expires') { + // Unquoted or single/double-quoted string + const str = rawVal.replace(/^["']|["']$/g, ''); + (current as Record)[key] = str; + } else if (key === 'advisories') { + // Inline array: ["GHSA-xxx", "CVE-yyy"] or ["*"] + const inner = rawVal.replace(/^\[|\]$/g, ''); + current.advisories = inner + .split(',') + .map((s) => s.trim().replace(/^["']|["']$/g, '')) + .filter(Boolean); + } + } + + if (current?.package) entries.push(current as IgnoreEntry); + + return entries; +} + +// ── Loader ──────────────────────────────────────────────────────────────────── + +export async function loadIgnoreList(): Promise { + if (NO_IGNORE) return { entries: [] }; + + try { + const text = await Bun.file(IGNORE_FILE).text(); + const entries = parseIgnoreToml(text); + + 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 }; + } catch { + // File doesn't exist or can't be read — that's fine + 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..902df4a 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,43 @@ 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` + * - `warn` advisories that are ignored are dropped entirely + * - Both cases are logged to stderr so they remain visible in CI output + */ +function applyIgnores( + advisories: Bun.Security.Advisory[], + ignoreList: IgnoreList +): Bun.Security.Advisory[] { + if (ignoreList.entries.length === 0) return advisories; + + 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') { + 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 + 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..7a48276 100644 --- a/src/snyk/config.ts +++ b/src/snyk/config.ts @@ -12,4 +12,5 @@ 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; +export const CACHE_TTL_MS = + Number(Bun.env.SNYK_CACHE_TTL_MS) || 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, From 5808a9a5197525a2c548472db01429a33f13c8ec Mon Sep 17 00:00:00 2001 From: Muneeb Date: Fri, 3 Apr 2026 20:52:43 +0200 Subject: [PATCH 02/18] build: add compiled output for improved performance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrite build.ts for the scanner (was a copy from tamper-detection-service) - Bundle src/index.ts → dist/index.js (8 KB, minified, target: bun) - Update package.json exports to point to dist/index.js - Ship only dist/ in the npm package (drop raw src/) - Add build step to publish:npm so dist is always fresh before release --- build.ts | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++ lefthook.yml | 44 +++++++++++++++++++++++++++++++++++++ package.json | 12 +++++----- 3 files changed, 113 insertions(+), 5 deletions(-) create mode 100644 build.ts create mode 100644 lefthook.yml diff --git a/build.ts b/build.ts new file mode 100644 index 0000000..c60cdde --- /dev/null +++ b/build.ts @@ -0,0 +1,62 @@ +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, + define: { + BUILD_VERSION: JSON.stringify(version), + BUILD_TIME: JSON.stringify(buildTime), + }, +}); + +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/lefthook.yml b/lefthook.yml new file mode 100644 index 0000000..ebb60bc --- /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: bun format {staged_files} + glob: "*.{js,ts,jsx,tsx}" + stage_fixed: true + - name: lint + run: bun 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" } } From c14ce889d06939db7fb89d83857efc7aa1494eca Mon Sep 17 00:00:00 2001 From: Muneeb Date: Fri, 3 Apr 2026 20:56:50 +0200 Subject: [PATCH 03/18] ci: build bundle before publishing in release workflow --- .github/workflows/release.yml | 3 +++ 1 file changed, 3 insertions(+) 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: | From ee955b7d757b025826923ab78f6e1eb1ed7e286d Mon Sep 17 00:00:00 2001 From: Muneeb Date: Fri, 3 Apr 2026 21:20:21 +0200 Subject: [PATCH 04/18] test: add ignore file tests and update README - New ignore.test.ts covers applyIgnoreList (keep/downgrade/drop, wildcard, expiry, case-insensitivity, NVD URLs) and loadIgnoreList (TOML parsing, multiple entries, missing reason) - Scanner integration tests verify downgrade, drop, wildcard, and package-scoping behaviour end-to-end through createScanner - README updated: configurable cache TTL, .bun-security-ignore section, env var tables, project structure, and feature list --- README.md | 154 ++++++++++----- build.ts | 4 - bun.lock | 23 +++ src/__tests__/ignore.test.ts | 359 ++++++++++++++++++++++++++++++++++ src/__tests__/scanner.test.ts | 111 +++++++++++ 5 files changed, 602 insertions(+), 49 deletions(-) create mode 100644 src/__tests__/ignore.test.ts diff --git a/README.md b/README.md index fbf5791..4803a33 100644 --- a/README.md +++ b/README.md @@ -7,16 +7,17 @@ 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 -- 🔀 **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 +- Runs transparently on every `bun install` +- 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) +- Fails open by default, so a downed API never blocks your install +- Falls back to CVSS score-based severity when a label isn't available +- Supports a `.bun-security-ignore` file for false positives and accepted risks +- Behaviour is tunable via environment variables --- -## 📦 Installation +## Installation ```sh bun add -d @nebzdev/bun-security-scanner @@ -31,18 +32,9 @@ 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 +## Backends The scanner ships with two backends, controlled by the `SCANNER_BACKEND` environment variable. @@ -74,27 +66,27 @@ SNYK_ORG_ID=your-org-id --- -## 🛡️ How it works +## How it works 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 --- -## ⚠️ Advisory levels +## Advisory levels | 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 | --- -## ⚙️ Configuration +## Configuration All options are set via environment variables — in your shell, or in a `.env` file at the project root (Bun loads it automatically). @@ -111,8 +103,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,33 +117,49 @@ 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 | | `SNYK_API_BASE` | `https://api.snyk.io/rest` | Regional endpoint override | | `SNYK_API_VERSION` | `2024-04-29` | Snyk REST API version date | +### Ignore file + +| Variable | Default | Description | +|----------|---------|-------------| +| `BUN_SECURITY_IGNORE_FILE` | `.bun-security-ignore` | Path to the ignore file | +| `OSV_NO_IGNORE` | `false` | Disable all ignore file processing | + ### 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 ``` --- -## 🗄️ Cache +## Cache + +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 | TTL env var | +|---------|-----------|-------------| +| OSV | `.osv.lock` | `OSV_CACHE_TTL_MS` | +| Snyk | `.snyk.lock` | `SNYK_CACHE_TTL_MS` | -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. +The default TTL is 24 hours. In CI environments where cold-start scan time is a concern, increase it: -| Backend | Lock file | -|---------|-----------| -| OSV | `.osv.lock` | -| Snyk | `.snyk.lock` | +```sh +# .env.ci +OSV_CACHE_TTL_MS=604800000 # 7 days +SNYK_CACHE_TTL_MS=604800000 # 7 days +``` -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 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,7 +175,49 @@ SNYK_NO_CACHE=true bun install --- -## 🛠️ Development +## 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 = "2025-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 + +| Level | Effect | +|-------|--------| +| `fatal` advisory matched | Downgraded to `warn` -- visible in output but no longer blocks the install | +| `warn` advisory matched | Dropped entirely | + +Both cases 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 + +### 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 @@ -175,6 +227,15 @@ cd bun-osv-scanner 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" +``` + ### Commands ```sh @@ -193,13 +254,16 @@ bun-osv-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 ``` @@ -215,24 +279,24 @@ bun-osv-scanner/ --- -## ⚠️ Limitations +## 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. --- -## 📄 License +## License MIT © [Muneeb Samuels](https://github.com/muneebs) --- -## 🔗 Links +## Links -- [📦 npm](https://www.npmjs.com/package/@nebzdev/bun-security-scanner) -- [🐛 Issue tracker](https://github.com/muneebs/bun-osv-scanner/issues) -- [🔍 OSV database](https://osv.dev) -- [📖 Bun security scanner docs](https://bun.com/docs/pm/security-scanner-api) +- [npm](https://www.npmjs.com/package/@nebzdev/bun-security-scanner) +- [Issue tracker](https://github.com/muneebs/bun-osv-scanner/issues) +- [OSV database](https://osv.dev) +- [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 index c60cdde..e03900e 100644 --- a/build.ts +++ b/build.ts @@ -32,10 +32,6 @@ const result = await Bun.build({ target: 'bun', format: 'esm', minify: true, - define: { - BUILD_VERSION: JSON.stringify(version), - BUILD_TIME: JSON.stringify(buildTime), - }, }); const elapsed = ((performance.now() - start) / 1000).toFixed(2); 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/src/__tests__/ignore.test.ts b/src/__tests__/ignore.test.ts new file mode 100644 index 0000000..548c963 --- /dev/null +++ b/src/__tests__/ignore.test.ts @@ -0,0 +1,359 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test'; +import { applyIgnoreList, loadIgnoreList, type IgnoreList } 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..f3cb0d2 100644 --- a/src/__tests__/scanner.test.ts +++ b/src/__tests__/scanner.test.ts @@ -221,3 +221,114 @@ 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('downgrades fatal advisory to warn when matched by ignore file', async () => { + 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'); + }); + + 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'); + }); +}); From 1e45ec7b032fa8e49a2cfbcd75d539d7311f4912 Mon Sep 17 00:00:00 2001 From: Muneeb Date: Fri, 3 Apr 2026 21:28:36 +0200 Subject: [PATCH 05/18] docs: restore original section emojis and add emoji for new sections --- README.md | 46 +++++++++++++++++++++++----------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 4803a33..46c118a 100644 --- a/README.md +++ b/README.md @@ -7,17 +7,17 @@ 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. -- Runs transparently on every `bun install` -- 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) -- Fails open by default, so a downed API never blocks your install -- Falls back to CVSS score-based severity when a label isn't available -- Supports a `.bun-security-ignore` file for false positives and accepted risks -- Behaviour is tunable via environment variables +- 🔍 **Automatic scanning**: runs transparently on every `bun install` +- ⚡ **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**: 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 --- -## Installation +## 📦 Installation ```sh bun add -d @nebzdev/bun-security-scanner @@ -34,7 +34,7 @@ That's it. The scanner runs automatically on the next `bun install`. --- -## Backends +## 🔀 Backends The scanner ships with two backends, controlled by the `SCANNER_BACKEND` environment variable. @@ -66,7 +66,7 @@ SNYK_ORG_ID=your-org-id --- -## How it works +## 🛡️ How it works When `bun install` runs, Bun calls the scanner with the full list of packages to be installed. The scanner: @@ -77,7 +77,7 @@ When `bun install` runs, Bun calls the scanner with the full list of packages to --- -## Advisory levels +## ⚠️ Advisory levels | Level | Trigger | Bun behaviour | |-------|---------|---------------| @@ -86,7 +86,7 @@ When `bun install` runs, Bun calls the scanner with the full list of packages to --- -## Configuration +## ⚙️ Configuration All options are set via environment variables — in your shell, or in a `.env` file at the project root (Bun loads it automatically). @@ -124,7 +124,7 @@ All options are set via environment variables — in your shell, or in a `.env` | `SNYK_API_BASE` | `https://api.snyk.io/rest` | Regional endpoint override | | `SNYK_API_VERSION` | `2024-04-29` | Snyk REST API version date | -### Ignore file +### 🙈 Ignore file | Variable | Default | Description | |----------|---------|-------------| @@ -142,7 +142,7 @@ OSV_FAIL_CLOSED=true --- -## Cache +## 🗄️ Cache 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. @@ -175,7 +175,7 @@ SNYK_NO_CACHE=true bun install --- -## Ignore file +## 🙈 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. @@ -217,7 +217,7 @@ The ignore file should be committed alongside your lockfile. It documents delibe --- -## Development +## 🛠️ Development ### Setup @@ -279,7 +279,7 @@ bun-osv-scanner/ --- -## Limitations +## ⚠️ 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, so coverage may lag slightly behind a vulnerability's public disclosure. @@ -288,15 +288,15 @@ bun-osv-scanner/ --- -## License +## 📄 License MIT © [Muneeb Samuels](https://github.com/muneebs) --- -## Links +## 🔗 Links -- [npm](https://www.npmjs.com/package/@nebzdev/bun-security-scanner) -- [Issue tracker](https://github.com/muneebs/bun-osv-scanner/issues) -- [OSV database](https://osv.dev) -- [Bun security scanner docs](https://bun.com/docs/pm/security-scanner-api) \ No newline at end of file +- [📦 npm](https://www.npmjs.com/package/@nebzdev/bun-security-scanner) +- [🐛 Issue tracker](https://github.com/muneebs/bun-osv-scanner/issues) +- [🔍 OSV database](https://osv.dev) +- [📖 Bun security scanner docs](https://bun.com/docs/pm/security-scanner-api) \ No newline at end of file From 3183024b3aa0923477098edeea6a6bdafa1db30b Mon Sep 17 00:00:00 2001 From: Muneeb Date: Fri, 3 Apr 2026 21:29:44 +0200 Subject: [PATCH 06/18] docs: consolidate ignore file env vars into ignore file section --- README.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 46c118a..e9a8948 100644 --- a/README.md +++ b/README.md @@ -124,13 +124,6 @@ All options are set via environment variables — in your shell, or in a `.env` | `SNYK_API_BASE` | `https://api.snyk.io/rest` | Regional endpoint override | | `SNYK_API_VERSION` | `2024-04-29` | Snyk REST API version date | -### 🙈 Ignore file - -| Variable | Default | Description | -|----------|---------|-------------| -| `BUN_SECURITY_IGNORE_FILE` | `.bun-security-ignore` | Path to the ignore file | -| `OSV_NO_IGNORE` | `false` | Disable all ignore file processing | - ### 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. @@ -211,6 +204,13 @@ Both cases are logged to stderr so they remain visible in CI output. Ignored adv - `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. From be604766ab5f19d05752cbb76a073951be895646 Mon Sep 17 00:00:00 2001 From: Muneeb Date: Fri, 3 Apr 2026 21:34:11 +0200 Subject: [PATCH 07/18] style: fix biome import ordering in ignore.test.ts --- src/__tests__/ignore.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/__tests__/ignore.test.ts b/src/__tests__/ignore.test.ts index 548c963..a42b416 100644 --- a/src/__tests__/ignore.test.ts +++ b/src/__tests__/ignore.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test'; -import { applyIgnoreList, loadIgnoreList, type IgnoreList } from '../ignore'; +import { applyIgnoreList, type IgnoreList, loadIgnoreList } from '../ignore'; // ── helpers ────────────────────────────────────────────────────────────────── From 39c0e977fffe3930ffbde8887a2f5c24d56d5806 Mon Sep 17 00:00:00 2001 From: Muneeb Date: Fri, 3 Apr 2026 21:40:41 +0200 Subject: [PATCH 08/18] docs: add CodeRabbit PR reviews badge to README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index e9a8948..418cd01 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ [![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. From 28e890fc8db96b78d3b5918c894cd377993f1454 Mon Sep 17 00:00:00 2001 From: Muneeb Date: Fri, 3 Apr 2026 21:54:44 +0200 Subject: [PATCH 09/18] fix(lefthook): invoke biome directly so staged_files and --write take effect --- lefthook.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lefthook.yml b/lefthook.yml index ebb60bc..a284d48 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -30,11 +30,11 @@ fail_on_changes: "always" jobs: - name: format - run: bun format {staged_files} + run: biome format --write {staged_files} glob: "*.{js,ts,jsx,tsx}" stage_fixed: true - name: lint - run: bun lint {staged_files} + run: biome lint {staged_files} glob: "*.{js,ts,jsx,tsx}" - name: type-check run: bun type-check From 8ad11b8dbf129268c130b1c7bcf368283344827a Mon Sep 17 00:00:00 2001 From: Muneeb Date: Fri, 3 Apr 2026 21:55:14 +0200 Subject: [PATCH 10/18] docs: fix local dev scanner path after repo rename --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 418cd01..798bf26 100644 --- a/README.md +++ b/README.md @@ -234,7 +234,7 @@ Point `bunfig.toml` directly at the entry file using an absolute or relative pat ```toml [install.security] -scanner = "../bun-osv-scanner/src/index.ts" +scanner = "../bun-security-scanner/src/index.ts" ``` ### Commands From c92bf3858d8ea8edc6411595e94a5ea70aac6556 Mon Sep 17 00:00:00 2001 From: Muneeb Date: Fri, 3 Apr 2026 21:55:50 +0200 Subject: [PATCH 11/18] docs: replace all remaining bun-osv-scanner references with bun-security-scanner --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 798bf26..2f5c692 100644 --- a/README.md +++ b/README.md @@ -223,8 +223,8 @@ The ignore file should be committed alongside your lockfile. It documents delibe ### 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 ``` @@ -251,7 +251,7 @@ 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 @@ -298,6 +298,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) \ No newline at end of file From 96c87311f86374fb441ccf58d0eb8e23dd868b71 Mon Sep 17 00:00:00 2001 From: Muneeb Date: Fri, 3 Apr 2026 21:57:25 +0200 Subject: [PATCH 12/18] fix(lefthook): use bunx biome so the binary resolves from node_modules --- lefthook.yml | 4 ++-- src/config.ts | 5 ++++- src/snyk/config.ts | 5 ++++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/lefthook.yml b/lefthook.yml index a284d48..43573a5 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -30,11 +30,11 @@ fail_on_changes: "always" jobs: - name: format - run: biome format --write {staged_files} + run: bunx biome format --write {staged_files} glob: "*.{js,ts,jsx,tsx}" stage_fixed: true - name: lint - run: biome lint {staged_files} + run: bunx biome lint {staged_files} glob: "*.{js,ts,jsx,tsx}" - name: type-check run: bun type-check diff --git a/src/config.ts b/src/config.ts index 91acbfc..a6c5d95 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,8 +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'; +const _osvCacheTtl = Number(Bun.env.OSV_CACHE_TTL_MS); export const CACHE_TTL_MS = - Number(Bun.env.OSV_CACHE_TTL_MS) || 24 * 60 * 60 * 1000; + 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/snyk/config.ts b/src/snyk/config.ts index 7a48276..b83c992 100644 --- a/src/snyk/config.ts +++ b/src/snyk/config.ts @@ -12,5 +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'; +const _snykCacheTtl = Number(Bun.env.SNYK_CACHE_TTL_MS); export const CACHE_TTL_MS = - Number(Bun.env.SNYK_CACHE_TTL_MS) || 24 * 60 * 60 * 1000; + Number.isFinite(_snykCacheTtl) && _snykCacheTtl >= 0 + ? _snykCacheTtl + : 24 * 60 * 60 * 1000; From b3f7ee1b3bb82d7231b7dc71e79fe05b64c72ec5 Mon Sep 17 00:00:00 2001 From: Muneeb Date: Fri, 3 Apr 2026 21:59:49 +0200 Subject: [PATCH 13/18] fix(ignore): strip inline comments from values and guard incomplete entries - Add stripInlineComment() so trailing # comments on string and array values are removed before parsing; fixes expires/advisories fields being corrupted when users follow the README examples with comments - Extract pushCurrent() helper used at both [[ignore]] boundaries and end-of-input; validates current.package before pushing and defaults current.advisories to [] so entry.advisories.includes() never throws on an entry that was missing its advisories field --- src/ignore.ts | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/src/ignore.ts b/src/ignore.ts index 4ff579c..6404da9 100644 --- a/src/ignore.ts +++ b/src/ignore.ts @@ -40,6 +40,24 @@ export const NO_IGNORE = Bun.env.OSV_NO_IGNORE === 'true'; // ── Parser ──────────────────────────────────────────────────────────────────── +/** Remove text after the first # that is not inside a quoted string. */ +function stripInlineComment(val: string): string { + let inStr = false; + let q = ''; + for (let i = 0; i < val.length; i++) { + const c = val[i]; + if (inStr) { + if (c === q) inStr = false; + } else if (c === '"' || c === "'") { + inStr = true; + q = c; + } else if (c === '#') { + return val.slice(0, i).trimEnd(); + } + } + return val; +} + /** * Minimal TOML parser for the [[ignore]] array-of-tables format. * Only handles the subset of TOML used by `.bun-security-ignore`. @@ -48,13 +66,22 @@ function parseIgnoreToml(source: string): IgnoreEntry[] { const entries: IgnoreEntry[] = []; let current: Partial | null = null; + const pushCurrent = () => { + if (current?.package) { + entries.push({ + ...current, + advisories: current.advisories ?? [], + } as IgnoreEntry); + } + }; + for (const rawLine of source.split('\n')) { const line = rawLine.trim(); if (line === '' || line.startsWith('#')) continue; if (line === '[[ignore]]') { - if (current) entries.push(current as IgnoreEntry); + pushCurrent(); current = {}; continue; } @@ -65,7 +92,7 @@ function parseIgnoreToml(source: string): IgnoreEntry[] { if (eqIdx === -1) continue; const key = line.slice(0, eqIdx).trim(); - const rawVal = line.slice(eqIdx + 1).trim(); + const rawVal = stripInlineComment(line.slice(eqIdx + 1).trim()); if (key === 'package' || key === 'reason' || key === 'expires') { // Unquoted or single/double-quoted string @@ -81,7 +108,7 @@ function parseIgnoreToml(source: string): IgnoreEntry[] { } } - if (current?.package) entries.push(current as IgnoreEntry); + pushCurrent(); return entries; } From 35732d424c90f1938f67683c0a869b26362dc667 Mon Sep 17 00:00:00 2001 From: Muneeb Date: Fri, 3 Apr 2026 22:02:57 +0200 Subject: [PATCH 14/18] fix(scanner): drop ignored fatal advisories in CI/non-interactive mode Downgrading to warn still auto-cancels bun install in CI, making the ignore file ineffective for fatal advisories. In non-interactive environments (CI=true or no stdin TTY), ignored fatal advisories are now dropped entirely while still logging to stderr for visibility. Interactive sessions retain the downgrade-to-warn behaviour so the user can make a final call at the prompt. --- src/__tests__/scanner.test.ts | 61 ++++++++++++++++++++++++++++------- src/scanner.ts | 17 +++++++--- 2 files changed, 62 insertions(+), 16 deletions(-) diff --git a/src/__tests__/scanner.test.ts b/src/__tests__/scanner.test.ts index f3cb0d2..d32370e 100644 --- a/src/__tests__/scanner.test.ts +++ b/src/__tests__/scanner.test.ts @@ -272,20 +272,59 @@ reason = "transitive only" renameSpy.mockRestore(); }); - test('downgrades fatal advisory to warn when matched by ignore file', async () => { - mockOsvResponses( - fetchSpy, - ['GHSA-aaa-bbb-cccc'], - [makeOsvVuln('GHSA-aaa-bbb-cccc', 'HIGH', 'Prototype Pollution')] - ); + 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; + } + }); - const advisories = await scanner.scan({ - packages: [pkg('lodash', '4.17.4')], + test('downgrades fatal advisory to warn in interactive mode when matched by ignore file', async () => { + const origCI = process.env.CI; + // Simulate an interactive terminal session. + process.env.CI = 'false'; + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + configurable: true, }); - expect(advisories).toHaveLength(1); - expect(advisories[0]?.level).toBe('warn'); - expect(advisories[0]?.package).toBe('lodash'); + 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; + Object.defineProperty(process.stdin, 'isTTY', { + value: undefined, + configurable: true, + }); + } }); test('drops warn advisory when matched by ignore file', async () => { diff --git a/src/scanner.ts b/src/scanner.ts index 902df4a..85168b6 100644 --- a/src/scanner.ts +++ b/src/scanner.ts @@ -93,9 +93,13 @@ export function createScanner(backend: Backend): Bun.Security.Scanner { /** * Apply the ignore list to a set of advisories: - * - `fatal` advisories that are ignored are downgraded to `warn` - * - `warn` advisories that are ignored are dropped entirely - * - Both cases are logged to stderr so they remain visible in CI output + * - `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[], @@ -103,6 +107,9 @@ function applyIgnores( ): 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) { @@ -110,13 +117,13 @@ function applyIgnores( if (decision.action === 'keep') { result.push(advisory); - } else if (decision.action === 'downgrade') { + } 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 + // 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` ); From 435ddd423bbebd315298573667069f515845f18b Mon Sep 17 00:00:00 2001 From: Muneeb Date: Fri, 3 Apr 2026 22:16:27 +0200 Subject: [PATCH 15/18] refactor(ignore): replace hand-rolled TOML parser with Bun.TOML.parse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bun's built-in parser handles the full TOML spec natively — inline comments, proper string escaping, multiline strings, date literals — removing the need for stripInlineComment, pushCurrent, and all the line-by-line parsing logic. Bare TOML local dates in the expires field are normalised to ISO strings for consistency with the quoted form. --- src/ignore.ts | 85 ++++++++++++++++----------------------------------- 1 file changed, 26 insertions(+), 59 deletions(-) diff --git a/src/ignore.ts b/src/ignore.ts index 6404da9..4ee4cb7 100644 --- a/src/ignore.ts +++ b/src/ignore.ts @@ -40,75 +40,42 @@ export const NO_IGNORE = Bun.env.OSV_NO_IGNORE === 'true'; // ── Parser ──────────────────────────────────────────────────────────────────── -/** Remove text after the first # that is not inside a quoted string. */ -function stripInlineComment(val: string): string { - let inStr = false; - let q = ''; - for (let i = 0; i < val.length; i++) { - const c = val[i]; - if (inStr) { - if (c === q) inStr = false; - } else if (c === '"' || c === "'") { - inStr = true; - q = c; - } else if (c === '#') { - return val.slice(0, i).trimEnd(); - } - } - return val; -} - /** - * Minimal TOML parser for the [[ignore]] array-of-tables format. - * Only handles the subset of TOML used by `.bun-security-ignore`. + * 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[] = []; - let current: Partial | null = null; - - const pushCurrent = () => { - if (current?.package) { - entries.push({ - ...current, - advisories: current.advisories ?? [], - } as IgnoreEntry); - } - }; - for (const rawLine of source.split('\n')) { - const line = rawLine.trim(); + for (const item of raw) { + if (typeof item !== 'object' || item === null) continue; + const row = item as Record; - if (line === '' || line.startsWith('#')) continue; + if (typeof row['package'] !== 'string' || !row['package']) continue; - if (line === '[[ignore]]') { - pushCurrent(); - current = {}; - 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']; - if (!current) continue; - - const eqIdx = line.indexOf('='); - if (eqIdx === -1) continue; - - const key = line.slice(0, eqIdx).trim(); - const rawVal = stripInlineComment(line.slice(eqIdx + 1).trim()); - - if (key === 'package' || key === 'reason' || key === 'expires') { - // Unquoted or single/double-quoted string - const str = rawVal.replace(/^["']|["']$/g, ''); - (current as Record)[key] = str; - } else if (key === 'advisories') { - // Inline array: ["GHSA-xxx", "CVE-yyy"] or ["*"] - const inner = rawVal.replace(/^\[|\]$/g, ''); - current.advisories = inner - .split(',') - .map((s) => s.trim().replace(/^["']|["']$/g, '')) - .filter(Boolean); + // `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); } - } - pushCurrent(); + entries.push(entry); + } return entries; } From 56e4a2b10dfdb6e80e413de18a1e52b4e2ee1254 Mon Sep 17 00:00:00 2001 From: Muneeb Samuels Date: Fri, 3 Apr 2026 22:19:27 +0200 Subject: [PATCH 16/18] Update README.md Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Muneeb Samuels --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2f5c692..f732bc8 100644 --- a/README.md +++ b/README.md @@ -182,7 +182,7 @@ Not every advisory is actionable. A vulnerability may affect a code path your pr package = "lodash" advisories = ["GHSA-35jh-r3h4-6jhm"] reason = "Only affects the cloneDeep path, which we do not use." -expires = "2025-12-31" # optional -- re-surfaces automatically after this date +expires = "2026-12-31" # optional -- re-surfaces automatically after this date [[ignore]] package = "minimist" From 35f3f6dd0c21d26d5c2721319c0efebd3acdaa20 Mon Sep 17 00:00:00 2001 From: Muneeb Date: Fri, 3 Apr 2026 22:26:50 +0200 Subject: [PATCH 17/18] docs: document CI/interactive ignore behaviour and fix test teardown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Expand README Behaviour table to show fatal advisories are downgraded to warn in interactive sessions but suppressed entirely in CI/non-TTY - Update ignore.ts module comment with the same CI/non-interactive detail - Update example expires date from 2025-12-31 to 2026-12-31 in both files - Fix scanner.test.ts to save and restore the original isTTY property descriptor instead of hardcoding value: undefined on teardown - Apply biome useLiteralKeys fixes (bracket → dot notation) in ignore.ts --- README.md | 11 ++++++----- src/__tests__/scanner.test.ts | 13 +++++++++---- src/ignore.ts | 27 +++++++++++++++------------ 3 files changed, 30 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index f732bc8..afc2370 100644 --- a/README.md +++ b/README.md @@ -192,12 +192,13 @@ reason = "Transitive only, no direct usage, no fix available." ### Behaviour -| Level | Effect | -|-------|--------| -| `fatal` advisory matched | Downgraded to `warn` -- visible in output but no longer blocks the install | -| `warn` advisory matched | Dropped entirely | +| 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 | -Both cases are logged to stderr so they remain visible in CI output. Ignored advisories are never silently swallowed. +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 diff --git a/src/__tests__/scanner.test.ts b/src/__tests__/scanner.test.ts index d32370e..576c956 100644 --- a/src/__tests__/scanner.test.ts +++ b/src/__tests__/scanner.test.ts @@ -296,6 +296,10 @@ reason = "transitive only" 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', { @@ -320,10 +324,11 @@ reason = "transitive only" } finally { if (origCI === undefined) delete process.env.CI; else process.env.CI = origCI; - Object.defineProperty(process.stdin, 'isTTY', { - value: undefined, - configurable: true, - }); + if (origIsTTYDescriptor) { + Object.defineProperty(process.stdin, 'isTTY', origIsTTYDescriptor); + } else { + Reflect.deleteProperty(process.stdin, 'isTTY'); + } } }); diff --git a/src/ignore.ts b/src/ignore.ts index 4ee4cb7..61d14c9 100644 --- a/src/ignore.ts +++ b/src/ignore.ts @@ -8,7 +8,7 @@ * package = "lodash" * advisories = ["GHSA-35jh-r3h4-6jhm"] * reason = "Only affects cloneDeep, which we do not use." - * expires = "2025-12-31" # optional ISO date + * expires = "2026-12-31" # optional ISO date * * [[ignore]] * package = "minimist" @@ -17,8 +17,11 @@ * ``` * * Behaviour: - * - `fatal` advisories matched by an active ignore entry are downgraded to `warn`. + * - `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. @@ -46,7 +49,7 @@ export const NO_IGNORE = Bun.env.OSV_NO_IGNORE === 'true'; */ function parseIgnoreToml(source: string): IgnoreEntry[] { const parsed = Bun.TOML.parse(source) as Record; - const raw = parsed['ignore']; + const raw = parsed.ignore; if (!Array.isArray(raw)) return []; const entries: IgnoreEntry[] = []; @@ -55,23 +58,23 @@ function parseIgnoreToml(source: string): IgnoreEntry[] { if (typeof item !== 'object' || item === null) continue; const row = item as Record; - if (typeof row['package'] !== 'string' || !row['package']) continue; + if (typeof row.package !== 'string' || !row.package) continue; - const advisories = Array.isArray(row['advisories']) - ? (row['advisories'] as unknown[]).filter( + 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 }; + const entry: IgnoreEntry = { package: row.package, advisories }; - if (typeof row['reason'] === 'string') entry.reason = row['reason']; + 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); + 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); From f76f05dedcdadec7c50e5a139348472638794098 Mon Sep 17 00:00:00 2001 From: Muneeb Date: Fri, 3 Apr 2026 22:33:28 +0200 Subject: [PATCH 18/18] fix: parse errors in ignore file, lefthook indentation, setup docs - loadIgnoreList: split file-read and TOML-parse into separate try/catch blocks so ENOENT is silently ignored but a malformed ignore file logs a warning to stderr instead of silently producing zero entries - lefthook.yml: remove leading space from pre-push and pre-commit root keys; normalise child indentation to 2 spaces throughout - README: add `bunx lefthook install` to the setup steps so contributors get the pre-commit hooks after cloning --- README.md | 1 + lefthook.yml | 50 +++++++++++++++++++++++++------------------------- src/ignore.ts | 36 +++++++++++++++++++++++------------- 3 files changed, 49 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index afc2370..5c458f8 100644 --- a/README.md +++ b/README.md @@ -227,6 +227,7 @@ The ignore file should be committed alongside your lockfile. It documents delibe git clone https://github.com/muneebs/bun-security-scanner.git cd bun-security-scanner bun install +bunx lefthook install ``` ### Local development diff --git a/lefthook.yml b/lefthook.yml index 43573a5..cf0f588 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -17,28 +17,28 @@ # - 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 +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/src/ignore.ts b/src/ignore.ts index 61d14c9..d5e51dc 100644 --- a/src/ignore.ts +++ b/src/ignore.ts @@ -88,23 +88,33 @@ function parseIgnoreToml(source: string): IgnoreEntry[] { export async function loadIgnoreList(): Promise { if (NO_IGNORE) return { entries: [] }; + let text: string; try { - const text = await Bun.file(IGNORE_FILE).text(); - const entries = parseIgnoreToml(text); - - 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 }; + text = await Bun.file(IGNORE_FILE).text(); } catch { - // File doesn't exist or can't be read — that's fine + // 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 ───────────────────────────────────────────────────────────────────