@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.
Files changed (2) hide show
  1. package/package.json +29 -0
  2. 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
+ }