mirror of
https://github.com/prompt-security/clawsec.git
synced 2026-06-13 05:28:02 +03:00
7cdb4ab7e2
* 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>
156 lines
6.1 KiB
TypeScript
156 lines
6.1 KiB
TypeScript
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");
|
|
}
|