UNPKG

@vigilbase/cli

Version:

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

184 lines (183 loc) • 13.1 kB
/** * 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)]; }