Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
2db8ce8
feat: configurable cache TTL and .bun-security-ignore file
muneebs Apr 3, 2026
5808a9a
build: add compiled output for improved performance
muneebs Apr 3, 2026
c14ce88
ci: build bundle before publishing in release workflow
muneebs Apr 3, 2026
ee955b7
test: add ignore file tests and update README
muneebs Apr 3, 2026
1e45ec7
docs: restore original section emojis and add emoji for new sections
muneebs Apr 3, 2026
3183024
docs: consolidate ignore file env vars into ignore file section
muneebs Apr 3, 2026
be60476
style: fix biome import ordering in ignore.test.ts
muneebs Apr 3, 2026
39c0e97
docs: add CodeRabbit PR reviews badge to README
muneebs Apr 3, 2026
28e890f
fix(lefthook): invoke biome directly so staged_files and --write take…
muneebs Apr 3, 2026
8ad11b8
docs: fix local dev scanner path after repo rename
muneebs Apr 3, 2026
c92bf38
docs: replace all remaining bun-osv-scanner references with bun-secur…
muneebs Apr 3, 2026
96c8731
fix(lefthook): use bunx biome so the binary resolves from node_modules
muneebs Apr 3, 2026
b3f7ee1
fix(ignore): strip inline comments from values and guard incomplete e…
muneebs Apr 3, 2026
35732d4
fix(scanner): drop ignored fatal advisories in CI/non-interactive mode
muneebs Apr 3, 2026
435ddd4
refactor(ignore): replace hand-rolled TOML parser with Bun.TOML.parse
muneebs Apr 3, 2026
56e4a2b
Update README.md
muneebs Apr 3, 2026
35f3f6d
docs: document CI/interactive ignore behaviour and fix test teardown
muneebs Apr 3, 2026
f76f05d
fix: parse errors in ignore file, lefthook indentation, setup docs
muneebs Apr 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ jobs:
- name: Test
run: bun test

- name: Build
run: bun run build

- name: Bump version
id: version
run: |
Expand Down
133 changes: 100 additions & 33 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

---

Expand All @@ -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
Expand Down Expand Up @@ -78,18 +71,18 @@ 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

---

## ⚠️ 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 |

---
Expand All @@ -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

Expand All @@ -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 |
Expand All @@ -131,25 +127,33 @@ 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
```

---

## 🗄️ 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
Expand All @@ -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
Expand All @@ -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
```
Expand All @@ -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.

Expand All @@ -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)
58 changes: 58 additions & 0 deletions build.ts
Original file line number Diff line number Diff line change
@@ -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}
`);
23 changes: 23 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

44 changes: 44 additions & 0 deletions lefthook.yml
Original file line number Diff line number Diff line change
@@ -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}"
Loading
Loading