mirror of
https://github.com/prompt-security/clawsec.git
synced 2026-06-23 18:31:21 +03:00
clawsec-suite: align signed feed defaults and checksum key compatibility
This commit is contained in:
+3
-3
@@ -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)
|
||||
- `<release>/checksums.json`
|
||||
- `<release>/checksums.json.sig`
|
||||
- `<release>/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
|
||||
|
||||
|
||||
@@ -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}"
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<string, string>} 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<string, string> }} manifest
|
||||
* @param {Record<string, string | Buffer>} 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})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user