@vigilbase/cli
Version:
Vigilbase CLI — security scanners, agent tools, and Vigilbase One from the terminal.
184 lines (183 loc) • 13.1 kB
JavaScript
/**
* The frozen allowlist of read-only cf command templates (spec §F4).
*
* argv only ever comes from these templates. Interpolated values are
* re-validated (32-hex IDs, the 19 zone setting IDs, integer pages), every API
* template carries `--quiet` and profile mode appends `--profile <p>`. No
* template has a confirmation-skipping, local-mode or request-body flag;
* `run-cf.ts` refuses such argv at spawn time as a second guard. `per_page`
* values equal the scanner's, so page boundaries and API order match live runs.
*
* The offline preflight runs each template with `--dry-run` and placeholder
* IDs. In cf beta.13 the dry-run object is a literal emitted by each generated
* handler, so the preflight is a command-name → API-path mapping check only:
* it detects renamed or remapped commands but does not prove the real request
* target. Scrubbing base-URL variables from the child env is that control.
*/
import { ZONE_SETTING_IDS } from '../contract.js';
const HEX32 = /^[0-9a-f]{32}$/;
const PLACEHOLDER_ID = '0'.repeat(32);
// Spec: ^[A-Za-z0-9_-]{1,64}$, minus a leading '-' so a name can never be parsed by cf as a flag.
const PROFILE = /^[A-Za-z0-9_][A-Za-z0-9_-]{0,63}$/;
export const DEFAULT_PROFILE = 'vigilbase-checkup';
const pages = (perPage, maxPages = 50) => ({ kind: 'pages', perPage, maxPages });
function deepFreeze(value) {
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
Object.freeze(value);
for (const child of Object.values(value))
deepFreeze(child);
}
return value;
}
export const TEMPLATES = deepFreeze({
account_selection: { args: ['accounts', 'list', '--per-page', '50', '--page', '{N}'], path: '/accounts', query: { page: '{N}', per_page: 50 }, paging: pages(50), output: 'array', scope: 'account' },
account: { args: ['accounts', 'get'], path: '/accounts/{A}', paging: { kind: 'single' }, output: 'object', scope: 'account' },
zones: { args: ['zones', 'list', '--account-id', '{A}', '--per-page', '50', '--page', '{N}'], path: '/zones', query: { 'account.id': '{A}', page: '{N}', per_page: 50 }, paging: pages(50), output: 'array', scope: 'account' },
account_members: { args: ['accounts', 'members', 'list', '--per-page', '50', '--page', '{N}'], path: '/accounts/{A}/members', query: { page: '{N}', per_page: 50 }, paging: pages(50), output: 'array', scope: 'account' },
account_rulesets: { args: ['rulesets', 'list', '--per-page', '100'], path: '/accounts/{A}/rulesets', query: { per_page: 100 }, paging: { kind: 'one_call', perPage: 100 }, output: 'array', scope: 'account' },
account_access_rules: { args: ['firewall', 'access-rules', 'list', '--per-page', '50', '--page', '{N}'], path: '/accounts/{A}/firewall/access_rules/rules', query: { page: '{N}', per_page: 50 }, paging: pages(50), output: 'array', scope: 'account' },
access_apps: { args: ['zero-trust', 'access', 'applications', 'list', '--per-page', '50', '--page', '{N}'], path: '/accounts/{A}/access/apps', query: { page: '{N}', per_page: 50 }, paging: pages(50), output: 'array', scope: 'account' },
access_identity_providers: { args: ['zero-trust', 'identity-providers', 'list', '--per-page', '50', '--page', '{N}'], path: '/accounts/{A}/access/identity_providers', query: { page: '{N}', per_page: 50 }, paging: pages(50), output: 'array', scope: 'account' },
access_service_tokens: { args: ['zero-trust', 'access', 'service-tokens', 'list', '--per-page', '50', '--page', '{N}'], path: '/accounts/{A}/access/service_tokens', query: { page: '{N}', per_page: 50 }, paging: pages(50), output: 'array', scope: 'account' },
gateway_rules: { args: ['zero-trust', 'gateway', 'rules', 'list'], path: '/accounts/{A}/gateway/rules', query: {}, paging: { kind: 'single' }, output: 'array', scope: 'account' },
gateway_locations: { args: ['zero-trust', 'gateway', 'locations', 'list'], path: '/accounts/{A}/gateway/locations', query: {}, paging: { kind: 'single' }, output: 'array', scope: 'account' },
// Substitute for GET /cfd_tunnel (no cf command in beta.13); response equivalence is launch gate 1.
cfd_tunnels: { args: ['tunnels', 'list', '--tun-types', 'cfd_tunnel', '--is-deleted', 'false', '--per-page', '50', '--page', '{N}'], path: '/accounts/{A}/tunnels', query: { is_deleted: false, tun_types: 'cfd_tunnel', per_page: 50, page: '{N}' }, paging: pages(50), output: 'array', scope: 'account' },
zone_setting: { args: ['zones', 'settings', 'get', '{S}', '--zone', '{Z}'], path: '/zones/{Z}/settings/{S}', paging: { kind: 'single' }, output: 'object', scope: 'setting' },
zone_dnssec: { args: ['dns', 'dnssec', 'get', '--zone', '{Z}'], path: '/zones/{Z}/dnssec', paging: { kind: 'single' }, output: 'object', scope: 'zone' },
zone_dns_records: { args: ['dns', 'records', 'list', '--zone', '{Z}', '--per-page', '100', '--page', '{N}'], path: '/zones/{Z}/dns_records', query: { page: '{N}', per_page: 100 }, paging: pages(100, 100), output: 'array', scope: 'zone' },
zone_universal_ssl: { args: ['ssl', 'universal', 'settings', 'get', '--zone', '{Z}'], path: '/zones/{Z}/ssl/universal/settings', paging: { kind: 'single' }, output: 'object', scope: 'zone' },
zone_total_tls: { args: ['acm', 'total-tls', 'get', '--zone', '{Z}'], path: '/zones/{Z}/acm/total_tls', paging: { kind: 'single' }, output: 'object', scope: 'zone' },
zone_rulesets: { args: ['rulesets', 'list', '--zone', '{Z}', '--per-page', '100'], path: '/zones/{Z}/rulesets', query: { per_page: 100 }, paging: { kind: 'one_call', perPage: 100 }, output: 'array', scope: 'zone' },
zone_ruleset: { args: ['rulesets', 'get', '{R}', '--zone', '{Z}'], path: '/zones/{Z}/rulesets/{R}', paging: { kind: 'single' }, output: 'object', scope: 'ruleset' },
zone_access_rules: { args: ['firewall', 'access-rules', 'list', '--zone', '{Z}', '--per-page', '100', '--page', '{N}'], path: '/zones/{Z}/firewall/access_rules/rules', query: { page: '{N}', per_page: 100 }, paging: pages(100), output: 'array', scope: 'zone' },
zone_page_rules: { args: ['page-rules', 'list', '--zone', '{Z}'], path: '/zones/{Z}/pagerules', query: {}, paging: { kind: 'single' }, output: 'array', scope: 'zone' },
zone_bot_management: { args: ['bot-management', 'get', '--zone', '{Z}'], path: '/zones/{Z}/bot_management', paging: { kind: 'single' }, output: 'object', scope: 'zone' },
zone_page_shield: { args: ['client-side-security', 'settings', 'get', '--zone', '{Z}'], path: '/zones/{Z}/page_shield', paging: { kind: 'single' }, output: 'object', scope: 'zone' },
zone_schema_validation_settings: { args: ['api-security', 'schema-validation', 'settings', 'get', '--zone', '{Z}'], path: '/zones/{Z}/schema_validation/settings', paging: { kind: 'single' }, output: 'object', scope: 'zone' },
zone_schema_validation_schemas: { args: ['api-security', 'schema-validation', 'schemas', 'list', '--zone', '{Z}', '--omit-source', '--per-page', '100', '--page', '{N}'], path: '/zones/{Z}/schema_validation/schemas', query: { page: '{N}', per_page: 100, omit_source: true }, paging: pages(100), output: 'array', scope: 'zone' },
});
export const TEMPLATE_IDS = Object.keys(TEMPLATES);
export function validateProfileName(profile) {
if (!PROFILE.test(profile))
throw new Error('--profile must be 1-64 letters, digits, "_" or "-", and must not start with "-".');
return profile;
}
function hex(value, label) {
if (value === undefined || !HEX32.test(value))
throw new Error(`Invalid ${label} for a cf command.`);
return value;
}
/** The exact argv for one call. Throws (a programming error) on any invalid interpolation. */
export function buildArgv(id, ids, auth, options = {}) {
const spec = TEMPLATES[id];
const argv = spec.args.map((arg) => {
switch (arg) {
case '{Z}': return hex(ids.zoneId, 'zone ID');
case '{R}': return hex(ids.rulesetId, 'ruleset ID');
// Only `zones list --account-id`; every other template gets the account from CLOUDFLARE_ACCOUNT_ID.
case '{A}': return hex(ids.accountId, 'account ID');
case '{S}':
if (!ids.settingId || !ZONE_SETTING_IDS.includes(ids.settingId))
throw new Error('Invalid zone setting ID for a cf command.');
return ids.settingId;
case '{N}':
if (!Number.isSafeInteger(ids.page) || ids.page < 1 || ids.page > 1000)
throw new Error('Invalid page for a cf command.');
return String(ids.page);
default: return arg;
}
});
argv.push('--quiet');
if (auth.authMode === 'profile')
argv.push('--profile', validateProfileName(auth.profile));
if (options.dryRun)
argv.push('--dry-run');
return argv;
}
function fill(template, values) {
return template.replace(/\{([AZRSN])\}/g, (_m, key) => String(values[key]));
}
/** One dry-run per template (one per zone setting), with placeholder IDs. */
export function preflightChecks(auth, only) {
const values = { A: PLACEHOLDER_ID, Z: PLACEHOLDER_ID, R: PLACEHOLDER_ID, S: '', N: 1 };
const checks = [];
for (const id of only ?? TEMPLATE_IDS) {
const spec = TEMPLATES[id];
const settings = id === 'zone_setting' ? [...ZONE_SETTING_IDS] : [undefined];
for (const settingId of settings) {
const filled = { ...values, S: settingId ?? '' };
const query = spec.query === undefined ? undefined : Object.fromEntries(Object.entries(spec.query)
.map(([key, value]) => [key, typeof value === 'string' ? (value === '{N}' ? 1 : fill(value, filled)) : value]));
checks.push({
id,
...(settingId ? { settingId } : {}),
argv: buildArgv(id, { accountId: PLACEHOLDER_ID, zoneId: PLACEHOLDER_ID, rulesetId: PLACEHOLDER_ID, settingId, page: 1 }, auth, { dryRun: true }),
expectedPath: fill(spec.path, filled),
...(query ? { expectedQuery: query } : {}),
});
}
}
return checks;
}
export const PREFLIGHT_ACCOUNT_ID = PLACEHOLDER_ID;
const API_PREFIX = 'https://api.cloudflare.com/client/v4';
function sameQuery(actual, expected) {
if (expected === undefined)
return actual === undefined;
if (!actual || typeof actual !== 'object' || Array.isArray(actual))
return false;
const left = Object.entries(actual).filter(([, value]) => value !== undefined && value !== null);
if (left.length !== Object.keys(expected).length)
return false;
return left.every(([key, value]) => Object.hasOwn(expected, key) && expected[key] === value);
}
/** True when a dry-run object maps to `GET` and exactly the expected path and query. */
export function dryRunMatches(stdout, check) {
let printed;
try {
printed = JSON.parse(stdout);
}
catch {
return false;
}
if (!printed || typeof printed !== 'object' || Array.isArray(printed))
return false;
const { method, url, query } = printed;
if (method !== 'GET' || typeof url !== 'string' || !url.startsWith(`${API_PREFIX}/`))
return false;
return url.slice(API_PREFIX.length) === check.expectedPath && sameQuery(query, check.expectedQuery);
}
/** Kinds whose mismatch aborts collection with CF_ALLOWLIST_MISMATCH. */
export const CRITICAL_TEMPLATES = ['account_selection', 'account', 'zones'];
/** Display form of every template, with placeholders, for `collect --dry-run`. */
export function templateDisplay(id, auth) {
const argv = TEMPLATES[id].args.map((arg) => arg === '{Z}' ? '<ZONE_ID>' : arg === '{R}' ? '<RULESET_ID>' : arg === '{S}' ? '<SETTING_ID>' : arg === '{N}' ? '<PAGE>' : arg === '{A}' ? '<ACCOUNT_ID>' : arg);
argv.push('--quiet');
if (auth.authMode === 'profile')
argv.push('--profile', auth.profile);
return argv;
}
/** The other cf argv the CLI uses: version, the serial whoami, and the login and logout commands. */
export function versionArgv() {
return ['--version'];
}
export function whoamiArgv(auth) {
return auth.authMode === 'profile' ? ['auth', 'whoami', '--profile', validateProfileName(auth.profile)] : ['auth', 'whoami'];
}
/** `cf auth create <p> [--no-browser] --scopes …` (re-authenticates an existing profile). */
export function loginArgv(profile, scopes, options) {
for (const scope of scopes)
if (!/^[a-z0-9_.:-]{1,64}$/.test(scope))
throw new Error('Invalid OAuth scope.');
return ['auth', 'create', validateProfileName(profile), ...(options.noBrowser ? ['--no-browser'] : []), '--scopes', ...scopes];
}
/**
* `cf auth delete <p>` (logout). In cf 1.0.0-beta.13 it takes only the name:
* it refuses `--profile` and a shell API token, fails when the profile does
* not exist, removes the profile's directory bindings, logs the profile out
* and deletes its stored credentials. It has no confirmation prompt.
*/
export function logoutArgv(profile) {
return ['auth', 'delete', validateProfileName(profile)];
}