文件预览

matching.ts

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

文件内容

hooks/clawsec-advisory-guardian/lib/matching.ts

import fs from "node:fs/promises";
import path from "node:path";
import { isObject, normalizeSkillName, uniqueStrings } from "./utils.mjs";
import { advisoryAppliesToOpenclaw } from "./advisory_scope.mjs";
import { versionMatches } from "./version.mjs";
import { parseAffectedSpecifier } from "./feed.mjs";
import type { Advisory, FeedPayload, InstalledSkill, AdvisoryMatch } from "./types.ts";

export async function discoverInstalledSkills(installRoot: string): Promise<InstalledSkill[]> {
  let entries: import("node:fs").Dirent[];
  try {
    entries = await fs.readdir(installRoot, { withFileTypes: true });
  } catch {
    return [];
  }

  const skills: InstalledSkill[] = [];
  for (const entry of entries) {
    if (!entry.isDirectory()) continue;

    const fallbackName = entry.name;
    const skillDir = path.join(installRoot, entry.name);
    const skillJsonPath = path.join(skillDir, "skill.json");

    let skillName = fallbackName;
    let version: string | null = "unknown";

    try {
      const rawSkillJson = await fs.readFile(skillJsonPath, "utf8");
      const parsedSkillJson = JSON.parse(rawSkillJson);
      if (isObject(parsedSkillJson) && typeof parsedSkillJson.name === "string" && parsedSkillJson.name.trim()) {
        skillName = parsedSkillJson.name.trim();
      }
      if (
        isObject(parsedSkillJson) &&
        typeof parsedSkillJson.version === "string" &&
        parsedSkillJson.version.trim()
      ) {
        version = parsedSkillJson.version.trim();
      }
    } catch {
      // best-effort scan: keep fallback directory name when skill.json is missing or invalid
    }

    skills.push({ name: skillName, dirName: entry.name, version });
  }

  return skills;
}

export function affectedSpecifierMatchesSkill(rawSpecifier: string, skill: InstalledSkill): boolean {
  const parsed = parseAffectedSpecifier(rawSpecifier);
  if (!parsed) return false;

  const specName = normalizeSkillName(parsed.name);
  const skillName = normalizeSkillName(skill.name);
  if (specName !== skillName) return false;

  return versionMatches(skill.version, parsed.versionSpec);
}

export function advisoryMatchesSkill(advisory: Advisory, skill: InstalledSkill): string[] {
  const affected = Array.isArray(advisory.affected) ? advisory.affected : [];
  const matches = affected.filter((specifier) => affectedSpecifierMatchesSkill(specifier, skill));
  return uniqueStrings(matches);
}

export function findMatches(feed: FeedPayload, installedSkills: InstalledSkill[]): AdvisoryMatch[] {
  const matches: AdvisoryMatch[] = [];

  for (const advisory of feed.advisories) {
    if (!advisoryAppliesToOpenclaw(advisory)) continue;

    const affected = Array.isArray(advisory.affected) ? advisory.affected : [];
    if (affected.length === 0) continue;

    for (const skill of installedSkills) {
      const matchedAffected = advisoryMatchesSkill(advisory, skill);
      if (matchedAffected.length === 0) continue;
      matches.push({ advisory, skill, matchedAffected });
    }
  }

  return matches;
}

export function matchKey(match: AdvisoryMatch): string {
  const normalizedSkillName = normalizeSkillName(match.skill.name);
  const version = match.skill.version ?? "unknown";
  const advisoryId =
    match.advisory.id ??
    `${match.advisory.title ?? "untitled"}::${match.advisory.published ?? match.advisory.updated ?? "unknown-ts"}`;
  return `${advisoryId}::${normalizedSkillName}@${version}`;
}

export function looksMalicious(advisory: Advisory): boolean {
  const type = String(advisory.type ?? "").toLowerCase();
  const combined = `${advisory.title ?? ""} ${advisory.description ?? ""} ${advisory.action ?? ""}`.toLowerCase();

  if (type === "malicious_skill" || type === "malicious_plugin") return true;
  if (/\b(malicious|exfiltrat(e|ion)|backdoor|trojan|credential theft|stealer)\b/.test(combined)) return true;
  return false;
}

export function looksRemovalRecommended(advisory: Advisory): boolean {
  const combined = `${advisory.action ?? ""} ${advisory.title ?? ""} ${advisory.description ?? ""}`.toLowerCase();
  return /\b(remove|uninstall|delete|disable|do not use|quarantine)\b/.test(combined);
}

export function buildAlertMessage(matches: AdvisoryMatch[], installRoot: string): string {
  const lines: string[] = [];
  lines.push("CLAWSEC ALERT: advisory feed matches installed skill(s).");
  lines.push("Affected skill advisories:");

  const MAX_LISTED = 8;
  for (const match of matches.slice(0, MAX_LISTED)) {
    const severity = String(match.advisory.severity ?? "unknown").toUpperCase();
    const advisoryId = match.advisory.id ?? "unknown-id";
    const version = match.skill.version ?? "unknown";
    const matched = match.matchedAffected.join(", ");
    lines.push(
      `- [${severity}] ${advisoryId} -> ${match.skill.name}@${version}` +
        (matched ? ` (matched: ${matched})` : ""),
    );
    if (match.advisory.action) {
      lines.push(`  Action: ${match.advisory.action}`);
    }
  }

  if (matches.length > MAX_LISTED) {
    lines.push(`- ... ${matches.length - MAX_LISTED} additional match(es) not shown`);
  }

  const removalMatches = matches.filter((entry) => looksMalicious(entry.advisory) || looksRemovalRecommended(entry.advisory));
  if (removalMatches.length > 0) {
    const impactedSkills = uniqueStrings(removalMatches.map((entry) => entry.skill.name));
    const impactedDirs = uniqueStrings(removalMatches.map((entry) => entry.skill.dirName));
    lines.push("");
    lines.push("Recommendation: one or more matches indicate potentially malicious or unsafe skills.");
    lines.push("Best practice: remove or disable affected skills only after explicit user approval.");
    lines.push(
      "Double-confirmation policy: treat the install request as first intent and require an additional explicit confirmation with this advisory context.",
    );
    lines.push(`Approval needed: ask the user to approve removal of: ${impactedSkills.join(", ")}.`);
    lines.push("Candidate removal paths:");
    for (const dir of impactedDirs) {
      lines.push(`- ${path.join(installRoot, dir)}`);
    }
  } else {
    lines.push("");
    lines.push("Recommendation: review advisories and update/remove affected skills as directed.");
  }

  return lines.join("\n");
}