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