diff --git a/bin/stasis.js b/bin/stasis.js index b9ef043..2fa19ff 100755 --- a/bin/stasis.js +++ b/bin/stasis.js @@ -21,8 +21,8 @@ function usage(prefix = '') { stasis run --lock=(add|replace|frozen|ignore) [--bundle=(add|replace|load|ignore)] [--bundle-file=path/to/bundle.br] [--full] path/to/file.js ... stasis bundle create path/to/lockfile stasis bundle verify path/to/lockfile - stasis advisories path/to/lockfile stasis prune [path/to/project] + stasis audit path/to/file ... `.trim()) process.exit(1) } @@ -80,6 +80,13 @@ if (command === 'run') { const { prune } = await import('../src/prune.js') const { removed, validated } = prune({ root }) console.warn(`[stasis] prune: validated ${validated.length} file(s), removed ${removed.length} file(s)`) +} else if (command === 'audit') { + if (argv.length === 0) usage('Nothing to audit: no path to file given') + const { audit, printAuditReport } = await import('../src/audit.js') + const files = argv.map((f) => resolve(f)) + const report = await audit(files) + printAuditReport(report) + process.exitCode = report.rows.length === 0 ? 0 : 1 } else { usage() } diff --git a/src/apis/npm/index.js b/src/apis/npm/index.js index 5614206..ac82796 100644 --- a/src/apis/npm/index.js +++ b/src/apis/npm/index.js @@ -4,10 +4,10 @@ import semver from './semver.cjs' const packageNameRegex = /^(@[\da-z-]+\/)?[\w-]+(\.[\w-]+)*$/u -export async function advisories(list) { +export async function advisories(list, { signal = AbortSignal.timeout(30_000) } = {}) { const groups = new Map() for (const { name, version } of list) { - assert(name && version && typeof name === 'string' && typeof version === 'string') + assert(typeof name === 'string' && typeof version === 'string') assert(packageNameRegex.test(name), `Unexpected package name: ${name}`) assert(semver.valid(version), `Invalid version: ${version}`) if (!groups.has(name)) groups.set(name, new Set()) @@ -17,12 +17,20 @@ export async function advisories(list) { const entries = [...groups].map(([k, v]) => [k, [...v].sort((a, b) => semver.compare(a, b))]) const body = Object.fromEntries(entries.sort((a, b) => a[0] < b[0] ? -1 : 1)) - const res = await fetch('https://registry.npmjs.org/-/npm/v1/security/advisories/bulk', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) - - assert(res.ok) + let res + try { + res = await fetch('https://registry.npmjs.org/-/npm/v1/security/advisories/bulk', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal, + }) + } catch (cause) { + throw new Error(`npm advisories request failed: ${cause.message}`, { cause }) + } + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new Error(`npm advisories request failed: ${res.status} ${res.statusText}${text ? ` — ${text.slice(0, 200)}` : ''}`) + } return res.json() } diff --git a/src/audit.js b/src/audit.js new file mode 100644 index 0000000..bfafab2 --- /dev/null +++ b/src/audit.js @@ -0,0 +1,149 @@ +import { readFileSync } from 'node:fs' +import { brotliDecompressSync } from 'node:zlib' + +import { advisories } from './apis/npm/index.js' +import semver from './apis/npm/semver.cjs' +import { Bundle } from './bundle.js' +import { Lockfile } from './lockfile.js' + +function parseFile(file) { + let buf + try { + buf = readFileSync(file) + } catch (cause) { + if (cause.code === 'ENOENT') throw new Error(`File not found: ${file}`, { cause }) + throw new Error(`Failed to read ${file}: ${cause.message}`, { cause }) + } + // Lockfiles are JSON text starting with `{`; code/resource bundles are + // brotli-compressed JSON. Sniff to route to the right parser so its + // diagnostic surfaces on failure. + if (buf[0] === 0x7b /* '{' */) { + try { + return Lockfile.parse(buf.toString('utf8')) + } catch (cause) { + throw new Error(`Failed to parse stasis lockfile: ${file}`, { cause }) + } + } + let text + try { + text = brotliDecompressSync(buf).toString('utf8') + } catch (cause) { + throw new Error(`Failed to read ${file} as a stasis bundle (not brotli)`, { cause }) + } + // Code bundles carry `formats`/`imports`; resource bundles don't. Route on shape. + try { + const isCode = JSON.parse(text).formats !== undefined + return isCode ? Bundle.parseCode(text) : Bundle.parseResources(text) + } catch (cause) { + throw new Error(`Failed to parse stasis bundle: ${file}`, { cause }) + } +} + +// Only audit installed dependencies; workspace/first-party packages live under +// non-`node_modules` keys in .modules (Lockfile/Bundle merge sources in) and +// shouldn't be sent to the public registry — they leak names and produce noise. +export function collectPackagesFromFile(file) { + const out = [] + for (const [dir, { name, version }] of parseFile(file).modules) { + if (!dir.includes('node_modules')) continue + if (name && version) out.push({ name, version }) + } + return out +} + +export function collectPackages(files) { + const seen = new Set() + const out = [] + for (const file of files) { + for (const { name, version } of collectPackagesFromFile(file)) { + const key = `${name}@${version}` + if (seen.has(key)) continue + seen.add(key) + out.push({ name, version }) + } + } + return out.sort((a, b) => a.name.localeCompare(b.name) || semver.compare(a.version, b.version)) +} + +const SEVERITY_ORDER = { critical: 0, high: 1, moderate: 2, low: 3, info: 4, none: 5 } + +export function flattenAdvisories(result, packages = []) { + const installed = new Map() + for (const { name, version } of packages) { + if (!installed.has(name)) installed.set(name, []) + installed.get(name).push(version) + } + const rows = [] + for (const [pkg, list] of Object.entries(result)) { + if (!Array.isArray(list)) continue + const installedVersions = installed.get(pkg) ?? [] + for (const adv of list) { + const range = adv.vulnerable_versions ?? '' + const affected = range + ? installedVersions.filter((v) => semver.satisfies(v, range)) + : installedVersions + // npm occasionally returns advisories whose vulnerable range matches none + // of the versions we submitted; drop them so the table (and the CLI exit + // code) reflect only real hits. + if (installedVersions.length > 0 && affected.length === 0) continue + rows.push({ + package: pkg, + installed: affected.join(', '), + vulnerable: range, + severity: adv.severity ?? '', + title: adv.title ?? '', + url: adv.url ?? '', + }) + } + } + rows.sort((a, b) => { + const sa = SEVERITY_ORDER[a.severity] ?? 99 + const sb = SEVERITY_ORDER[b.severity] ?? 99 + if (sa !== sb) return sa - sb + if (a.package !== b.package) return a.package < b.package ? -1 : 1 + return a.title < b.title ? -1 : 1 + }) + return rows +} + +// Line breaks in npm advisory titles would otherwise break the box layout. +// Covers LF, bare CR (and CRLF), and U+2028 / U+2029 line/paragraph separators. +const cell = (v) => String(v ?? '').replace(/[\r\n\u2028\u2029]+/gu, ' ') + +export function formatTable(rows, columns) { + if (rows.length === 0) return '' + const widths = columns.map((c) => Math.max(c.length, ...rows.map((r) => cell(r[c]).length))) + const pad = (s, w) => s.padEnd(w) + const line = (l, m, r, fill) => l + widths.map((w) => fill.repeat(w + 2)).join(m) + r + const row = (vals) => '│ ' + vals.map((v, i) => pad(cell(v), widths[i])).join(' │ ') + ' │' + return [ + line('┌', '┬', '┐', '─'), + row(columns), + line('├', '┼', '┤', '─'), + ...rows.map((r) => row(columns.map((c) => r[c]))), + line('└', '┴', '┘', '─'), + ].join('\n') +} + +export async function audit(files) { + const packages = collectPackages(files) + if (packages.length === 0) { + return { packages, advisories: {}, rows: [] } + } + const result = await advisories(packages) + const rows = flattenAdvisories(result, packages) + return { packages, advisories: result, rows } +} + +export function printAuditReport({ packages, rows }, { out = process.stdout, err = process.stderr } = {}) { + err.write(`Scanned ${packages.length} package${packages.length === 1 ? '' : 's'}\n`) + if (packages.length === 0) { + err.write('No node_modules entries found in the input files\n') + return + } + if (rows.length === 0) { + err.write('No advisories found\n') + return + } + out.write(formatTable(rows, ['severity', 'package', 'installed', 'vulnerable', 'title', 'url']) + '\n') +} diff --git a/tests/audit.test.js b/tests/audit.test.js new file mode 100644 index 0000000..59e5009 --- /dev/null +++ b/tests/audit.test.js @@ -0,0 +1,345 @@ +import { test } from 'node:test' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { brotliCompressSync } from 'node:zlib' +import { spawnSync } from 'node:child_process' +import { stripVTControlCharacters } from 'node:util' + +import { audit, collectPackages, collectPackagesFromFile, flattenAdvisories, formatTable, printAuditReport } from '../src/audit.js' + +const here = dirname(fileURLToPath(import.meta.url)) +const cli = join(here, '..', 'bin', 'stasis.js') + +const withTmp = (fn) => (t) => { + const dir = mkdtempSync(join(tmpdir(), 'stasis-audit-')) + try { + return fn(t, dir) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +} + +const writeLock = (dir, name = 'stasis.lock.json', extra = {}) => { + const path = join(dir, name) + const lock = { + version: 0, + config: { scope: 'full' }, + entries: ['src/entry.js'], + sources: { + '.': { name: 'top-pkg', version: '1.0.0', files: { 'src/entry.js': 'sha512-x' } }, + }, + modules: { + 'node_modules/foo': { name: 'foo', version: '1.2.3', files: { 'index.js': 'sha512-y' } }, + 'node_modules/bar': { name: 'bar', version: '4.5.6', files: { 'index.js': 'sha512-z' } }, + }, + ...extra, + } + writeFileSync(path, JSON.stringify(lock)) + return path +} + +const writeBundle = (dir, name = 'snapshot.br') => { + const path = join(dir, name) + const bundle = { + version: 1, + config: { scope: 'full' }, + entries: ['src/entry.js'], + sources: { + '.': { name: 'top-pkg', version: '9.9.9', files: { 'src/entry.js': 'export const x = 1\n' } }, + }, + modules: { + 'node_modules/foo': { name: 'foo', version: '2.0.0', files: { 'index.js': 'export const f = 1\n' } }, + 'node_modules/baz': { name: 'baz', version: '0.0.1', files: { 'index.js': 'export const b = 1\n' } }, + }, + formats: { 'node_modules/foo/index.js': 'module' }, + imports: {}, + } + writeFileSync(path, brotliCompressSync(Buffer.from(JSON.stringify(bundle)))) + return path +} + +test('collectPackages reads name/version from a lockfile (node_modules only)', withTmp((t, tmp) => { + const file = writeLock(tmp) + const pkgs = collectPackagesFromFile(file) + t.assert.deepEqual( + pkgs.sort((a, b) => (a.name < b.name ? -1 : 1)), + [ + { name: 'bar', version: '4.5.6' }, + { name: 'foo', version: '1.2.3' }, + ] + ) +})) + +test('collectPackages reads name/version from a brotli bundle (node_modules only)', withTmp((t, tmp) => { + const file = writeBundle(tmp) + const pkgs = collectPackagesFromFile(file) + t.assert.deepEqual( + pkgs.sort((a, b) => (a.name < b.name ? -1 : 1)), + [ + { name: 'baz', version: '0.0.1' }, + { name: 'foo', version: '2.0.0' }, + ] + ) +})) + +test('collectPackages skips workspace (first-party) modules', withTmp((t, tmp) => { + const lock = writeLock(tmp) + const bundle = writeBundle(tmp) + const pkgs = collectPackages([lock, bundle]) + t.assert.ok(!pkgs.some((p) => p.name === 'top-pkg'), 'workspace package must not be audited') +})) + +test('collectPackages skips bundle modules without name/version (v0 legacy)', withTmp((t, tmp) => { + const path = join(tmp, 'legacy.br') + const legacy = { + version: 0, + config: { scope: 'full' }, + formats: {}, + imports: {}, + sources: { 'node_modules/foo/index.js': 'x' }, + } + writeFileSync(path, brotliCompressSync(Buffer.from(JSON.stringify(legacy)))) + t.assert.deepEqual(collectPackagesFromFile(path), []) +})) + +test('collectPackages deduplicates across files', withTmp((t, tmp) => { + const lock = writeLock(tmp) + const bundle = writeBundle(tmp) + const pkgs = collectPackages([lock, bundle]) + // foo appears in both at different versions, both should remain + t.assert.deepEqual(pkgs, [ + { name: 'bar', version: '4.5.6' }, + { name: 'baz', version: '0.0.1' }, + { name: 'foo', version: '1.2.3' }, + { name: 'foo', version: '2.0.0' }, + ]) +})) + +test('collectPackages dedupes exact name+version duplicates', withTmp((t, tmp) => { + const a = writeLock(tmp, 'a.json') + const b = writeLock(tmp, 'b.json') + const pkgs = collectPackages([a, b]) + t.assert.equal(pkgs.length, 2) +})) + +test('collectPackagesFromFile rejects unknown JSON shape with a lockfile-specific error', withTmp((t, tmp) => { + const file = join(tmp, 'junk.json') + writeFileSync(file, JSON.stringify({ hello: 'world' })) + t.assert.throws(() => collectPackagesFromFile(file), /Failed to parse stasis lockfile/) +})) + +test('collectPackagesFromFile rejects non-brotli non-JSON binary with a bundle-specific error', withTmp((t, tmp) => { + const file = join(tmp, 'junk.bin') + writeFileSync(file, Buffer.from([0xff, 0xfe, 0xfd, 0xfc])) + t.assert.throws(() => collectPackagesFromFile(file), /Failed to read .* as a stasis bundle/) +})) + +test('collectPackagesFromFile reports a clean error when the input is missing', (t) => { + const file = join(tmpdir(), 'definitely-does-not-exist-stasis.lock.json') + t.assert.throws(() => collectPackagesFromFile(file), /File not found:/) +}) + +test('collectPackagesFromFile wraps brotli-valid but JSON-corrupt bundles', withTmp((t, tmp) => { + const file = join(tmp, 'corrupt.br') + writeFileSync(file, brotliCompressSync(Buffer.from('not valid json {'))) + t.assert.throws(() => collectPackagesFromFile(file), /Failed to parse stasis bundle/) +})) + +test('collectPackagesFromFile accepts a resource bundle', withTmp((t, tmp) => { + const file = join(tmp, 'resources.br') + const json = { + version: 1, + config: { scope: 'full' }, + sources: { + '.': { name: 'top', version: '1.0.0', files: { 'a.bin': 'AAA=' } }, + }, + modules: { + 'node_modules/lib': { name: 'lib', version: '3.2.1', files: { 'b.bin': 'BBB=' } }, + }, + } + writeFileSync(file, brotliCompressSync(Buffer.from(JSON.stringify(json)))) + t.assert.deepEqual(collectPackagesFromFile(file), [{ name: 'lib', version: '3.2.1' }]) +})) + +test('collectPackages does not collapse different packages at the same version', withTmp((t, tmp) => { + const file = join(tmp, 'lock.json') + writeFileSync(file, JSON.stringify({ + version: 0, + config: { scope: 'node_modules' }, + modules: { + 'node_modules/a': { name: 'a', version: '1.0.0', files: { 'i.js': 'sha512-x' } }, + 'node_modules/b': { name: 'b', version: '1.0.0', files: { 'i.js': 'sha512-y' } }, + }, + })) + t.assert.deepEqual(collectPackages([file]), [ + { name: 'a', version: '1.0.0' }, + { name: 'b', version: '1.0.0' }, + ]) +})) + +test('flattenAdvisories sorts by severity then package', (t) => { + const result = { + foo: [ + { id: 1, severity: 'low', title: 't1', url: 'u1', vulnerable_versions: '<2' }, + { id: 2, severity: 'critical', title: 't2', url: 'u2', vulnerable_versions: '<2' }, + ], + bar: [ + { id: 3, severity: 'critical', title: 'aaa', url: 'u3', vulnerable_versions: '<5' }, + ], + } + const rows = flattenAdvisories(result) + t.assert.deepEqual(rows.map((r) => [r.severity, r.package, r.title]), [ + ['critical', 'bar', 'aaa'], + ['critical', 'foo', 't2'], + ['low', 'foo', 't1'], + ]) +}) + +test('flattenAdvisories joins installed versions matching vulnerable_versions', (t) => { + const packages = [ + { name: 'foo', version: '1.0.0' }, + { name: 'foo', version: '3.0.0' }, + { name: 'bar', version: '2.0.0' }, + ] + const result = { + foo: [{ severity: 'high', title: 'x', url: 'u', vulnerable_versions: '<2' }], + bar: [{ severity: 'low', title: 'y', url: 'u', vulnerable_versions: '*' }], + } + const rows = flattenAdvisories(result, packages) + const foo = rows.find((r) => r.package === 'foo') + const bar = rows.find((r) => r.package === 'bar') + t.assert.equal(foo.installed, '1.0.0', 'only the affected installed version of foo is listed') + t.assert.equal(bar.installed, '2.0.0') +}) + +test('printAuditReport hints when nothing was scanned', (t) => { + const lines = [] + const err = { write: (s) => lines.push(s) } + printAuditReport({ packages: [], rows: [] }, { out: { write: () => {} }, err }) + t.assert.equal(lines.join(''), 'Scanned 0 packages\nNo node_modules entries found in the input files\n') +}) + +test('formatTable produces a boxed table with header and separator', (t) => { + const out = formatTable( + [ + { a: 'x', b: 'yyy' }, + { a: 'xxx', b: 'y' }, + ], + ['a', 'b'] + ) + const lines = out.split('\n') + t.assert.equal(lines.length, 6) + t.assert.match(lines[0], /^┌─+┬─+┐$/u) + t.assert.match(lines[1], /^│ a +│ b +│$/u) + t.assert.match(lines[2], /^├─+┼─+┤$/u) + t.assert.match(lines[5], /^└─+┴─+┘$/u) +}) + +// CLI integration +const { + EXODUS_STASIS_LOCK: _l, + EXODUS_STASIS_SCOPE: _s, + EXODUS_STASIS_BUNDLE: _b, + EXODUS_STASIS_BUNDLE_FILE: _bf, + EXODUS_STASIS_DEBUG: _d, + ...cleanEnv +} = process.env + +const runCli = (args, opts = {}) => { + const r = spawnSync(process.execPath, [cli, ...args], { encoding: 'utf-8', env: cleanEnv, ...opts }) + r.stdout = stripVTControlCharacters(r.stdout) + r.stderr = stripVTControlCharacters(r.stderr) + return r +} + +test('audit with no files prints usage', (t) => { + const r = runCli(['audit']) + t.assert.equal(r.status, 1) + t.assert.match(r.stderr, /Nothing to audit/) +}) + +test('audit rejects unknown file shape', withTmp((t, tmp) => { + const file = join(tmp, 'junk.json') + writeFileSync(file, JSON.stringify({ hello: 'world' })) + const r = runCli(['audit', file]) + t.assert.notEqual(r.status, 0) + t.assert.match(r.stderr, /Failed to parse stasis lockfile/) +})) + +const withFetch = (impl, fn) => async (t) => { + const original = globalThis.fetch + const calls = [] + globalThis.fetch = async (url, opts) => { + calls.push({ url, opts }) + return impl({ url, opts }) + } + try { + return await fn(t, calls) + } finally { + globalThis.fetch = original + } +} + +test('audit() POSTs grouped versions to the npm bulk endpoint and joins rows', withFetch( + () => new Response(JSON.stringify({ + foo: [{ id: 1, severity: 'high', title: 'bug', url: 'https://x', vulnerable_versions: '<2' }], + }), { status: 200, headers: { 'content-type': 'application/json' } }), + async (t, calls) => { + const tmp = mkdtempSync(join(tmpdir(), 'stasis-audit-')) + try { + const lock = writeLock(tmp) + const report = await audit([lock]) + t.assert.equal(calls.length, 1) + t.assert.equal(calls[0].url, 'https://registry.npmjs.org/-/npm/v1/security/advisories/bulk') + t.assert.equal(calls[0].opts.method, 'POST') + const body = JSON.parse(calls[0].opts.body) + t.assert.deepEqual(body, { bar: ['4.5.6'], foo: ['1.2.3'] }, 'workspace top-pkg must not be sent') + t.assert.equal(report.rows.length, 1) + t.assert.equal(report.rows[0].severity, 'high') + t.assert.equal(report.rows[0].installed, '1.2.3') + } finally { + rmSync(tmp, { recursive: true, force: true }) + } + } +)) + +test('audit() wraps non-2xx npm responses in a helpful error', withFetch( + () => new Response('boom', { status: 503, statusText: 'Service Unavailable' }), + async (t) => { + const tmp = mkdtempSync(join(tmpdir(), 'stasis-audit-')) + try { + const lock = writeLock(tmp) + await t.assert.rejects(() => audit([lock]), /npm advisories request failed: 503/) + } finally { + rmSync(tmp, { recursive: true, force: true }) + } + } +)) + +test('audit() wraps network/abort errors with the cause preserved', withFetch( + () => { throw new Error('connection refused') }, + async (t) => { + const tmp = mkdtempSync(join(tmpdir(), 'stasis-audit-')) + try { + const lock = writeLock(tmp) + await t.assert.rejects(() => audit([lock]), /npm advisories request failed: connection refused/) + } finally { + rmSync(tmp, { recursive: true, force: true }) + } + } +)) + +test('flattenAdvisories drops advisories that match no installed version', (t) => { + const packages = [{ name: 'foo', version: '5.0.0' }] + const result = { + foo: [ + { severity: 'high', title: 'old', url: 'u', vulnerable_versions: '<2' }, + { severity: 'low', title: 'current', url: 'u', vulnerable_versions: '>=5' }, + ], + } + const rows = flattenAdvisories(result, packages) + t.assert.equal(rows.length, 1) + t.assert.equal(rows[0].title, 'current') +})