@vulnix/scan 1.0.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/package.json +29 -0
- package/scan-ci.js +302 -0
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vulnix/scan",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Vulnix dependency vulnerability scanner CLI — scan lock files in CI/CD pipelines",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"vulnix-scan": "./scan-ci.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"scan-ci.js"
|
|
11
|
+
],
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=18"
|
|
14
|
+
},
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"homepage": "https://vulnix.io",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "https://github.com/pbhasin/vulnix"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"vulnerability",
|
|
23
|
+
"security",
|
|
24
|
+
"sast",
|
|
25
|
+
"dependency-scanning",
|
|
26
|
+
"ci",
|
|
27
|
+
"sbom"
|
|
28
|
+
]
|
|
29
|
+
}
|
package/scan-ci.js
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* VulnScanner CI CLI
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* node scan-ci.js <manifest-file> [options]
|
|
7
|
+
*
|
|
8
|
+
* Options:
|
|
9
|
+
* --fail-on=CRITICAL|HIGH|MEDIUM|LOW Minimum severity that fails the build (default: HIGH)
|
|
10
|
+
* --url=https://... Scanner API base URL (or VULNSCANNER_URL env)
|
|
11
|
+
* --token=vsk_... API token (or VULNSCANNER_TOKEN env)
|
|
12
|
+
* --timeout=120 Seconds to wait for scan completion (default: 120)
|
|
13
|
+
* --json Print findings as JSON to stdout
|
|
14
|
+
* --output=vulnix-findings.json Write findings JSON to a file (for CI artifacts)
|
|
15
|
+
*
|
|
16
|
+
* Exit codes:
|
|
17
|
+
* 0 No findings above threshold (or findings all dismissed)
|
|
18
|
+
* 1 Findings found above threshold — block the pipeline
|
|
19
|
+
* 2 Scan error or timeout
|
|
20
|
+
* 3 Bad arguments / config
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { readFileSync, writeFileSync } from 'fs'
|
|
24
|
+
import { basename } from 'path'
|
|
25
|
+
|
|
26
|
+
// ─── Parse args ─────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
const args = process.argv.slice(2)
|
|
29
|
+
const flags = Object.fromEntries(
|
|
30
|
+
args.filter(a => a.startsWith('--')).map(a => {
|
|
31
|
+
const [k, v] = a.slice(2).split('=')
|
|
32
|
+
return [k, v ?? true]
|
|
33
|
+
})
|
|
34
|
+
)
|
|
35
|
+
const positional = args.filter(a => !a.startsWith('--'))
|
|
36
|
+
const manifestPath = positional[0]
|
|
37
|
+
|
|
38
|
+
const API_URL = flags.url || process.env.VULNSCANNER_URL
|
|
39
|
+
const API_TOKEN = flags.token || process.env.VULNSCANNER_TOKEN
|
|
40
|
+
const FAIL_ON = (flags['fail-on'] || process.env.VULNSCANNER_FAIL_ON || 'HIGH').toUpperCase()
|
|
41
|
+
const TIMEOUT = parseInt(flags.timeout || process.env.VULNSCANNER_TIMEOUT || '120', 10)
|
|
42
|
+
const JSON_OUT = !!flags.json
|
|
43
|
+
const OUTPUT_FILE = flags.output || process.env.VULNSCANNER_OUTPUT || null
|
|
44
|
+
|
|
45
|
+
const SEVERITY_RANK = { LOW: 1, MEDIUM: 2, HIGH: 3, CRITICAL: 4 }
|
|
46
|
+
|
|
47
|
+
function bail(msg, code = 3) {
|
|
48
|
+
console.error(`\n[vulnscanner] ERROR: ${msg}`)
|
|
49
|
+
process.exit(code)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!manifestPath) bail('Usage: node scan-ci.js <manifest-file> [--fail-on=HIGH] [--url=...] [--token=...]')
|
|
53
|
+
if (!API_URL) bail('Set VULNSCANNER_URL or pass --url=')
|
|
54
|
+
if (!API_TOKEN) bail('Set VULNSCANNER_TOKEN or pass --token=')
|
|
55
|
+
if (!SEVERITY_RANK[FAIL_ON]) bail(`Invalid --fail-on value: ${FAIL_ON}. Must be CRITICAL, HIGH, MEDIUM, or LOW`)
|
|
56
|
+
|
|
57
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
const base = API_URL.replace(/\/$/, '') + '/api'
|
|
60
|
+
|
|
61
|
+
async function apiFetch(path, options = {}) {
|
|
62
|
+
const res = await fetch(`${base}${path}`, {
|
|
63
|
+
...options,
|
|
64
|
+
headers: {
|
|
65
|
+
'Content-Type': 'application/json',
|
|
66
|
+
'Authorization': `Bearer ${API_TOKEN}`,
|
|
67
|
+
...options.headers,
|
|
68
|
+
},
|
|
69
|
+
})
|
|
70
|
+
const body = await res.json()
|
|
71
|
+
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`)
|
|
72
|
+
return body
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function sleep(ms) { return new Promise(r => setTimeout(r, ms)) }
|
|
76
|
+
|
|
77
|
+
function severityColor(s) {
|
|
78
|
+
if (!s) return '\x1b[90m'
|
|
79
|
+
return { CRITICAL: '\x1b[31m', HIGH: '\x1b[33m', MEDIUM: '\x1b[36m', LOW: '\x1b[34m' }[s] ?? '\x1b[0m'
|
|
80
|
+
}
|
|
81
|
+
const RESET = '\x1b[0m'
|
|
82
|
+
const BOLD = '\x1b[1m'
|
|
83
|
+
const DIM = '\x1b[2m'
|
|
84
|
+
const GREEN = '\x1b[32m'
|
|
85
|
+
const RED = '\x1b[31m'
|
|
86
|
+
|
|
87
|
+
function printTable(findings) {
|
|
88
|
+
if (findings.length === 0) return
|
|
89
|
+
const cols = [
|
|
90
|
+
{ h: 'Package', w: 30, f: r => `${r.package_name}@${r.package_version}` },
|
|
91
|
+
{ h: 'Severity', w: 10, f: r => r.severity ?? '—' },
|
|
92
|
+
{ h: 'ID', w: 22, f: r => r.vulnerability_id },
|
|
93
|
+
{ h: 'Fix', w: 15, f: r => r.fixed_in ?? '—' },
|
|
94
|
+
{ h: 'Summary', w: 50, f: r => (r.description ?? '').slice(0, 48) },
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
const hr = cols.map(c => '─'.repeat(c.w)).join('─┼─')
|
|
98
|
+
const header = cols.map(c => c.h.padEnd(c.w)).join(' │ ')
|
|
99
|
+
console.log(`\n ${BOLD}${header}${RESET}`)
|
|
100
|
+
console.log(` ${hr}`)
|
|
101
|
+
for (const row of findings) {
|
|
102
|
+
const sev = row.severity ?? '—'
|
|
103
|
+
const color = severityColor(sev)
|
|
104
|
+
const cells = cols.map((c, i) => {
|
|
105
|
+
const val = c.f(row).padEnd(c.w).slice(0, c.w)
|
|
106
|
+
return i === 1 ? `${color}${val}${RESET}` : val
|
|
107
|
+
})
|
|
108
|
+
console.log(` ${cells.join(' │ ')}`)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ─── Detect CI repo context ──────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
function detectRepoContext() {
|
|
115
|
+
const env = process.env
|
|
116
|
+
|
|
117
|
+
// GitHub Actions
|
|
118
|
+
if (env.GITHUB_ACTIONS) {
|
|
119
|
+
return {
|
|
120
|
+
repoName: env.GITHUB_REPOSITORY,
|
|
121
|
+
repoUrl: `${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}`,
|
|
122
|
+
branch: env.GITHUB_HEAD_REF || env.GITHUB_REF_NAME,
|
|
123
|
+
commitSha: env.GITHUB_SHA,
|
|
124
|
+
pipelineUrl: `${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}`,
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// GitLab CI
|
|
129
|
+
if (env.GITLAB_CI) {
|
|
130
|
+
return {
|
|
131
|
+
repoName: env.CI_PROJECT_PATH,
|
|
132
|
+
repoUrl: env.CI_PROJECT_URL,
|
|
133
|
+
branch: env.CI_COMMIT_REF_NAME,
|
|
134
|
+
commitSha: env.CI_COMMIT_SHA,
|
|
135
|
+
pipelineUrl: env.CI_PIPELINE_URL,
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Bitbucket Pipelines
|
|
140
|
+
if (env.BITBUCKET_BUILD_NUMBER) {
|
|
141
|
+
return {
|
|
142
|
+
repoName: `${env.BITBUCKET_WORKSPACE}/${env.BITBUCKET_REPO_SLUG}`,
|
|
143
|
+
repoUrl: `https://bitbucket.org/${env.BITBUCKET_WORKSPACE}/${env.BITBUCKET_REPO_SLUG}`,
|
|
144
|
+
branch: env.BITBUCKET_BRANCH,
|
|
145
|
+
commitSha: env.BITBUCKET_COMMIT,
|
|
146
|
+
pipelineUrl: `https://bitbucket.org/${env.BITBUCKET_WORKSPACE}/${env.BITBUCKET_REPO_SLUG}/pipelines/results/${env.BITBUCKET_BUILD_NUMBER}`,
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return {}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function extractProjectName(fname, text) {
|
|
154
|
+
try {
|
|
155
|
+
if (fname === 'package.json' || fname === 'package-lock.json') {
|
|
156
|
+
const pkg = JSON.parse(text)
|
|
157
|
+
if (pkg.name) return pkg.version ? `${pkg.name}@${pkg.version}` : pkg.name
|
|
158
|
+
}
|
|
159
|
+
if (fname === 'go.mod') {
|
|
160
|
+
const m = text.match(/^module\s+(\S+)/m)
|
|
161
|
+
if (m) return m[1]
|
|
162
|
+
}
|
|
163
|
+
if (fname === 'pom.xml') {
|
|
164
|
+
const a = text.match(/<artifactId>([^<]+)<\/artifactId>/)
|
|
165
|
+
const v = text.match(/<version>([^<]+)<\/version>/)
|
|
166
|
+
if (a) return v ? `${a[1]}@${v[1]}` : a[1]
|
|
167
|
+
}
|
|
168
|
+
if (fname === 'Cargo.toml') {
|
|
169
|
+
const n = text.match(/^\s*name\s*=\s*"([^"]+)"/m)
|
|
170
|
+
const v = text.match(/^\s*version\s*=\s*"([^"]+)"/m)
|
|
171
|
+
if (n) return v ? `${n[1]}@${v[1]}` : n[1]
|
|
172
|
+
}
|
|
173
|
+
} catch {}
|
|
174
|
+
return null
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ─── Main ────────────────────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
let content, filename
|
|
180
|
+
try {
|
|
181
|
+
content = readFileSync(manifestPath, 'utf8')
|
|
182
|
+
filename = basename(manifestPath)
|
|
183
|
+
} catch {
|
|
184
|
+
bail(`Cannot read file: ${manifestPath}`)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
console.log(`\n${BOLD}VulnScanner CI${RESET}`)
|
|
188
|
+
console.log(`${DIM} API ${RESET} ${base}`)
|
|
189
|
+
console.log(`${DIM} File ${RESET} ${filename}`)
|
|
190
|
+
console.log(`${DIM} Fail ${RESET} severity >= ${FAIL_ON}\n`)
|
|
191
|
+
|
|
192
|
+
// Submit scan
|
|
193
|
+
const repoContext = detectRepoContext()
|
|
194
|
+
if (!repoContext.repoName) {
|
|
195
|
+
const projectName = extractProjectName(filename, content)
|
|
196
|
+
if (projectName) repoContext.repoName = projectName
|
|
197
|
+
}
|
|
198
|
+
if (repoContext.repoName) {
|
|
199
|
+
console.log(`${DIM} Repo ${RESET} ${repoContext.repoName}`)
|
|
200
|
+
console.log(`${DIM} Branch ${RESET} ${repoContext.branch ?? '—'}`)
|
|
201
|
+
console.log(`${DIM} Commit ${RESET} ${repoContext.commitSha?.slice(0, 12) ?? '—'}\n`)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
let scanId
|
|
205
|
+
try {
|
|
206
|
+
console.log(' Submitting scan…')
|
|
207
|
+
const res = await apiFetch('/scans', {
|
|
208
|
+
method: 'POST',
|
|
209
|
+
body: JSON.stringify({ filename, content, ...repoContext }),
|
|
210
|
+
})
|
|
211
|
+
scanId = res.scanId
|
|
212
|
+
console.log(` Scan ID: ${DIM}${scanId}${RESET}`)
|
|
213
|
+
console.log(` Dependencies detected: ${res.dependencyCount} (${res.manifestType})\n`)
|
|
214
|
+
} catch (err) {
|
|
215
|
+
bail(`Failed to submit scan: ${err.message}`, 2)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Poll for completion
|
|
219
|
+
const deadline = Date.now() + TIMEOUT * 1000
|
|
220
|
+
let scan = null
|
|
221
|
+
let timedOut = true
|
|
222
|
+
|
|
223
|
+
process.stdout.write(' Waiting for results ')
|
|
224
|
+
while (Date.now() < deadline) {
|
|
225
|
+
await sleep(3000)
|
|
226
|
+
process.stdout.write('.')
|
|
227
|
+
try {
|
|
228
|
+
scan = await apiFetch(`/scans/${scanId}`)
|
|
229
|
+
if (scan.status === 'done' || scan.status === 'error') { timedOut = false; break }
|
|
230
|
+
} catch { /* retry */ }
|
|
231
|
+
}
|
|
232
|
+
console.log()
|
|
233
|
+
|
|
234
|
+
if (!scan || scan.status !== 'done') {
|
|
235
|
+
if (OUTPUT_FILE) {
|
|
236
|
+
try { writeFileSync(OUTPUT_FILE, JSON.stringify({ scanId, status: scan?.status ?? 'unknown', findings: [], failing: 0 }, null, 2)) }
|
|
237
|
+
catch {}
|
|
238
|
+
}
|
|
239
|
+
bail(timedOut
|
|
240
|
+
? `Scan timed out after ${TIMEOUT}s (status: ${scan?.status ?? 'unknown'})`
|
|
241
|
+
: `Scan failed (status: ${scan.status})`,
|
|
242
|
+
2)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ─── Results ─────────────────────────────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
const findings = (scan.findings ?? []).filter(f => !f.dismissed)
|
|
248
|
+
const failing = findings.filter(f => (SEVERITY_RANK[f.severity] ?? 0) >= SEVERITY_RANK[FAIL_ON])
|
|
249
|
+
const passing = findings.filter(f => (SEVERITY_RANK[f.severity] ?? 0) < SEVERITY_RANK[FAIL_ON])
|
|
250
|
+
|
|
251
|
+
const jsonPayload = JSON.stringify({ scanId, status: scan.status, findings, failing: failing.length }, null, 2)
|
|
252
|
+
|
|
253
|
+
if (OUTPUT_FILE) {
|
|
254
|
+
try {
|
|
255
|
+
writeFileSync(OUTPUT_FILE, jsonPayload)
|
|
256
|
+
console.log(` ${DIM}Findings written to ${OUTPUT_FILE}${RESET}`)
|
|
257
|
+
} catch (err) {
|
|
258
|
+
console.error(` WARNING: could not write ${OUTPUT_FILE}: ${err.message}`)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (JSON_OUT) {
|
|
263
|
+
console.log(jsonPayload)
|
|
264
|
+
process.exit(failing.length > 0 ? 1 : 0)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Severity breakdown across all findings
|
|
268
|
+
const bySeverity = {}
|
|
269
|
+
for (const f of findings) {
|
|
270
|
+
bySeverity[f.severity ?? 'UNKNOWN'] = (bySeverity[f.severity ?? 'UNKNOWN'] ?? 0) + 1
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const summaryParts = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']
|
|
274
|
+
.filter(s => bySeverity[s] > 0)
|
|
275
|
+
.map(s => `${severityColor(s)}${bySeverity[s]} ${s}${RESET}`)
|
|
276
|
+
|
|
277
|
+
console.log(` Total findings: ${findings.length}${summaryParts.length ? ' (' + summaryParts.join(', ') + ')' : ''}`)
|
|
278
|
+
console.log(` Fail threshold: ${FAIL_ON} and above (${failing.length} failing, ${passing.length} informational)`)
|
|
279
|
+
|
|
280
|
+
if (findings.length > 0) {
|
|
281
|
+
if (failing.length > 0) {
|
|
282
|
+
console.log(`\n ${BOLD}Findings above threshold (${FAIL_ON}+):${RESET}`)
|
|
283
|
+
printTable(failing)
|
|
284
|
+
}
|
|
285
|
+
if (passing.length > 0) {
|
|
286
|
+
console.log(`\n ${DIM}${BOLD}Informational findings (below ${FAIL_ON}):${RESET}`)
|
|
287
|
+
printTable(passing)
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
console.log(`\n ${DIM}View full report: ${base.replace('/api', '')}/scans/${scanId}${RESET}`)
|
|
292
|
+
|
|
293
|
+
if (failing.length > 0) {
|
|
294
|
+
const failParts = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']
|
|
295
|
+
.filter(s => (bySeverity[s] ?? 0) > 0 && SEVERITY_RANK[s] >= SEVERITY_RANK[FAIL_ON])
|
|
296
|
+
.map(s => `${severityColor(s)}${bySeverity[s]} ${s}${RESET}`)
|
|
297
|
+
console.log(`\n ${RED}${BOLD}✖ Build failed${RESET} — ${failParts.join(', ')}\n`)
|
|
298
|
+
process.exit(1)
|
|
299
|
+
} else {
|
|
300
|
+
console.log(`\n ${GREEN}${BOLD}✔ All findings below fail threshold (${FAIL_ON})${RESET}\n`)
|
|
301
|
+
process.exit(0)
|
|
302
|
+
}
|