UNPKG

@vigilbase/cli

Version:

Vigilbase CLI — security scanners, agent tools, and Vigilbase One from the terminal.

188 lines (187 loc) • 8.46 kB
/** * Snapshot files (spec §F7). * * Both modes write the bytes to a `wx` 0600 temp file in the same directory * first, so `--out` never holds a partial snapshot. The default mode publishes * it with `link(2)`, which never replaces an existing file (deviation from * §F7's direct `open(path, 'wx')`; on filesystems without hard links it falls * back to that, removing the file if the write fails). `--overwrite` renames * the temp file over the target. `submit` uploads the exact bytes of the file * the user inspected. */ import { createHash, randomBytes } from 'node:crypto'; import { link, open, readFile, rename, rm, stat } from 'node:fs/promises'; import { basename, dirname, join } from 'node:path'; import { CloudflareCheckupSnapshotV1Schema, SNAPSHOT_LIMITS, SNAPSHOT_SCHEMA_VERSION, expandRuleRuns, snapshotIssueList, snapshotRejectReason, } from './contract.js'; import { LocalCheckupError, SNAPSHOT_MISSING_HINT } from './errors.js'; export function sha256Hex(bytes) { return createHash('sha256').update(bytes).digest('hex'); } /** `link(2)` errors from filesystems without hard links (FAT/exFAT, some SMB mounts). */ const NO_HARD_LINK_CODES = new Set(['EPERM', 'ENOTSUP', 'EOPNOTSUPP', 'ENOSYS']); /** Create `path` exclusively (0600), write and fsync; a partially written file is removed. */ async function writeExclusive(path, bytes) { // no-dd-sa:javascript-node-security/detect-non-literal-fs-filename — the user's --out path; created exclusively, never followed. const handle = await open(path, 'wx', 0o600); try { try { await handle.writeFile(bytes); await handle.sync(); } finally { await handle.close(); } } catch (error) { // This process created the file (`wx`), so removing it cannot touch anyone else's data. await rm(path, { force: true }).catch(() => undefined); throw error; } } /** Give the complete temp file its final name without ever replacing an existing file. */ async function publishExclusive(temp, path, bytes) { try { await link(temp, path); } catch (error) { if (!NO_HARD_LINK_CODES.has(error.code ?? '')) throw error; await writeExclusive(path, bytes); } } /** Write the snapshot; refuses an existing file unless `overwrite` (atomic rename). */ export async function writeSnapshotFile(path, text, options) { const bytes = Buffer.from(text, 'utf8'); if (options.overwrite) { const existing = await stat(path).catch(() => null); if (existing && !existing.isFile()) throw new Error('--out must name a regular file.'); } const temp = join(dirname(path), `.${basename(path)}.${randomBytes(6).toString('hex')}.tmp`); try { await writeExclusive(temp, bytes); if (options.overwrite) await rename(temp, path); else await publishExclusive(temp, path, bytes); } catch (error) { const code = error.code; if (!options.overwrite && code === 'EEXIST') { throw new LocalCheckupError('OUTPUT_EXISTS', 'The --out file already exists; it was not modified.'); } throw new Error(`Could not write the snapshot file (${code ?? 'error'}).`); } finally { // A no-op after rename; drops the temp name after link. await rm(temp, { force: true }).catch(() => undefined); } return { bytes: bytes.byteLength, sha256: sha256Hex(bytes) }; } /** Map a failed contract parse to the CLI error (no values, issue codes only). */ export function snapshotParseError(issues) { const reason = snapshotRejectReason(issues); const codes = [...new Set(snapshotIssueList(issues).map((issue) => issue.code))].slice(0, 5).join(', '); if (reason === 'secret_like_value') { return new LocalCheckupError('SNAPSHOT_SECRET_LIKE_VALUE', 'The snapshot contains a value that looks like a credential, so it will not be written or uploaded.'); } return new LocalCheckupError('SNAPSHOT_INVALID', `The snapshot does not match ${SNAPSHOT_SCHEMA_VERSION} (${codes || 'invalid'}).`); } /** Read, hash and validate a snapshot file against the vendored contract. */ export async function loadSnapshotFile(path) { const info = await stat(path).catch(() => null); if (!info) throw new LocalCheckupError('SNAPSHOT_INVALID', 'The snapshot file does not exist.', { hint: SNAPSHOT_MISSING_HINT }); if (!info.isFile()) throw new LocalCheckupError('SNAPSHOT_INVALID', 'The snapshot path is not a file.', { hint: SNAPSHOT_MISSING_HINT }); if (info.size > SNAPSHOT_LIMITS.maxBytes) { throw new LocalCheckupError('SNAPSHOT_TOO_LARGE', `The snapshot is larger than the ${SNAPSHOT_LIMITS.maxBytes / 1024 / 1024} MiB contract limit.`); } // no-dd-sa:javascript-node-security/detect-non-literal-fs-filename — the user's own snapshot path. const bytes = await readFile(path); if (bytes.byteLength > SNAPSHOT_LIMITS.maxBytes) throw new LocalCheckupError('SNAPSHOT_TOO_LARGE', 'The snapshot is larger than the contract limit.'); let raw; try { raw = JSON.parse(bytes.toString('utf8')); } catch { throw new LocalCheckupError('SNAPSHOT_INVALID', 'The snapshot file is not valid JSON.'); } const parsed = CloudflareCheckupSnapshotV1Schema.safeParse(raw); if (!parsed.success) throw snapshotParseError(parsed.error.issues); return { path, bytes, sha256: sha256Hex(bytes), snapshot: parsed.data }; } export function countEntries(entries) { const counts = { total: entries.length, ok: 0, httpError: 0, notCollected: 0, collectionFailed: 0 }; for (const entry of entries) { const result = entry.result; if (result.ok) counts.ok++; else if (result.reason === 'http_error') counts.httpError++; else if (result.reason === 'not_collected') counts.notCollected++; else counts.collectionFailed++; } return counts; } export function countEntriesByKind(entries) { const byKind = {}; for (const entry of entries) { byKind[entry.kind] ??= { total: 0, ok: 0, httpError: 0, notCollected: 0, collectionFailed: 0 }; const counts = byKind[entry.kind]; const result = entry.result; counts.total++; if (result.ok) counts.ok++; else if (result.reason === 'http_error') counts.httpError++; else if (result.reason === 'not_collected') counts.notCollected++; else counts.collectionFailed++; } return byKind; } export function dataCategories(snapshot) { const categories = { memberEmails: 0, accessRuleValues: 0, dnsHostnames: 0, pseudonymizedIps: 0, ruleDescriptions: 0 }; const ips = new Set(); for (const entry of snapshot.entries) { if (!entry.result.ok) continue; switch (entry.kind) { case 'account_members': categories.memberEmails += entry.result.data.filter((member) => member.email !== undefined).length; break; case 'account_access_rules': case 'zone_access_rules': categories.accessRuleValues += entry.result.data.filter((rule) => rule.configuration?.value !== undefined).length; break; case 'zone_dns_records': for (const record of entry.result.data) { if (record.name !== undefined) categories.dnsHostnames++; ips.add(record.content); } break; default: break; } } categories.pseudonymizedIps = ips.size; for (const runs of Object.values(snapshot.ruleBodies)) { categories.ruleDescriptions += expandRuleRuns(runs).filter((rule) => rule.description !== undefined || rule.ref !== undefined).length; } return categories; } export function zoneCount(snapshot) { const zones = snapshot.entries.find((entry) => entry.kind === 'zones'); return zones && zones.kind === 'zones' ? zones.result.data.length : 0; } export function accountName(snapshot) { const account = snapshot.entries.find((entry) => entry.kind === 'account'); return account && account.kind === 'account' && account.result.ok ? account.result.data.name : null; }