mirror of
https://github.com/prompt-security/clawsec.git
synced 2026-06-13 05:28:02 +03:00
073e771b73
* feat(advisories): add exploitability context for CVE advisories * fix(ci): align exploitability workflow with signing model * docs(skills): add patch release changelog entries * chore(clawsec-feed): bump version to 0.0.5 * chore(clawsec-suite): bump version to 0.1.4 * fix(clawsec-nanoclaw): align exploitability handling and nanoclaw integration * chore(clawsec-nanoclaw): bump version to 0.0.2 * refactor(scripts): share feed path and mirror sync helpers * refactor(utils): unify cvss vector parsing flow * refactor(clawsec-nanoclaw): centralize advisory risk evaluation * docs(exploitability): refresh release metadata dates * fix(review): align feed signing and advisory dedupe * chore(clawsec-feed): bump version to 0.0.6 * chore(clawsec-nanoclaw): bump version to 0.0.3 * fix(backfill): limit signing to target feed only * fix(review): keep skill runtime verify-only and dedupe matching * chore(clawsec-nanoclaw): bump version to 0.0.4 * chore(skills): align versions with published tags * feat(feed): enrich local population with exploitability analysis * docs(exploitability): mark backfill as historical flow
350 lines
11 KiB
TypeScript
350 lines
11 KiB
TypeScript
/**
|
|
* Advisory Feed Loading and Matching for NanoClaw
|
|
* Ported from ClawSec's feed.mjs with fail-closed verification
|
|
*/
|
|
|
|
import fs from 'fs/promises';
|
|
import path from 'path';
|
|
import {
|
|
Advisory,
|
|
AdvisoryFeed,
|
|
AdvisoryMatch,
|
|
AffectedSpecifier,
|
|
SignatureVerificationOptions,
|
|
} from './types.js';
|
|
import {
|
|
verifySignedPayload,
|
|
parseChecksumsManifest,
|
|
verifyChecksums,
|
|
fetchText,
|
|
defaultChecksumsUrl,
|
|
SecurityPolicyError,
|
|
} from './signatures.js';
|
|
|
|
const DEFAULT_FEED_URL = 'https://clawsec.prompt.security/advisories/feed.json';
|
|
|
|
/**
|
|
* Validates that a payload is a valid advisory feed.
|
|
*/
|
|
export function isValidFeedPayload(raw: unknown): raw is AdvisoryFeed {
|
|
if (typeof raw !== 'object' || raw === null) return false;
|
|
const obj = raw as Record<string, unknown>;
|
|
|
|
if (typeof obj.version !== 'string' || !obj.version.trim()) return false;
|
|
if (!Array.isArray(obj.advisories)) return false;
|
|
|
|
for (const advisory of obj.advisories) {
|
|
if (typeof advisory !== 'object' || advisory === null) return false;
|
|
const adv = advisory as Record<string, unknown>;
|
|
|
|
if (typeof adv.id !== 'string' || !adv.id.trim()) return false;
|
|
if (typeof adv.severity !== 'string' || !adv.severity.trim()) return false;
|
|
if (!Array.isArray(adv.affected)) return false;
|
|
if (!adv.affected.every((entry) => typeof entry === 'string' && entry.trim())) return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Parses an affected specifier like "skill-name@version-spec".
|
|
*/
|
|
export function parseAffectedSpecifier(rawSpecifier: string): AffectedSpecifier | null {
|
|
const specifier = rawSpecifier.trim();
|
|
if (!specifier) return null;
|
|
|
|
const atIndex = specifier.lastIndexOf('@');
|
|
if (atIndex <= 0) {
|
|
return { name: specifier, versionSpec: '*' };
|
|
}
|
|
|
|
return {
|
|
name: specifier.slice(0, atIndex),
|
|
versionSpec: specifier.slice(atIndex + 1),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Normalizes a skill name for comparison.
|
|
*/
|
|
export function normalizeSkillName(name: string): string {
|
|
return name.toLowerCase().trim().replace(/[^a-z0-9-]/g, '');
|
|
}
|
|
|
|
/**
|
|
* Checks if a version matches a version specifier.
|
|
* Supports: exact match, semver range (^, ~, *), wildcards
|
|
*/
|
|
export function versionMatches(version: string, versionSpec: string): boolean {
|
|
const v = version.trim();
|
|
const spec = versionSpec.trim();
|
|
|
|
// Wildcard matches everything
|
|
if (spec === '*' || spec === '') return true;
|
|
|
|
// Exact match
|
|
if (v === spec) return true;
|
|
|
|
// Parse semver components
|
|
const parseVersion = (ver: string): number[] => {
|
|
const match = ver.match(/^(\d+)\.(\d+)\.(\d+)/);
|
|
if (!match) return [];
|
|
return [parseInt(match[1], 10), parseInt(match[2], 10), parseInt(match[3], 10)];
|
|
};
|
|
|
|
const vParts = parseVersion(v);
|
|
const specParts = parseVersion(spec.replace(/^[~^]/, ''));
|
|
|
|
if (vParts.length === 0 || specParts.length === 0) return false;
|
|
|
|
// Caret range (^1.2.3): compatible with 1.x.x where x >= 2.3
|
|
if (spec.startsWith('^')) {
|
|
if (vParts[0] !== specParts[0]) return false;
|
|
if (vParts[0] === 0) {
|
|
// ^0.2.3 means 0.2.x where x >= 3
|
|
if (vParts[1] !== specParts[1]) return false;
|
|
return vParts[2] >= specParts[2];
|
|
}
|
|
// ^1.2.3 means 1.x.x where x.x >= 2.3
|
|
if (vParts[1] > specParts[1]) return true;
|
|
if (vParts[1] < specParts[1]) return false;
|
|
return vParts[2] >= specParts[2];
|
|
}
|
|
|
|
// Tilde range (~1.2.3): patch-level compatibility (1.2.x where x >= 3)
|
|
if (spec.startsWith('~')) {
|
|
if (vParts[0] !== specParts[0]) return false;
|
|
if (vParts[1] !== specParts[1]) return false;
|
|
return vParts[2] >= specParts[2];
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Checks whether an affected specifier matches a skill name/version.
|
|
* Optionally matches against a skill directory name as alias.
|
|
*/
|
|
export function matchesAffectedSpecifier(
|
|
affected: string,
|
|
skillName: string,
|
|
skillVersion: string | null,
|
|
skillDirName?: string
|
|
): boolean {
|
|
const parsed = parseAffectedSpecifier(affected);
|
|
if (!parsed) return false;
|
|
|
|
const normalizedTarget = normalizeSkillName(parsed.name);
|
|
const normalizedSkillName = normalizeSkillName(skillName);
|
|
const normalizedDirName = skillDirName ? normalizeSkillName(skillDirName) : null;
|
|
|
|
if (normalizedTarget !== normalizedSkillName && normalizedTarget !== normalizedDirName) {
|
|
return false;
|
|
}
|
|
|
|
if (!skillVersion) {
|
|
return true;
|
|
}
|
|
|
|
return versionMatches(skillVersion, parsed.versionSpec);
|
|
}
|
|
|
|
/**
|
|
* Loads advisory feed from a remote URL with signature verification.
|
|
*/
|
|
export async function loadRemoteFeed(
|
|
feedUrl: string,
|
|
options: SignatureVerificationOptions
|
|
): Promise<AdvisoryFeed | null> {
|
|
const signatureUrl = options.signatureUrl || `${feedUrl}.sig`;
|
|
const checksumsUrl = options.checksumsUrl || defaultChecksumsUrl(feedUrl);
|
|
const checksumsSignatureUrl = options.checksumsSignatureUrl || `${checksumsUrl}.sig`;
|
|
const publicKeyPem = options.publicKeyPem;
|
|
const checksumsPublicKeyPem = options.checksumsPublicKeyPem || publicKeyPem;
|
|
const allowUnsigned = options.allowUnsigned || false;
|
|
const verifyChecksumManifest = options.verifyChecksumManifest !== false;
|
|
|
|
try {
|
|
const payloadRaw = await fetchText(feedUrl);
|
|
if (!payloadRaw) return null;
|
|
|
|
if (!allowUnsigned) {
|
|
const signatureRaw = await fetchText(signatureUrl);
|
|
if (!signatureRaw) return null;
|
|
|
|
if (!verifySignedPayload(payloadRaw, signatureRaw, publicKeyPem)) {
|
|
return null;
|
|
}
|
|
|
|
// Verify checksum manifest if available
|
|
if (verifyChecksumManifest) {
|
|
const checksumsRaw = await fetchText(checksumsUrl);
|
|
const checksumsSignatureRaw = await fetchText(checksumsSignatureUrl);
|
|
|
|
// Only proceed if BOTH checksum files are present
|
|
if (checksumsRaw && checksumsSignatureRaw) {
|
|
if (!verifySignedPayload(checksumsRaw, checksumsSignatureRaw, checksumsPublicKeyPem)) {
|
|
return null; // Fail-closed: invalid signature
|
|
}
|
|
|
|
const checksumsManifest = parseChecksumsManifest(checksumsRaw);
|
|
const checksumFeedEntry = feedUrl.split('/').pop() || 'feed.json';
|
|
const checksumSignatureEntry = signatureUrl.split('/').pop() || 'feed.json.sig';
|
|
verifyChecksums(checksumsManifest, {
|
|
[checksumFeedEntry]: payloadRaw,
|
|
[checksumSignatureEntry]: signatureRaw,
|
|
});
|
|
}
|
|
// If checksum files missing: continue without checksum verification
|
|
// (feed signature was already verified above)
|
|
}
|
|
}
|
|
|
|
try {
|
|
const payload = JSON.parse(payloadRaw);
|
|
if (!isValidFeedPayload(payload)) return null;
|
|
return payload;
|
|
} catch {
|
|
return null;
|
|
}
|
|
} catch (error) {
|
|
// Security policy violations return null to allow graceful fallback to local feed
|
|
if (error instanceof SecurityPolicyError) {
|
|
return null;
|
|
}
|
|
// Re-throw unexpected errors
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Loads advisory feed from a local file with signature verification.
|
|
*/
|
|
export async function loadLocalFeed(
|
|
feedPath: string,
|
|
options: SignatureVerificationOptions
|
|
): Promise<AdvisoryFeed> {
|
|
const signaturePath = options.signatureUrl || `${feedPath}.sig`;
|
|
const checksumsPath = options.checksumsUrl || path.join(path.dirname(feedPath), 'checksums.json');
|
|
const checksumsSignaturePath = options.checksumsSignatureUrl || `${checksumsPath}.sig`;
|
|
const publicKeyPem = options.publicKeyPem;
|
|
const checksumsPublicKeyPem = options.checksumsPublicKeyPem || publicKeyPem;
|
|
const allowUnsigned = options.allowUnsigned || false;
|
|
const verifyChecksumManifest = options.verifyChecksumManifest !== false;
|
|
|
|
const payloadRaw = await fs.readFile(feedPath, 'utf8');
|
|
|
|
if (!allowUnsigned) {
|
|
const signatureRaw = await fs.readFile(signaturePath, 'utf8');
|
|
if (!verifySignedPayload(payloadRaw, signatureRaw, publicKeyPem)) {
|
|
throw new Error(`Feed signature verification failed for local feed: ${feedPath}`);
|
|
}
|
|
|
|
if (verifyChecksumManifest) {
|
|
const checksumsRaw = await fs.readFile(checksumsPath, 'utf8');
|
|
const checksumsSignatureRaw = await fs.readFile(checksumsSignaturePath, 'utf8');
|
|
|
|
if (!verifySignedPayload(checksumsRaw, checksumsSignatureRaw, checksumsPublicKeyPem)) {
|
|
throw new Error(`Checksum manifest signature verification failed: ${checksumsPath}`);
|
|
}
|
|
|
|
const checksumsManifest = parseChecksumsManifest(checksumsRaw);
|
|
const checksumFeedEntry = path.basename(feedPath);
|
|
const checksumSignatureEntry = path.basename(signaturePath);
|
|
verifyChecksums(checksumsManifest, {
|
|
[checksumFeedEntry]: payloadRaw,
|
|
[checksumSignatureEntry]: signatureRaw,
|
|
});
|
|
}
|
|
}
|
|
|
|
const payload = JSON.parse(payloadRaw);
|
|
if (!isValidFeedPayload(payload)) {
|
|
throw new Error(`Invalid advisory feed format: ${feedPath}`);
|
|
}
|
|
return payload;
|
|
}
|
|
|
|
/**
|
|
* Loads advisory feed from remote or falls back to local.
|
|
*/
|
|
export async function loadFeed(
|
|
feedUrl: string = DEFAULT_FEED_URL,
|
|
localFeedPath: string,
|
|
publicKeyPem: string,
|
|
allowUnsigned: boolean = false
|
|
): Promise<{ feed: AdvisoryFeed; source: string }> {
|
|
const options: SignatureVerificationOptions = {
|
|
publicKeyPem,
|
|
allowUnsigned,
|
|
verifyChecksumManifest: true,
|
|
};
|
|
|
|
// Try remote feed first
|
|
const remoteFeed = await loadRemoteFeed(feedUrl, options);
|
|
if (remoteFeed) {
|
|
return { feed: remoteFeed, source: `remote:${feedUrl}` };
|
|
}
|
|
|
|
// Fall back to local feed
|
|
const localFeed = await loadLocalFeed(localFeedPath, options);
|
|
return { feed: localFeed, source: `local:${localFeedPath}` };
|
|
}
|
|
|
|
/**
|
|
* Checks if an advisory looks high-risk.
|
|
*/
|
|
export function advisoryLooksHighRisk(advisory: Advisory): boolean {
|
|
const type = advisory.type.toLowerCase();
|
|
const severity = advisory.severity.toLowerCase();
|
|
const exploitability = (advisory.exploitability_score || 'unknown').toLowerCase();
|
|
const combined = `${advisory.title} ${advisory.description} ${advisory.action}`.toLowerCase();
|
|
|
|
if (type.includes('malicious')) return true;
|
|
if (severity === 'critical') return true;
|
|
if (exploitability === 'high') return true;
|
|
if (/\b(malicious|exfiltrate|exfiltration|backdoor|trojan|stealer|credential theft)\b/.test(combined)) return true;
|
|
if (/\b(remove|uninstall|disable|do not use|quarantine)\b/.test(combined)) return true;
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Finds advisory matches for a skill.
|
|
*/
|
|
export function findAdvisoryMatches(
|
|
feed: AdvisoryFeed,
|
|
skillName: string,
|
|
version: string | null
|
|
): AdvisoryMatch[] {
|
|
const matches: AdvisoryMatch[] = [];
|
|
|
|
for (const advisory of feed.advisories) {
|
|
const affected = advisory.affected || [];
|
|
if (affected.length === 0) continue;
|
|
|
|
for (const specifier of affected) {
|
|
if (!matchesAffectedSpecifier(specifier, skillName, version)) {
|
|
continue;
|
|
}
|
|
|
|
// Match found
|
|
matches.push({
|
|
advisory,
|
|
matchedSpecifier: specifier,
|
|
isHighRisk: advisoryLooksHighRisk(advisory),
|
|
});
|
|
break; // Only count each advisory once
|
|
}
|
|
}
|
|
|
|
return matches;
|
|
}
|
|
|
|
/**
|
|
* Removes duplicate strings from an array.
|
|
*/
|
|
export function uniqueStrings(arr: string[]): string[] {
|
|
return Array.from(new Set(arr));
|
|
}
|