UNPKG

@vigilbase/cli

Version:

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

102 lines (101 loc) • 4.75 kB
import { lookup } from 'node:dns/promises'; import { request } from 'node:https'; import { BlockList, isIP } from 'node:net'; import { NetworkError, networkError } from './network.js'; const SKILL_HOSTS = new Set(['vigilbase.com', 'www.vigilbase.com']); export const MAX_SKILL_BYTES = 256 * 1024; const blocked = new BlockList(); for (const [address, prefix] of [ ['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], ['169.254.0.0', 16], ['172.16.0.0', 12], ['192.0.0.0', 24], ['192.0.2.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15], ['198.51.100.0', 24], ['203.0.113.0', 24], ['224.0.0.0', 3], ]) blocked.addSubnet(address, prefix, 'ipv4'); for (const [address, prefix] of [ ['2001::', 23], ['2001:db8::', 32], ['2002::', 16], ['3fff::', 20], ]) blocked.addSubnet(address, prefix, 'ipv6'); const globalV6 = new BlockList(); globalV6.addSubnet('2000::', 3, 'ipv6'); export function isPublicSkillAddress(address) { const family = isIP(address); if (family === 4) return !blocked.check(address, 'ipv4'); if (family === 6) return globalV6.check(address, 'ipv6') && !blocked.check(address, 'ipv6'); return false; } export function validateSkillUrl(raw) { let url; try { url = new URL(raw); } catch { throw new NetworkError('Invalid skill documentation URL.', 'UNSAFE_SKILL_URL'); } if (/[\u0000-\u0020\u007f-\u009f]/.test(raw) || url.protocol !== 'https:' || !SKILL_HOSTS.has(url.hostname) || url.port || url.username || url.password || url.hash || url.search || !/^\/agent-skills\/[a-z0-9-]+\.md$/.test(url.pathname)) { throw new NetworkError('Skill documentation must use an approved HTTPS Vigilbase skill URL.', 'UNSAFE_SKILL_URL'); } return url; } /** Resolve once, reject mixed private/public DNS, and pin the validated address to TLS. */ export async function fetchSkillText(config, raw) { const url = validateSkillUrl(raw); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), config.timeoutMs); const aborted = new Promise((_resolve, reject) => { controller.signal.addEventListener('abort', () => reject(new Error('Aborted')), { once: true }); }); try { const addresses = await Promise.race([lookup(url.hostname, { all: true }), aborted]); if (!addresses.length || addresses.some(({ address }) => !isPublicSkillAddress(address))) { throw new NetworkError('Skill destination resolves to a prohibited network address.', 'UNSAFE_SKILL_ADDRESS'); } const address = addresses[0]; return await Promise.race([new Promise((resolve, reject) => { const req = request(url, { method: 'GET', agent: false, family: address.family, // Preserve hostname/SNI and certificate verification, but do not resolve again. lookup: (_hostname, _options, callback) => callback(null, address.address, address.family), signal: controller.signal, headers: { Accept: 'text/markdown, text/plain', 'User-Agent': config.userAgent }, }, (response) => { const status = response.statusCode ?? 0; // Reject every redirect, including redirects to otherwise approved hosts. if (status < 200 || status >= 300) { response.destroy(); reject(new NetworkError(`Skill request failed (HTTP ${status}); redirects are not allowed.`, 'SKILL_HTTP_ERROR')); return; } let size = 0; const chunks = []; response.on('error', reject); response.on('data', (chunk) => { size += chunk.length; if (size > MAX_SKILL_BYTES) { response.destroy(); reject(new NetworkError('Skill documentation exceeds the size limit.', 'RESPONSE_TOO_LARGE')); return; } chunks.push(chunk); }); response.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); }); req.on('error', reject); req.end(); }), aborted]); } catch (error) { if (error instanceof NetworkError) throw error; throw networkError(error, url.href, config.timeoutMs, controller.signal.aborted); } finally { clearTimeout(timer); } }