mirror of
https://github.com/prompt-security/clawsec.git
synced 2026-06-13 05:28:02 +03:00
fix(attestation): harden writes and fail-closed config parsing
This commit is contained in:
@@ -113,14 +113,8 @@ export function resolveHermesScopedOutputPath(outputPath, hermesHome = detectHer
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (fs.lstatSync(resolvedOutput).isSymbolicLink()) {
|
||||
throw new Error(`output path must not be a symlink: ${resolvedOutput}`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.code !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
if (fs.existsSync(resolvedOutput) && fs.lstatSync(resolvedOutput).isSymbolicLink()) {
|
||||
throw new Error(`output path must not be a symlink: ${resolvedOutput}`);
|
||||
}
|
||||
|
||||
return resolvedOutput;
|
||||
@@ -200,10 +194,14 @@ function readEnvBool(name, fallback = false) {
|
||||
if (typeof raw !== "string") {
|
||||
return fallback;
|
||||
}
|
||||
const norm = raw.trim().toLowerCase();
|
||||
if (["1", "true", "yes", "on", "enabled"].includes(norm)) return true;
|
||||
if (["0", "false", "no", "off", "disabled"].includes(norm)) return false;
|
||||
return fallback;
|
||||
return bool(raw, fallback);
|
||||
}
|
||||
|
||||
function configBool(value, envFallback = false) {
|
||||
if (value === undefined || value === null) {
|
||||
return envFallback;
|
||||
}
|
||||
return bool(value, false);
|
||||
}
|
||||
|
||||
function normalizePath(input, hermesHome) {
|
||||
@@ -237,14 +235,14 @@ export function buildAttestation({
|
||||
const config = configState.config || {};
|
||||
|
||||
const gateways = {
|
||||
telegram: bool(config?.gateways?.telegram?.enabled, readEnvBool("HERMES_GATEWAY_TELEGRAM_ENABLED", false)),
|
||||
matrix: bool(config?.gateways?.matrix?.enabled, readEnvBool("HERMES_GATEWAY_MATRIX_ENABLED", false)),
|
||||
discord: bool(config?.gateways?.discord?.enabled, readEnvBool("HERMES_GATEWAY_DISCORD_ENABLED", false)),
|
||||
telegram: configBool(config?.gateways?.telegram?.enabled, readEnvBool("HERMES_GATEWAY_TELEGRAM_ENABLED", false)),
|
||||
matrix: configBool(config?.gateways?.matrix?.enabled, readEnvBool("HERMES_GATEWAY_MATRIX_ENABLED", false)),
|
||||
discord: configBool(config?.gateways?.discord?.enabled, readEnvBool("HERMES_GATEWAY_DISCORD_ENABLED", false)),
|
||||
};
|
||||
|
||||
const riskyToggles = {
|
||||
allow_unsigned_mode: bool(config?.security?.allow_unsigned_mode, readEnvBool("HERMES_ALLOW_UNSIGNED_MODE", false)),
|
||||
bypass_verification: bool(config?.security?.bypass_verification, readEnvBool("HERMES_BYPASS_VERIFICATION", false)),
|
||||
allow_unsigned_mode: configBool(config?.security?.allow_unsigned_mode, readEnvBool("HERMES_ALLOW_UNSIGNED_MODE", false)),
|
||||
bypass_verification: configBool(config?.security?.bypass_verification, readEnvBool("HERMES_BYPASS_VERIFICATION", false)),
|
||||
};
|
||||
|
||||
const feedStatus = String(
|
||||
|
||||
@@ -88,6 +88,49 @@ function parseArgs(argv) {
|
||||
return args;
|
||||
}
|
||||
|
||||
function isSymlinkPath(filePath) {
|
||||
try {
|
||||
return fs.lstatSync(filePath).isSymbolicLink();
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function writeAtomically(outPath, body) {
|
||||
const dir = path.dirname(outPath);
|
||||
const base = path.basename(outPath);
|
||||
const tempPath = path.join(dir, `.${base}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
||||
let fd = null;
|
||||
|
||||
try {
|
||||
fd = fs.openSync(tempPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, 0o600);
|
||||
fs.writeFileSync(fd, body, "utf8");
|
||||
fs.fsyncSync(fd);
|
||||
fs.closeSync(fd);
|
||||
fd = null;
|
||||
|
||||
if (isSymlinkPath(outPath)) {
|
||||
throw new Error(`output path must not be a symlink: ${outPath}`);
|
||||
}
|
||||
|
||||
fs.renameSync(tempPath, outPath);
|
||||
} finally {
|
||||
if (fd !== null) {
|
||||
try {
|
||||
fs.closeSync(fd);
|
||||
} catch {
|
||||
// best-effort cleanup
|
||||
}
|
||||
}
|
||||
if (fs.existsSync(tempPath)) {
|
||||
fs.unlinkSync(tempPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function run() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.help) {
|
||||
@@ -113,7 +156,7 @@ function run() {
|
||||
const outPath = resolveHermesScopedOutputPath(args.output);
|
||||
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
||||
const body = stableStringify(attestation, args.compact ? 0 : 2);
|
||||
fs.writeFileSync(outPath, `${body}\n`, "utf8");
|
||||
writeAtomically(outPath, `${body}\n`);
|
||||
|
||||
if (args.writeSha256) {
|
||||
const shaPath = `${outPath}.sha256`;
|
||||
|
||||
@@ -76,6 +76,21 @@ await withTempDir(async (tempDir) => {
|
||||
]);
|
||||
assert.equal(verifyTrustedBaseline.status, 0, `trusted baseline should verify: ${verifyTrustedBaseline.stderr}`);
|
||||
|
||||
const hardLinkPath = path.join(attestationsDir, "current-hardlink.json");
|
||||
const oldContent = "old-attestation-body\n";
|
||||
await fs.writeFile(outputPath, oldContent, "utf8");
|
||||
await fs.link(outputPath, hardLinkPath);
|
||||
|
||||
const atomicRewrite = runNode(generatorScript, ["--output", outputPath, "--generated-at", generatedAt], {
|
||||
HERMES_HOME: hermesHome,
|
||||
});
|
||||
assert.equal(atomicRewrite.status, 0, `atomic rewrite failed: ${atomicRewrite.stderr}`);
|
||||
|
||||
const rewrittenContent = await fs.readFile(outputPath, "utf8");
|
||||
const hardLinkedContent = await fs.readFile(hardLinkPath, "utf8");
|
||||
assert.notEqual(rewrittenContent, hardLinkedContent, "output rewrite should atomically replace file entry");
|
||||
assert.equal(hardLinkedContent, oldContent, "hard link should preserve previous file body after atomic replace");
|
||||
|
||||
const invalidCurrent = JSON.parse(attestationRaw);
|
||||
delete invalidCurrent.platform;
|
||||
await fs.writeFile(outputPath, JSON.stringify(invalidCurrent, null, 2), "utf8");
|
||||
|
||||
@@ -244,6 +244,31 @@ async function testBooleanConfigCoercionDoesNotEnableFalseStrings() {
|
||||
assert.equal(attestation.posture.runtime.gateways.telegram, true);
|
||||
},
|
||||
);
|
||||
|
||||
await withPatchedEnv(
|
||||
{
|
||||
HERMES_HOME: hermesHome,
|
||||
HERMES_GATEWAY_TELEGRAM_ENABLED: "true",
|
||||
HERMES_ALLOW_UNSIGNED_MODE: "true",
|
||||
},
|
||||
async () => {
|
||||
await fs.writeFile(
|
||||
path.join(hermesHome, "config.json"),
|
||||
JSON.stringify({
|
||||
gateways: {
|
||||
telegram: { enabled: "maybe" },
|
||||
},
|
||||
security: {
|
||||
allow_unsigned_mode: { bad: true },
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const attestation = buildAttestation({ generatedAt: "2026-04-15T18:00:00.000Z" });
|
||||
assert.equal(attestation.posture.runtime.gateways.telegram, false);
|
||||
assert.equal(attestation.posture.runtime.risky_toggles.allow_unsigned_mode, false);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user