mirror of
https://github.com/prompt-security/clawsec.git
synced 2026-06-13 05:28:02 +03:00
feat(advisories): add provisional GHSA feed (#242)
* 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
This commit is contained in:
@@ -46,6 +46,66 @@ class VerifySkillReleaseImportClosureTests(unittest.TestCase):
|
||||
|
||||
self.assertEqual(failures, [])
|
||||
|
||||
def test_ts_source_accepts_js_import_specifier(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir)
|
||||
(root / "types.ts").write_text("export type Value = string;\n", encoding="utf-8")
|
||||
(root / "main.ts").write_text("import type { Value } from './types.js';\n", encoding="utf-8")
|
||||
|
||||
failures = self.module.verify_import_closure(root)
|
||||
|
||||
self.assertEqual(failures, [])
|
||||
|
||||
def test_comment_import_examples_are_ignored(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir)
|
||||
(root / "main.ts").write_text(
|
||||
"/*\n"
|
||||
" * Example integration:\n"
|
||||
" * import { Missing } from '../external/project/file';\n"
|
||||
" */\n"
|
||||
"export {};\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
failures = self.module.verify_import_closure(root)
|
||||
|
||||
self.assertEqual(failures, [])
|
||||
|
||||
def test_url_string_does_not_hide_following_relative_import(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir)
|
||||
(root / "main.ts").write_text(
|
||||
'const feedUrl = "https://example.test/feed.json"; import value from "./missing.js";\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
failures = self.module.verify_import_closure(root)
|
||||
|
||||
self.assertEqual(len(failures), 1)
|
||||
self.assertIn("main.ts imports ./missing.js", failures[0])
|
||||
|
||||
def test_remote_import_spec_survives_comment_stripping(self) -> None:
|
||||
source = 'import remote from "https://example.test/module.mjs";\n'
|
||||
stripped = self.module.strip_js_ts_comments(source)
|
||||
|
||||
specs = [match.group("spec") for match in self.module.IMPORT_RE.finditer(stripped)]
|
||||
|
||||
self.assertEqual(specs, ["https://example.test/module.mjs"])
|
||||
|
||||
def test_remote_runtime_import_is_rejected(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir)
|
||||
(root / "main.mjs").write_text(
|
||||
'import remote from "https://example.test/module.mjs";\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
failures = self.module.verify_import_closure(root)
|
||||
|
||||
self.assertEqual(len(failures), 1)
|
||||
self.assertIn("remote runtime import https://example.test/module.mjs", failures[0])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify staged skill release JS/TS relative imports are self-contained.
|
||||
"""Verify staged skill release JS/TS imports are self-contained.
|
||||
|
||||
The skill release workflow builds archives from `skill.json.sbom.files`. If a
|
||||
runtime helper exists in the repo but is omitted from the SBOM, the staged
|
||||
release can contain files whose relative imports point at missing files. This
|
||||
script checks the staged payload, not the source tree, so it catches exactly
|
||||
what would ship.
|
||||
release can contain files whose relative imports point at missing files or
|
||||
remote runtime imports. This script checks the staged payload, not the source
|
||||
tree, so it catches exactly what would ship.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -22,18 +22,88 @@ IMPORT_RE = re.compile(
|
||||
r"|\bimport\s*\(\s*"
|
||||
r"|\brequire\s*\(\s*"
|
||||
r")"
|
||||
r"['\"](?P<spec>\.{1,2}/[^'\"]+)['\"]",
|
||||
r"['\"](?P<spec>(?:\.{1,2}/|https?://)[^'\"]+)['\"]",
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
SOURCE_SUFFIXES = {".js", ".mjs", ".cjs", ".ts", ".mts", ".cts"}
|
||||
RESOLUTION_SUFFIXES = ["", ".mjs", ".js", ".cjs", ".mts", ".ts", ".cts", ".json"]
|
||||
INDEX_FILENAMES = ["index.mjs", "index.js", "index.cjs", "index.mts", "index.ts", "index.cts", "index.json"]
|
||||
TS_IMPORTER_SUFFIXES = {".ts", ".mts", ".cts"}
|
||||
JS_TO_TS_SUFFIX = {".js": ".ts", ".mjs": ".mts", ".cjs": ".cts"}
|
||||
|
||||
|
||||
def strip_js_ts_comments(text: str) -> str:
|
||||
stripped: list[str] = []
|
||||
state = "code"
|
||||
i = 0
|
||||
|
||||
while i < len(text):
|
||||
char = text[i]
|
||||
next_char = text[i + 1] if i + 1 < len(text) else ""
|
||||
|
||||
if state == "line_comment":
|
||||
if char in "\r\n":
|
||||
stripped.append(char)
|
||||
state = "code"
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if state == "block_comment":
|
||||
if char == "*" and next_char == "/":
|
||||
state = "code"
|
||||
i += 2
|
||||
continue
|
||||
if char in "\r\n":
|
||||
stripped.append(char)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if state in {"single", "double", "template"}:
|
||||
stripped.append(char)
|
||||
if char == "\\" and i + 1 < len(text):
|
||||
stripped.append(text[i + 1])
|
||||
i += 2
|
||||
continue
|
||||
if (state == "single" and char == "'") or (state == "double" and char == '"') or (
|
||||
state == "template" and char == "`"
|
||||
):
|
||||
state = "code"
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if char == "/" and next_char == "/":
|
||||
stripped.append(" ")
|
||||
state = "line_comment"
|
||||
i += 2
|
||||
continue
|
||||
if char == "/" and next_char == "*":
|
||||
stripped.append(" ")
|
||||
state = "block_comment"
|
||||
i += 2
|
||||
continue
|
||||
|
||||
stripped.append(char)
|
||||
if char == "'":
|
||||
state = "single"
|
||||
elif char == '"':
|
||||
state = "double"
|
||||
elif char == "`":
|
||||
state = "template"
|
||||
i += 1
|
||||
|
||||
return "".join(stripped)
|
||||
|
||||
|
||||
def is_remote_spec(spec: str) -> bool:
|
||||
return spec.startswith(("http://", "https://"))
|
||||
|
||||
|
||||
def candidate_paths(importer: Path, spec: str) -> list[Path]:
|
||||
base = (importer.parent / spec).resolve()
|
||||
candidates = [base]
|
||||
if importer.suffix in TS_IMPORTER_SUFFIXES and base.suffix in JS_TO_TS_SUFFIX:
|
||||
candidates.append(base.with_suffix(JS_TO_TS_SUFFIX[base.suffix]))
|
||||
candidates.extend(base.with_suffix(suffix) for suffix in RESOLUTION_SUFFIXES if suffix and base.suffix == "")
|
||||
candidates.extend(base / name for name in INDEX_FILENAMES)
|
||||
return candidates
|
||||
@@ -57,13 +127,18 @@ def verify_import_closure(root: Path) -> list[str]:
|
||||
|
||||
for source in sorted(p for p in root.rglob("*") if p.is_file() and p.suffix in SOURCE_SUFFIXES):
|
||||
text = source.read_text(encoding="utf-8", errors="ignore")
|
||||
text = strip_js_ts_comments(text)
|
||||
for match in IMPORT_RE.finditer(text):
|
||||
spec = match.group("spec")
|
||||
rel_source = source.relative_to(root).as_posix()
|
||||
if is_remote_spec(spec):
|
||||
failures.append(f"{rel_source} imports remote runtime import {spec}")
|
||||
continue
|
||||
|
||||
candidates = candidate_paths(source, spec)
|
||||
if any(is_resolved_file(candidate, root) for candidate in candidates):
|
||||
continue
|
||||
|
||||
rel_source = source.relative_to(root).as_posix()
|
||||
display_target = (source.parent / spec).resolve()
|
||||
try:
|
||||
rel_target = display_target.relative_to(root).as_posix()
|
||||
|
||||
@@ -0,0 +1,514 @@
|
||||
#!/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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
const workflowPath = new URL("../.github/workflows/deploy-pages.yml", import.meta.url);
|
||||
const workflow = await readFile(workflowPath, "utf8");
|
||||
|
||||
function stepIndex(name) {
|
||||
const marker = `- name: ${name}`;
|
||||
const index = workflow.indexOf(marker);
|
||||
assert.notEqual(index, -1, `missing workflow step: ${name}`);
|
||||
return index;
|
||||
}
|
||||
|
||||
const signFeedIndex = stepIndex("Sign advisory feed and verify");
|
||||
const signGhsaIndex = stepIndex("Sign provisional GHSA feed and verify");
|
||||
const generateChecksumsIndex = stepIndex("Generate advisory checksums manifest");
|
||||
const signChecksumsIndex = stepIndex("Sign checksums and verify");
|
||||
|
||||
assert.ok(
|
||||
signFeedIndex < generateChecksumsIndex,
|
||||
"advisory checksums manifest must be generated after feed.json.sig is created",
|
||||
);
|
||||
assert.ok(
|
||||
signGhsaIndex < generateChecksumsIndex,
|
||||
"advisory checksums manifest must be generated after ghsa-without-cve.json.sig is created",
|
||||
);
|
||||
assert.ok(
|
||||
generateChecksumsIndex < signChecksumsIndex,
|
||||
"checksums signature must be generated after checksums.json is refreshed",
|
||||
);
|
||||
|
||||
const generateStepBody = workflow.slice(generateChecksumsIndex, signChecksumsIndex);
|
||||
assert.match(
|
||||
generateStepBody,
|
||||
/public\/advisories\/\*\.json\.sig/,
|
||||
"advisory checksums manifest must include detached advisory signatures",
|
||||
);
|
||||
|
||||
const mirrorBlockIndex = workflow.indexOf(
|
||||
"# Mirror advisories feed + signatures at the path referenced by suite docs/heartbeat",
|
||||
);
|
||||
assert.notEqual(mirrorBlockIndex, -1, "missing advisory release mirror block");
|
||||
|
||||
const mirrorBlock = workflow.slice(mirrorBlockIndex, workflow.indexOf("if [ -f \"public/checksums.json\"", mirrorBlockIndex));
|
||||
assert.match(
|
||||
mirrorBlock,
|
||||
/cp "public\/advisories\/ghsa-without-cve\.json" "\$MIRROR_LATEST_DIR\/ghsa-without-cve\.json"/,
|
||||
"GHSA provisional feed must be mirrored at the release-root compatibility path",
|
||||
);
|
||||
assert.match(
|
||||
mirrorBlock,
|
||||
/cp "public\/advisories\/ghsa-without-cve\.json\.sig" "\$MIRROR_LATEST_DIR\/ghsa-without-cve\.json\.sig"/,
|
||||
"GHSA provisional feed signature must be mirrored at the release-root compatibility path",
|
||||
);
|
||||
@@ -0,0 +1,37 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
const workflowPath = new URL('../.github/workflows/poll-ghsa-without-cve.yml', import.meta.url);
|
||||
const workflow = await readFile(workflowPath, 'utf8');
|
||||
|
||||
assert.match(workflow, /workflow_dispatch:/, 'GHSA poll workflow must remain runnable as a manual fallback');
|
||||
assert.doesNotMatch(
|
||||
workflow,
|
||||
/\n\s+schedule:/,
|
||||
'Scheduled GHSA consolidation belongs to the NVD workflow to avoid duplicate automated feed PRs',
|
||||
);
|
||||
assert.match(
|
||||
workflow,
|
||||
/FEED_PATH:\s+advisories\/feed\.json/,
|
||||
'GHSA poll workflow must know the consolidated agent feed path',
|
||||
);
|
||||
assert.match(
|
||||
workflow,
|
||||
/SKILL_FEED_PATH:\s+skills\/clawsec-feed\/advisories\/feed\.json/,
|
||||
'GHSA poll workflow must sync the consolidated agent feed into clawsec-feed',
|
||||
);
|
||||
assert.match(
|
||||
workflow,
|
||||
/--consolidated-feed "\$FEED_PATH"/,
|
||||
'GHSA poll workflow must merge GHSA advisories into the agent-facing feed',
|
||||
);
|
||||
assert.match(
|
||||
workflow,
|
||||
/input_file: \$\{\{ env\.FEED_PATH \}\}/,
|
||||
'GHSA poll workflow must sign the consolidated agent feed when it changes',
|
||||
);
|
||||
assert.match(
|
||||
workflow,
|
||||
/cp "\$FEED_SIG_PATH" "\$SKILL_FEED_SIG_PATH"/,
|
||||
'GHSA poll workflow must sync consolidated feed signature into clawsec-feed',
|
||||
);
|
||||
@@ -0,0 +1,425 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
buildConsolidatedAdvisoryFeed,
|
||||
buildGhsaWithoutCveFeed,
|
||||
fetchGitHubSecurityAdvisories,
|
||||
inferPlatforms,
|
||||
normalizeGhsaAdvisory,
|
||||
} from './ghsa-without-cve-feed.mjs';
|
||||
|
||||
const fixedNow = '2026-05-24T00:00:00Z';
|
||||
|
||||
function advisory(overrides = {}) {
|
||||
return {
|
||||
ghsa_id: 'GHSA-test-1111-2222',
|
||||
cve_id: null,
|
||||
html_url: 'https://github.com/openclaw/openclaw/security/advisories/GHSA-test-1111-2222',
|
||||
summary: 'Workspace bridge allows sandbox escape',
|
||||
description: 'OpenClaw before 2026.4.25 allowed a sandbox escape.',
|
||||
severity: 'high',
|
||||
published_at: '2026-04-24T00:00:00Z',
|
||||
updated_at: '2026-04-25T00:00:00Z',
|
||||
vulnerabilities: [
|
||||
{
|
||||
package: { ecosystem: 'npm', name: 'openclaw' },
|
||||
vulnerable_version_range: '<2026.4.25',
|
||||
patched_versions: '2026.4.25',
|
||||
},
|
||||
],
|
||||
cvss: {
|
||||
vector_string: 'CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H',
|
||||
score: 7.8,
|
||||
},
|
||||
cwe_ids: ['CWE-94'],
|
||||
credits: [{ login: 'researcher', type: 'reporter' }],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('inferPlatforms maps known repositories to feed platforms', () => {
|
||||
assert.deepEqual(inferPlatforms('openclaw/openclaw'), ['openclaw']);
|
||||
assert.deepEqual(inferPlatforms('qwibitai/nanoclaw'), ['nanoclaw']);
|
||||
assert.deepEqual(inferPlatforms('softwarepub/hermes'), ['hermes']);
|
||||
assert.deepEqual(inferPlatforms('sipeed/picoclaw'), ['picoclaw']);
|
||||
});
|
||||
|
||||
test('fetchGitHubSecurityAdvisories follows cursor pagination links', async (t) => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const nextUrl =
|
||||
'https://api.github.com/repositories/1103012935/security-advisories?per_page=100&after=cursor';
|
||||
const calls = [];
|
||||
|
||||
globalThis.fetch = async (url) => {
|
||||
calls.push(String(url));
|
||||
if (calls.length === 1) {
|
||||
return new globalThis.Response(
|
||||
JSON.stringify(
|
||||
Array.from({ length: 100 }, (_, index) =>
|
||||
advisory({ ghsa_id: `GHSA-page-1111-${String(index).padStart(4, '0')}` }),
|
||||
),
|
||||
),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
Link: `<${nextUrl}>; rel="next"`,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
if (String(url) !== nextUrl) {
|
||||
throw new Error(`unexpected pagination URL: ${url}`);
|
||||
}
|
||||
return new globalThis.Response(JSON.stringify([advisory({ ghsa_id: 'GHSA-next-1111-2222' })]), {
|
||||
status: 200,
|
||||
});
|
||||
};
|
||||
t.after(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
const advisories = await fetchGitHubSecurityAdvisories('openclaw/openclaw', {
|
||||
token: 'test-token',
|
||||
});
|
||||
|
||||
assert.equal(calls.length, 2);
|
||||
assert.equal(calls[1], nextUrl);
|
||||
assert.equal(advisories.length, 101);
|
||||
assert.equal(advisories.at(-1).ghsa_id, 'GHSA-next-1111-2222');
|
||||
});
|
||||
|
||||
test('normalizeGhsaAdvisory marks fresh GHSA-only advisories active', () => {
|
||||
const normalized = normalizeGhsaAdvisory(advisory(), {
|
||||
now: fixedNow,
|
||||
repository: 'openclaw/openclaw',
|
||||
staleAfterDays: 60,
|
||||
});
|
||||
|
||||
assert.equal(normalized.id, 'GHSA-test-1111-2222');
|
||||
assert.equal(normalized.status, 'active');
|
||||
assert.equal(normalized.cve_id, null);
|
||||
assert.equal(normalized.stale, false);
|
||||
assert.deepEqual(normalized.platforms, ['openclaw']);
|
||||
assert.deepEqual(normalized.affected, ['openclaw@<2026.4.25']);
|
||||
});
|
||||
|
||||
test('normalizeGhsaAdvisory marks old GHSA-only advisories stale after threshold', () => {
|
||||
const normalized = normalizeGhsaAdvisory(
|
||||
advisory({ published_at: '2026-03-01T00:00:00Z' }),
|
||||
{
|
||||
now: fixedNow,
|
||||
repository: 'openclaw/openclaw',
|
||||
staleAfterDays: 60,
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(normalized.status, 'stale');
|
||||
assert.equal(normalized.stale, true);
|
||||
assert.equal(normalized.cve_id, null);
|
||||
});
|
||||
|
||||
test('normalizeGhsaAdvisory marks existing GHSA entries matured when a CVE appears', () => {
|
||||
const normalized = normalizeGhsaAdvisory(
|
||||
advisory({ cve_id: 'CVE-2026-9999' }),
|
||||
{
|
||||
now: fixedNow,
|
||||
repository: 'openclaw/openclaw',
|
||||
staleAfterDays: 60,
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(normalized.status, 'matured');
|
||||
assert.equal(normalized.stale, false);
|
||||
assert.equal(normalized.cve_id, 'CVE-2026-9999');
|
||||
assert.equal(normalized.nvd_url, 'https://nvd.nist.gov/vuln/detail/CVE-2026-9999');
|
||||
});
|
||||
|
||||
test('buildGhsaWithoutCveFeed only imports CVE-backed advisories that were already tracked', () => {
|
||||
const existing = {
|
||||
version: '0.1.0',
|
||||
advisories: [
|
||||
normalizeGhsaAdvisory(advisory({ ghsa_id: 'GHSA-old-1111-2222' }), {
|
||||
now: '2026-04-25T00:00:00Z',
|
||||
repository: 'openclaw/openclaw',
|
||||
staleAfterDays: 60,
|
||||
}),
|
||||
],
|
||||
};
|
||||
const fetched = [
|
||||
{
|
||||
repository: 'openclaw/openclaw',
|
||||
advisories: [
|
||||
advisory({ ghsa_id: 'GHSA-new-1111-2222', cve_id: null }),
|
||||
advisory({ ghsa_id: 'GHSA-old-1111-2222', cve_id: 'CVE-2026-1111' }),
|
||||
advisory({ ghsa_id: 'GHSA-cve-only-1111-2222', cve_id: 'CVE-2026-2222' }),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const feed = buildGhsaWithoutCveFeed({
|
||||
fetched,
|
||||
existingFeed: existing,
|
||||
nvdFeed: { advisories: [] },
|
||||
now: fixedNow,
|
||||
staleAfterDays: 60,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
feed.advisories.map((entry) => [entry.id, entry.status, entry.cve_id]),
|
||||
[
|
||||
['GHSA-new-1111-2222', 'active', null],
|
||||
['GHSA-old-1111-2222', 'matured', 'CVE-2026-1111'],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('buildGhsaWithoutCveFeed matures tracked GHSAs when the CVE feed references them', () => {
|
||||
const existing = {
|
||||
version: '0.1.0',
|
||||
advisories: [
|
||||
normalizeGhsaAdvisory(advisory({ ghsa_id: 'GHSA-oooo-3333-4444' }), {
|
||||
now: '2026-04-25T00:00:00Z',
|
||||
repository: 'openclaw/openclaw',
|
||||
staleAfterDays: 60,
|
||||
}),
|
||||
],
|
||||
};
|
||||
const feed = buildGhsaWithoutCveFeed({
|
||||
fetched: [
|
||||
{
|
||||
repository: 'openclaw/openclaw',
|
||||
advisories: [advisory({ ghsa_id: 'GHSA-oooo-3333-4444', cve_id: null })],
|
||||
},
|
||||
],
|
||||
existingFeed: existing,
|
||||
nvdFeed: {
|
||||
advisories: [
|
||||
{
|
||||
id: 'CVE-2026-3333',
|
||||
references: [
|
||||
'https://github.com/openclaw/openclaw/security/advisories/GHSA-oooo-3333-4444',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
now: fixedNow,
|
||||
staleAfterDays: 60,
|
||||
});
|
||||
|
||||
assert.equal(feed.advisories[0].status, 'matured');
|
||||
assert.equal(feed.advisories[0].cve_id, 'CVE-2026-3333');
|
||||
});
|
||||
|
||||
test('buildConsolidatedAdvisoryFeed appends active GHSA advisories without moving the NVD poll cursor', () => {
|
||||
const canonicalFeed = {
|
||||
version: '1.0.0',
|
||||
updated: '2026-05-23T00:00:00Z',
|
||||
description: 'Community-driven security advisory feed for ClawSec',
|
||||
advisories: [
|
||||
{
|
||||
id: 'CVE-2026-1111',
|
||||
severity: 'high',
|
||||
type: 'os_command_injection',
|
||||
title: 'Existing CVE',
|
||||
description: 'Existing CVE advisory',
|
||||
affected: ['openclaw@*'],
|
||||
platforms: ['openclaw'],
|
||||
action: 'Review NVD.',
|
||||
published: '2026-05-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
const ghsaFeed = {
|
||||
advisories: [
|
||||
normalizeGhsaAdvisory(advisory({ ghsa_id: 'GHSA-active-1111-2222', cve_id: null }), {
|
||||
now: fixedNow,
|
||||
repository: 'openclaw/openclaw',
|
||||
staleAfterDays: 60,
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
const consolidated = buildConsolidatedAdvisoryFeed({
|
||||
canonicalFeed,
|
||||
ghsaFeed,
|
||||
now: fixedNow,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
consolidated.advisories.map((entry) => entry.id),
|
||||
['CVE-2026-1111', 'GHSA-active-1111-2222'],
|
||||
);
|
||||
assert.equal(consolidated.updated, canonicalFeed.updated);
|
||||
assert.equal(consolidated.advisories[1].source_feed, 'ghsa-without-cve');
|
||||
});
|
||||
|
||||
test('buildConsolidatedAdvisoryFeed keeps existing GHSA advisories when replacement feed is empty', () => {
|
||||
const canonicalFeed = {
|
||||
version: '1.0.0',
|
||||
updated: '2026-05-23T00:00:00Z',
|
||||
advisories: [
|
||||
{
|
||||
id: 'CVE-2026-1111',
|
||||
published: '2026-05-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'GHSA-keep-1111-2222',
|
||||
ghsa_id: 'GHSA-keep-1111-2222',
|
||||
status: 'active',
|
||||
published: '2026-05-02T00:00:00Z',
|
||||
source_feed: 'ghsa-without-cve',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const consolidated = buildConsolidatedAdvisoryFeed({
|
||||
canonicalFeed,
|
||||
ghsaFeed: { advisories: [] },
|
||||
now: fixedNow,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
consolidated.advisories.map((entry) => entry.id),
|
||||
['GHSA-keep-1111-2222', 'CVE-2026-1111'],
|
||||
);
|
||||
});
|
||||
|
||||
test('buildConsolidatedAdvisoryFeed replaces only matching GHSA canonical entries', () => {
|
||||
const canonicalFeed = {
|
||||
version: '1.0.0',
|
||||
updated: '2026-05-23T00:00:00Z',
|
||||
advisories: [
|
||||
{
|
||||
id: 'GHSA-repl-1111-2222',
|
||||
ghsa_id: 'GHSA-repl-1111-2222',
|
||||
status: 'active',
|
||||
title: 'Old GHSA payload',
|
||||
published: '2026-05-01T00:00:00Z',
|
||||
source_feed: 'ghsa-without-cve',
|
||||
},
|
||||
{
|
||||
id: 'GHSA-keep-3333-4444',
|
||||
ghsa_id: 'GHSA-keep-3333-4444',
|
||||
status: 'active',
|
||||
title: 'Retained GHSA payload',
|
||||
published: '2026-05-02T00:00:00Z',
|
||||
source_feed: 'ghsa-without-cve',
|
||||
},
|
||||
],
|
||||
};
|
||||
const ghsaFeed = {
|
||||
advisories: [
|
||||
{
|
||||
id: 'GHSA-repl-1111-2222',
|
||||
ghsa_id: 'GHSA-repl-1111-2222',
|
||||
status: 'stale',
|
||||
title: 'Replacement GHSA payload',
|
||||
published: '2026-05-03T00:00:00Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const consolidated = buildConsolidatedAdvisoryFeed({
|
||||
canonicalFeed,
|
||||
ghsaFeed,
|
||||
now: fixedNow,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
consolidated.advisories.map((entry) => [entry.id, entry.title, entry.status]),
|
||||
[
|
||||
['GHSA-repl-1111-2222', 'Replacement GHSA payload', 'stale'],
|
||||
['GHSA-keep-3333-4444', 'Retained GHSA payload', 'active'],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('buildConsolidatedAdvisoryFeed drops GHSA duplicate when matching CVE is present', () => {
|
||||
const canonicalFeed = {
|
||||
version: '1.0.0',
|
||||
updated: '2026-05-23T00:00:00Z',
|
||||
advisories: [
|
||||
{
|
||||
id: 'CVE-2026-2222',
|
||||
severity: 'high',
|
||||
type: 'code_injection',
|
||||
title: 'Canonical CVE',
|
||||
description: 'Canonical CVE advisory',
|
||||
affected: ['openclaw@*'],
|
||||
platforms: ['openclaw'],
|
||||
action: 'Review NVD.',
|
||||
published: '2026-05-02T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'GHSA-old-duplicate',
|
||||
ghsa_id: 'GHSA-old-duplicate',
|
||||
cve_id: 'CVE-2026-2222',
|
||||
status: 'matured',
|
||||
source_feed: 'ghsa-without-cve',
|
||||
severity: 'high',
|
||||
type: 'github_security_advisory',
|
||||
title: 'Old duplicate',
|
||||
description: 'Old provisional duplicate',
|
||||
affected: ['openclaw@*'],
|
||||
platforms: ['openclaw'],
|
||||
action: 'Track CVE.',
|
||||
published: '2026-05-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
const ghsaFeed = {
|
||||
advisories: [
|
||||
normalizeGhsaAdvisory(
|
||||
advisory({ ghsa_id: 'GHSA-new-duplicate', cve_id: 'CVE-2026-2222' }),
|
||||
{
|
||||
now: fixedNow,
|
||||
repository: 'openclaw/openclaw',
|
||||
staleAfterDays: 60,
|
||||
},
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
const consolidated = buildConsolidatedAdvisoryFeed({
|
||||
canonicalFeed,
|
||||
ghsaFeed,
|
||||
now: fixedNow,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
consolidated.advisories.map((entry) => entry.id),
|
||||
['CVE-2026-2222'],
|
||||
);
|
||||
});
|
||||
|
||||
test('buildConsolidatedAdvisoryFeed keeps matured GHSA until CVE lands in canonical feed', () => {
|
||||
const canonicalFeed = {
|
||||
version: '1.0.0',
|
||||
updated: '2026-05-23T00:00:00Z',
|
||||
advisories: [],
|
||||
};
|
||||
const ghsaFeed = {
|
||||
advisories: [
|
||||
normalizeGhsaAdvisory(
|
||||
advisory({ ghsa_id: 'GHSA-matured-1111-2222', cve_id: 'CVE-2026-4444' }),
|
||||
{
|
||||
now: fixedNow,
|
||||
repository: 'openclaw/openclaw',
|
||||
staleAfterDays: 60,
|
||||
},
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
const consolidated = buildConsolidatedAdvisoryFeed({
|
||||
canonicalFeed,
|
||||
ghsaFeed,
|
||||
now: fixedNow,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
consolidated.advisories.map((entry) => [entry.id, entry.status, entry.cve_id]),
|
||||
[['GHSA-matured-1111-2222', 'matured', 'CVE-2026-4444']],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
const workflowPath = new URL('../.github/workflows/poll-nvd-cves.yml', import.meta.url);
|
||||
const workflow = await readFile(workflowPath, 'utf8');
|
||||
const ciWorkflowPath = new URL('../.github/workflows/ci.yml', import.meta.url);
|
||||
const ciWorkflow = await readFile(ciWorkflowPath, 'utf8');
|
||||
|
||||
function requiredIndex(snippet, message) {
|
||||
const index = workflow.indexOf(snippet);
|
||||
assert.notEqual(index, -1, message);
|
||||
return index;
|
||||
}
|
||||
|
||||
assert.match(
|
||||
workflow,
|
||||
/GHSA_FEED_PATH:\s+advisories\/ghsa-without-cve\.json/,
|
||||
'NVD workflow must write the provisional GHSA source feed',
|
||||
);
|
||||
assert.match(
|
||||
workflow,
|
||||
/GHSA_FEED_SIG_PATH:\s+advisories\/ghsa-without-cve\.json\.sig/,
|
||||
'NVD workflow must sign the provisional GHSA source feed',
|
||||
);
|
||||
assert.match(
|
||||
workflow,
|
||||
/node scripts\/ghsa-without-cve-feed\.mjs[\s\S]*--output "\$GHSA_FEED_PATH"[\s\S]*--consolidated-feed "\$FEED_PATH"[\s\S]*--existing-feed "\$GHSA_FEED_PATH"[\s\S]*--nvd-feed "\$FEED_PATH"/,
|
||||
'NVD workflow must merge GHSA advisories into the signed agent feed',
|
||||
);
|
||||
assert.match(
|
||||
workflow,
|
||||
/id: feed_changes[\s\S]*ghsa_changed=\$GHSA_CHANGED[\s\S]*agent_changed=\$AGENT_CHANGED[\s\S]*changed=true/,
|
||||
'NVD workflow must detect GHSA and consolidated agent feed changes separately',
|
||||
);
|
||||
assert.match(
|
||||
workflow,
|
||||
/if: steps\.feed_changes\.outputs\.ghsa_changed == 'true'[\s\S]*input_file: \$\{\{ env\.GHSA_FEED_PATH \}\}[\s\S]*signature_file: \$\{\{ env\.GHSA_FEED_SIG_PATH \}\}/,
|
||||
'NVD workflow must sign the provisional GHSA feed when it changes',
|
||||
);
|
||||
assert.match(
|
||||
workflow,
|
||||
/if: steps\.feed_changes\.outputs\.agent_changed == 'true'[\s\S]*input_file: \$\{\{ env\.FEED_PATH \}\}[\s\S]*signature_file: \$\{\{ env\.FEED_SIG_PATH \}\}/,
|
||||
'NVD workflow must sign the consolidated agent feed when it changes',
|
||||
);
|
||||
assert.match(
|
||||
workflow,
|
||||
/git add "\$FEED_PATH" "\$FEED_SIG_PATH" "\$GHSA_FEED_PATH" "\$GHSA_FEED_SIG_PATH" "\$SKILL_FEED_PATH" "\$SKILL_FEED_SIG_PATH"/,
|
||||
'NVD workflow PR must include both NVD and GHSA feed artifacts',
|
||||
);
|
||||
assert.match(
|
||||
ciWorkflow,
|
||||
/name: NVD \+ GHSA Pipeline Dry Run[\s\S]*node scripts\/test-nvd-ghsa-pipeline-dry-run\.mjs/,
|
||||
'CI must run the deterministic NVD + GHSA pipeline dry run before merge',
|
||||
);
|
||||
|
||||
const updateFeedIndex = requiredIndex('name: Update feed.json', 'NVD workflow must update the CVE feed first');
|
||||
const pollGhsaIndex = requiredIndex(
|
||||
'name: Poll GHSA without CVE and consolidate feed',
|
||||
'NVD workflow must poll GHSA before signing',
|
||||
);
|
||||
const detectChangesIndex = requiredIndex(
|
||||
'name: Detect advisory feed changes',
|
||||
'NVD workflow must detect combined feed changes before signing',
|
||||
);
|
||||
const signGhsaIndex = requiredIndex(
|
||||
'name: Sign GHSA feed and verify',
|
||||
'NVD workflow must sign the GHSA source feed',
|
||||
);
|
||||
const signAgentIndex = requiredIndex(
|
||||
'name: Sign advisory feed and verify',
|
||||
'NVD workflow must sign the consolidated agent feed',
|
||||
);
|
||||
const upsertPrIndex = requiredIndex(
|
||||
'name: Upsert NVD advisory PR',
|
||||
'NVD workflow must upsert a PR for any feed change',
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
updateFeedIndex < pollGhsaIndex,
|
||||
'GHSA consolidation must run after the NVD update step so matured advisories can reconcile against new CVEs',
|
||||
);
|
||||
assert.ok(
|
||||
pollGhsaIndex < detectChangesIndex,
|
||||
'Combined feed change detection must run after GHSA consolidation',
|
||||
);
|
||||
assert.ok(detectChangesIndex < signGhsaIndex, 'GHSA signing must run after change detection');
|
||||
assert.ok(detectChangesIndex < signAgentIndex, 'Agent feed signing must run after change detection');
|
||||
assert.ok(signAgentIndex < upsertPrIndex, 'The PR must be created after feed signing');
|
||||
@@ -0,0 +1,187 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { generateKeyPairSync, sign, verify } from 'node:crypto';
|
||||
import { mkdtemp, mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
buildConsolidatedAdvisoryFeed,
|
||||
buildGhsaWithoutCveFeed,
|
||||
normalizeGhsaAdvisory,
|
||||
} from './ghsa-without-cve-feed.mjs';
|
||||
|
||||
const now = '2026-05-24T00:00:00Z';
|
||||
|
||||
function cveAdvisory(overrides = {}) {
|
||||
return {
|
||||
id: 'CVE-2026-1111',
|
||||
severity: 'high',
|
||||
type: 'code_injection',
|
||||
title: 'OpenClaw command execution advisory',
|
||||
description: 'OpenClaw allowed unsafe tool execution in a guarded workspace.',
|
||||
affected: ['openclaw@<2026.5.20'],
|
||||
patched: ['openclaw@2026.5.20'],
|
||||
platforms: ['openclaw'],
|
||||
action: 'Update OpenClaw and verify guarded workspace execution.',
|
||||
published: '2026-05-01T00:00:00Z',
|
||||
updated: '2026-05-01T00:00:00Z',
|
||||
references: ['https://nvd.nist.gov/vuln/detail/CVE-2026-1111'],
|
||||
nvd_url: 'https://nvd.nist.gov/vuln/detail/CVE-2026-1111',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function ghsaAdvisory(overrides = {}) {
|
||||
return {
|
||||
ghsa_id: 'GHSA-actv-1111-2222',
|
||||
cve_id: null,
|
||||
html_url: 'https://github.com/openclaw/openclaw/security/advisories/GHSA-actv-1111-2222',
|
||||
summary: 'OpenClaw advisory without CVE',
|
||||
description: 'OpenClaw published a public GitHub advisory before CVE assignment.',
|
||||
severity: 'high',
|
||||
published_at: '2026-05-20T00:00:00Z',
|
||||
updated_at: '2026-05-21T00:00:00Z',
|
||||
vulnerabilities: [
|
||||
{
|
||||
package: { ecosystem: 'npm', name: 'openclaw' },
|
||||
vulnerable_version_range: '<2026.5.21',
|
||||
patched_versions: '2026.5.21',
|
||||
},
|
||||
],
|
||||
cvss: {
|
||||
vector_string: 'CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H',
|
||||
score: 7.8,
|
||||
},
|
||||
cwe_ids: ['CWE-94'],
|
||||
credits: [{ login: 'security-researcher', type: 'reporter' }],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function signBuffer(data, privateKey) {
|
||||
return sign(null, data, privateKey).toString('base64');
|
||||
}
|
||||
|
||||
function verifySignature(data, signature, publicKey) {
|
||||
return verify(null, data, publicKey, Buffer.from(signature, 'base64'));
|
||||
}
|
||||
|
||||
async function writeJson(filePath, value) {
|
||||
await mkdir(path.dirname(filePath), { recursive: true });
|
||||
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
const tempDir = await mkdtemp(path.join(tmpdir(), 'clawsec-nvd-ghsa-ci-dry-run-'));
|
||||
const canonicalFeedPath = path.join(tempDir, 'advisories/feed.json');
|
||||
const ghsaFeedPath = path.join(tempDir, 'advisories/ghsa-without-cve.json');
|
||||
const skillFeedPath = path.join(tempDir, 'skills/clawsec-feed/advisories/feed.json');
|
||||
|
||||
const existingCanonicalFeed = {
|
||||
version: '1.0.0',
|
||||
updated: '2026-05-23T00:00:00Z',
|
||||
description: 'Community-driven security advisory feed for ClawSec',
|
||||
advisories: [
|
||||
cveAdvisory({
|
||||
id: 'CVE-2026-1111',
|
||||
references: [
|
||||
'https://nvd.nist.gov/vuln/detail/CVE-2026-1111',
|
||||
'https://github.com/openclaw/openclaw/security/advisories/GHSA-matd-1111-2222',
|
||||
],
|
||||
}),
|
||||
],
|
||||
};
|
||||
const nvdPollResultFeed = {
|
||||
...existingCanonicalFeed,
|
||||
updated: now,
|
||||
advisories: [
|
||||
cveAdvisory({
|
||||
id: 'CVE-2026-2222',
|
||||
title: 'Fresh NVD advisory from the poll window',
|
||||
published: '2026-05-24T00:00:00Z',
|
||||
updated: '2026-05-24T00:00:00Z',
|
||||
references: [
|
||||
'https://nvd.nist.gov/vuln/detail/CVE-2026-2222',
|
||||
'https://github.com/openclaw/openclaw/security/advisories/GHSA-cvea-1111-2222',
|
||||
],
|
||||
nvd_url: 'https://nvd.nist.gov/vuln/detail/CVE-2026-2222',
|
||||
}),
|
||||
...existingCanonicalFeed.advisories,
|
||||
],
|
||||
};
|
||||
const existingGhsaFeed = {
|
||||
version: '0.1.0',
|
||||
updated: '2026-05-20T00:00:00Z',
|
||||
advisories: [
|
||||
normalizeGhsaAdvisory(ghsaAdvisory({ ghsa_id: 'GHSA-matd-1111-2222' }), {
|
||||
now: '2026-05-20T00:00:00Z',
|
||||
repository: 'openclaw/openclaw',
|
||||
staleAfterDays: 60,
|
||||
}),
|
||||
],
|
||||
};
|
||||
const fetchedGhsaAdvisories = [
|
||||
{
|
||||
repository: 'openclaw/openclaw',
|
||||
advisories: [
|
||||
ghsaAdvisory({ ghsa_id: 'GHSA-actv-1111-2222' }),
|
||||
ghsaAdvisory({ ghsa_id: 'GHSA-matd-1111-2222' }),
|
||||
ghsaAdvisory({ ghsa_id: 'GHSA-cvea-1111-2222', cve_id: 'CVE-2026-2222' }),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const ghsaFeed = buildGhsaWithoutCveFeed({
|
||||
fetched: fetchedGhsaAdvisories,
|
||||
existingFeed: existingGhsaFeed,
|
||||
nvdFeed: nvdPollResultFeed,
|
||||
now,
|
||||
staleAfterDays: 60,
|
||||
});
|
||||
assert.deepEqual(
|
||||
ghsaFeed.advisories.map((entry) => [entry.id, entry.status, entry.cve_id]),
|
||||
[
|
||||
['GHSA-actv-1111-2222', 'active', null],
|
||||
['GHSA-matd-1111-2222', 'matured', 'CVE-2026-1111'],
|
||||
],
|
||||
'GHSA dry run should retain active GHSA-only advisories and mature tracked GHSAs',
|
||||
);
|
||||
|
||||
const consolidatedFeed = buildConsolidatedAdvisoryFeed({
|
||||
canonicalFeed: nvdPollResultFeed,
|
||||
ghsaFeed,
|
||||
now,
|
||||
});
|
||||
assert.deepEqual(
|
||||
consolidatedFeed.advisories.map((entry) => entry.id),
|
||||
['CVE-2026-2222', 'GHSA-actv-1111-2222', 'CVE-2026-1111'],
|
||||
'Consolidated feed should include NVD CVEs plus active GHSA-only advisories without duplicate matured GHSAs',
|
||||
);
|
||||
assert.equal(consolidatedFeed.advisories[1].source_feed, 'ghsa-without-cve');
|
||||
assert.equal(consolidatedFeed.updated, nvdPollResultFeed.updated);
|
||||
|
||||
await writeJson(canonicalFeedPath, consolidatedFeed);
|
||||
await writeJson(ghsaFeedPath, ghsaFeed);
|
||||
await writeJson(skillFeedPath, consolidatedFeed);
|
||||
|
||||
const { privateKey, publicKey } = generateKeyPairSync('ed25519');
|
||||
const canonicalFeedBytes = await readFile(canonicalFeedPath);
|
||||
const ghsaFeedBytes = await readFile(ghsaFeedPath);
|
||||
const skillFeedBytes = await readFile(skillFeedPath);
|
||||
const canonicalSignature = signBuffer(canonicalFeedBytes, privateKey);
|
||||
const ghsaSignature = signBuffer(ghsaFeedBytes, privateKey);
|
||||
|
||||
await writeFile(`${canonicalFeedPath}.sig`, `${canonicalSignature}\n`);
|
||||
await writeFile(`${ghsaFeedPath}.sig`, `${ghsaSignature}\n`);
|
||||
await writeFile(`${skillFeedPath}.sig`, `${canonicalSignature}\n`);
|
||||
|
||||
assert.deepEqual(skillFeedBytes, canonicalFeedBytes, 'skill advisory feed must match the signed agent feed');
|
||||
assert.ok(
|
||||
verifySignature(canonicalFeedBytes, canonicalSignature, publicKey),
|
||||
'canonical consolidated feed signature must verify',
|
||||
);
|
||||
assert.ok(verifySignature(skillFeedBytes, canonicalSignature, publicKey), 'skill feed signature must verify');
|
||||
assert.ok(verifySignature(ghsaFeedBytes, ghsaSignature, publicKey), 'GHSA source feed signature must verify');
|
||||
|
||||
console.log(
|
||||
`NVD + GHSA dry run passed: ${consolidatedFeed.advisories.length} consolidated advisories, ${ghsaFeed.advisories.length} GHSA source advisories, signatures verified.`,
|
||||
);
|
||||
@@ -0,0 +1,29 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
const workflowPath = new URL('../.github/workflows/skill-release.yml', import.meta.url);
|
||||
const workflow = await readFile(workflowPath, 'utf8');
|
||||
|
||||
assert.match(
|
||||
workflow,
|
||||
/pull_request:[\s\S]*paths:[\s\S]*- 'skills\/\*\*'/,
|
||||
'Skill release workflow must run when any skill package file changes',
|
||||
);
|
||||
|
||||
assert.match(
|
||||
workflow,
|
||||
/git diff --name-only "\$\{BASE_SHA\}\.\.\.\$\{HEAD_SHA\}" --[\s\S]*'skills\/\*\/\*\*'[\s\S]*':\(exclude\)skills\/\*\/test\/\*\*'[\s\S]*':\(exclude\)skills\/\*\/tests\/\*\*'/,
|
||||
'Skill release validation must ignore test-only skill changes while inspecting release-relevant skill files',
|
||||
);
|
||||
|
||||
assert.doesNotMatch(
|
||||
workflow,
|
||||
/No version bump detected for \$\{skill_dir\}; skipping\./,
|
||||
'Changed skill directories without a version bump must fail validation instead of being skipped',
|
||||
);
|
||||
|
||||
assert.match(
|
||||
workflow,
|
||||
/::error file=\$\{skill_dir\}::Changed skill package has no version bump\./,
|
||||
'Skill release validation must emit an explicit missing-version-bump error',
|
||||
);
|
||||
Reference in New Issue
Block a user