mirror of
https://github.com/prompt-security/clawsec.git
synced 2026-06-13 05:28:02 +03:00
4dbac421ab
* feat(advisories): add provisional ghsa feed * fix(workflows): include advisory signatures in checksums * fix(workflows): mirror ghsa feed at release root * feat(advisories): consolidate ghsa into agent feed * ci(advisories): consolidate ghsa during nvd poll * fix(advisories): retain unreplaced ghsa feed entries * chore(skills): bump advisory feed consumers * fix(release): resolve ts import closure dry run * fix(release): preserve urls while stripping comments * fix(release): ignore skill test-only changes * fix(advisories): follow ghsa pagination links * test(advisories): add nvd ghsa pipeline dry run
515 lines
16 KiB
JavaScript
515 lines
16 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { existsSync } from 'node:fs';
|
|
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
import { dirname } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
export const DEFAULT_REPOSITORIES = [
|
|
'openclaw/openclaw',
|
|
'qwibitai/nanoclaw',
|
|
'softwarepub/hermes',
|
|
'nousresearch/hermes-agent',
|
|
'sipeed/picoclaw',
|
|
];
|
|
|
|
export const DEFAULT_STALE_AFTER_DAYS = 60;
|
|
export const FEED_VERSION = '0.1.0';
|
|
|
|
const PLATFORM_BY_REPOSITORY = new Map([
|
|
['openclaw/openclaw', 'openclaw'],
|
|
['qwibitai/nanoclaw', 'nanoclaw'],
|
|
['softwarepub/hermes', 'hermes'],
|
|
['nousresearch/hermes-agent', 'hermes'],
|
|
['sipeed/picoclaw', 'picoclaw'],
|
|
]);
|
|
|
|
const CWE_TYPE_BY_ID = new Map([
|
|
['CWE-22', 'path_traversal'],
|
|
['CWE-78', 'os_command_injection'],
|
|
['CWE-79', 'cross_site_scripting'],
|
|
['CWE-94', 'code_injection'],
|
|
['CWE-200', 'exposure_of_sensitive_information'],
|
|
['CWE-284', 'improper_access_control'],
|
|
['CWE-287', 'improper_authentication'],
|
|
['CWE-306', 'missing_authentication_for_critical_function'],
|
|
['CWE-352', 'cross_site_request_forgery'],
|
|
['CWE-400', 'uncontrolled_resource_consumption'],
|
|
['CWE-502', 'deserialization_of_untrusted_data'],
|
|
['CWE-862', 'missing_authorization'],
|
|
['CWE-863', 'incorrect_authorization'],
|
|
['CWE-918', 'server_side_request_forgery'],
|
|
]);
|
|
|
|
function cleanText(value) {
|
|
return String(value ?? '')
|
|
.replace(/\r/g, '')
|
|
.replace(/```[\s\S]*?```/g, ' ')
|
|
.replace(/`([^`]+)`/g, '$1')
|
|
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
|
.replace(/^#+\s+/gm, '')
|
|
.replace(/[*_>]/g, '')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
}
|
|
|
|
function daysBetween(startIso, endIso) {
|
|
const start = Date.parse(startIso);
|
|
const end = Date.parse(endIso);
|
|
if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) {
|
|
return 0;
|
|
}
|
|
return Math.floor((end - start) / 86_400_000);
|
|
}
|
|
|
|
function toArray(value) {
|
|
return Array.isArray(value) ? value : [];
|
|
}
|
|
|
|
function uniqueStrings(values) {
|
|
return [...new Set(values.filter((value) => typeof value === 'string' && value.length > 0))];
|
|
}
|
|
|
|
export function inferPlatforms(repository) {
|
|
const known = PLATFORM_BY_REPOSITORY.get(String(repository).toLowerCase());
|
|
return known ? [known] : [];
|
|
}
|
|
|
|
function nextLinkFromHeader(linkHeader) {
|
|
if (!linkHeader) {
|
|
return null;
|
|
}
|
|
for (const part of linkHeader.split(',')) {
|
|
const match = part.trim().match(/^<([^>]+)>;\s*rel="next"$/);
|
|
if (match) {
|
|
return match[1];
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function affectedFromVulnerabilities(advisory, platforms) {
|
|
const affected = toArray(advisory.vulnerabilities).flatMap((vulnerability) => {
|
|
const packageName = vulnerability?.package?.name;
|
|
const versionRange = vulnerability?.vulnerable_version_range;
|
|
if (!packageName) {
|
|
return [];
|
|
}
|
|
return [`${packageName}@${versionRange || '*'}`];
|
|
});
|
|
|
|
if (affected.length > 0) {
|
|
return uniqueStrings(affected);
|
|
}
|
|
|
|
return platforms.length > 0 ? platforms.map((platform) => `${platform}@*`) : [];
|
|
}
|
|
|
|
function patchedFromVulnerabilities(advisory) {
|
|
return uniqueStrings(
|
|
toArray(advisory.vulnerabilities).flatMap((vulnerability) => {
|
|
const packageName = vulnerability?.package?.name;
|
|
const patchedVersions = vulnerability?.patched_versions;
|
|
if (!packageName || !patchedVersions) {
|
|
return [];
|
|
}
|
|
return [`${packageName}@${patchedVersions}`];
|
|
}),
|
|
);
|
|
}
|
|
|
|
function githubAdvisoryUrl(advisory) {
|
|
return advisory.html_url || advisory.url || `https://github.com/advisories/${advisory.ghsa_id}`;
|
|
}
|
|
|
|
function resolveCveId(advisory, cveIdByGhsa) {
|
|
return advisory.cve_id || cveIdByGhsa.get(advisory.ghsa_id) || null;
|
|
}
|
|
|
|
export function normalizeGhsaAdvisory(
|
|
advisory,
|
|
{
|
|
now,
|
|
repository,
|
|
staleAfterDays = DEFAULT_STALE_AFTER_DAYS,
|
|
cveId = advisory.cve_id || null,
|
|
},
|
|
) {
|
|
const platforms = inferPlatforms(repository);
|
|
const published = advisory.published_at || advisory.created_at || advisory.updated_at || now;
|
|
const ageDays = daysBetween(published, now);
|
|
const stale = !cveId && ageDays >= staleAfterDays;
|
|
const status = cveId ? 'matured' : stale ? 'stale' : 'active';
|
|
const cweIds = uniqueStrings(toArray(advisory.cwe_ids));
|
|
const cvss = advisory.cvss || advisory.cvss_severities?.cvss_v3 || {};
|
|
const ghsaUrl = githubAdvisoryUrl(advisory);
|
|
const affected = affectedFromVulnerabilities(advisory, platforms);
|
|
const patched = patchedFromVulnerabilities(advisory);
|
|
const title = cleanText(advisory.summary) || advisory.ghsa_id;
|
|
const description = cleanText(advisory.description) || title;
|
|
|
|
return {
|
|
id: advisory.ghsa_id,
|
|
ghsa_id: advisory.ghsa_id,
|
|
cve_id: cveId,
|
|
status,
|
|
stale,
|
|
stale_after_days: staleAfterDays,
|
|
severity: advisory.severity || 'medium',
|
|
type: CWE_TYPE_BY_ID.get(cweIds[0]) || 'github_security_advisory',
|
|
nvd_category_id: cweIds[0] || null,
|
|
title,
|
|
description,
|
|
affected,
|
|
patched,
|
|
platforms,
|
|
action: cveId
|
|
? `Track ${cveId} in the canonical CVE advisory feed and verify affected components.`
|
|
: 'Review the GitHub Security Advisory and update affected components; no CVE is assigned yet.',
|
|
published,
|
|
updated: advisory.updated_at || published,
|
|
references: uniqueStrings([ghsaUrl, cveId ? `https://nvd.nist.gov/vuln/detail/${cveId}` : null]),
|
|
source: 'GitHub Security Advisory',
|
|
repository,
|
|
github_advisory_url: ghsaUrl,
|
|
nvd_url: cveId ? `https://nvd.nist.gov/vuln/detail/${cveId}` : null,
|
|
cvss_score: cvss.score ?? null,
|
|
cvss_vector: cvss.vector_string ?? null,
|
|
cwe_ids: cweIds,
|
|
credits: uniqueStrings(toArray(advisory.credits).map((credit) => credit?.login)),
|
|
aliases: uniqueStrings([advisory.ghsa_id, cveId]),
|
|
};
|
|
}
|
|
|
|
function ghsaToCveMapFromNvdFeed(nvdFeed) {
|
|
const map = new Map();
|
|
for (const advisory of toArray(nvdFeed?.advisories)) {
|
|
const cveId = advisory?.id;
|
|
if (typeof cveId !== 'string' || !cveId.startsWith('CVE-')) {
|
|
continue;
|
|
}
|
|
const references = toArray(advisory.references).join('\n');
|
|
for (const match of references.matchAll(/GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}/gi)) {
|
|
map.set(match[0], cveId);
|
|
}
|
|
}
|
|
return map;
|
|
}
|
|
|
|
function equivalentAdvisories(left, right) {
|
|
return JSON.stringify(left ?? []) === JSON.stringify(right ?? []);
|
|
}
|
|
|
|
function isCveId(value) {
|
|
return typeof value === 'string' && /^CVE-\d{4}-\d{4,}$/i.test(value);
|
|
}
|
|
|
|
function ghsaIdentifier(entry) {
|
|
if (typeof entry?.ghsa_id === 'string' && entry.ghsa_id.length > 0) {
|
|
return entry.ghsa_id.toLowerCase();
|
|
}
|
|
if (/^GHSA-/i.test(String(entry?.id || ''))) {
|
|
return String(entry.id).toLowerCase();
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function refreshExistingEntry(entry, { now, staleAfterDays, cveIdByGhsa }) {
|
|
const cveId = entry.cve_id || cveIdByGhsa.get(entry.ghsa_id || entry.id) || null;
|
|
const ageDays = daysBetween(entry.published, now);
|
|
const stale = !cveId && ageDays >= staleAfterDays;
|
|
return {
|
|
...entry,
|
|
cve_id: cveId,
|
|
status: cveId ? 'matured' : stale ? 'stale' : 'active',
|
|
stale,
|
|
stale_after_days: staleAfterDays,
|
|
references: uniqueStrings([
|
|
...toArray(entry.references),
|
|
cveId ? `https://nvd.nist.gov/vuln/detail/${cveId}` : null,
|
|
]),
|
|
nvd_url: cveId ? `https://nvd.nist.gov/vuln/detail/${cveId}` : null,
|
|
aliases: uniqueStrings([...(entry.aliases || []), entry.ghsa_id || entry.id, cveId]),
|
|
};
|
|
}
|
|
|
|
export function buildConsolidatedAdvisoryFeed({ canonicalFeed = {}, ghsaFeed = {}, now }) {
|
|
const canonicalFeedEntries = toArray(canonicalFeed.advisories);
|
|
const canonicalCveIds = new Set(canonicalFeedEntries.map((entry) => entry?.id).filter(isCveId));
|
|
const replacementGhsaIds = new Set(toArray(ghsaFeed.advisories).map(ghsaIdentifier).filter(Boolean));
|
|
const canonicalEntries = canonicalFeedEntries.filter((entry) => {
|
|
const ghsaId = ghsaIdentifier(entry);
|
|
if (!ghsaId) {
|
|
return true;
|
|
}
|
|
if (entry?.cve_id && canonicalCveIds.has(entry.cve_id)) {
|
|
return false;
|
|
}
|
|
return !replacementGhsaIds.has(ghsaId);
|
|
});
|
|
const ghsaEntries = toArray(ghsaFeed.advisories)
|
|
.filter((entry) => !(entry?.cve_id && canonicalCveIds.has(entry.cve_id)))
|
|
.map((entry) => ({
|
|
...entry,
|
|
source_feed: 'ghsa-without-cve',
|
|
}));
|
|
|
|
const advisories = [...canonicalEntries, ...ghsaEntries].sort((a, b) => {
|
|
const published = Date.parse(b.published || '') - Date.parse(a.published || '');
|
|
if (Number.isFinite(published) && published !== 0) {
|
|
return published;
|
|
}
|
|
return String(a.id || '').localeCompare(String(b.id || ''));
|
|
});
|
|
|
|
return {
|
|
...canonicalFeed,
|
|
version: canonicalFeed.version || '1.0.0',
|
|
updated: canonicalFeed.updated || now,
|
|
description: canonicalFeed.description || 'Community-driven security advisory feed for ClawSec',
|
|
advisories,
|
|
};
|
|
}
|
|
|
|
export function buildGhsaWithoutCveFeed({
|
|
fetched,
|
|
existingFeed = {},
|
|
nvdFeed = {},
|
|
now,
|
|
staleAfterDays = DEFAULT_STALE_AFTER_DAYS,
|
|
}) {
|
|
const existingEntries = toArray(existingFeed.advisories);
|
|
const existingIds = new Set(existingEntries.map((entry) => entry.ghsa_id || entry.id));
|
|
const cveIdByGhsa = ghsaToCveMapFromNvdFeed(nvdFeed);
|
|
const entriesById = new Map();
|
|
|
|
for (const { repository, advisories } of fetched) {
|
|
for (const advisory of advisories) {
|
|
const ghsaId = advisory.ghsa_id;
|
|
if (!ghsaId) {
|
|
continue;
|
|
}
|
|
const cveId = resolveCveId(advisory, cveIdByGhsa);
|
|
if (cveId && !existingIds.has(ghsaId)) {
|
|
continue;
|
|
}
|
|
entriesById.set(
|
|
ghsaId,
|
|
normalizeGhsaAdvisory(advisory, {
|
|
now,
|
|
repository,
|
|
staleAfterDays,
|
|
cveId,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
for (const entry of existingEntries) {
|
|
const ghsaId = entry.ghsa_id || entry.id;
|
|
if (!ghsaId || entriesById.has(ghsaId)) {
|
|
continue;
|
|
}
|
|
entriesById.set(ghsaId, refreshExistingEntry(entry, { now, staleAfterDays, cveIdByGhsa }));
|
|
}
|
|
|
|
const advisories = [...entriesById.values()].sort((a, b) => {
|
|
const published = Date.parse(b.published) - Date.parse(a.published);
|
|
if (published !== 0) {
|
|
return published;
|
|
}
|
|
return a.id.localeCompare(b.id);
|
|
});
|
|
|
|
const updated = equivalentAdvisories(advisories, existingEntries)
|
|
? existingFeed.updated || now
|
|
: now;
|
|
|
|
return {
|
|
version: FEED_VERSION,
|
|
updated,
|
|
description:
|
|
'Provisional ClawSec advisory feed for public GitHub Security Advisories that do not yet have CVE identifiers.',
|
|
stale_after_days: staleAfterDays,
|
|
semantics: {
|
|
active: 'GHSA is published and has no CVE identifier yet.',
|
|
matured: 'GHSA now has a CVE identifier and should be reconciled with the canonical CVE feed.',
|
|
stale: 'GHSA is older than stale_after_days and still has no CVE identifier.',
|
|
},
|
|
sources: DEFAULT_REPOSITORIES.map((repository) => ({
|
|
repository,
|
|
platform: inferPlatforms(repository)[0] || 'unknown',
|
|
url: `https://github.com/${repository}/security/advisories`,
|
|
})),
|
|
advisories,
|
|
};
|
|
}
|
|
|
|
export async function fetchGitHubSecurityAdvisories(repository, { token } = {}) {
|
|
const advisories = [];
|
|
let url = `https://api.github.com/repos/${repository}/security-advisories?per_page=100`;
|
|
const seenUrls = new Set();
|
|
|
|
while (url) {
|
|
if (seenUrls.has(url)) {
|
|
throw new Error(`GitHub advisory pagination loop detected for ${repository}: ${url}`);
|
|
}
|
|
seenUrls.add(url);
|
|
|
|
const response = await globalThis.fetch(url, {
|
|
headers: {
|
|
Accept: 'application/vnd.github+json',
|
|
'User-Agent': 'clawsec-ghsa-without-cve-poller',
|
|
'X-GitHub-Api-Version': '2022-11-28',
|
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const message = await response.text();
|
|
throw new Error(
|
|
`GitHub advisory fetch failed for ${repository}: HTTP ${response.status} ${message.slice(0, 200)}`,
|
|
);
|
|
}
|
|
|
|
const pageItems = await response.json();
|
|
advisories.push(...pageItems);
|
|
if (!Array.isArray(pageItems)) {
|
|
break;
|
|
}
|
|
url = nextLinkFromHeader(response.headers.get('link'));
|
|
}
|
|
return advisories;
|
|
}
|
|
|
|
async function readJsonIfExists(path, fallback) {
|
|
if (!existsSync(path)) {
|
|
return fallback;
|
|
}
|
|
return JSON.parse(await readFile(path, 'utf8'));
|
|
}
|
|
|
|
async function writeJson(path, value) {
|
|
await mkdir(dirname(path), { recursive: true });
|
|
await writeFile(`${path}.tmp`, `${JSON.stringify(value, null, 2)}\n`);
|
|
await rename(`${path}.tmp`, path);
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
const options = {
|
|
output: 'advisories/ghsa-without-cve.json',
|
|
consolidatedFeed: null,
|
|
existingFeed: null,
|
|
nvdFeed: 'advisories/feed.json',
|
|
repositories: [...DEFAULT_REPOSITORIES],
|
|
staleAfterDays: DEFAULT_STALE_AFTER_DAYS,
|
|
token: process.env.GITHUB_TOKEN || process.env.GH_TOKEN || '',
|
|
};
|
|
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const arg = argv[index];
|
|
if (arg === '--output') {
|
|
options.output = argv[++index];
|
|
} else if (arg === '--consolidated-feed') {
|
|
options.consolidatedFeed = argv[++index];
|
|
} else if (arg === '--existing-feed') {
|
|
options.existingFeed = argv[++index];
|
|
} else if (arg === '--nvd-feed') {
|
|
options.nvdFeed = argv[++index];
|
|
} else if (arg === '--repo') {
|
|
options.repositories.push(argv[++index]);
|
|
} else if (arg === '--only-default-repos') {
|
|
options.repositories = [...DEFAULT_REPOSITORIES];
|
|
} else if (arg === '--stale-after-days') {
|
|
options.staleAfterDays = Number.parseInt(argv[++index], 10);
|
|
} else if (arg === '--help') {
|
|
options.help = true;
|
|
} else {
|
|
throw new Error(`Unknown argument: ${arg}`);
|
|
}
|
|
}
|
|
|
|
if (!Number.isInteger(options.staleAfterDays) || options.staleAfterDays < 1) {
|
|
throw new Error('--stale-after-days must be a positive integer');
|
|
}
|
|
|
|
options.repositories = uniqueStrings(options.repositories.map((repo) => repo.toLowerCase()));
|
|
options.existingFeed ||= options.output;
|
|
return options;
|
|
}
|
|
|
|
function printHelp() {
|
|
console.log(`Usage: node scripts/ghsa-without-cve-feed.mjs [options]
|
|
|
|
Options:
|
|
--output PATH Feed output path (default: advisories/ghsa-without-cve.json)
|
|
--consolidated-feed PATH Also merge active GHSA advisories into agent-facing feed PATH
|
|
--existing-feed PATH Existing provisional feed path (default: output path)
|
|
--nvd-feed PATH Canonical CVE feed path for GHSA-to-CVE reconciliation
|
|
--repo OWNER/NAME Additional repository to poll
|
|
--only-default-repos Reset repository list to built-in ClawSec sources
|
|
--stale-after-days N Mark GHSA-only advisories stale after N days (default: 60)
|
|
`);
|
|
}
|
|
|
|
async function main() {
|
|
const options = parseArgs(process.argv.slice(2));
|
|
if (options.help) {
|
|
printHelp();
|
|
return;
|
|
}
|
|
|
|
const now = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
const fetched = [];
|
|
for (const repository of options.repositories) {
|
|
const advisories = await fetchGitHubSecurityAdvisories(repository, { token: options.token });
|
|
console.log(`Fetched ${advisories.length} GitHub Security Advisories from ${repository}`);
|
|
fetched.push({ repository, advisories });
|
|
}
|
|
|
|
const existingFeed = await readJsonIfExists(options.existingFeed, {});
|
|
const nvdFeed = await readJsonIfExists(options.nvdFeed, { advisories: [] });
|
|
const feed = buildGhsaWithoutCveFeed({
|
|
fetched,
|
|
existingFeed,
|
|
nvdFeed,
|
|
now,
|
|
staleAfterDays: options.staleAfterDays,
|
|
});
|
|
|
|
await writeJson(options.output, feed);
|
|
console.log(`Wrote ${feed.advisories.length} provisional GHSA advisories to ${options.output}`);
|
|
|
|
if (options.consolidatedFeed) {
|
|
const canonicalFeed = await readJsonIfExists(options.consolidatedFeed, {
|
|
version: '1.0.0',
|
|
advisories: [],
|
|
});
|
|
const consolidatedFeed = buildConsolidatedAdvisoryFeed({
|
|
canonicalFeed,
|
|
ghsaFeed: feed,
|
|
now,
|
|
});
|
|
await writeJson(options.consolidatedFeed, consolidatedFeed);
|
|
console.log(
|
|
`Wrote ${consolidatedFeed.advisories.length} consolidated agent advisories to ${options.consolidatedFeed}`,
|
|
);
|
|
}
|
|
|
|
console.log(
|
|
`Status counts: ${JSON.stringify(
|
|
feed.advisories.reduce((counts, advisory) => {
|
|
counts[advisory.status] = (counts[advisory.status] || 0) + 1;
|
|
return counts;
|
|
}, {}),
|
|
)}`,
|
|
);
|
|
}
|
|
|
|
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
main().catch((error) => {
|
|
console.error(error);
|
|
process.exit(1);
|
|
});
|
|
}
|