auto-claude: subtask-3-2 - Implement pre-installation risk assessor

This commit is contained in:
David Abutbul
2026-02-27 21:06:03 +02:00
parent ec632155ab
commit 893a64fa3e
+496
View File
@@ -0,0 +1,496 @@
/**
* Pre-installation risk assessor for skills
* Analyzes skill metadata and SBOM to identify security risks
* Cross-references dependencies against advisory feed
*/
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import type {
RiskAssessment,
RiskFinding,
AdvisoryMatch,
SkillMetadata,
FeedPayload,
} from './types.js';
import { ClaudeClient } from './claude-client.js';
import { loadLocalFeed, loadRemoteFeed, parseAffectedSpecifier } from './feed-reader.js';
/**
* Configuration for risk assessment
*/
export interface RiskAssessmentConfig {
/**
* Path to local advisory feed (fallback if remote fails)
*/
localFeedPath?: string;
/**
* Remote advisory feed URL
*/
remoteFeedUrl?: string;
/**
* Public key PEM for signature verification
*/
publicKeyPem?: string;
/**
* Allow unsigned feeds (emergency bypass, dev only)
*/
allowUnsigned?: boolean;
/**
* Claude API client instance
*/
claudeClient?: ClaudeClient;
}
/**
* Risk score calculation thresholds
*/
const RISK_THRESHOLDS = {
CRITICAL: 80,
HIGH: 60,
MEDIUM: 30,
LOW: 0,
} as const;
/**
* Default configuration values
*/
const DEFAULT_CONFIG = {
localFeedPath: 'advisories/feed.json',
remoteFeedUrl: 'https://clawsec.prompt.security/advisories/feed.json',
allowUnsigned: process.env['CLAWSEC_ALLOW_UNSIGNED_FEED'] === '1',
} as const;
/**
* Parses skill.json file
* @param skillJsonPath - Path to skill.json file
* @returns Parsed skill metadata
*/
async function parseSkillJson(skillJsonPath: string): Promise<SkillMetadata> {
try {
const content = await fs.readFile(skillJsonPath, 'utf-8');
const parsed = JSON.parse(content);
// Validate required fields
if (!parsed.name || typeof parsed.name !== 'string') {
throw new Error('skill.json missing required field: name');
}
if (!parsed.version || typeof parsed.version !== 'string') {
throw new Error('skill.json missing required field: version');
}
if (!Array.isArray(parsed.files)) {
throw new Error('skill.json missing required field: files (SBOM)');
}
return parsed as SkillMetadata;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
throw new Error(`skill.json not found: ${skillJsonPath}`);
}
throw new Error(`Failed to parse skill.json: ${(error as Error).message}`);
}
}
/**
* Reads SKILL.md file if it exists
* @param skillMdPath - Path to SKILL.md file
* @returns SKILL.md content or null if not found
*/
async function readSkillMd(skillMdPath: string): Promise<string | null> {
try {
return await fs.readFile(skillMdPath, 'utf-8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return null;
}
// Log but don't fail - SKILL.md is optional for risk assessment
console.warn(`Failed to read SKILL.md: ${(error as Error).message}`);
return null;
}
}
/**
* Loads advisory feed with fallback to local if remote fails
* @param config - Risk assessment configuration
* @returns Advisory feed payload
*/
async function loadAdvisoryFeed(config: RiskAssessmentConfig): Promise<FeedPayload> {
const remoteFeedUrl = config.remoteFeedUrl || DEFAULT_CONFIG.remoteFeedUrl;
const localFeedPath = config.localFeedPath || DEFAULT_CONFIG.localFeedPath;
const allowUnsigned = config.allowUnsigned ?? DEFAULT_CONFIG.allowUnsigned;
// Try remote feed first
try {
const remoteFeed = await loadRemoteFeed(remoteFeedUrl, {
publicKeyPem: config.publicKeyPem,
allowUnsigned,
});
if (remoteFeed) {
return remoteFeed;
}
} catch (error) {
console.warn(`Failed to load remote feed from ${remoteFeedUrl}:`, (error as Error).message);
}
// Fallback to local feed
try {
return await loadLocalFeed(localFeedPath, {
publicKeyPem: config.publicKeyPem,
allowUnsigned,
});
} catch (error) {
throw new Error(`Failed to load advisory feed (tried remote and local): ${(error as Error).message}`);
}
}
/**
* Matches skill dependencies against advisory feed
* @param skillMetadata - Parsed skill metadata
* @param feed - Advisory feed payload
* @returns Array of matched advisories
*/
function matchDependenciesAgainstFeed(
skillMetadata: SkillMetadata,
feed: FeedPayload
): AdvisoryMatch[] {
const matches: AdvisoryMatch[] = [];
const dependencies = skillMetadata.dependencies || {};
const skillName = skillMetadata.name;
for (const advisory of feed.advisories) {
for (const affected of advisory.affected) {
// Parse affected specifier (e.g., "package@1.0.0", "cpe:2.3:...")
const parsed = parseAffectedSpecifier(affected);
if (!parsed) {
continue;
}
// Check if skill name matches
if (parsed.name === skillName) {
matches.push({
advisory,
matchedDependency: skillName,
matchReason: `Skill name matches advisory affected component: ${affected}`,
});
continue;
}
// Check if any dependency matches
for (const [depName, depVersion] of Object.entries(dependencies)) {
if (parsed.name === depName) {
// Simple version matching - exact or wildcard
// More sophisticated semver matching would require additional library
const versionMatches = parsed.versionSpec === '*' ||
parsed.versionSpec === depVersion ||
depVersion === '*';
if (versionMatches) {
matches.push({
advisory,
matchedDependency: `${depName}@${depVersion}`,
matchReason: `Dependency matches advisory: ${affected}`,
});
}
}
}
}
}
return matches;
}
/**
* Analyzes skill for security risks using Claude API
* @param skillMetadata - Parsed skill metadata
* @param skillMd - SKILL.md content (if available)
* @param advisoryMatches - Matched advisories from feed
* @param claudeClient - Claude API client
* @returns Claude's risk assessment response
*/
async function analyzeSkillWithClaude(
skillMetadata: SkillMetadata,
skillMd: string | null,
advisoryMatches: AdvisoryMatch[],
claudeClient: ClaudeClient
): Promise<string> {
// Build comprehensive metadata for Claude analysis
const analysisPayload = {
skillMetadata,
skillMdExcerpt: skillMd ? skillMd.substring(0, 2000) : null, // Limit SKILL.md to first 2000 chars
matchedAdvisories: advisoryMatches.map(match => ({
advisoryId: match.advisory.id,
severity: match.advisory.severity,
title: match.advisory.title,
description: match.advisory.description,
matchedDependency: match.matchedDependency,
matchReason: match.matchReason,
cvssScore: match.advisory.cvss_score,
})),
requiredBinaries: skillMetadata.openclaw?.required_bins || [],
fileCount: skillMetadata.files.length,
hasDependencies: Object.keys(skillMetadata.dependencies || {}).length > 0,
};
return await claudeClient.assessSkillRisk(analysisPayload);
}
/**
* Parses Claude's JSON response into RiskAssessment
* @param response - Raw JSON response from Claude
* @param skillName - Skill name
* @param advisoryMatches - Matched advisories from feed
* @returns Structured risk assessment
*/
function parseClaudeResponse(
response: string,
skillName: string,
advisoryMatches: AdvisoryMatch[]
): RiskAssessment {
try {
// Extract JSON from response (Claude might wrap it in markdown)
const jsonMatch = response.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
throw new Error('No JSON found in Claude response');
}
const parsed = JSON.parse(jsonMatch[0]);
// Validate required fields
if (typeof parsed.riskScore !== 'number' || parsed.riskScore < 0 || parsed.riskScore > 100) {
throw new Error('Invalid riskScore in Claude response');
}
if (!['critical', 'high', 'medium', 'low'].includes(parsed.severity)) {
throw new Error('Invalid severity in Claude response');
}
if (!Array.isArray(parsed.findings)) {
throw new Error('Invalid findings array in Claude response');
}
if (!['approve', 'review', 'block'].includes(parsed.recommendation)) {
throw new Error('Invalid recommendation in Claude response');
}
if (typeof parsed.rationale !== 'string') {
throw new Error('Invalid rationale in Claude response');
}
return {
skillName,
riskScore: parsed.riskScore,
severity: parsed.severity,
findings: parsed.findings,
matchedAdvisories: advisoryMatches,
recommendation: parsed.recommendation,
rationale: parsed.rationale,
};
} catch (error) {
throw new Error(`Failed to parse Claude response: ${(error as Error).message}`);
}
}
/**
* Calculates fallback risk score based on advisory matches
* (used when Claude API is unavailable)
* @param advisoryMatches - Matched advisories
* @returns Risk score 0-100
*/
function calculateFallbackRiskScore(advisoryMatches: AdvisoryMatch[]): number {
if (advisoryMatches.length === 0) {
return 10; // Base score for any skill installation
}
let score = 10;
for (const match of advisoryMatches) {
const advisory = match.advisory;
// Add score based on severity
switch (advisory.severity.toLowerCase()) {
case 'critical':
score += 30;
break;
case 'high':
score += 20;
break;
case 'medium':
score += 10;
break;
case 'low':
score += 5;
break;
}
// Add score based on CVSS score if available
if (advisory.cvss_score) {
score += Math.floor(advisory.cvss_score);
}
}
// Cap at 100
return Math.min(score, 100);
}
/**
* Generates fallback risk assessment when Claude API is unavailable
* @param skillName - Skill name
* @param advisoryMatches - Matched advisories
* @returns Fallback risk assessment
*/
function generateFallbackAssessment(
skillName: string,
advisoryMatches: AdvisoryMatch[]
): RiskAssessment {
const riskScore = calculateFallbackRiskScore(advisoryMatches);
let severity: 'critical' | 'high' | 'medium' | 'low';
let recommendation: 'approve' | 'review' | 'block';
if (riskScore >= RISK_THRESHOLDS.CRITICAL) {
severity = 'critical';
recommendation = 'block';
} else if (riskScore >= RISK_THRESHOLDS.HIGH) {
severity = 'high';
recommendation = 'review';
} else if (riskScore >= RISK_THRESHOLDS.MEDIUM) {
severity = 'medium';
recommendation = 'review';
} else {
severity = 'low';
recommendation = 'approve';
}
const findings: RiskFinding[] = advisoryMatches.map(match => ({
category: 'dependencies',
severity: match.advisory.severity as 'critical' | 'high' | 'medium' | 'low',
description: `Known vulnerability: ${match.advisory.id}`,
evidence: `${match.matchedDependency} - ${match.advisory.description}`,
}));
const rationale = advisoryMatches.length > 0
? `Fallback assessment based on ${advisoryMatches.length} matched advisory/advisories. ` +
`Claude API was unavailable for detailed analysis. Risk score calculated from advisory severity.`
: `No known vulnerabilities found in advisory feed. Base risk score assigned. ` +
`Claude API was unavailable for detailed analysis.`;
return {
skillName,
riskScore,
severity,
findings,
matchedAdvisories: advisoryMatches,
recommendation,
rationale,
};
}
/**
* Assesses security risk for a skill before installation
* @param skillDir - Path to skill directory (containing skill.json)
* @param config - Risk assessment configuration
* @returns Risk assessment with score 0-100
*/
export async function assessSkillRisk(
skillDir: string,
config: RiskAssessmentConfig = {}
): Promise<RiskAssessment> {
// Parse skill metadata
const skillJsonPath = path.join(skillDir, 'skill.json');
const skillMdPath = path.join(skillDir, 'SKILL.md');
const skillMetadata = await parseSkillJson(skillJsonPath);
const skillMd = await readSkillMd(skillMdPath);
// Load advisory feed
const feed = await loadAdvisoryFeed(config);
// Match dependencies against advisory feed
const advisoryMatches = matchDependenciesAgainstFeed(skillMetadata, feed);
// Create Claude client if not provided
const claudeClient = config.claudeClient || new ClaudeClient();
// Analyze with Claude API
try {
const claudeResponse = await analyzeSkillWithClaude(
skillMetadata,
skillMd,
advisoryMatches,
claudeClient
);
return parseClaudeResponse(claudeResponse, skillMetadata.name, advisoryMatches);
} catch (error) {
console.warn('Claude API analysis failed, using fallback assessment:', (error as Error).message);
return generateFallbackAssessment(skillMetadata.name, advisoryMatches);
}
}
/**
* Batch assess multiple skills
* @param skillDirs - Array of skill directory paths
* @param config - Risk assessment configuration
* @returns Array of risk assessments
*/
export async function assessMultipleSkills(
skillDirs: string[],
config: RiskAssessmentConfig = {}
): Promise<RiskAssessment[]> {
const assessments: RiskAssessment[] = [];
for (const skillDir of skillDirs) {
try {
const assessment = await assessSkillRisk(skillDir, config);
assessments.push(assessment);
} catch (error) {
console.warn(`Failed to assess skill at ${skillDir}:`, (error as Error).message);
// Continue with other skills
}
}
return assessments;
}
/**
* Formats risk assessment as human-readable text
* @param assessment - Risk assessment result
* @returns Formatted text report
*/
export function formatRiskAssessment(assessment: RiskAssessment): string {
const lines: string[] = [];
lines.push(`# Risk Assessment: ${assessment.skillName}`);
lines.push('');
lines.push(`**Risk Score:** ${assessment.riskScore}/100 (${assessment.severity.toUpperCase()})`);
lines.push(`**Recommendation:** ${assessment.recommendation.toUpperCase()}`);
lines.push('');
lines.push('## Rationale');
lines.push(assessment.rationale);
lines.push('');
if (assessment.findings.length > 0) {
lines.push('## Security Findings');
for (const finding of assessment.findings) {
lines.push(`- **[${finding.severity.toUpperCase()}] ${finding.category}**`);
lines.push(` ${finding.description}`);
lines.push(` Evidence: ${finding.evidence}`);
lines.push('');
}
}
if (assessment.matchedAdvisories.length > 0) {
lines.push('## Matched Advisories');
for (const match of assessment.matchedAdvisories) {
lines.push(`- **${match.advisory.id}** (${match.advisory.severity})`);
lines.push(` ${match.advisory.title}`);
lines.push(` Matched: ${match.matchedDependency}`);
lines.push(` Reason: ${match.matchReason}`);
lines.push('');
}
}
return lines.join('\n');
}