@nebzdev/bun-security-scanner 1.0.1 โ 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +100 -33
- package/dist/index.js +9 -0
- package/package.json +8 -6
- package/src/cache.ts +0 -48
- package/src/client.ts +0 -72
- package/src/config.ts +0 -11
- package/src/display.ts +0 -24
- package/src/index.ts +0 -21
- package/src/osv.ts +0 -61
- package/src/scanner.ts +0 -81
- package/src/severity.ts +0 -22
- package/src/snyk/client.ts +0 -137
- package/src/snyk/config.ts +0 -15
- package/src/snyk/index.ts +0 -38
- package/src/snyk/severity.ts +0 -10
package/README.md
CHANGED
|
@@ -4,15 +4,17 @@
|
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
5
|
[](https://osv.dev)
|
|
6
6
|
[](https://snyk.io)
|
|
7
|
+

|
|
7
8
|
|
|
8
9
|
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.
|
|
9
10
|
|
|
10
11
|
- ๐ **Automatic scanning**: runs transparently on every `bun install`
|
|
11
|
-
- โก **Fast**:
|
|
12
|
+
- โก **Fast**: per-package lockfile cache (24h by default, configurable) means repeat installs skip the network entirely
|
|
12
13
|
- ๐ **Two backends**: OSV (free, no setup) or Snyk (commercial, broader coverage)
|
|
13
14
|
- ๐ **Fail-open by default**: a downed API never blocks your install
|
|
14
|
-
- ๐ฏ **CVSS fallback**:
|
|
15
|
-
-
|
|
15
|
+
- ๐ฏ **CVSS fallback**: falls back to score-based severity when a label isn't available
|
|
16
|
+
- ๐ **Ignore file**: suppress false positives and accepted risks with `.bun-security-ignore`
|
|
17
|
+
- โ๏ธ **Configurable**: tune behaviour via environment variables
|
|
16
18
|
|
|
17
19
|
---
|
|
18
20
|
|
|
@@ -31,15 +33,6 @@ scanner = "@nebzdev/bun-security-scanner"
|
|
|
31
33
|
|
|
32
34
|
That's it. The scanner runs automatically on the next `bun install`.
|
|
33
35
|
|
|
34
|
-
### Local development
|
|
35
|
-
|
|
36
|
-
Point `bunfig.toml` directly at the entry file using an absolute or relative path:
|
|
37
|
-
|
|
38
|
-
```toml
|
|
39
|
-
[install.security]
|
|
40
|
-
scanner = "../bun-osv-scanner/src/index.ts"
|
|
41
|
-
```
|
|
42
|
-
|
|
43
36
|
---
|
|
44
37
|
|
|
45
38
|
## ๐ Backends
|
|
@@ -78,10 +71,10 @@ SNYK_ORG_ID=your-org-id
|
|
|
78
71
|
|
|
79
72
|
When `bun install` runs, Bun calls the scanner with the full list of packages to be installed. The scanner:
|
|
80
73
|
|
|
81
|
-
1.
|
|
82
|
-
2.
|
|
83
|
-
3.
|
|
84
|
-
4.
|
|
74
|
+
1. Filters non-resolvable versions โ workspace, git, file, and path dependencies are skipped
|
|
75
|
+
2. Checks the cache โ packages seen within the cache TTL (24h by default) skip the network entirely
|
|
76
|
+
3. Queries the backend for any uncached packages
|
|
77
|
+
4. Returns advisories to Bun, which surfaces them as warnings or fatal errors
|
|
85
78
|
|
|
86
79
|
---
|
|
87
80
|
|
|
@@ -89,7 +82,7 @@ When `bun install` runs, Bun calls the scanner with the full list of packages to
|
|
|
89
82
|
|
|
90
83
|
| Level | Trigger | Bun behaviour |
|
|
91
84
|
|-------|---------|---------------|
|
|
92
|
-
| `fatal` | CRITICAL or HIGH severity; or CVSS score
|
|
85
|
+
| `fatal` | CRITICAL or HIGH severity; or CVSS score >= 7.0 | Installation halts |
|
|
93
86
|
| `warn` | MODERATE or LOW severity; or CVSS score < 7.0 | User is prompted; auto-cancelled in CI |
|
|
94
87
|
|
|
95
88
|
---
|
|
@@ -111,8 +104,10 @@ All options are set via environment variables โ in your shell, or in a `.env`
|
|
|
111
104
|
| `OSV_FAIL_CLOSED` | `false` | Throw on network error instead of failing open |
|
|
112
105
|
| `OSV_NO_CACHE` | `false` | Always query OSV fresh, bypassing the local cache |
|
|
113
106
|
| `OSV_CACHE_FILE` | `.osv.lock` | Path to the cache file |
|
|
107
|
+
| `OSV_CACHE_TTL_MS` | `86400000` | Cache TTL in milliseconds (default: 24 hours) |
|
|
114
108
|
| `OSV_TIMEOUT_MS` | `10000` | Per-request timeout in milliseconds |
|
|
115
109
|
| `OSV_API_BASE` | `https://api.osv.dev/v1` | OSV API base URL |
|
|
110
|
+
| `OSV_NO_IGNORE` | `false` | Disable `.bun-security-ignore` processing |
|
|
116
111
|
|
|
117
112
|
### Snyk backend
|
|
118
113
|
|
|
@@ -123,6 +118,7 @@ All options are set via environment variables โ in your shell, or in a `.env`
|
|
|
123
118
|
| `SNYK_FAIL_CLOSED` | `false` | Throw on network error instead of failing open |
|
|
124
119
|
| `SNYK_NO_CACHE` | `false` | Always query Snyk fresh, bypassing the local cache |
|
|
125
120
|
| `SNYK_CACHE_FILE` | `.snyk.lock` | Path to the cache file |
|
|
121
|
+
| `SNYK_CACHE_TTL_MS` | `86400000` | Cache TTL in milliseconds (default: 24 hours) |
|
|
126
122
|
| `SNYK_TIMEOUT_MS` | `10000` | Per-request timeout in milliseconds |
|
|
127
123
|
| `SNYK_RATE_LIMIT` | `160` | Max requests per minute (hard cap: 180) |
|
|
128
124
|
| `SNYK_CONCURRENCY` | `10` | Max concurrent connections |
|
|
@@ -131,10 +127,10 @@ All options are set via environment variables โ in your shell, or in a `.env`
|
|
|
131
127
|
|
|
132
128
|
### Fail-open vs fail-closed
|
|
133
129
|
|
|
134
|
-
By default the scanner
|
|
130
|
+
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.
|
|
135
131
|
|
|
136
132
|
```sh
|
|
137
|
-
# .env
|
|
133
|
+
# .env -- strict mode
|
|
138
134
|
OSV_FAIL_CLOSED=true
|
|
139
135
|
```
|
|
140
136
|
|
|
@@ -142,14 +138,22 @@ OSV_FAIL_CLOSED=true
|
|
|
142
138
|
|
|
143
139
|
## ๐๏ธ Cache
|
|
144
140
|
|
|
145
|
-
Results are cached per `package@version` in a lock file at the project root
|
|
141
|
+
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.
|
|
146
142
|
|
|
147
|
-
| Backend | Lock file |
|
|
148
|
-
|
|
149
|
-
| OSV | `.osv.lock` |
|
|
150
|
-
| Snyk | `.snyk.lock` |
|
|
143
|
+
| Backend | Lock file | TTL env var |
|
|
144
|
+
|---------|-----------|-------------|
|
|
145
|
+
| OSV | `.osv.lock` | `OSV_CACHE_TTL_MS` |
|
|
146
|
+
| Snyk | `.snyk.lock` | `SNYK_CACHE_TTL_MS` |
|
|
151
147
|
|
|
152
|
-
The
|
|
148
|
+
The default TTL is 24 hours. In CI environments where cold-start scan time is a concern, increase it:
|
|
149
|
+
|
|
150
|
+
```sh
|
|
151
|
+
# .env.ci
|
|
152
|
+
OSV_CACHE_TTL_MS=604800000 # 7 days
|
|
153
|
+
SNYK_CACHE_TTL_MS=604800000 # 7 days
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
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.
|
|
153
157
|
|
|
154
158
|
```sh
|
|
155
159
|
git add .osv.lock # or .snyk.lock
|
|
@@ -165,14 +169,74 @@ SNYK_NO_CACHE=true bun install
|
|
|
165
169
|
|
|
166
170
|
---
|
|
167
171
|
|
|
172
|
+
## ๐ Ignore file
|
|
173
|
+
|
|
174
|
+
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.
|
|
175
|
+
|
|
176
|
+
### Format
|
|
177
|
+
|
|
178
|
+
```toml
|
|
179
|
+
# .bun-security-ignore
|
|
180
|
+
|
|
181
|
+
[[ignore]]
|
|
182
|
+
package = "lodash"
|
|
183
|
+
advisories = ["GHSA-35jh-r3h4-6jhm"]
|
|
184
|
+
reason = "Only affects the cloneDeep path, which we do not use."
|
|
185
|
+
expires = "2026-12-31" # optional -- re-surfaces automatically after this date
|
|
186
|
+
|
|
187
|
+
[[ignore]]
|
|
188
|
+
package = "minimist"
|
|
189
|
+
advisories = ["*"] # wildcard -- suppress all advisories for this package
|
|
190
|
+
reason = "Transitive only, no direct usage, no fix available."
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### Behaviour
|
|
194
|
+
|
|
195
|
+
| Advisory level | Session type | Effect |
|
|
196
|
+
|----------------|--------------|--------|
|
|
197
|
+
| `fatal` matched | Interactive (no `CI=true`, stdin is a TTY) | Downgraded to `warn` โ visible in output but no longer blocks the install |
|
|
198
|
+
| `fatal` matched | CI / non-interactive | Suppressed entirely โ logged to stderr but not returned |
|
|
199
|
+
| `warn` matched | Any | Suppressed entirely โ logged to stderr but not returned |
|
|
200
|
+
|
|
201
|
+
All suppressions are logged to stderr so they remain visible in CI output. Ignored advisories are never silently swallowed.
|
|
202
|
+
|
|
203
|
+
- `expires` -- entries re-activate at UTC midnight on the given date, so you're reminded when to reassess
|
|
204
|
+
- `advisories = ["*"]` -- wildcard suppresses all advisories for the package
|
|
205
|
+
- `reason` -- encouraged but not required; a notice is printed to stderr if omitted
|
|
206
|
+
- `OSV_NO_IGNORE=true` -- disables all ignore file processing for strict environments
|
|
207
|
+
- `BUN_SECURITY_IGNORE_FILE` -- override the default `.bun-security-ignore` path
|
|
208
|
+
|
|
209
|
+
### Environment variables
|
|
210
|
+
|
|
211
|
+
| Variable | Default | Description |
|
|
212
|
+
|----------|---------|-------------|
|
|
213
|
+
| `BUN_SECURITY_IGNORE_FILE` | `.bun-security-ignore` | Path to the ignore file |
|
|
214
|
+
| `OSV_NO_IGNORE` | `false` | Disable all ignore file processing |
|
|
215
|
+
|
|
216
|
+
### Committing the file
|
|
217
|
+
|
|
218
|
+
The ignore file should be committed alongside your lockfile. It documents deliberate risk-acceptance decisions for your whole team and CI.
|
|
219
|
+
|
|
220
|
+
---
|
|
221
|
+
|
|
168
222
|
## ๐ ๏ธ Development
|
|
169
223
|
|
|
170
224
|
### Setup
|
|
171
225
|
|
|
172
226
|
```sh
|
|
173
|
-
git clone https://github.com/muneebs/bun-
|
|
174
|
-
cd bun-
|
|
227
|
+
git clone https://github.com/muneebs/bun-security-scanner.git
|
|
228
|
+
cd bun-security-scanner
|
|
175
229
|
bun install
|
|
230
|
+
bunx lefthook install
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
### Local development
|
|
234
|
+
|
|
235
|
+
Point `bunfig.toml` directly at the entry file using an absolute or relative path:
|
|
236
|
+
|
|
237
|
+
```toml
|
|
238
|
+
[install.security]
|
|
239
|
+
scanner = "../bun-security-scanner/src/index.ts"
|
|
176
240
|
```
|
|
177
241
|
|
|
178
242
|
### Commands
|
|
@@ -189,17 +253,20 @@ bun run check:write # Lint + format, auto-fix what it can
|
|
|
189
253
|
### Project structure
|
|
190
254
|
|
|
191
255
|
```
|
|
192
|
-
bun-
|
|
256
|
+
bun-security-scanner/
|
|
193
257
|
โโโ src/
|
|
194
258
|
โ โโโ __tests__/ # Test suite (bun:test)
|
|
195
259
|
โ โโโ snyk/ # Snyk backend
|
|
196
|
-
โ โโโ cache.ts #
|
|
260
|
+
โ โโโ cache.ts # Lockfile cache (configurable TTL)
|
|
197
261
|
โ โโโ client.ts # OSV API client
|
|
198
262
|
โ โโโ config.ts # OSV constants and env vars
|
|
199
263
|
โ โโโ display.ts # TTY progress spinner
|
|
200
|
-
โ โโโ
|
|
264
|
+
โ โโโ ignore.ts # .bun-security-ignore loader and matcher
|
|
265
|
+
โ โโโ index.ts # Entry point -- dispatches to OSV or Snyk
|
|
201
266
|
โ โโโ osv.ts # OSV scanner implementation
|
|
267
|
+
โ โโโ scanner.ts # Shared scanner factory (cache + ignore orchestration)
|
|
202
268
|
โ โโโ severity.ts # OSV level classification
|
|
269
|
+
โโโ dist/ # Compiled output (published to npm)
|
|
203
270
|
โโโ bunfig.toml
|
|
204
271
|
โโโ package.json
|
|
205
272
|
```
|
|
@@ -218,7 +285,7 @@ bun-osv-scanner/
|
|
|
218
285
|
## โ ๏ธ Limitations
|
|
219
286
|
|
|
220
287
|
- Only scans npm packages with concrete semver versions. `workspace:`, `file:`, `git:`, and range-only specifiers are skipped.
|
|
221
|
-
- OSV aggregates GitHub Advisory, NVD, and other feeds
|
|
288
|
+
- OSV aggregates GitHub Advisory, NVD, and other feeds, so coverage may lag slightly behind a vulnerability's public disclosure.
|
|
222
289
|
- The OSV batch API has a hard limit of 1,000 queries per request. Larger projects are split across multiple requests automatically.
|
|
223
290
|
- 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.
|
|
224
291
|
|
|
@@ -233,6 +300,6 @@ MIT ยฉ [Muneeb Samuels](https://github.com/muneebs)
|
|
|
233
300
|
## ๐ Links
|
|
234
301
|
|
|
235
302
|
- [๐ฆ npm](https://www.npmjs.com/package/@nebzdev/bun-security-scanner)
|
|
236
|
-
- [๐ Issue tracker](https://github.com/muneebs/bun-
|
|
303
|
+
- [๐ Issue tracker](https://github.com/muneebs/bun-security-scanner/issues)
|
|
237
304
|
- [๐ OSV database](https://osv.dev)
|
|
238
|
-
- [๐ Bun security scanner docs](https://bun.com/docs/pm/security-scanner-api)
|
|
305
|
+
- [๐ Bun security scanner docs](https://bun.com/docs/pm/security-scanner-api)
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
var Z=Bun.env.OSV_API_BASE??"https://api.osv.dev/v1",C=1000,K=Number(Bun.env.OSV_TIMEOUT_MS)||1e4,F=["ADVISORY","WEB","ARTICLE"],G=Bun.env.OSV_CACHE_FILE??".osv.lock",A=Number(Bun.env.OSV_CACHE_TTL_MS),L=Number.isFinite(A)&&A>=0?A:86400000,N=Bun.env.OSV_FAIL_CLOSED==="true",Y=Bun.env.OSV_NO_CACHE==="true";function O(f,$){let x=new AbortController,j=setTimeout(()=>x.abort(),K);return fetch(f,{...$,signal:x.signal}).finally(()=>clearTimeout(j))}function P(f){return/^v?\d+\.\d+/.test(f)}async function t(f){let $=[];for(let x=0;x<f.length;x+=C){let j=f.slice(x,x+C),z=await O(`${Z}/querybatch`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({queries:j.map((X)=>({version:X.version,package:{name:X.name,ecosystem:"npm"}}))})});if(!z.ok){let X=await z.text().catch(()=>"");throw Error(`OSV API ${z.status}: ${X||z.statusText}`)}let{results:B}=await z.json();$.push(...B)}return $}async function E(f){let $=await O(`${Z}/vulns/${f}`);if(!$.ok)return null;return $.json()}import{rename as zf}from"fs/promises";var jf=86400000;function Jf(f){if(typeof f!=="object"||f===null||Array.isArray(f))return!1;return Object.values(f).every(($)=>typeof $==="object"&&$!==null&&Array.isArray($.advisories)&&typeof $.cachedAt==="number")}async function w(f){try{let $=JSON.parse(await Bun.file(f).text());return Jf($)?$:{}}catch{return{}}}async function v(f,$){try{let x=`${$}.tmp`;await Bun.write(x,JSON.stringify(f,null,2)),await zf(x,$)}catch{}}function b(f,$=jf){return Date.now()-f.cachedAt<$}function I(f){if(!process.stderr.isTTY)return{update:(B)=>{},stop:()=>{}};let $=["\u280B","\u2819","\u2839","\u2838","\u283C","\u2834","\u2826","\u2827","\u2807","\u280F"],x=0,j=f;process.stderr.write(`${$[0]} ${j}`);let z=setInterval(()=>process.stderr.write(`\r${$[++x%$.length]} ${j}`),80);return{update(B){j=B},stop(){clearInterval(z),process.stderr.write("\r\x1B[2K")}}}var R=Bun.env.BUN_SECURITY_IGNORE_FILE??".bun-security-ignore",Xf=Bun.env.OSV_NO_IGNORE==="true";function mf(f){let x=Bun.TOML.parse(f).ignore;if(!Array.isArray(x))return[];let j=[];for(let z of x){if(typeof z!=="object"||z===null)continue;let B=z;if(typeof B.package!=="string"||!B.package)continue;let X=Array.isArray(B.advisories)?B.advisories.filter((D)=>typeof D==="string"):[],J={package:B.package,advisories:X};if(typeof B.reason==="string")J.reason=B.reason;if(typeof B.expires==="string")J.expires=B.expires;else if(B.expires instanceof Date)J.expires=B.expires.toISOString().slice(0,10);j.push(J)}return j}async function l(){if(Xf)return{entries:[]};let f;try{f=await Bun.file(R).text()}catch{return{entries:[]}}let $;try{$=mf(f)}catch(x){return process.stderr.write(`[@nebzdev/bun-security-scanner] Warning: failed to parse "${R}" \u2014 ${x instanceof Error?x.message:x}. All ignore entries will be skipped.
|
|
3
|
+
`),{entries:[]}}for(let x of $)if(!x.reason)process.stderr.write(`[@nebzdev/bun-security-scanner] Warning: ignore entry for "${x.package}" has no reason \u2014 consider documenting why.
|
|
4
|
+
`);return{entries:$}}function Df(f){if(!f.expires)return!0;let $=new Date(`${f.expires}T00:00:00Z`);return Date.now()<$.getTime()}function Hf(f){return f?.split("/").pop()?.toUpperCase()??""}function T(f,$){let x=Hf(f.url);for(let j of $.entries){if(j.package!==f.package)continue;if(!Df(j))continue;if(!(j.advisories.includes("*")||j.advisories.map((J)=>J.toUpperCase()).includes(x)))continue;let X=j.reason??"(no reason provided)";if(f.level==="fatal")return{action:"downgrade",reason:X};return{action:"drop",reason:X}}return{action:"keep"}}function S(f){return{version:"1",async scan({packages:$}){f.validateConfig?.();let x=$.filter((m)=>m.name&&P(m.version));if(x.length===0)return[];let[j,z]=await Promise.all([f.noCache?Promise.resolve({}):w(f.cacheFile),l()]),B=[],X=[];for(let m of x){let H=j[`${m.name}@${m.version}`];if(H&&b(H,f.ttl))B.push(...H.advisories);else X.push(m)}if(X.length===0)return V(B,z);let J=x.length-X.length,D=I(J>0?`Scanning ${X.length} packages via ${f.name} (${J} cached)...`:`Scanning ${x.length} packages via ${f.name}...`);try{let m=await f.fetchAdvisories(X,(H)=>D.update(H));D.stop();for(let[H,xf]of m)j[H]={advisories:xf,cachedAt:Date.now()};if(!f.noCache)v(j,f.cacheFile);return V([...B,...[...m.values()].flat()],z)}catch(m){if(D.stop(),f.failClosed)throw Error(`${f.name} scan failed: ${m instanceof Error?m.message:m}`);return process.stderr.write(`
|
|
5
|
+
${f.name} scan failed (${m instanceof Error?m.message:m}), skipping.
|
|
6
|
+
`),V(B,z)}}}}function V(f,$){if($.entries.length===0)return f;let x=process.env.CI!=="true"&&(process.stdin?.isTTY??!1),j=[];for(let z of f){let B=T(z,$);if(B.action==="keep")j.push(z);else if(B.action==="downgrade"&&x)process.stderr.write(`[@nebzdev/bun-security-scanner] Downgrading ${z.package} fatal advisory to warn (${z.url}) \u2014 ${B.reason}
|
|
7
|
+
`),j.push({...z,level:"warn"});else process.stderr.write(`[@nebzdev/bun-security-scanner] Suppressing ${z.package} advisory (${z.url}) \u2014 ${B.reason}
|
|
8
|
+
`)}return j}function _(f){let $=f.database_specific?.severity?.toUpperCase();if($==="CRITICAL"||$==="HIGH")return"fatal";if($==="MODERATE"||$==="LOW")return"warn";let x=f.database_specific?.cvss?.score;if(typeof x==="number")return x>=7?"fatal":"warn";return"warn"}function p(f){for(let $ of F){let x=f.references?.find((j)=>j.type===$);if(x)return x.url}return`https://osv.dev/vulnerability/${f.id}`}var U={name:"OSV",cacheFile:G,ttl:L,noCache:Y,failClosed:N,async fetchAdvisories(f,$){let x=await t(f),j=[];for(let B=0;B<f.length;B++)for(let{id:X}of x[B]?.vulns??[])j.push({pkg:f[B],vulnId:X});let z=new Map;for(let B of f)z.set(`${B.name}@${B.version}`,[]);if(j.length>0){let B=[...new Set(j.map((J)=>J.vulnId))],X=new Map;$(`Fetching details for ${B.length} ${B.length===1?"vulnerability":"vulnerabilities"}...`),await Promise.all(B.map(async(J)=>{let D=await E(J);if(D)X.set(J,D)}));for(let{pkg:J,vulnId:D}of j){let m=X.get(D);if(m)z.get(`${J.name}@${J.version}`)?.push({level:_(m),package:J.name,url:p(m),description:m.summary??m.id})}}return z}};var vf=S(U);var n=Bun.env.SNYK_API_BASE??"https://api.snyk.io/rest",g=Bun.env.SNYK_API_VERSION??"2024-04-29",u=Bun.env.SNYK_TOKEN,M=Bun.env.SNYK_ORG_ID,c=Number(Bun.env.SNYK_TIMEOUT_MS)||1e4,k=Bun.env.SNYK_FAIL_CLOSED==="true",y=Bun.env.SNYK_NO_CACHE==="true",Q=Number(Bun.env.SNYK_CONCURRENCY)||10,h=Math.min(Number(Bun.env.SNYK_RATE_LIMIT)||160,180),o=Bun.env.SNYK_CACHE_FILE??".snyk.lock",q=Number(Bun.env.SNYK_CACHE_TTL_MS),d=Number.isFinite(q)&&q>=0?q:86400000;class i{limit;timestamps=[];windowMs=60000;constructor(f){this.limit=f}async acquire(){let f=Date.now();while(this.timestamps.length>0&&f-this.timestamps[0]>=this.windowMs)this.timestamps.shift();if(this.timestamps.length<this.limit){this.timestamps.push(Date.now());return}let $=this.windowMs-(Date.now()-this.timestamps[0])+10;return await Bun.sleep($),this.acquire()}}var Sf=new i(h);function Uf(f,$){let x=new AbortController,j=setTimeout(()=>x.abort(),c);return fetch(f,{...$,signal:x.signal}).finally(()=>clearTimeout(j))}function s(){if(!u)throw Error("SNYK_TOKEN is required for the Snyk scanner");if(!M)throw Error("SNYK_ORG_ID is required for the Snyk scanner")}function Af(f,$){if(f.startsWith("@")){let x=f.indexOf("/",1),j=f.slice(1,x),z=f.slice(x+1);return`pkg:npm/${j}/${z}@${$}`}return`pkg:npm/${f}@${$}`}async function r(f,$,x=3){await Sf.acquire();let j=encodeURIComponent(Af(f,$)),z=`${n}/orgs/${M}/packages/${j}/issues?version=${g}&limit=1000`,B=await Uf(z,{headers:{Authorization:`token ${u}`,"Content-Type":"application/vnd.api+json"}});if(B.status===429&&x>0){let J=B.headers.get("Retry-After"),D=J?Number(J)*1000:60000;return await Bun.sleep(D),r(f,$,x-1)}if(B.status===404)return[];if(!B.ok){let J=await B.text().catch(()=>"");throw Error(`Snyk API ${B.status}: ${J||B.statusText}`)}let{data:X}=await B.json();return X??[]}async function a(f,$){let x=new Map,j=0;for(let z=0;z<f.length;z+=Q)await Promise.all(f.slice(z,z+Q).map(async(B)=>{let X=await r(B.name,B.version);x.set(`${B.name}@${B.version}`,X),$?.(++j,f.length)}));return x}function e(f){let $=f.attributes.effective_severity_level;return $==="critical"||$==="high"?"fatal":"warn"}function ff(f){return`https://security.snyk.io/vuln/${f.id}`}var W={name:"Snyk",cacheFile:o,ttl:d,noCache:y,failClosed:k,validateConfig:s,async fetchAdvisories(f,$){let x=await a(f,(z,B)=>{$(`Scanning packages via Snyk (${z}/${B})...`)}),j=new Map;for(let z of f){let B=`${z.name}@${z.version}`,X=x.get(B)??[];j.set(B,X.map((J)=>({level:e(J),package:z.name,url:ff(J),description:J.attributes.title})))}return j}};var cf=S(W);var Zf={osv:U,snyk:W},$f=(Bun.env.SCANNER_BACKEND??"osv").toLowerCase(),Bf=Zf[$f];if(!Bf)process.stderr.write(`[@nebzdev/bun-security-scanner] Unknown SCANNER_BACKEND "${$f}", falling back to osv.
|
|
9
|
+
`);var df=S(Bf??U);export{df as scanner};
|
package/package.json
CHANGED
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nebzdev/bun-security-scanner",
|
|
3
3
|
"description": "Bun security scanner powered by Google's OSV vulnerability database",
|
|
4
|
-
"version": "1.0
|
|
4
|
+
"version": "1.1.0",
|
|
5
5
|
"author": "Muneeb Samuels",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"exports": {
|
|
8
8
|
"./package.json": "./package.json",
|
|
9
|
-
".": "./
|
|
9
|
+
".": "./dist/index.js"
|
|
10
10
|
},
|
|
11
11
|
"files": [
|
|
12
|
-
"
|
|
13
|
-
"!src/__tests__",
|
|
12
|
+
"dist",
|
|
14
13
|
"README.md"
|
|
15
14
|
],
|
|
16
15
|
"keywords": [
|
|
@@ -22,16 +21,19 @@
|
|
|
22
21
|
"vulnerability"
|
|
23
22
|
],
|
|
24
23
|
"scripts": {
|
|
25
|
-
"
|
|
24
|
+
"build": "bun run build.ts",
|
|
25
|
+
"publish:npm": "bun run build && npm publish --access public",
|
|
26
26
|
"lint": "biome lint ./src",
|
|
27
27
|
"format": "biome format ./src",
|
|
28
28
|
"format:write": "biome format --write ./src",
|
|
29
29
|
"check": "biome check ./src",
|
|
30
|
-
"check:write": "biome check --write ./src"
|
|
30
|
+
"check:write": "biome check --write ./src",
|
|
31
|
+
"type-check": "tsc --noEmit"
|
|
31
32
|
},
|
|
32
33
|
"devDependencies": {
|
|
33
34
|
"@biomejs/biome": "2.4.10",
|
|
34
35
|
"@types/bun": "latest",
|
|
36
|
+
"lefthook": "^2.1.4",
|
|
35
37
|
"typescript": "^5.0.0"
|
|
36
38
|
}
|
|
37
39
|
}
|
package/src/cache.ts
DELETED
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
import { rename } from 'node:fs/promises';
|
|
2
|
-
|
|
3
|
-
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
4
|
-
|
|
5
|
-
export interface CacheEntry {
|
|
6
|
-
advisories: Bun.Security.Advisory[];
|
|
7
|
-
cachedAt: number;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
type Cache = Record<string, CacheEntry>;
|
|
11
|
-
|
|
12
|
-
function isValidCache(data: unknown): data is Cache {
|
|
13
|
-
if (typeof data !== 'object' || data === null || Array.isArray(data))
|
|
14
|
-
return false;
|
|
15
|
-
return Object.values(data).every(
|
|
16
|
-
(entry) =>
|
|
17
|
-
typeof entry === 'object' &&
|
|
18
|
-
entry !== null &&
|
|
19
|
-
Array.isArray((entry as CacheEntry).advisories) &&
|
|
20
|
-
typeof (entry as CacheEntry).cachedAt === 'number'
|
|
21
|
-
);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export async function readCache(cacheFile: string): Promise<Cache> {
|
|
25
|
-
try {
|
|
26
|
-
const data: unknown = JSON.parse(await Bun.file(cacheFile).text());
|
|
27
|
-
return isValidCache(data) ? data : {};
|
|
28
|
-
} catch {
|
|
29
|
-
return {};
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export async function writeCache(
|
|
34
|
-
cache: Cache,
|
|
35
|
-
cacheFile: string
|
|
36
|
-
): Promise<void> {
|
|
37
|
-
try {
|
|
38
|
-
// Write to a temp file first, then rename โ prevents partial-write corruption
|
|
39
|
-
// if the process is killed or two installs run concurrently.
|
|
40
|
-
const tmp = `${cacheFile}.tmp`;
|
|
41
|
-
await Bun.write(tmp, JSON.stringify(cache, null, 2));
|
|
42
|
-
await rename(tmp, cacheFile);
|
|
43
|
-
} catch {}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export function isFresh(entry: CacheEntry): boolean {
|
|
47
|
-
return Date.now() - entry.cachedAt < CACHE_TTL_MS;
|
|
48
|
-
}
|
package/src/client.ts
DELETED
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
import { FETCH_TIMEOUT_MS, OSV_API_BASE, OSV_BATCH_SIZE } from './config';
|
|
2
|
-
|
|
3
|
-
export interface OsvBatchResponse {
|
|
4
|
-
results: Array<{
|
|
5
|
-
vulns?: Array<{ id: string; modified: string }>;
|
|
6
|
-
next_page_token?: string;
|
|
7
|
-
}>;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export interface OsvVulnerability {
|
|
11
|
-
id: string;
|
|
12
|
-
summary?: string;
|
|
13
|
-
references?: Array<{ type: string; url: string }>;
|
|
14
|
-
severity?: Array<{ type: string; score: string }>;
|
|
15
|
-
database_specific?: {
|
|
16
|
-
severity?: string;
|
|
17
|
-
cvss?: { score?: number };
|
|
18
|
-
[key: string]: unknown;
|
|
19
|
-
};
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function fetchWithTimeout(
|
|
23
|
-
url: string,
|
|
24
|
-
options?: RequestInit
|
|
25
|
-
): Promise<Response> {
|
|
26
|
-
const controller = new AbortController();
|
|
27
|
-
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
28
|
-
return fetch(url, { ...options, signal: controller.signal }).finally(() =>
|
|
29
|
-
clearTimeout(timer)
|
|
30
|
-
);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// workspace:, file:, git:, and range specifiers cause a 400 for the whole batch.
|
|
34
|
-
export function isResolvable(version: string): boolean {
|
|
35
|
-
return /^v?\d+\.\d+/.test(version);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export async function batchQuery(
|
|
39
|
-
packages: Bun.Security.Package[]
|
|
40
|
-
): Promise<OsvBatchResponse['results']> {
|
|
41
|
-
const results: OsvBatchResponse['results'] = [];
|
|
42
|
-
|
|
43
|
-
for (let i = 0; i < packages.length; i += OSV_BATCH_SIZE) {
|
|
44
|
-
const chunk = packages.slice(i, i + OSV_BATCH_SIZE);
|
|
45
|
-
const res = await fetchWithTimeout(`${OSV_API_BASE}/querybatch`, {
|
|
46
|
-
method: 'POST',
|
|
47
|
-
headers: { 'Content-Type': 'application/json' },
|
|
48
|
-
body: JSON.stringify({
|
|
49
|
-
queries: chunk.map((p) => ({
|
|
50
|
-
version: p.version,
|
|
51
|
-
package: { name: p.name, ecosystem: 'npm' },
|
|
52
|
-
})),
|
|
53
|
-
}),
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
if (!res.ok) {
|
|
57
|
-
const body = await res.text().catch(() => '');
|
|
58
|
-
throw new Error(`OSV API ${res.status}: ${body || res.statusText}`);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const { results: chunkResults } = (await res.json()) as OsvBatchResponse;
|
|
62
|
-
results.push(...chunkResults);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
return results;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export async function fetchVuln(id: string): Promise<OsvVulnerability | null> {
|
|
69
|
-
const res = await fetchWithTimeout(`${OSV_API_BASE}/vulns/${id}`);
|
|
70
|
-
if (!res.ok) return null;
|
|
71
|
-
return res.json() as Promise<OsvVulnerability>;
|
|
72
|
-
}
|
package/src/config.ts
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
export const OSV_API_BASE = Bun.env.OSV_API_BASE ?? 'https://api.osv.dev/v1';
|
|
2
|
-
// Hard limit enforced by the OSV API โ exceeding it returns 400 "Too many queries".
|
|
3
|
-
export const OSV_BATCH_SIZE = 1000;
|
|
4
|
-
export const FETCH_TIMEOUT_MS = Number(Bun.env.OSV_TIMEOUT_MS) || 10_000;
|
|
5
|
-
export const PREFERRED_REF_TYPES = ['ADVISORY', 'WEB', 'ARTICLE'] as const;
|
|
6
|
-
export const CACHE_FILE = Bun.env.OSV_CACHE_FILE ?? '.osv.lock';
|
|
7
|
-
export const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
8
|
-
|
|
9
|
-
// When true, network failures throw and cancel installation rather than failing open.
|
|
10
|
-
export const FAIL_CLOSED = Bun.env.OSV_FAIL_CLOSED === 'true';
|
|
11
|
-
export const NO_CACHE = Bun.env.OSV_NO_CACHE === 'true';
|
package/src/display.ts
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
export function startSpinner(message: string) {
|
|
2
|
-
if (!process.stderr.isTTY)
|
|
3
|
-
return { update: (_: string) => {}, stop: () => {} };
|
|
4
|
-
|
|
5
|
-
const frames = ['โ ', 'โ ', 'โ น', 'โ ธ', 'โ ผ', 'โ ด', 'โ ฆ', 'โ ง', 'โ ', 'โ '];
|
|
6
|
-
let i = 0;
|
|
7
|
-
let current = message;
|
|
8
|
-
|
|
9
|
-
process.stderr.write(`${frames[0]} ${current}`);
|
|
10
|
-
const interval = setInterval(
|
|
11
|
-
() => process.stderr.write(`\r${frames[++i % frames.length]} ${current}`),
|
|
12
|
-
80
|
|
13
|
-
);
|
|
14
|
-
|
|
15
|
-
return {
|
|
16
|
-
update(msg: string) {
|
|
17
|
-
current = msg;
|
|
18
|
-
},
|
|
19
|
-
stop() {
|
|
20
|
-
clearInterval(interval);
|
|
21
|
-
process.stderr.write('\r\x1b[2K');
|
|
22
|
-
},
|
|
23
|
-
};
|
|
24
|
-
}
|
package/src/index.ts
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
import { backend as osvBackend } from './osv';
|
|
2
|
-
import { type Backend, createScanner } from './scanner';
|
|
3
|
-
import { backend as snykBackend } from './snyk/index';
|
|
4
|
-
|
|
5
|
-
const registry: Record<string, Backend> = {
|
|
6
|
-
osv: osvBackend,
|
|
7
|
-
snyk: snykBackend,
|
|
8
|
-
};
|
|
9
|
-
|
|
10
|
-
const backendName = (Bun.env.SCANNER_BACKEND ?? 'osv').toLowerCase();
|
|
11
|
-
const selected = registry[backendName];
|
|
12
|
-
|
|
13
|
-
if (!selected) {
|
|
14
|
-
process.stderr.write(
|
|
15
|
-
`[@nebzdev/bun-security-scanner] Unknown SCANNER_BACKEND "${backendName}", falling back to osv.\n`
|
|
16
|
-
);
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export const scanner: Bun.Security.Scanner = createScanner(
|
|
20
|
-
selected ?? osvBackend
|
|
21
|
-
);
|
package/src/osv.ts
DELETED
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
import type { OsvVulnerability } from './client';
|
|
2
|
-
import { batchQuery, fetchVuln } from './client';
|
|
3
|
-
import { CACHE_FILE, FAIL_CLOSED, NO_CACHE } from './config';
|
|
4
|
-
import { type Backend, createScanner } from './scanner';
|
|
5
|
-
import { advisoryUrl, severityLevel } from './severity';
|
|
6
|
-
|
|
7
|
-
const backend: Backend = {
|
|
8
|
-
name: 'OSV',
|
|
9
|
-
cacheFile: CACHE_FILE,
|
|
10
|
-
noCache: NO_CACHE,
|
|
11
|
-
failClosed: FAIL_CLOSED,
|
|
12
|
-
|
|
13
|
-
async fetchAdvisories(packages, onStatus) {
|
|
14
|
-
const batchResults = await batchQuery(packages);
|
|
15
|
-
|
|
16
|
-
const affected: Array<{ pkg: Bun.Security.Package; vulnId: string }> = [];
|
|
17
|
-
for (let i = 0; i < packages.length; i++) {
|
|
18
|
-
for (const { id } of batchResults[i]?.vulns ?? []) {
|
|
19
|
-
affected.push({ pkg: packages[i], vulnId: id });
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const result = new Map<string, Bun.Security.Advisory[]>();
|
|
24
|
-
for (const pkg of packages) {
|
|
25
|
-
result.set(`${pkg.name}@${pkg.version}`, []);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
if (affected.length > 0) {
|
|
29
|
-
const uniqueIds = [...new Set(affected.map((a) => a.vulnId))];
|
|
30
|
-
const vulnById = new Map<string, OsvVulnerability>();
|
|
31
|
-
|
|
32
|
-
onStatus(
|
|
33
|
-
`Fetching details for ${uniqueIds.length} ${uniqueIds.length === 1 ? 'vulnerability' : 'vulnerabilities'}...`
|
|
34
|
-
);
|
|
35
|
-
|
|
36
|
-
await Promise.all(
|
|
37
|
-
uniqueIds.map(async (id) => {
|
|
38
|
-
const vuln = await fetchVuln(id);
|
|
39
|
-
if (vuln) vulnById.set(id, vuln);
|
|
40
|
-
})
|
|
41
|
-
);
|
|
42
|
-
|
|
43
|
-
for (const { pkg, vulnId } of affected) {
|
|
44
|
-
const vuln = vulnById.get(vulnId);
|
|
45
|
-
if (vuln) {
|
|
46
|
-
result.get(`${pkg.name}@${pkg.version}`)?.push({
|
|
47
|
-
level: severityLevel(vuln),
|
|
48
|
-
package: pkg.name,
|
|
49
|
-
url: advisoryUrl(vuln),
|
|
50
|
-
description: vuln.summary ?? vuln.id,
|
|
51
|
-
});
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
return result;
|
|
57
|
-
},
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
export { backend };
|
|
61
|
-
export const scanner = createScanner(backend);
|
package/src/scanner.ts
DELETED
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
import { isFresh, readCache, writeCache } from './cache';
|
|
2
|
-
import { isResolvable } from './client';
|
|
3
|
-
import { startSpinner } from './display';
|
|
4
|
-
|
|
5
|
-
export interface Backend {
|
|
6
|
-
readonly name: string;
|
|
7
|
-
readonly cacheFile: string;
|
|
8
|
-
readonly noCache: boolean;
|
|
9
|
-
readonly failClosed: boolean;
|
|
10
|
-
validateConfig?(): void;
|
|
11
|
-
fetchAdvisories(
|
|
12
|
-
packages: Bun.Security.Package[],
|
|
13
|
-
onStatus: (message: string) => void
|
|
14
|
-
): Promise<Map<string, Bun.Security.Advisory[]>>;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export function createScanner(backend: Backend): Bun.Security.Scanner {
|
|
18
|
-
return {
|
|
19
|
-
version: '1',
|
|
20
|
-
|
|
21
|
-
async scan({ packages }) {
|
|
22
|
-
backend.validateConfig?.();
|
|
23
|
-
|
|
24
|
-
const queryable = packages.filter(
|
|
25
|
-
(p) => p.name && isResolvable(p.version)
|
|
26
|
-
);
|
|
27
|
-
if (queryable.length === 0) return [];
|
|
28
|
-
|
|
29
|
-
const cache = backend.noCache ? {} : await readCache(backend.cacheFile);
|
|
30
|
-
|
|
31
|
-
const cachedAdvisories: Bun.Security.Advisory[] = [];
|
|
32
|
-
const toQuery: Bun.Security.Package[] = [];
|
|
33
|
-
|
|
34
|
-
for (const pkg of queryable) {
|
|
35
|
-
const entry = cache[`${pkg.name}@${pkg.version}`];
|
|
36
|
-
if (entry && isFresh(entry)) {
|
|
37
|
-
cachedAdvisories.push(...entry.advisories);
|
|
38
|
-
} else {
|
|
39
|
-
toQuery.push(pkg);
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
if (toQuery.length === 0) return cachedAdvisories;
|
|
44
|
-
|
|
45
|
-
const hitCount = queryable.length - toQuery.length;
|
|
46
|
-
const spinner = startSpinner(
|
|
47
|
-
hitCount > 0
|
|
48
|
-
? `Scanning ${toQuery.length} packages via ${backend.name} (${hitCount} cached)...`
|
|
49
|
-
: `Scanning ${queryable.length} packages via ${backend.name}...`
|
|
50
|
-
);
|
|
51
|
-
|
|
52
|
-
try {
|
|
53
|
-
const advisoryMap = await backend.fetchAdvisories(toQuery, (msg) =>
|
|
54
|
-
spinner.update(msg)
|
|
55
|
-
);
|
|
56
|
-
|
|
57
|
-
spinner.stop();
|
|
58
|
-
|
|
59
|
-
for (const [key, advisories] of advisoryMap) {
|
|
60
|
-
cache[key] = { advisories, cachedAt: Date.now() };
|
|
61
|
-
}
|
|
62
|
-
if (!backend.noCache) void writeCache(cache, backend.cacheFile);
|
|
63
|
-
|
|
64
|
-
return [...cachedAdvisories, ...[...advisoryMap.values()].flat()];
|
|
65
|
-
} catch (err) {
|
|
66
|
-
spinner.stop();
|
|
67
|
-
|
|
68
|
-
if (backend.failClosed) {
|
|
69
|
-
throw new Error(
|
|
70
|
-
`${backend.name} scan failed: ${err instanceof Error ? err.message : err}`
|
|
71
|
-
);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
process.stderr.write(
|
|
75
|
-
`\n${backend.name} scan failed (${err instanceof Error ? err.message : err}), skipping.\n`
|
|
76
|
-
);
|
|
77
|
-
return cachedAdvisories;
|
|
78
|
-
}
|
|
79
|
-
},
|
|
80
|
-
};
|
|
81
|
-
}
|
package/src/severity.ts
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
import type { OsvVulnerability } from './client';
|
|
2
|
-
import { PREFERRED_REF_TYPES } from './config';
|
|
3
|
-
|
|
4
|
-
export function severityLevel(vuln: OsvVulnerability): 'fatal' | 'warn' {
|
|
5
|
-
const s = vuln.database_specific?.severity?.toUpperCase();
|
|
6
|
-
if (s === 'CRITICAL' || s === 'HIGH') return 'fatal';
|
|
7
|
-
if (s === 'MODERATE' || s === 'LOW') return 'warn';
|
|
8
|
-
|
|
9
|
-
// Fallback: numeric CVSS score (โฅ7.0 = HIGH/CRITICAL threshold).
|
|
10
|
-
const score = vuln.database_specific?.cvss?.score;
|
|
11
|
-
if (typeof score === 'number') return score >= 7.0 ? 'fatal' : 'warn';
|
|
12
|
-
|
|
13
|
-
return 'warn';
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export function advisoryUrl(vuln: OsvVulnerability): string {
|
|
17
|
-
for (const type of PREFERRED_REF_TYPES) {
|
|
18
|
-
const ref = vuln.references?.find((r) => r.type === type);
|
|
19
|
-
if (ref) return ref.url;
|
|
20
|
-
}
|
|
21
|
-
return `https://osv.dev/vulnerability/${vuln.id}`;
|
|
22
|
-
}
|
package/src/snyk/client.ts
DELETED
|
@@ -1,137 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
CONCURRENCY,
|
|
3
|
-
FETCH_TIMEOUT_MS,
|
|
4
|
-
RATE_LIMIT,
|
|
5
|
-
SNYK_API_BASE,
|
|
6
|
-
SNYK_API_VERSION,
|
|
7
|
-
SNYK_ORG_ID,
|
|
8
|
-
SNYK_TOKEN,
|
|
9
|
-
} from './config';
|
|
10
|
-
|
|
11
|
-
export interface SnykIssue {
|
|
12
|
-
id: string;
|
|
13
|
-
attributes: {
|
|
14
|
-
title: string;
|
|
15
|
-
type: string;
|
|
16
|
-
effective_severity_level: 'critical' | 'high' | 'medium' | 'low';
|
|
17
|
-
description?: string;
|
|
18
|
-
problems?: Array<{ id: string; source: string }>;
|
|
19
|
-
};
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
interface SnykResponse {
|
|
23
|
-
data: SnykIssue[];
|
|
24
|
-
links?: { next?: string };
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
// Sliding window rate limiter โ tracks request timestamps within the last minute.
|
|
28
|
-
class RateLimiter {
|
|
29
|
-
private readonly timestamps: number[] = [];
|
|
30
|
-
private readonly windowMs = 60_000;
|
|
31
|
-
|
|
32
|
-
constructor(private readonly limit: number) {}
|
|
33
|
-
|
|
34
|
-
async acquire(): Promise<void> {
|
|
35
|
-
const now = Date.now();
|
|
36
|
-
while (
|
|
37
|
-
this.timestamps.length > 0 &&
|
|
38
|
-
now - this.timestamps[0] >= this.windowMs
|
|
39
|
-
) {
|
|
40
|
-
this.timestamps.shift();
|
|
41
|
-
}
|
|
42
|
-
if (this.timestamps.length < this.limit) {
|
|
43
|
-
this.timestamps.push(Date.now());
|
|
44
|
-
return;
|
|
45
|
-
}
|
|
46
|
-
// Wait until the oldest request falls outside the window, then retry.
|
|
47
|
-
const waitMs = this.windowMs - (Date.now() - this.timestamps[0]) + 10;
|
|
48
|
-
await Bun.sleep(waitMs);
|
|
49
|
-
return this.acquire();
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const rateLimiter = new RateLimiter(RATE_LIMIT);
|
|
54
|
-
|
|
55
|
-
function fetchWithTimeout(
|
|
56
|
-
url: string,
|
|
57
|
-
options?: RequestInit
|
|
58
|
-
): Promise<Response> {
|
|
59
|
-
const controller = new AbortController();
|
|
60
|
-
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
61
|
-
return fetch(url, { ...options, signal: controller.signal }).finally(() =>
|
|
62
|
-
clearTimeout(timer)
|
|
63
|
-
);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export function validateConfig(): void {
|
|
67
|
-
if (!SNYK_TOKEN)
|
|
68
|
-
throw new Error('SNYK_TOKEN is required for the Snyk scanner');
|
|
69
|
-
if (!SNYK_ORG_ID)
|
|
70
|
-
throw new Error('SNYK_ORG_ID is required for the Snyk scanner');
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function buildPurl(name: string, version: string): string {
|
|
74
|
-
// Scoped packages: @scope/name -> pkg:npm/scope/name@version (PURL spec ยง7)
|
|
75
|
-
if (name.startsWith('@')) {
|
|
76
|
-
const slash = name.indexOf('/', 1);
|
|
77
|
-
const scope = name.slice(1, slash);
|
|
78
|
-
const pkg = name.slice(slash + 1);
|
|
79
|
-
return `pkg:npm/${scope}/${pkg}@${version}`;
|
|
80
|
-
}
|
|
81
|
-
return `pkg:npm/${name}@${version}`;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
async function fetchPackageIssues(
|
|
85
|
-
name: string,
|
|
86
|
-
version: string,
|
|
87
|
-
retries = 3
|
|
88
|
-
): Promise<SnykIssue[]> {
|
|
89
|
-
await rateLimiter.acquire();
|
|
90
|
-
|
|
91
|
-
const purl = encodeURIComponent(buildPurl(name, version));
|
|
92
|
-
const url = `${SNYK_API_BASE}/orgs/${SNYK_ORG_ID}/packages/${purl}/issues?version=${SNYK_API_VERSION}&limit=1000`;
|
|
93
|
-
|
|
94
|
-
const res = await fetchWithTimeout(url, {
|
|
95
|
-
headers: {
|
|
96
|
-
Authorization: `token ${SNYK_TOKEN}`,
|
|
97
|
-
'Content-Type': 'application/vnd.api+json',
|
|
98
|
-
},
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
if (res.status === 429 && retries > 0) {
|
|
102
|
-
const retryAfter = res.headers.get('Retry-After');
|
|
103
|
-
const waitMs = retryAfter ? Number(retryAfter) * 1000 : 60_000;
|
|
104
|
-
await Bun.sleep(waitMs);
|
|
105
|
-
return fetchPackageIssues(name, version, retries - 1);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
if (res.status === 404) return [];
|
|
109
|
-
|
|
110
|
-
if (!res.ok) {
|
|
111
|
-
const body = await res.text().catch(() => '');
|
|
112
|
-
throw new Error(`Snyk API ${res.status}: ${body || res.statusText}`);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
const { data } = (await res.json()) as SnykResponse;
|
|
116
|
-
return data ?? [];
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
export async function batchFetchIssues(
|
|
120
|
-
packages: Bun.Security.Package[],
|
|
121
|
-
onProgress?: (completed: number, total: number) => void
|
|
122
|
-
): Promise<Map<string, SnykIssue[]>> {
|
|
123
|
-
const results = new Map<string, SnykIssue[]>();
|
|
124
|
-
let completed = 0;
|
|
125
|
-
|
|
126
|
-
for (let i = 0; i < packages.length; i += CONCURRENCY) {
|
|
127
|
-
await Promise.all(
|
|
128
|
-
packages.slice(i, i + CONCURRENCY).map(async (pkg) => {
|
|
129
|
-
const issues = await fetchPackageIssues(pkg.name, pkg.version);
|
|
130
|
-
results.set(`${pkg.name}@${pkg.version}`, issues);
|
|
131
|
-
onProgress?.(++completed, packages.length);
|
|
132
|
-
})
|
|
133
|
-
);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
return results;
|
|
137
|
-
}
|
package/src/snyk/config.ts
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
export const SNYK_API_BASE =
|
|
2
|
-
Bun.env.SNYK_API_BASE ?? 'https://api.snyk.io/rest';
|
|
3
|
-
export const SNYK_API_VERSION = Bun.env.SNYK_API_VERSION ?? '2024-04-29';
|
|
4
|
-
export const SNYK_TOKEN = Bun.env.SNYK_TOKEN;
|
|
5
|
-
export const SNYK_ORG_ID = Bun.env.SNYK_ORG_ID;
|
|
6
|
-
export const FETCH_TIMEOUT_MS = Number(Bun.env.SNYK_TIMEOUT_MS) || 10_000;
|
|
7
|
-
export const FAIL_CLOSED = Bun.env.SNYK_FAIL_CLOSED === 'true';
|
|
8
|
-
export const NO_CACHE = Bun.env.SNYK_NO_CACHE === 'true';
|
|
9
|
-
// Max concurrent connections (independent of rate limit)
|
|
10
|
-
export const CONCURRENCY = Number(Bun.env.SNYK_CONCURRENCY) || 10;
|
|
11
|
-
// Requests per minute โ hard ceiling is 180; default leaves headroom
|
|
12
|
-
export const RATE_LIMIT = Math.min(Number(Bun.env.SNYK_RATE_LIMIT) || 160, 180);
|
|
13
|
-
|
|
14
|
-
export const CACHE_FILE = Bun.env.SNYK_CACHE_FILE ?? '.snyk.lock';
|
|
15
|
-
export const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
package/src/snyk/index.ts
DELETED
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
import { type Backend, createScanner } from '../scanner';
|
|
2
|
-
import { batchFetchIssues, validateConfig } from './client';
|
|
3
|
-
import { CACHE_FILE, FAIL_CLOSED, NO_CACHE } from './config';
|
|
4
|
-
import { advisoryUrl, severityLevel } from './severity';
|
|
5
|
-
|
|
6
|
-
const backend: Backend = {
|
|
7
|
-
name: 'Snyk',
|
|
8
|
-
cacheFile: CACHE_FILE,
|
|
9
|
-
noCache: NO_CACHE,
|
|
10
|
-
failClosed: FAIL_CLOSED,
|
|
11
|
-
validateConfig,
|
|
12
|
-
|
|
13
|
-
async fetchAdvisories(packages, onStatus) {
|
|
14
|
-
const issueMap = await batchFetchIssues(packages, (done, total) => {
|
|
15
|
-
onStatus(`Scanning packages via Snyk (${done}/${total})...`);
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
const result = new Map<string, Bun.Security.Advisory[]>();
|
|
19
|
-
for (const pkg of packages) {
|
|
20
|
-
const key = `${pkg.name}@${pkg.version}`;
|
|
21
|
-
const issues = issueMap.get(key) ?? [];
|
|
22
|
-
result.set(
|
|
23
|
-
key,
|
|
24
|
-
issues.map((issue) => ({
|
|
25
|
-
level: severityLevel(issue),
|
|
26
|
-
package: pkg.name,
|
|
27
|
-
url: advisoryUrl(issue),
|
|
28
|
-
description: issue.attributes.title,
|
|
29
|
-
}))
|
|
30
|
-
);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
return result;
|
|
34
|
-
},
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
export { backend };
|
|
38
|
-
export const scanner = createScanner(backend);
|
package/src/snyk/severity.ts
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
import type { SnykIssue } from './client';
|
|
2
|
-
|
|
3
|
-
export function severityLevel(issue: SnykIssue): 'fatal' | 'warn' {
|
|
4
|
-
const level = issue.attributes.effective_severity_level;
|
|
5
|
-
return level === 'critical' || level === 'high' ? 'fatal' : 'warn';
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
export function advisoryUrl(issue: SnykIssue): string {
|
|
9
|
-
return `https://security.snyk.io/vuln/${issue.id}`;
|
|
10
|
-
}
|