mirror of
https://github.com/prompt-security/clawsec.git
synced 2026-06-18 16:01:21 +03:00
auto-claude: subtask-3-2 - Implement pre-installation risk assessor
This commit is contained in:
@@ -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');
|
||||
}
|
||||
Reference in New Issue
Block a user