@vigilbase/cli
Version:
Vigilbase CLI — security scanners, agent tools, and Vigilbase One from the terminal.
188 lines (187 loc) • 8.46 kB
JavaScript
/**
* 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;
}