文件预览

handler.ts

查看 clawsec-suite 技能包中的文件内容。

文件内容

hooks/clawsec-advisory-guardian/handler.ts

import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { uniqueStrings, resolveConfiguredPath } from "./lib/utils.mjs";
import { defaultChecksumsUrl, loadLocalFeed, loadRemoteFeed } from "./lib/feed.mjs";
import type { HookEvent, FeedPayload, AdvisoryMatch } from "./lib/types.ts";
import { loadState, persistState } from "./lib/state.ts";
import { discoverInstalledSkills, findMatches, matchKey, buildAlertMessage } from "./lib/matching.ts";
import { loadAdvisorySuppression, isAdvisorySuppressed } from "./lib/suppression.mjs";

const DEFAULT_FEED_URL =
  "https://clawsec.prompt.security/advisories/feed.json";
const DEFAULT_SCAN_INTERVAL_SECONDS = 300;
let unsignedModeWarningShown = false;

function parsePositiveInteger(value: string | undefined, fallback: number): number {
  const parsed = Number.parseInt(String(value ?? ""), 10);
  if (!Number.isFinite(parsed) || parsed <= 0) {
    return fallback;
  }
  return parsed;
}

function toEventName(event: HookEvent): string {
  const eventType = String(event.type ?? "").trim();
  const action = String(event.action ?? "").trim();
  if (!eventType || !action) return "";
  return `${eventType}:${action}`;
}

function shouldHandleEvent(event: HookEvent): boolean {
  const eventName = toEventName(event);
  return eventName === "agent:bootstrap" || eventName === "command:new";
}

function epochMs(isoTimestamp: string | null): number {
  if (!isoTimestamp) return 0;
  const parsed = Date.parse(isoTimestamp);
  return Number.isNaN(parsed) ? 0 : parsed;
}

function scannedRecently(lastScan: string | null, minIntervalSeconds: number): boolean {
  const sinceMs = Date.now() - epochMs(lastScan);
  return sinceMs >= 0 && sinceMs < minIntervalSeconds * 1000;
}

function configuredPath(
  explicit: string | undefined,
  fallback: string,
  label: string,
): string {
  return resolveConfiguredPath(explicit, fallback, {
    label,
    onInvalid: (error, rawValue) => {
      console.warn(
        `[clawsec-advisory-guardian] invalid ${label} path "${rawValue}", using default "${fallback}": ${String(error)}`,
      );
    },
  });
}

async function loadFeed(options: {
  feedUrl: string;
  feedSignatureUrl: string;
  feedChecksumsUrl: string;
  feedChecksumsSignatureUrl: string;
  localFeedPath: string;
  localFeedSignaturePath: string;
  localFeedChecksumsPath: string;
  localFeedChecksumsSignaturePath: string;
  feedPublicKeyPath: string;
  allowUnsigned: boolean;
  verifyChecksumManifest: boolean;
}): Promise<FeedPayload> {
  const publicKeyPem = options.allowUnsigned ? "" : await fs.readFile(options.feedPublicKeyPath, "utf8");

  const remoteFeed = await loadRemoteFeed(options.feedUrl, {
    signatureUrl: options.feedSignatureUrl,
    checksumsUrl: options.feedChecksumsUrl,
    checksumsSignatureUrl: options.feedChecksumsSignatureUrl,
    publicKeyPem,
    checksumsPublicKeyPem: publicKeyPem,
    allowUnsigned: options.allowUnsigned,
    verifyChecksumManifest: options.verifyChecksumManifest,
  });
  if (remoteFeed) return remoteFeed;

  return await loadLocalFeed(options.localFeedPath, {
    signaturePath: options.localFeedSignaturePath,
    checksumsPath: options.localFeedChecksumsPath,
    checksumsSignaturePath: options.localFeedChecksumsSignaturePath,
    publicKeyPem,
    checksumsPublicKeyPem: publicKeyPem,
    allowUnsigned: options.allowUnsigned,
    verifyChecksumManifest: options.verifyChecksumManifest,
    checksumPublicKeyEntry: path.basename(options.feedPublicKeyPath),
  });
}

const handler = async (event: HookEvent): Promise<void> => {
  if (!shouldHandleEvent(event)) return;

  const installRoot = configuredPath(
    process.env.CLAWSEC_INSTALL_ROOT || process.env.INSTALL_ROOT,
    path.join(os.homedir(), ".openclaw", "skills"),
    "CLAWSEC_INSTALL_ROOT",
  );
  const suiteDir = configuredPath(
    process.env.CLAWSEC_SUITE_DIR,
    path.join(installRoot, "clawsec-suite"),
    "CLAWSEC_SUITE_DIR",
  );
  const localFeedPath = configuredPath(
    process.env.CLAWSEC_LOCAL_FEED,
    path.join(suiteDir, "advisories", "feed.json"),
    "CLAWSEC_LOCAL_FEED",
  );
  const localFeedSignaturePath = configuredPath(
    process.env.CLAWSEC_LOCAL_FEED_SIG,
    `${localFeedPath}.sig`,
    "CLAWSEC_LOCAL_FEED_SIG",
  );
  const localFeedChecksumsPath = configuredPath(
    process.env.CLAWSEC_LOCAL_FEED_CHECKSUMS,
    path.join(path.dirname(localFeedPath), "checksums.json"),
    "CLAWSEC_LOCAL_FEED_CHECKSUMS",
  );
  const localFeedChecksumsSignaturePath = configuredPath(
    process.env.CLAWSEC_LOCAL_FEED_CHECKSUMS_SIG,
    `${localFeedChecksumsPath}.sig`,
    "CLAWSEC_LOCAL_FEED_CHECKSUMS_SIG",
  );
  const feedPublicKeyPath = configuredPath(
    process.env.CLAWSEC_FEED_PUBLIC_KEY,
    path.join(suiteDir, "advisories", "feed-signing-public.pem"),
    "CLAWSEC_FEED_PUBLIC_KEY",
  );
  const stateFile = configuredPath(
    process.env.CLAWSEC_SUITE_STATE_FILE,
    path.join(os.homedir(), ".openclaw", "clawsec-suite-feed-state.json"),
    "CLAWSEC_SUITE_STATE_FILE",
  );
  const feedUrl = process.env.CLAWSEC_FEED_URL || DEFAULT_FEED_URL;
  const feedSignatureUrl = process.env.CLAWSEC_FEED_SIG_URL || `${feedUrl}.sig`;
  const feedChecksumsUrl = process.env.CLAWSEC_FEED_CHECKSUMS_URL || defaultChecksumsUrl(feedUrl);
  const feedChecksumsSignatureUrl =
    process.env.CLAWSEC_FEED_CHECKSUMS_SIG_URL || `${feedChecksumsUrl}.sig`;
  const allowUnsigned = process.env.CLAWSEC_ALLOW_UNSIGNED_FEED === "1";
  const verifyChecksumManifest = process.env.CLAWSEC_VERIFY_CHECKSUM_MANIFEST !== "0";
  const scanIntervalSeconds = parsePositiveInteger(
    process.env.CLAWSEC_HOOK_INTERVAL_SECONDS,
    DEFAULT_SCAN_INTERVAL_SECONDS,
  );

  if (allowUnsigned && !unsignedModeWarningShown) {
    unsignedModeWarningShown = true;
    console.warn(
      "[clawsec-advisory-guardian] CLAWSEC_ALLOW_UNSIGNED_FEED=1 is enabled. " +
        "This bypass is temporary migration compatibility and should be removed as soon as signed feed artifacts are available.",
    );
  }

  const forceScan = toEventName(event) === "command:new";
  const state = await loadState(stateFile);
  if (!forceScan && scannedRecently(state.last_hook_scan, scanIntervalSeconds)) {
    return;
  }

  let feed: FeedPayload;
  try {
    feed = await loadFeed({
      feedUrl,
      feedSignatureUrl,
      feedChecksumsUrl,
      feedChecksumsSignatureUrl,
      localFeedPath,
      localFeedSignaturePath,
      localFeedChecksumsPath,
      localFeedChecksumsSignaturePath,
      feedPublicKeyPath,
      allowUnsigned,
      verifyChecksumManifest,
    });
  } catch (error) {
    console.warn(`[clawsec-advisory-guardian] failed to load advisory feed: ${String(error)}`);
    return;
  }

  const nowIso = new Date().toISOString();
  state.last_hook_scan = nowIso;
  state.last_feed_check = nowIso;

  if (typeof feed.updated === "string" && feed.updated.trim()) {
    state.last_feed_updated = feed.updated;
  }

  const advisoryIds = feed.advisories
    .map((advisory) => advisory.id)
    .filter((id): id is string => typeof id === "string" && id.trim() !== "");
  state.known_advisories = uniqueStrings([...state.known_advisories, ...advisoryIds]);

  const installedSkills = await discoverInstalledSkills(installRoot);
  const allMatches = findMatches(feed, installedSkills);

  if (allMatches.length === 0) {
    await persistState(stateFile, state);
    return;
  }

  // Load advisory suppression config (sentinel-gated: requires enabledFor: ["advisory"])
  let suppressionConfig;
  try {
    suppressionConfig = await loadAdvisorySuppression();
  } catch (err) {
    console.warn(`[clawsec-advisory-guardian] failed to load suppression config: ${String(err)}`);
    suppressionConfig = { suppressions: [], enabledFor: [], source: "none" };
  }

  // Partition matches into active and suppressed
  const matches: AdvisoryMatch[] = [];
  const suppressedMatches: AdvisoryMatch[] = [];
  for (const match of allMatches) {
    if (isAdvisorySuppressed(match, suppressionConfig.suppressions)) {
      suppressedMatches.push(match);
    } else {
      matches.push(match);
    }
  }

  const unseenMatches: AdvisoryMatch[] = [];
  for (const match of matches) {
    const key = matchKey(match);
    if (state.notified_matches[key]) {
      continue;
    }
    unseenMatches.push(match);
    state.notified_matches[key] = nowIso;
  }

  if (unseenMatches.length > 0 && Array.isArray(event.messages)) {
    event.messages.push(buildAlertMessage(unseenMatches, installRoot));
  }

  if (suppressedMatches.length > 0 && Array.isArray(event.messages)) {
    event.messages.push(
      `[clawsec-advisory-guardian] ${suppressedMatches.length} advisory match(es) suppressed by allowlist config.`,
    );
  }

  await persistState(stateFile, state);
};

export default handler;