mirror of
https://github.com/prompt-security/clawsec.git
synced 2026-06-13 05:28:02 +03:00
26af277afd
* feat(hermes-attestation-guardian): release v0.0.2 hardening * docs(wiki): add v0.0.2 hardening update note * docs: add Hermes support coverage to README and compatibility report * fix(hermes-attestation-guardian): address baz review on crontab detection and doc dedup * feat(wiki): add PR-200 skill feature/platform matrix * docs(wiki): rewrite PR-200 matrix as narrative capability mapping * docs(readme): add skill feature matrix with requested headers * docs(readme): replace unknowns with mapped yes/no feature matrix * docs: move NanoClaw and CI/CD details from README to wiki modules * docs(readme): remove platform/suite sections and keep wiki module pointers * docs(readme): refresh project structure to match current repo * feat(hermes-attestation-guardian): add signed advisory feed verification pipeline * feat(hermes-attestation-guardian): add advisory-gated guarded skill verification * feat(hermes-attestation-guardian): add advisory scheduler helper and phase-3 parity docs * docs(wiki): expand hermes attestation guardian capability coverage * fix(pr-200): address Baz review findings across Hermes parity rollout * test(sandbox): extend Hermes regression to cover feed, guarded verify, and advisory scheduler * fix(pr-200): address Baz semver parsing and feed-state fallback visibility * fix(ci): suppress shellcheck false positives in sandbox inline docker script * fix(hermes-attestation-guardian): fail closed on unsupported advisory ranges * fix(hermes-attestation-guardian): restore safe install verdict in sandbox * fix(sandbox): capture guarded verify exit under set -e * fix(semver): fail closed on malformed affected specifiers * docs(readme): clarify hermes capability matrix wording * refactor(feed): share signed artifact verification flow * refactor(cron): share managed block helpers across setup scripts * fix(feed): require checksum manifest artifacts when enabled * chore(hermes-skill): relocate sandbox test, refresh docs, and add v0.1.0 release notes * chore(docs): remove remaining hermes parity plan file * chore(release): roll hermes-attestation-guardian to v0.1.0 * chore(release): remove standalone v0.1.0 release notes file * docs(hermes): update README status to v0.1.0 --------- Co-authored-by: David Abutbul <David.a@prompt.security>
203 lines
6.4 KiB
JavaScript
203 lines
6.4 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import fs from "node:fs";
|
|
import { refreshAdvisoryFeed } from "../lib/feed.mjs";
|
|
import { parseAffectedSpecifier, parseVersionSpec, versionMatches } from "../lib/semver.mjs";
|
|
|
|
const EXIT_CONFIRM_REQUIRED = 42;
|
|
|
|
function usage() {
|
|
process.stdout.write(
|
|
[
|
|
"Usage: node scripts/guarded_skill_verify.mjs --skill <name> [--version <semver>] [--confirm-advisory] [--allow-unsigned]",
|
|
"",
|
|
"Verifies advisory feed state using the Hermes feed verification pipeline, then gates",
|
|
"a candidate skill by advisory match before install/verification flows continue.",
|
|
"",
|
|
"Exit codes:",
|
|
" 0 no advisory match, or explicit advisory confirmation supplied",
|
|
" 42 advisory match found and --confirm-advisory was not provided",
|
|
" 1 verification/feed failure or invalid arguments",
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
const parsed = {
|
|
skill: "",
|
|
version: "",
|
|
confirmAdvisory: false,
|
|
allowUnsigned: undefined,
|
|
help: false,
|
|
};
|
|
|
|
for (let i = 0; i < argv.length; i += 1) {
|
|
const token = argv[i];
|
|
if (token === "--skill") {
|
|
parsed.skill = String(argv[i + 1] || "").trim();
|
|
i += 1;
|
|
continue;
|
|
}
|
|
if (token === "--version") {
|
|
parsed.version = String(argv[i + 1] || "").trim();
|
|
i += 1;
|
|
continue;
|
|
}
|
|
if (token === "--confirm-advisory") {
|
|
parsed.confirmAdvisory = true;
|
|
continue;
|
|
}
|
|
if (token === "--allow-unsigned") {
|
|
parsed.allowUnsigned = true;
|
|
continue;
|
|
}
|
|
if (token === "--help" || token === "-h") {
|
|
parsed.help = true;
|
|
continue;
|
|
}
|
|
throw new Error(`Unknown argument: ${token}`);
|
|
}
|
|
|
|
if (parsed.help) return parsed;
|
|
|
|
if (!parsed.skill) {
|
|
throw new Error("Missing required argument: --skill");
|
|
}
|
|
if (!/^[a-z0-9-]+$/.test(parsed.skill)) {
|
|
throw new Error("Invalid --skill value. Use lowercase letters, digits, and hyphens only.");
|
|
}
|
|
if (parsed.version && !/^v?\d+\.\d+\.\d+(?:[-+][0-9a-zA-Z.-]+)?$/.test(parsed.version)) {
|
|
throw new Error("Invalid --version value. Expected semver (for example: 1.2.3).");
|
|
}
|
|
|
|
return parsed;
|
|
}
|
|
|
|
function normalizeSkillName(value) {
|
|
return String(value || "").trim().toLowerCase();
|
|
}
|
|
|
|
function findAdvisoryMatches(feed, skillName, version = "") {
|
|
const advisories = Array.isArray(feed?.advisories) ? feed.advisories : [];
|
|
const targetName = normalizeSkillName(skillName);
|
|
const matches = [];
|
|
|
|
for (const advisory of advisories) {
|
|
const affected = Array.isArray(advisory?.affected) ? advisory.affected : [];
|
|
if (affected.length === 0) continue;
|
|
|
|
const matchedAffected = [];
|
|
const unsupportedSpecs = [];
|
|
for (const specifier of affected) {
|
|
const parsed = parseAffectedSpecifier(specifier);
|
|
if (!parsed) continue;
|
|
if (normalizeSkillName(parsed.name) !== targetName) continue;
|
|
|
|
const parsedSpec = parseVersionSpec(parsed.versionSpec);
|
|
if (!parsedSpec.supported) {
|
|
// Fail closed: unsupported range syntax is treated as a match to avoid bypass.
|
|
matchedAffected.push(specifier);
|
|
unsupportedSpecs.push(specifier);
|
|
continue;
|
|
}
|
|
|
|
// Conservative default: if operator did not provide --version, any name match gates.
|
|
if (!version || versionMatches(version, parsed.versionSpec)) {
|
|
matchedAffected.push(specifier);
|
|
}
|
|
}
|
|
|
|
if (matchedAffected.length > 0) {
|
|
matches.push({ advisory, matchedAffected, unsupportedSpecs });
|
|
}
|
|
}
|
|
|
|
return matches;
|
|
}
|
|
|
|
function printMatches(matches, args) {
|
|
process.stdout.write("Advisory matches detected for requested candidate.\n");
|
|
process.stdout.write(`Target: ${args.skill}${args.version ? `@${args.version}` : ""}\n`);
|
|
|
|
for (const match of matches) {
|
|
const advisory = match.advisory || {};
|
|
const severity = String(advisory.severity || "unknown").toUpperCase();
|
|
const advisoryId = String(advisory.id || "unknown-id");
|
|
const title = String(advisory.title || "Untitled advisory");
|
|
|
|
process.stdout.write(`- [${severity}] ${advisoryId}: ${title}\n`);
|
|
process.stdout.write(` matched: ${match.matchedAffected.join(", ")}\n`);
|
|
if (Array.isArray(match.unsupportedSpecs) && match.unsupportedSpecs.length > 0) {
|
|
process.stdout.write(
|
|
` warning: unsupported advisory version syntax treated as match (fail-closed): ${match.unsupportedSpecs.join(", ")}\n`,
|
|
);
|
|
}
|
|
if (advisory.action) {
|
|
process.stdout.write(` action: ${advisory.action}\n`);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
const args = parseArgs(process.argv.slice(2));
|
|
if (args.help) {
|
|
usage();
|
|
return;
|
|
}
|
|
|
|
let refreshResult;
|
|
try {
|
|
refreshResult = await refreshAdvisoryFeed(args.allowUnsigned === true ? { allowUnsigned: true } : {});
|
|
} catch (error) {
|
|
process.stderr.write(`CRITICAL: advisory feed verification failed (fail-closed): ${error?.message || String(error)}\n`);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (refreshResult.status === "unverified") {
|
|
const warningSource = args.allowUnsigned === true ? "--allow-unsigned" : "resolved env/config policy";
|
|
process.stderr.write(
|
|
`WARNING: unsigned advisory bypass enabled via ${warningSource}. This weakens supply-chain guarantees and should be emergency-only.\n`,
|
|
);
|
|
}
|
|
|
|
let feed;
|
|
try {
|
|
feed = JSON.parse(fs.readFileSync(refreshResult.cachedFeedPath, "utf8"));
|
|
} catch (error) {
|
|
process.stderr.write(
|
|
`CRITICAL: cached advisory feed load failed after verification: ${error?.message || String(error)}\n`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
process.stdout.write(`Advisory feed status: ${refreshResult.status} (${refreshResult.source})\n`);
|
|
if (!args.version) {
|
|
process.stdout.write("No --version provided; applying conservative name-based advisory gate.\n");
|
|
}
|
|
|
|
const matches = findAdvisoryMatches(feed, args.skill, args.version);
|
|
if (matches.length === 0) {
|
|
process.stdout.write("No advisory matches found for candidate.\n");
|
|
return;
|
|
}
|
|
|
|
printMatches(matches, args);
|
|
|
|
if (!args.confirmAdvisory) {
|
|
process.stdout.write("Re-run with --confirm-advisory to proceed with explicit operator acknowledgement.\n");
|
|
process.exit(EXIT_CONFIRM_REQUIRED);
|
|
}
|
|
|
|
process.stderr.write(
|
|
`WARNING: proceeding despite ${matches.length} advisory match(es) because --confirm-advisory was provided.\n`,
|
|
);
|
|
}
|
|
|
|
try {
|
|
await main();
|
|
} catch (error) {
|
|
process.stderr.write(`CRITICAL: ${error?.message || String(error)}\n`);
|
|
process.exit(1);
|
|
}
|