Files
clawsec/skills/openclaw-audit-watchdog/scripts/load_suppression_config.mjs
T
Aldo Delgado 7cdb4ab7e2 fix(portability): harden cross-platform path handling and install workflows (#62)
* docs: add agent collaboration and git safety rules to AGENTS.md

* fix(portability): harden cross-platform path handling and install workflows

- add shared path resolution utility for advisory guardian components
- expand and normalize home-path tokens: ~, $HOME, ${HOME}, %USERPROFILE%, $env:USERPROFILE
- reject unresolved/escaped home tokens to prevent literal "$HOME" directory creation
- fix install/runtime path handling in:
  - openclaw-audit-watchdog setup_cron and suppression config loader
  - clawsec-suite advisory hook handler, suppression loader, and guarded installer
- remove hardcoded Homebrew binary assumptions in watchdog scripts/tests
- add LF enforcement via .gitattributes to reduce CRLF script breakage
- expand CI Node checks to linux/macos/windows matrix
- add cross-platform test coverage for path expansion and token rejection
- update README and SKILL docs with bash/zsh/PowerShell-safe path guidance
- add compatibility deliverables:
  - docs/COMPATIBILITY_REPORT.md
  - docs/REMEDIATION_PLAN.md
  - docs/PLATFORM_VERIFICATION.md

Validation:
- node skills/clawsec-suite/test/path_resolution.test.mjs
- node skills/clawsec-suite/test/guarded_install.test.mjs
- node skills/clawsec-suite/test/advisory_suppression.test.mjs
- node skills/openclaw-audit-watchdog/test/suppression_config.test.mjs
- node skills/openclaw-audit-watchdog/test/render_report_suppression.test.mjs

* fix(advisory): avoid fail-open on invalid path vars and cover watchdog tests

* docs: move signing runbooks into docs folder

* docs: remove root-level signing runbooks after move

* chore(clawsec-suite): bump version to 0.1.3

* chore(openclaw-audit-watchdog): bump version to 0.1.1

* docs(changelog): add entries for clawsec-suite 0.1.3 and watchdog 0.1.1

* docs(changelog): credit @aldodelgado for PR #62 contributions

* feat(clawsec-suite): scope advisories to openclaw application

* fix(ci): run advisory scope tests without TypeScript loader

---------

Co-authored-by: David Abutbul <David.a@prompt.security>
2026-02-25 13:24:31 +02:00

279 lines
8.9 KiB
JavaScript
Executable File

#!/usr/bin/env node
import fs from "node:fs/promises";
import path from "node:path";
import os from "node:os";
const DEFAULT_PRIMARY_PATH = path.join(os.homedir(), ".openclaw", "security-audit.json");
const DEFAULT_FALLBACK_PATH = ".clawsec/allowlist.json";
const UNEXPANDED_HOME_TOKEN_PATTERN =
/(?:^|[\\/])(?:\\?\$HOME|\\?\$\{HOME\}|\\?\$USERPROFILE|\\?\$\{USERPROFILE\}|%HOME%|%USERPROFILE%|\$env:HOME|\$env:USERPROFILE)(?:$|[\\/])/i;
function detectHomeDirectory(env = process.env) {
if (typeof env.HOME === "string" && env.HOME.trim()) return env.HOME.trim();
if (typeof env.USERPROFILE === "string" && env.USERPROFILE.trim()) return env.USERPROFILE.trim();
if (
typeof env.HOMEDRIVE === "string" &&
env.HOMEDRIVE.trim() &&
typeof env.HOMEPATH === "string" &&
env.HOMEPATH.trim()
) {
return `${env.HOMEDRIVE.trim()}${env.HOMEPATH.trim()}`;
}
return os.homedir();
}
function resolveUserPath(inputPath, label) {
const raw = String(inputPath ?? "").trim();
if (!raw) return raw;
const homeDir = detectHomeDirectory(process.env);
let expanded = raw;
if (expanded === "~") {
expanded = homeDir;
} else if (expanded.startsWith("~/") || expanded.startsWith("~\\")) {
expanded = path.join(homeDir, expanded.slice(2));
}
expanded = expanded
.replace(/(?<!\\)\$\{HOME\}/g, homeDir)
.replace(/(?<!\\)\$HOME(?=$|[\\/])/g, homeDir)
.replace(/(?<!\\)\$\{USERPROFILE\}/gi, homeDir)
.replace(/(?<!\\)\$USERPROFILE(?=$|[\\/])/gi, homeDir)
.replace(/%HOME%/gi, homeDir)
.replace(/%USERPROFILE%/gi, homeDir)
.replace(/(?<!\\)\$env:HOME/gi, homeDir)
.replace(/(?<!\\)\$env:USERPROFILE/gi, homeDir);
const normalized = path.normalize(expanded);
if (UNEXPANDED_HOME_TOKEN_PATTERN.test(normalized)) {
throw new Error(
`Unexpanded home token detected in ${label}: ${raw}. ` +
"Use an absolute path or an unquoted home-path expression.",
);
}
return normalized;
}
function isObject(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function normalizeString(value, fallback = "") {
return String(value ?? fallback).trim();
}
function normalizeDate(value) {
const str = normalizeString(value);
if (!str) return null;
// Validate ISO 8601 date format (YYYY-MM-DD)
const iso8601Pattern = /^\d{4}-\d{2}-\d{2}$/;
if (!iso8601Pattern.test(str)) {
return null;
}
return str;
}
function validateSuppression(entry, index) {
if (!isObject(entry)) {
throw new Error(`Suppression entry at index ${index} must be an object`);
}
const checkId = normalizeString(entry.checkId);
if (!checkId) {
throw new Error(`Suppression entry at index ${index} missing required field: checkId`);
}
const skill = normalizeString(entry.skill);
if (!skill) {
throw new Error(`Suppression entry at index ${index} missing required field: skill`);
}
const reason = normalizeString(entry.reason);
if (!reason) {
throw new Error(`Suppression entry at index ${index} missing required field: reason`);
}
if (!entry.suppressedAt) {
throw new Error(`Suppression entry at index ${index} missing required field: suppressedAt`);
}
const suppressedAt = normalizeDate(entry.suppressedAt);
if (!suppressedAt) {
// Warn but don't fail - allow suppression to work with malformed date
process.stderr.write(
`Warning: Suppression entry at index ${index} has malformed date '${entry.suppressedAt}'. Expected ISO 8601 format (YYYY-MM-DD).\n`
);
}
return {
checkId,
skill,
reason,
suppressedAt: suppressedAt || normalizeString(entry.suppressedAt),
};
}
function normalizeSuppressionConfig(payload, source) {
if (!isObject(payload)) {
throw new Error(`Config file at ${source} must be a JSON object`);
}
const rawSuppressions = payload.suppressions;
if (!Array.isArray(rawSuppressions)) {
throw new Error(`Config file at ${source} missing 'suppressions' array`);
}
const suppressions = [];
for (let i = 0; i < rawSuppressions.length; i++) {
try {
const normalized = validateSuppression(rawSuppressions[i], i);
suppressions.push(normalized);
} catch (err) {
throw new Error(`Invalid suppression at index ${i} in ${source}: ${err.message}`, { cause: err });
}
}
// Extract enabledFor sentinel (array of pipeline names this config activates for)
const enabledFor = Array.isArray(payload.enabledFor)
? payload.enabledFor.filter((v) => typeof v === "string" && v.trim() !== "").map((v) => v.trim().toLowerCase())
: [];
return {
suppressions,
enabledFor,
source,
};
}
async function loadConfigFromPath(configPath) {
try {
const raw = await fs.readFile(configPath, "utf8");
const parsed = JSON.parse(raw);
return normalizeSuppressionConfig(parsed, configPath);
} catch (err) {
if (err.code === "ENOENT") {
// File doesn't exist - return null to try fallback
return null;
}
if (err.code === "EACCES") {
throw new Error(`Permission denied reading config file: ${configPath}`, { cause: err });
}
if (err instanceof SyntaxError) {
throw new Error(`Malformed JSON in config file ${configPath}: ${err.message}`, { cause: err });
}
// Re-throw validation errors or other errors
throw err;
}
}
const EMPTY_RESULT = Object.freeze({ suppressions: [], source: "none" });
/**
* Resolve config from the 4-tier priority chain.
* Returns the loaded config or null if no config found.
*/
async function resolveConfig(customPath) {
// Priority 1: Custom path provided as argument
if (customPath) {
const resolved = resolveUserPath(customPath, "custom suppression config path");
const config = await loadConfigFromPath(resolved);
if (!config) {
throw new Error(`Custom config file not found: ${resolved}`);
}
return config;
}
// Priority 2: Environment variable
const envPath = process.env.OPENCLAW_AUDIT_CONFIG;
if (envPath) {
const resolved = resolveUserPath(envPath, "OPENCLAW_AUDIT_CONFIG");
const config = await loadConfigFromPath(resolved);
if (!config) {
throw new Error(`Config file from OPENCLAW_AUDIT_CONFIG not found: ${resolved}`);
}
return config;
}
// Priority 3: Primary default path
const primaryConfig = await loadConfigFromPath(DEFAULT_PRIMARY_PATH);
if (primaryConfig) return primaryConfig;
// Priority 4: Fallback path
const fallbackConfig = await loadConfigFromPath(DEFAULT_FALLBACK_PATH);
if (fallbackConfig) return fallbackConfig;
return null;
}
/**
* Load suppression configuration with multi-path fallback and opt-in gating.
*
* Suppression requires explicit opt-in to prevent ambient activation:
* 1. The `enabled` flag must be true (set via --enable-suppressions CLI flag)
* 2. The config file must contain an `enabledFor` array including "audit"
*
* Without both gates, returns empty suppressions.
*
* @param {string} [customPath] - Optional custom config file path
* @param {object} [options]
* @param {boolean} [options.enabled=false] - Whether suppression is explicitly enabled
* @param {string} [options.pipeline="audit"] - Pipeline to check in enabledFor sentinel
* @returns {Promise<{suppressions: Array, source: string}>}
*/
export async function loadSuppressionConfig(customPath = null, { enabled = false, pipeline = "audit" } = {}) {
// Gate 1: suppression must be explicitly opted-in via CLI flag
if (!enabled) {
return EMPTY_RESULT;
}
const config = await resolveConfig(customPath);
if (!config) {
return EMPTY_RESULT;
}
// Gate 2: config must declare this pipeline in enabledFor sentinel
if (!Array.isArray(config.enabledFor) || !config.enabledFor.includes(pipeline)) {
return EMPTY_RESULT;
}
process.stderr.write(
`WARNING: Suppression mechanism is enabled for "${pipeline}" pipeline via --enable-suppressions flag.\n`
);
return config;
}
// CLI usage when run directly
if (import.meta.url === `file://${process.argv[1]}`) {
const args = process.argv.slice(2);
const enableFlag = args.includes("--enable-suppressions");
const customPath = args.find((a) => !a.startsWith("--")) || null;
if (!enableFlag) {
process.stdout.write("Suppression is disabled. Pass --enable-suppressions to activate.\n");
process.exit(0);
}
try {
const config = await loadSuppressionConfig(customPath, { enabled: true });
if (config.suppressions.length === 0) {
process.stdout.write("No active suppressions (config missing, no enabledFor sentinel, or empty)\n");
process.stdout.write(JSON.stringify(config, null, 2) + "\n");
process.exit(0);
}
process.stdout.write(`Config loaded successfully from: ${config.source}\n`);
process.stdout.write(`Found ${config.suppressions.length} suppression(s):\n`);
process.stdout.write(JSON.stringify(config, null, 2) + "\n");
process.exit(0);
} catch (err) {
process.stderr.write(`Error loading suppression config: ${err.message}\n`);
process.exit(1);
}
}