mirror of
https://github.com/prompt-security/clawsec.git
synced 2026-06-24 10:51:22 +03:00
fix(attestation): address Baz review findings for schema and verifier
This commit is contained in:
@@ -172,7 +172,21 @@ function bool(value, defaultValue = false) {
|
||||
if (value === undefined || value === null) {
|
||||
return defaultValue;
|
||||
}
|
||||
return !!value;
|
||||
if (typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
if (value === 1) return true;
|
||||
if (value === 0) return false;
|
||||
return defaultValue;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const norm = value.trim().toLowerCase();
|
||||
if (["1", "true", "yes", "on", "enabled"].includes(norm)) return true;
|
||||
if (["0", "false", "no", "off", "disabled"].includes(norm)) return false;
|
||||
return defaultValue;
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
function readEnvBool(name, fallback = false) {
|
||||
@@ -341,6 +355,10 @@ export function validateAttestationSchema(attestation) {
|
||||
|
||||
if (!isPlainObject(attestation.generator)) {
|
||||
errors.push("generator object is required");
|
||||
} else {
|
||||
if (typeof attestation.generator.version !== "string" || !attestation.generator.version.trim()) {
|
||||
errors.push("generator.version must be a non-empty string");
|
||||
}
|
||||
}
|
||||
if (!isPlainObject(attestation.host)) {
|
||||
errors.push("host object is required");
|
||||
@@ -349,8 +367,29 @@ export function validateAttestationSchema(attestation) {
|
||||
if (!isPlainObject(attestation.posture)) {
|
||||
errors.push("posture object is required");
|
||||
} else {
|
||||
if (!isPlainObject(attestation.posture.runtime)) {
|
||||
const runtime = attestation.posture.runtime;
|
||||
if (!isPlainObject(runtime)) {
|
||||
errors.push("posture.runtime object is required");
|
||||
} else {
|
||||
if (!isPlainObject(runtime.gateways)) {
|
||||
errors.push("posture.runtime.gateways object is required");
|
||||
} else {
|
||||
for (const gateway of ["telegram", "matrix", "discord"]) {
|
||||
if (typeof runtime.gateways[gateway] !== "boolean") {
|
||||
errors.push(`posture.runtime.gateways.${gateway} must be a boolean`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isPlainObject(runtime.risky_toggles)) {
|
||||
errors.push("posture.runtime.risky_toggles object is required");
|
||||
} else {
|
||||
for (const toggle of ["allow_unsigned_mode", "bypass_verification"]) {
|
||||
if (typeof runtime.risky_toggles[toggle] !== "boolean") {
|
||||
errors.push(`posture.runtime.risky_toggles.${toggle} must be a boolean`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!isPlainObject(attestation.posture.feed_verification)) {
|
||||
errors.push("posture.feed_verification object is required");
|
||||
@@ -365,12 +404,35 @@ export function validateAttestationSchema(attestation) {
|
||||
if (!isPlainObject(integrity)) {
|
||||
errors.push("posture.integrity object is required");
|
||||
} else {
|
||||
if (!Array.isArray(integrity.watched_files)) {
|
||||
errors.push("posture.integrity.watched_files must be an array");
|
||||
}
|
||||
if (!Array.isArray(integrity.trust_anchors)) {
|
||||
errors.push("posture.integrity.trust_anchors must be an array");
|
||||
}
|
||||
const validateIntegrityEntries = (entries, fieldPath) => {
|
||||
if (!Array.isArray(entries)) {
|
||||
errors.push(`${fieldPath} must be an array`);
|
||||
return;
|
||||
}
|
||||
|
||||
entries.forEach((entry, index) => {
|
||||
const itemPath = `${fieldPath}[${index}]`;
|
||||
if (!isPlainObject(entry)) {
|
||||
errors.push(`${itemPath} must be an object`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof entry.path !== "string" || !entry.path.trim()) {
|
||||
errors.push(`${itemPath}.path must be a non-empty string`);
|
||||
}
|
||||
|
||||
if (typeof entry.exists !== "boolean") {
|
||||
errors.push(`${itemPath}.exists must be a boolean`);
|
||||
}
|
||||
|
||||
if (entry.sha256 !== null && !/^[a-f0-9]{64}$/i.test(String(entry.sha256 || ""))) {
|
||||
errors.push(`${itemPath}.sha256 must be null or a 64-char sha256 hex string`);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
validateIntegrityEntries(integrity.watched_files, "posture.integrity.watched_files");
|
||||
validateIntegrityEntries(integrity.trust_anchors, "posture.integrity.trust_anchors");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -136,6 +136,20 @@ function printFinding(finding) {
|
||||
process.stdout.write(`${sev}: ${finding.code} - ${finding.message}\n`);
|
||||
}
|
||||
|
||||
function validateSchemaAndDigestBinding({ attestation, schemaInvalidCode, canonicalDigestMismatchCode, verificationFindings, failures }) {
|
||||
const schemaErrors = validateAttestationSchema(attestation);
|
||||
for (const message of schemaErrors) {
|
||||
verificationFindings.push({ severity: "critical", code: schemaInvalidCode, message });
|
||||
failures.push(message);
|
||||
}
|
||||
|
||||
const digestBindingError = validateDigestBinding(attestation);
|
||||
if (digestBindingError) {
|
||||
verificationFindings.push({ severity: "critical", code: canonicalDigestMismatchCode, message: digestBindingError });
|
||||
failures.push(digestBindingError);
|
||||
}
|
||||
}
|
||||
|
||||
function run() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.help) {
|
||||
@@ -167,17 +181,13 @@ function run() {
|
||||
throw new Error(`invalid JSON attestation: ${error.message}`);
|
||||
}
|
||||
|
||||
const schemaErrors = validateAttestationSchema(attestation);
|
||||
for (const message of schemaErrors) {
|
||||
verificationFindings.push({ severity: "critical", code: "SCHEMA_INVALID", message });
|
||||
failures.push(message);
|
||||
}
|
||||
|
||||
const digestBindingError = validateDigestBinding(attestation);
|
||||
if (digestBindingError) {
|
||||
verificationFindings.push({ severity: "critical", code: "CANONICAL_DIGEST_MISMATCH", message: digestBindingError });
|
||||
failures.push(digestBindingError);
|
||||
}
|
||||
validateSchemaAndDigestBinding({
|
||||
attestation,
|
||||
schemaInvalidCode: "SCHEMA_INVALID",
|
||||
canonicalDigestMismatchCode: "CANONICAL_DIGEST_MISMATCH",
|
||||
verificationFindings,
|
||||
failures,
|
||||
});
|
||||
|
||||
const fileDigest = sha256Hex(inputBytes);
|
||||
if (args.expectedSha256) {
|
||||
@@ -262,20 +272,13 @@ function run() {
|
||||
|
||||
try {
|
||||
const baseline = JSON.parse(baselineBytes.toString("utf8"));
|
||||
const baselineSchemaErrors = validateAttestationSchema(baseline);
|
||||
for (const message of baselineSchemaErrors) {
|
||||
verificationFindings.push({ severity: "critical", code: "BASELINE_SCHEMA_INVALID", message });
|
||||
failures.push(message);
|
||||
}
|
||||
const baselineDigestBindingError = validateDigestBinding(baseline);
|
||||
if (baselineDigestBindingError) {
|
||||
verificationFindings.push({
|
||||
severity: "critical",
|
||||
code: "BASELINE_CANONICAL_DIGEST_MISMATCH",
|
||||
message: baselineDigestBindingError,
|
||||
});
|
||||
failures.push(baselineDigestBindingError);
|
||||
}
|
||||
validateSchemaAndDigestBinding({
|
||||
attestation: baseline,
|
||||
schemaInvalidCode: "BASELINE_SCHEMA_INVALID",
|
||||
canonicalDigestMismatchCode: "BASELINE_CANONICAL_DIGEST_MISMATCH",
|
||||
verificationFindings,
|
||||
failures,
|
||||
});
|
||||
|
||||
if (failures.length === 0) {
|
||||
diff = diffAttestations(baseline, attestation);
|
||||
|
||||
@@ -76,6 +76,53 @@ await withTempDir(async (tempDir) => {
|
||||
]);
|
||||
assert.equal(verifyTrustedBaseline.status, 0, `trusted baseline should verify: ${verifyTrustedBaseline.stderr}`);
|
||||
|
||||
const invalidCurrent = JSON.parse(attestationRaw);
|
||||
delete invalidCurrent.platform;
|
||||
await fs.writeFile(outputPath, JSON.stringify(invalidCurrent, null, 2), "utf8");
|
||||
|
||||
const verifyInvalidCurrent = runNode(verifierScript, ["--input", outputPath]);
|
||||
assert.notEqual(verifyInvalidCurrent.status, 0, "schema-invalid current attestation must be rejected");
|
||||
assert.ok(verifyInvalidCurrent.stdout.includes("SCHEMA_INVALID"), verifyInvalidCurrent.stdout);
|
||||
|
||||
await fs.writeFile(outputPath, attestationRaw, "utf8");
|
||||
|
||||
const baselineCanonicalMismatch = JSON.parse(attestationRaw);
|
||||
baselineCanonicalMismatch.posture.runtime.risky_toggles.allow_unsigned_mode = true;
|
||||
const baselineCanonicalMismatchRaw = JSON.stringify(baselineCanonicalMismatch, null, 2);
|
||||
await fs.writeFile(baselinePath, baselineCanonicalMismatchRaw, "utf8");
|
||||
const baselineCanonicalMismatchDigest = crypto.createHash("sha256").update(baselineCanonicalMismatchRaw).digest("hex");
|
||||
|
||||
const verifyBaselineCanonicalMismatch = runNode(verifierScript, [
|
||||
"--input",
|
||||
outputPath,
|
||||
"--baseline",
|
||||
baselinePath,
|
||||
"--baseline-expected-sha256",
|
||||
baselineCanonicalMismatchDigest,
|
||||
]);
|
||||
assert.notEqual(verifyBaselineCanonicalMismatch.status, 0, "baseline canonical digest mismatch must be rejected");
|
||||
assert.ok(
|
||||
verifyBaselineCanonicalMismatch.stdout.includes("BASELINE_CANONICAL_DIGEST_MISMATCH"),
|
||||
verifyBaselineCanonicalMismatch.stdout,
|
||||
);
|
||||
|
||||
const baselineSchemaInvalid = JSON.parse(attestationRaw);
|
||||
delete baselineSchemaInvalid.platform;
|
||||
const baselineSchemaInvalidRaw = JSON.stringify(baselineSchemaInvalid, null, 2);
|
||||
await fs.writeFile(baselinePath, baselineSchemaInvalidRaw, "utf8");
|
||||
const baselineSchemaInvalidDigest = crypto.createHash("sha256").update(baselineSchemaInvalidRaw).digest("hex");
|
||||
|
||||
const verifyBaselineSchemaInvalid = runNode(verifierScript, [
|
||||
"--input",
|
||||
outputPath,
|
||||
"--baseline",
|
||||
baselinePath,
|
||||
"--baseline-expected-sha256",
|
||||
baselineSchemaInvalidDigest,
|
||||
]);
|
||||
assert.notEqual(verifyBaselineSchemaInvalid.status, 0, "schema-invalid baseline must be rejected");
|
||||
assert.ok(verifyBaselineSchemaInvalid.stdout.includes("BASELINE_SCHEMA_INVALID"), verifyBaselineSchemaInvalid.stdout);
|
||||
|
||||
const baselineTampered = JSON.parse(attestationRaw);
|
||||
baselineTampered.posture.runtime.risky_toggles.allow_unsigned_mode = true;
|
||||
await fs.writeFile(baselinePath, JSON.stringify(baselineTampered, null, 2), "utf8");
|
||||
|
||||
@@ -21,6 +21,30 @@ async function withTempDir(run) {
|
||||
}
|
||||
}
|
||||
|
||||
async function withPatchedEnv(patch, run) {
|
||||
const previous = new Map();
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
previous.set(key, process.env[key]);
|
||||
if (value === undefined || value === null) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = String(value);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
for (const [key, value] of previous.entries()) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function testBuildAttestationIsSchemaValidAndDeterministic() {
|
||||
await withTempDir(async (tempDir) => {
|
||||
const watchedFile = path.join(tempDir, "watch.txt");
|
||||
@@ -76,7 +100,158 @@ function testDigestBindingRejectsUnsupportedAlgorithm() {
|
||||
assert.ok(digestBindingError?.includes("unsupported digest algorithm"));
|
||||
}
|
||||
|
||||
function testSchemaValidationRequiresGeneratorVersionNonEmptyString() {
|
||||
const missingVersion = buildAttestation({ generatedAt: "2026-04-15T18:00:00.000Z" });
|
||||
delete missingVersion.generator.version;
|
||||
const missingVersionErrors = validateAttestationSchema(missingVersion);
|
||||
assert.ok(missingVersionErrors.includes("generator.version must be a non-empty string"));
|
||||
|
||||
const nonStringVersion = buildAttestation({ generatedAt: "2026-04-15T18:00:00.000Z" });
|
||||
nonStringVersion.generator.version = 7;
|
||||
const nonStringVersionErrors = validateAttestationSchema(nonStringVersion);
|
||||
assert.ok(nonStringVersionErrors.includes("generator.version must be a non-empty string"));
|
||||
|
||||
const emptyVersion = buildAttestation({ generatedAt: "2026-04-15T18:00:00.000Z" });
|
||||
emptyVersion.generator.version = " ";
|
||||
const emptyVersionErrors = validateAttestationSchema(emptyVersion);
|
||||
assert.ok(emptyVersionErrors.includes("generator.version must be a non-empty string"));
|
||||
}
|
||||
|
||||
function testSchemaValidationRequiresRuntimeGatewaysAndRiskyTogglesBooleans() {
|
||||
const valid = buildAttestation({ generatedAt: "2026-04-15T18:00:00.000Z" });
|
||||
const validErrors = validateAttestationSchema(valid);
|
||||
assert.equal(validErrors.length, 0, `valid attestation should pass schema: ${validErrors.join(", ")}`);
|
||||
|
||||
const missingGateways = buildAttestation({ generatedAt: "2026-04-15T18:00:00.000Z" });
|
||||
delete missingGateways.posture.runtime.gateways;
|
||||
const missingGatewaysErrors = validateAttestationSchema(missingGateways);
|
||||
assert.ok(missingGatewaysErrors.includes("posture.runtime.gateways object is required"));
|
||||
|
||||
const malformedGateways = buildAttestation({ generatedAt: "2026-04-15T18:00:00.000Z" });
|
||||
malformedGateways.posture.runtime.gateways = "enabled";
|
||||
const malformedGatewaysErrors = validateAttestationSchema(malformedGateways);
|
||||
assert.ok(malformedGatewaysErrors.includes("posture.runtime.gateways object is required"));
|
||||
|
||||
const invalidGatewayLeaf = buildAttestation({ generatedAt: "2026-04-15T18:00:00.000Z" });
|
||||
delete invalidGatewayLeaf.posture.runtime.gateways.matrix;
|
||||
invalidGatewayLeaf.posture.runtime.gateways.telegram = "true";
|
||||
const invalidGatewayLeafErrors = validateAttestationSchema(invalidGatewayLeaf);
|
||||
assert.ok(invalidGatewayLeafErrors.includes("posture.runtime.gateways.telegram must be a boolean"));
|
||||
assert.ok(invalidGatewayLeafErrors.includes("posture.runtime.gateways.matrix must be a boolean"));
|
||||
|
||||
const missingRiskyToggles = buildAttestation({ generatedAt: "2026-04-15T18:00:00.000Z" });
|
||||
delete missingRiskyToggles.posture.runtime.risky_toggles;
|
||||
const missingRiskyTogglesErrors = validateAttestationSchema(missingRiskyToggles);
|
||||
assert.ok(missingRiskyTogglesErrors.includes("posture.runtime.risky_toggles object is required"));
|
||||
|
||||
const malformedRiskyToggles = buildAttestation({ generatedAt: "2026-04-15T18:00:00.000Z" });
|
||||
malformedRiskyToggles.posture.runtime.risky_toggles = [];
|
||||
const malformedRiskyTogglesErrors = validateAttestationSchema(malformedRiskyToggles);
|
||||
assert.ok(malformedRiskyTogglesErrors.includes("posture.runtime.risky_toggles object is required"));
|
||||
|
||||
const invalidRiskyToggleLeaf = buildAttestation({ generatedAt: "2026-04-15T18:00:00.000Z" });
|
||||
delete invalidRiskyToggleLeaf.posture.runtime.risky_toggles.bypass_verification;
|
||||
invalidRiskyToggleLeaf.posture.runtime.risky_toggles.allow_unsigned_mode = "false";
|
||||
const invalidRiskyToggleLeafErrors = validateAttestationSchema(invalidRiskyToggleLeaf);
|
||||
assert.ok(
|
||||
invalidRiskyToggleLeafErrors.includes("posture.runtime.risky_toggles.allow_unsigned_mode must be a boolean"),
|
||||
);
|
||||
assert.ok(
|
||||
invalidRiskyToggleLeafErrors.includes("posture.runtime.risky_toggles.bypass_verification must be a boolean"),
|
||||
);
|
||||
}
|
||||
|
||||
function testSchemaValidationRequiresIntegrityEntryShapes() {
|
||||
const attestation = buildAttestation({ generatedAt: "2026-04-15T18:00:00.000Z" });
|
||||
attestation.posture.integrity.watched_files = [
|
||||
null,
|
||||
{ path: "", exists: true, sha256: null },
|
||||
{ path: "/etc/hermes/config.json", exists: "yes", sha256: "abc" },
|
||||
];
|
||||
attestation.posture.integrity.trust_anchors = [{ exists: false, sha256: 7 }];
|
||||
|
||||
const errors = validateAttestationSchema(attestation);
|
||||
assert.ok(errors.includes("posture.integrity.watched_files[0] must be an object"));
|
||||
assert.ok(errors.includes("posture.integrity.watched_files[1].path must be a non-empty string"));
|
||||
assert.ok(errors.includes("posture.integrity.watched_files[2].exists must be a boolean"));
|
||||
assert.ok(
|
||||
errors.includes("posture.integrity.watched_files[2].sha256 must be null or a 64-char sha256 hex string"),
|
||||
);
|
||||
assert.ok(errors.includes("posture.integrity.trust_anchors[0].path must be a non-empty string"));
|
||||
assert.ok(errors.includes("posture.integrity.trust_anchors[0].sha256 must be null or a 64-char sha256 hex string"));
|
||||
|
||||
const valid = buildAttestation({ generatedAt: "2026-04-15T18:00:00.000Z" });
|
||||
valid.posture.integrity.watched_files = [{ path: "/tmp/a", exists: false, sha256: null }];
|
||||
valid.posture.integrity.trust_anchors = [
|
||||
{
|
||||
path: "/tmp/t.pem",
|
||||
exists: true,
|
||||
sha256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
},
|
||||
];
|
||||
|
||||
const validErrors = validateAttestationSchema(valid);
|
||||
assert.equal(validErrors.length, 0, `valid integrity entries should pass schema: ${validErrors.join(", ")}`);
|
||||
}
|
||||
|
||||
async function testBooleanConfigCoercionDoesNotEnableFalseStrings() {
|
||||
await withTempDir(async (tempDir) => {
|
||||
const hermesHome = path.join(tempDir, ".hermes");
|
||||
await fs.mkdir(hermesHome, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(hermesHome, "config.json"),
|
||||
JSON.stringify({
|
||||
gateways: {
|
||||
telegram: { enabled: "false" },
|
||||
matrix: { enabled: "0" },
|
||||
discord: { enabled: "off" },
|
||||
},
|
||||
security: {
|
||||
allow_unsigned_mode: "false",
|
||||
bypass_verification: "off",
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
await withPatchedEnv(
|
||||
{
|
||||
HERMES_HOME: hermesHome,
|
||||
HERMES_GATEWAY_TELEGRAM_ENABLED: "true",
|
||||
HERMES_GATEWAY_MATRIX_ENABLED: "1",
|
||||
HERMES_GATEWAY_DISCORD_ENABLED: "yes",
|
||||
HERMES_ALLOW_UNSIGNED_MODE: "true",
|
||||
HERMES_BYPASS_VERIFICATION: "true",
|
||||
},
|
||||
async () => {
|
||||
const attestation = buildAttestation({ generatedAt: "2026-04-15T18:00:00.000Z" });
|
||||
assert.equal(attestation.posture.runtime.gateways.telegram, false);
|
||||
assert.equal(attestation.posture.runtime.gateways.matrix, false);
|
||||
assert.equal(attestation.posture.runtime.gateways.discord, false);
|
||||
assert.equal(attestation.posture.runtime.risky_toggles.allow_unsigned_mode, false);
|
||||
assert.equal(attestation.posture.runtime.risky_toggles.bypass_verification, false);
|
||||
},
|
||||
);
|
||||
|
||||
await withPatchedEnv(
|
||||
{
|
||||
HERMES_HOME: hermesHome,
|
||||
HERMES_GATEWAY_TELEGRAM_ENABLED: "true",
|
||||
},
|
||||
async () => {
|
||||
await fs.writeFile(path.join(hermesHome, "config.json"), JSON.stringify({}), "utf8");
|
||||
const attestation = buildAttestation({ generatedAt: "2026-04-15T18:00:00.000Z" });
|
||||
assert.equal(attestation.posture.runtime.gateways.telegram, true);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
await testBuildAttestationIsSchemaValidAndDeterministic();
|
||||
testSchemaValidationFailsClosed();
|
||||
testDigestBindingRejectsUnsupportedAlgorithm();
|
||||
testSchemaValidationRequiresGeneratorVersionNonEmptyString();
|
||||
testSchemaValidationRequiresRuntimeGatewaysAndRiskyTogglesBooleans();
|
||||
testSchemaValidationRequiresIntegrityEntryShapes();
|
||||
await testBooleanConfigCoercionDoesNotEnableFalseStrings();
|
||||
console.log("attestation_schema.test.mjs: ok");
|
||||
|
||||
Reference in New Issue
Block a user