diff --git a/SECURITY-SIGNING.md b/SECURITY-SIGNING.md index 14321ee..a775e17 100644 --- a/SECURITY-SIGNING.md +++ b/SECURITY-SIGNING.md @@ -24,7 +24,7 @@ As of branch `integration/signing-work`, advisory distribution is **unsigned**: - Feed consumers: - `skills/clawsec-suite/hooks/clawsec-advisory-guardian/handler.ts` - `skills/clawsec-suite/scripts/guarded_skill_install.mjs` - - both default to `https://raw.githubusercontent.com/prompt-security/clawsec/main/advisories/feed.json` + - both default to `https://clawsec.prompt.security/advisories/feed.json` This document defines the **target operating model** for signed artifacts while preserving compatibility during migration. @@ -37,7 +37,7 @@ This document defines the **target operating model** for signed artifacts while ### Release artifact channel (recommended) - `/checksums.json` -- `/checksums.json.sig` +- `/checksums.sig` - `advisories/release-signing-public.pem` (or equivalent repo-pinned location) ## 4) Key roles and custody @@ -138,7 +138,7 @@ Current release generator: Target update: - sign `checksums.json` before `softprops/action-gh-release` -- attach `checksums.json.sig` to each release +- attach `checksums.sig` to each release ## 8) Rotation policy and runbook diff --git a/skills/clawsec-suite/HEARTBEAT.md b/skills/clawsec-suite/HEARTBEAT.md index c090d41..8f9b4d8 100644 --- a/skills/clawsec-suite/HEARTBEAT.md +++ b/skills/clawsec-suite/HEARTBEAT.md @@ -16,7 +16,7 @@ Run this periodically (cron/systemd/CI/agent scheduler). It assumes POSIX shell, INSTALL_ROOT="${INSTALL_ROOT:-$HOME/.openclaw/skills}" SUITE_DIR="$INSTALL_ROOT/clawsec-suite" CHECKSUMS_URL="${CHECKSUMS_URL:-https://clawsec.prompt.security/releases/latest/download/checksums.json}" -FEED_URL="${CLAWSEC_FEED_URL:-https://raw.githubusercontent.com/prompt-security/clawsec/main/advisories/feed.json}" +FEED_URL="${CLAWSEC_FEED_URL:-https://clawsec.prompt.security/advisories/feed.json}" STATE_FILE="${CLAWSEC_SUITE_STATE_FILE:-$HOME/.openclaw/clawsec-suite-feed-state.json}" MIN_FEED_INTERVAL_SECONDS="${MIN_FEED_INTERVAL_SECONDS:-300}" ``` diff --git a/skills/clawsec-suite/SKILL.md b/skills/clawsec-suite/SKILL.md index b506d72..2d62a9a 100644 --- a/skills/clawsec-suite/SKILL.md +++ b/skills/clawsec-suite/SKILL.md @@ -204,7 +204,7 @@ The embedded feed logic uses these defaults: - State file: `~/.openclaw/clawsec-suite-feed-state.json` - Hook rate-limit env (OpenClaw hook): `CLAWSEC_HOOK_INTERVAL_SECONDS` (default `300`) -**Fail-closed verification:** Both signature and checksum manifest verification are required by default. Set `CLAWSEC_ALLOW_UNSIGNED_FEED=1` only as a temporary migration bypass when adopting this version before signed feed artifacts are available upstream. +**Fail-closed verification:** Feed signatures are required by default. Checksum manifests are verified when companion checksum artifacts are available. Set `CLAWSEC_ALLOW_UNSIGNED_FEED=1` only as a temporary migration bypass when adopting this version before signed feed artifacts are available upstream. ### Quick feed check diff --git a/skills/clawsec-suite/hooks/clawsec-advisory-guardian/handler.ts b/skills/clawsec-suite/hooks/clawsec-advisory-guardian/handler.ts index a7065b3..73c5632 100644 --- a/skills/clawsec-suite/hooks/clawsec-advisory-guardian/handler.ts +++ b/skills/clawsec-suite/hooks/clawsec-advisory-guardian/handler.ts @@ -8,7 +8,7 @@ import { loadState, persistState } from "./lib/state.ts"; import { discoverInstalledSkills, findMatches, matchKey, buildAlertMessage } from "./lib/matching.ts"; const DEFAULT_FEED_URL = - "https://raw.githubusercontent.com/prompt-security/clawsec/main/advisories/feed.json"; + "https://clawsec.prompt.security/advisories/feed.json"; const DEFAULT_SCAN_INTERVAL_SECONDS = 300; let unsignedModeWarningShown = false; diff --git a/skills/clawsec-suite/hooks/clawsec-advisory-guardian/lib/feed.mjs b/skills/clawsec-suite/hooks/clawsec-advisory-guardian/lib/feed.mjs index 4935da3..d0e475f 100644 --- a/skills/clawsec-suite/hooks/clawsec-advisory-guardian/lib/feed.mjs +++ b/skills/clawsec-suite/hooks/clawsec-advisory-guardian/lib/feed.mjs @@ -273,6 +273,62 @@ function parseChecksumsManifest(manifestRaw) { }; } +/** + * @param {string} entryName + * @returns {string} + */ +function normalizeChecksumEntryName(entryName) { + return String(entryName ?? "") + .trim() + .replace(/\\/g, "/") + .replace(/^(?:\.\/)+/, "") + .replace(/^\/+/, ""); +} + +/** + * @param {Record} files + * @param {string} entryName + * @returns {{ key: string; digest: string } | null} + */ +function resolveChecksumManifestEntry(files, entryName) { + const normalizedEntry = normalizeChecksumEntryName(entryName); + if (!normalizedEntry) return null; + + const directCandidates = [ + normalizedEntry, + path.posix.basename(normalizedEntry), + `advisories/${path.posix.basename(normalizedEntry)}`, + ].filter((candidate, index, all) => candidate && all.indexOf(candidate) === index); + + for (const candidate of directCandidates) { + if (Object.prototype.hasOwnProperty.call(files, candidate)) { + return { key: candidate, digest: files[candidate] }; + } + } + + const basename = path.posix.basename(normalizedEntry); + if (!basename) return null; + + const basenameMatches = Object.entries(files).filter(([key]) => { + const normalizedKey = normalizeChecksumEntryName(key); + return path.posix.basename(normalizedKey) === basename; + }); + + if (basenameMatches.length > 1) { + throw new Error( + `Checksum manifest entry is ambiguous for ${entryName}; ` + + `multiple manifest keys share basename ${basename}`, + ); + } + + if (basenameMatches.length === 1) { + const [resolvedKey, digest] = basenameMatches[0]; + return { key: resolvedKey, digest }; + } + + return null; +} + /** * @param {{ files: Record }} manifest * @param {Record} expectedEntries @@ -281,14 +337,14 @@ function verifyChecksums(manifest, expectedEntries) { for (const [entryName, entryContent] of Object.entries(expectedEntries)) { if (!entryName) continue; - const expectedDigest = manifest.files[entryName]; - if (!expectedDigest) { + const resolved = resolveChecksumManifestEntry(manifest.files, entryName); + if (!resolved) { throw new Error(`Checksum manifest missing required entry: ${entryName}`); } const actualDigest = sha256Hex(entryContent); - if (actualDigest !== expectedDigest) { - throw new Error(`Checksum mismatch for ${entryName}`); + if (actualDigest !== resolved.digest) { + throw new Error(`Checksum mismatch for ${entryName} (manifest key: ${resolved.key})`); } } } diff --git a/skills/clawsec-suite/scripts/guarded_skill_install.mjs b/skills/clawsec-suite/scripts/guarded_skill_install.mjs index 6c01fb7..c04dc6d 100644 --- a/skills/clawsec-suite/scripts/guarded_skill_install.mjs +++ b/skills/clawsec-suite/scripts/guarded_skill_install.mjs @@ -14,7 +14,7 @@ import { } from "../hooks/clawsec-advisory-guardian/lib/feed.mjs"; const DEFAULT_FEED_URL = - "https://raw.githubusercontent.com/prompt-security/clawsec/main/advisories/feed.json"; + "https://clawsec.prompt.security/advisories/feed.json"; const DEFAULT_SUITE_DIR = path.join(os.homedir(), ".openclaw", "skills", "clawsec-suite"); const DEFAULT_LOCAL_FEED = path.join(DEFAULT_SUITE_DIR, "advisories", "feed.json"); const DEFAULT_LOCAL_FEED_SIG = `${DEFAULT_LOCAL_FEED}.sig`; diff --git a/skills/clawsec-suite/test/feed_verification.test.mjs b/skills/clawsec-suite/test/feed_verification.test.mjs index c195b5f..854aebb 100644 --- a/skills/clawsec-suite/test/feed_verification.test.mjs +++ b/skills/clawsec-suite/test/feed_verification.test.mjs @@ -283,6 +283,57 @@ async function testLoadLocalFeed_ValidSignedFeed() { } } +// ----------------------------------------------------------------------------- +// Test: loadLocalFeed - supports advisories/* checksum keys +// ----------------------------------------------------------------------------- +async function testLoadLocalFeed_AdvisoriesPrefixedChecksumKeys() { + const testName = "loadLocalFeed: advisories/* checksum keys are accepted"; + try { + const { publicKeyPem, privateKeyPem } = generateEd25519KeyPair(); + const feedContent = createValidFeed(); + const feedSignature = signPayload(feedContent, privateKeyPem); + + const advisoriesDir = path.join(tempDir, "advisories"); + await fs.mkdir(advisoriesDir, { recursive: true }); + + const checksumManifest = createChecksumManifest({ + "advisories/feed.json": feedContent, + "advisories/feed.json.sig": feedSignature + "\n", + "advisories/feed-signing-public.pem": publicKeyPem, + }); + const checksumSignature = signPayload(checksumManifest, privateKeyPem); + + const feedPath = path.join(advisoriesDir, "feed.json"); + const sigPath = path.join(advisoriesDir, "feed.json.sig"); + const checksumPath = path.join(advisoriesDir, "checksums.json"); + const checksumSigPath = path.join(advisoriesDir, "checksums.json.sig"); + const keyPath = path.join(advisoriesDir, "feed-signing-public.pem"); + + await fs.writeFile(feedPath, feedContent); + await fs.writeFile(sigPath, feedSignature + "\n"); + await fs.writeFile(checksumPath, checksumManifest); + await fs.writeFile(checksumSigPath, checksumSignature + "\n"); + await fs.writeFile(keyPath, publicKeyPem); + + const feed = await loadLocalFeed(feedPath, { + signaturePath: sigPath, + checksumsPath: checksumPath, + checksumsSignaturePath: checksumSigPath, + publicKeyPem, + verifyChecksumManifest: true, + checksumPublicKeyEntry: path.basename(keyPath), + }); + + if (feed && feed.version === "1.0.0" && feed.advisories.length === 1) { + pass(testName); + } else { + fail(testName, "Feed did not load with advisories/* checksum keys"); + } + } catch (error) { + fail(testName, error); + } +} + // ----------------------------------------------------------------------------- // Test: loadLocalFeed - tampered feed fails (fail-closed) // ----------------------------------------------------------------------------- @@ -542,6 +593,7 @@ async function runTests() { // Local feed loading tests await testLoadLocalFeed_ValidSignedFeed(); + await testLoadLocalFeed_AdvisoriesPrefixedChecksumKeys(); await testLoadLocalFeed_TamperedFeedFails(); await testLoadLocalFeed_MissingSignatureFails(); await testLoadLocalFeed_AllowUnsignedBypasses();