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:
davida-ps
2026-05-24 21:41:59 +03:00
committed by GitHub
parent 8a9bdfcd23
commit 4dbac421ab
34 changed files with 1944 additions and 81 deletions
@@ -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()
+514
View File
@@ -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);
});
}
+54
View File
@@ -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",
);
+37
View File
@@ -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',
);
+425
View File
@@ -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');
+187
View File
@@ -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.`,
);
+29
View File
@@ -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',
);