auto-claude: subtask-4-3 - Add environment variable validation and startup checks

- Added validateEnvironment() function to check ANTHROPIC_API_KEY and other env vars
- Added CLI entry point supporting --dry-run flag for environment validation
- Validates CLAWSEC_HOOK_INTERVAL_SECONDS is a positive integer if set
- Outputs clear error messages on validation failure
- Exits with proper status codes (0=success, 1=failure)
- Compiled TypeScript to JavaScript for runtime execution

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
David Abutbul
2026-02-27 21:17:06 +02:00
parent 2edf87e3b7
commit 0e95d771c5
10 changed files with 2221 additions and 0 deletions
+264
View File
@@ -0,0 +1,264 @@
/**
* ClawSec Analyst - Main Handler
* OpenClaw hook handler for AI-powered security analysis
*
* Events:
* - agent:bootstrap: Runs on agent initialization, provides security context
* - command:new: Runs on new commands, provides contextual security guidance
*/
import * as os from 'node:os';
import * as path from 'node:path';
import { ClaudeClient } from './lib/claude-client.js';
import { analyzeAdvisories, filterByPriority } from './lib/advisory-analyzer.js';
import { loadState, persistState } from './lib/state.js';
import { loadLocalFeed, loadRemoteFeed } from './lib/feed-reader.js';
/**
* Default configuration values
*/
const DEFAULT_SCAN_INTERVAL_SECONDS = 300;
const DEFAULT_STATE_FILE = path.join(os.homedir(), '.openclaw', 'clawsec-analyst-state.json');
const DEFAULT_FEED_URL = 'https://clawsec.prompt.security/advisories/feed.json';
const DEFAULT_LOCAL_FEED_PATH = path.join(os.homedir(), '.openclaw', 'skills', 'clawsec-suite', 'advisories', 'feed.json');
/**
* Parse positive integer from environment variable with fallback
*/
function parsePositiveInteger(value, fallback) {
const parsed = Number.parseInt(String(value ?? ''), 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
return fallback;
}
return parsed;
}
/**
* Convert event to canonical event name (type:action)
*/
function toEventName(event) {
const eventType = String(event.type ?? '').trim();
const action = String(event.action ?? '').trim();
if (!eventType || !action)
return '';
return `${eventType}:${action}`;
}
/**
* Check if this handler should process the event
*/
function shouldHandleEvent(event) {
const eventName = toEventName(event);
return eventName === 'agent:bootstrap' || eventName === 'command:new';
}
/**
* Convert ISO timestamp to epoch milliseconds
*/
function epochMs(isoTimestamp) {
if (!isoTimestamp)
return 0;
const parsed = Date.parse(isoTimestamp);
return Number.isNaN(parsed) ? 0 : parsed;
}
/**
* Check if last scan was recent (within interval)
*/
function scannedRecently(lastScan, minIntervalSeconds) {
const sinceMs = Date.now() - epochMs(lastScan);
return sinceMs >= 0 && sinceMs < minIntervalSeconds * 1000;
}
/**
* Build security analysis message for agent
*/
function buildAnalysisMessage(highPriorityCount, mediumPriorityCount, eventName) {
const totalCritical = highPriorityCount + mediumPriorityCount;
if (totalCritical === 0) {
return '';
}
const summary = [
'🔍 **ClawSec Security Analysis**',
'',
`Found ${highPriorityCount} HIGH and ${mediumPriorityCount} MEDIUM priority advisories.`,
'',
];
if (eventName === 'agent:bootstrap') {
summary.push('Security context: These advisories may affect dependencies or operations in your environment.', 'Use `/analyze-advisory <CVE-ID>` for detailed analysis.');
}
else {
summary.push('Consider security implications before proceeding with operations that involve:', '- Installing new dependencies', '- Executing external commands', '- Processing untrusted data', '', 'Use `/assess-skill-risk <skill-path>` to analyze a skill before installation.');
}
return summary.join('\n');
}
/**
* Validate required environment variables
* Returns validation result with errors if any
*/
function validateEnvironment() {
const errors = [];
// Check ANTHROPIC_API_KEY (required)
const apiKey = process.env['ANTHROPIC_API_KEY'];
if (!apiKey || apiKey.trim() === '') {
errors.push('ANTHROPIC_API_KEY is not set or empty');
}
// Validate optional environment variables for type correctness
const scanInterval = process.env['CLAWSEC_HOOK_INTERVAL_SECONDS'];
if (scanInterval !== undefined) {
const parsed = Number.parseInt(scanInterval, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
errors.push(`CLAWSEC_HOOK_INTERVAL_SECONDS must be a positive integer, got: ${scanInterval}`);
}
}
return {
valid: errors.length === 0,
errors,
};
}
/**
* Main hook handler
* Mutates event.messages in-place (does not return value)
*/
const handler = async (event) => {
// Only handle relevant events
if (!shouldHandleEvent(event)) {
return;
}
// Check for required API key
const apiKey = process.env['ANTHROPIC_API_KEY'];
if (!apiKey || apiKey.trim() === '') {
// Don't fail the hook, but log warning
if (process.env['NODE_ENV'] !== 'test') {
// eslint-disable-next-line no-console
console.warn('[clawsec-analyst] ANTHROPIC_API_KEY not set. ' +
'AI-powered analysis disabled. Set the environment variable to enable.');
}
return;
}
// Load configuration from environment
const stateFile = process.env['CLAWSEC_ANALYST_STATE_FILE'] || DEFAULT_STATE_FILE;
const scanIntervalSeconds = parsePositiveInteger(process.env['CLAWSEC_HOOK_INTERVAL_SECONDS'], DEFAULT_SCAN_INTERVAL_SECONDS);
const feedUrl = process.env['CLAWSEC_FEED_URL'] || DEFAULT_FEED_URL;
const localFeedPath = process.env['CLAWSEC_LOCAL_FEED'] || DEFAULT_LOCAL_FEED_PATH;
const allowUnsigned = process.env['CLAWSEC_ALLOW_UNSIGNED_FEED'] === '1';
// Check if we should run (rate limiting)
const eventName = toEventName(event);
const forceScan = eventName === 'command:new';
const state = await loadState(stateFile);
if (!forceScan && scannedRecently(state.last_feed_check, scanIntervalSeconds)) {
// Too soon since last scan, skip
return;
}
// Initialize Claude client
const claudeClient = new ClaudeClient({ apiKey });
// Perform advisory analysis
try {
const nowIso = new Date().toISOString();
state.last_feed_check = nowIso;
// Load advisory feed (try remote first, then local fallback)
let feed = null;
try {
feed = await loadRemoteFeed(feedUrl, {
allowUnsigned,
});
}
catch (remoteError) {
if (process.env['NODE_ENV'] !== 'test') {
// eslint-disable-next-line no-console
console.warn('[clawsec-analyst] Remote feed unavailable, trying local fallback:', remoteError);
}
try {
feed = await loadLocalFeed(localFeedPath, {
allowUnsigned,
});
}
catch (localError) {
if (process.env['NODE_ENV'] !== 'test') {
// eslint-disable-next-line no-console
console.warn('[clawsec-analyst] Local feed unavailable:', localError);
}
}
}
if (!feed || !feed.advisories || feed.advisories.length === 0) {
// No advisories to analyze
return;
}
// Analyze advisories from feed
const allAnalyses = await analyzeAdvisories(feed.advisories, claudeClient);
// Filter to only HIGH and MEDIUM priority
const analysisResults = filterByPriority(allAnalyses, 'MEDIUM');
// Count priority advisories
const highPriorityCount = analysisResults.filter(a => a.priority === 'HIGH').length;
const mediumPriorityCount = analysisResults.filter(a => a.priority === 'MEDIUM').length;
// Build message for agent
const message = buildAnalysisMessage(highPriorityCount, mediumPriorityCount, eventName);
// Mutate event.messages in-place (OpenClaw hook pattern)
if (message) {
event.messages.push({
role: 'assistant',
content: message,
});
}
// Update state with latest analysis
state.last_feed_updated = nowIso;
// Store analysis results in history (keep last 50 entries)
state.analysis_history.push({
timestamp: nowIso,
type: 'advisory_triage',
targetId: 'feed',
result: 'success',
details: `Found ${highPriorityCount} HIGH, ${mediumPriorityCount} MEDIUM priority advisories`,
});
// Trim history to last 50 entries
if (state.analysis_history.length > 50) {
state.analysis_history = state.analysis_history.slice(-50);
}
// Persist state
await persistState(stateFile, state);
}
catch (error) {
// Don't fail the hook on analysis errors
if (process.env['NODE_ENV'] !== 'test') {
// eslint-disable-next-line no-console
console.warn('[clawsec-analyst] Analysis failed:', error);
}
// Log error to state
const nowIso = new Date().toISOString();
state.analysis_history.push({
timestamp: nowIso,
type: 'advisory_triage',
targetId: 'feed',
result: 'error',
details: `Analysis failed: ${error instanceof Error ? error.message : String(error)}`,
});
await persistState(stateFile, state);
}
};
export default handler;
/**
* CLI entry point for startup validation
* Supports --dry-run flag for environment validation
*/
if (import.meta.url === `file://${process.argv[1]}`) {
const args = process.argv.slice(2);
const isDryRun = args.includes('--dry-run');
if (isDryRun) {
// Validate environment variables
const validation = validateEnvironment();
if (!validation.valid) {
// eslint-disable-next-line no-console
console.error('[clawsec-analyst] Environment validation failed:');
for (const error of validation.errors) {
// eslint-disable-next-line no-console
console.error(` - ${error}`);
}
process.exit(1);
}
// Success - output expected message
// eslint-disable-next-line no-console
console.log('[clawsec-analyst] Environment validation passed');
// eslint-disable-next-line no-console
console.log('[clawsec-analyst] API key configured');
// eslint-disable-next-line no-console
console.log('[clawsec-analyst] Ready for operation');
process.exit(0);
}
else {
// eslint-disable-next-line no-console
console.error('[clawsec-analyst] Usage: node handler.ts --dry-run');
process.exit(1);
}
}
+65
View File
@@ -129,6 +129,34 @@ function buildAnalysisMessage(
return summary.join('\n');
}
/**
* Validate required environment variables
* Returns validation result with errors if any
*/
function validateEnvironment(): { valid: boolean; errors: string[] } {
const errors: string[] = [];
// Check ANTHROPIC_API_KEY (required)
const apiKey = process.env['ANTHROPIC_API_KEY'];
if (!apiKey || apiKey.trim() === '') {
errors.push('ANTHROPIC_API_KEY is not set or empty');
}
// Validate optional environment variables for type correctness
const scanInterval = process.env['CLAWSEC_HOOK_INTERVAL_SECONDS'];
if (scanInterval !== undefined) {
const parsed = Number.parseInt(scanInterval, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
errors.push(`CLAWSEC_HOOK_INTERVAL_SECONDS must be a positive integer, got: ${scanInterval}`);
}
}
return {
valid: errors.length === 0,
errors,
};
}
/**
* Main hook handler
* Mutates event.messages in-place (does not return value)
@@ -274,3 +302,40 @@ const handler = async (event: HookEvent): Promise<void> => {
};
export default handler;
/**
* CLI entry point for startup validation
* Supports --dry-run flag for environment validation
*/
if (import.meta.url === `file://${process.argv[1]}`) {
const args = process.argv.slice(2);
const isDryRun = args.includes('--dry-run');
if (isDryRun) {
// Validate environment variables
const validation = validateEnvironment();
if (!validation.valid) {
// eslint-disable-next-line no-console
console.error('[clawsec-analyst] Environment validation failed:');
for (const error of validation.errors) {
// eslint-disable-next-line no-console
console.error(` - ${error}`);
}
process.exit(1);
}
// Success - output expected message
// eslint-disable-next-line no-console
console.log('[clawsec-analyst] Environment validation passed');
// eslint-disable-next-line no-console
console.log('[clawsec-analyst] API key configured');
// eslint-disable-next-line no-console
console.log('[clawsec-analyst] Ready for operation');
process.exit(0);
} else {
// eslint-disable-next-line no-console
console.error('[clawsec-analyst] Usage: node handler.ts --dry-run');
process.exit(1);
}
}
@@ -0,0 +1,196 @@
/**
* Advisory triage analyzer
* Analyzes security advisories using Claude API to assess actual risk,
* identify affected components, and recommend remediation actions
*/
import { getCachedAnalysis, setCachedAnalysis } from './cache.js';
/**
* Analyzes a single advisory and returns structured analysis
* @param advisory - Advisory to analyze
* @param client - Claude API client instance
* @returns Promise with structured analysis result
*/
export async function analyzeAdvisory(advisory, client) {
// Validate advisory has required fields
if (!advisory.id || !advisory.severity || !advisory.description) {
throw createError('INVALID_ADVISORY_SCHEMA', `Advisory missing required fields (id: ${advisory.id})`, false);
}
// Try to get cached analysis first
try {
const cached = await getCachedAnalysis(advisory.id);
if (cached) {
if (process.env['NODE_ENV'] !== 'test') {
// eslint-disable-next-line no-console
console.log(`Using cached analysis for ${advisory.id}`);
}
return cached;
}
}
catch (error) {
// Cache errors are non-critical, continue with API call
if (process.env['NODE_ENV'] !== 'test') {
// eslint-disable-next-line no-console
console.warn(`Cache lookup failed for ${advisory.id}:`, error);
}
}
// Call Claude API for analysis
try {
const responseText = await client.analyzeAdvisory(advisory);
// Parse JSON response
const analysis = parseAnalysisResponse(advisory.id, responseText);
// Cache the result for offline resilience
await setCachedAnalysis(advisory.id, analysis);
return analysis;
}
catch (error) {
// If API fails, try to use cached analysis (even if stale)
if (process.env['NODE_ENV'] !== 'test') {
// eslint-disable-next-line no-console
console.warn(`Claude API failed for ${advisory.id}, checking cache...`, error);
}
const cached = await getCachedAnalysis(advisory.id);
if (cached) {
if (process.env['NODE_ENV'] !== 'test') {
// eslint-disable-next-line no-console
console.warn(`Using cached analysis for ${advisory.id} (may be outdated)`);
}
return cached;
}
// No cache available, re-throw the error
throw createError('CLAUDE_API_ERROR', `Claude API unavailable and no cache found for ${advisory.id}: ${error.message}`, false);
}
}
/**
* Analyzes multiple advisories in batch
* @param advisories - Array of advisories to analyze
* @param client - Claude API client instance
* @returns Promise with array of analysis results
*/
export async function analyzeAdvisories(advisories, client) {
const results = [];
// Process advisories sequentially to avoid rate limits
// In production, this could be parallelized with a concurrency limit
for (const advisory of advisories) {
try {
const analysis = await analyzeAdvisory(advisory, client);
results.push(analysis);
}
catch (error) {
// Log error but continue processing other advisories
if (process.env['NODE_ENV'] !== 'test') {
// eslint-disable-next-line no-console
console.error(`Failed to analyze advisory ${advisory.id}:`, error);
}
// Add a fallback analysis with LOW priority for failed analyses
results.push(createFallbackAnalysis(advisory));
}
}
return results;
}
/**
* Filters advisories by priority threshold
* @param analyses - Array of analysis results
* @param minPriority - Minimum priority to include (HIGH, MEDIUM, or LOW)
* @returns Filtered array of high-priority analyses
*/
export function filterByPriority(analyses, minPriority = 'MEDIUM') {
const priorityOrder = {
HIGH: 3,
MEDIUM: 2,
LOW: 1,
};
const threshold = priorityOrder[minPriority];
return analyses.filter(analysis => {
const analysisPriority = priorityOrder[analysis.priority];
return analysisPriority >= threshold;
});
}
/**
* Parses Claude API response text into structured AdvisoryAnalysis
* @param advisoryId - Advisory ID for error context
* @param responseText - Raw text response from Claude API
* @returns Parsed and validated AdvisoryAnalysis object
*/
function parseAnalysisResponse(advisoryId, responseText) {
try {
// Extract JSON from response (Claude may wrap it in markdown code blocks)
let jsonText = responseText.trim();
// Remove markdown code blocks if present
if (jsonText.startsWith('```json')) {
jsonText = jsonText.replace(/^```json\s*/, '').replace(/\s*```$/, '');
}
else if (jsonText.startsWith('```')) {
jsonText = jsonText.replace(/^```\s*/, '').replace(/\s*```$/, '');
}
const parsed = JSON.parse(jsonText);
// Validate required fields
if (!parsed.priority || !parsed.rationale || !parsed.affected_components || !parsed.recommended_actions) {
throw new Error('Missing required fields in Claude API response');
}
// Validate priority value
if (!['HIGH', 'MEDIUM', 'LOW'].includes(parsed.priority)) {
throw new Error(`Invalid priority value: ${parsed.priority}`);
}
// Validate arrays
if (!Array.isArray(parsed.affected_components) || !Array.isArray(parsed.recommended_actions)) {
throw new Error('affected_components and recommended_actions must be arrays');
}
// Validate confidence if present
const confidence = typeof parsed.confidence === 'number' ? parsed.confidence : 0.8;
if (confidence < 0 || confidence > 1) {
throw new Error(`Invalid confidence value: ${confidence}`);
}
return {
advisoryId,
priority: parsed.priority,
rationale: parsed.rationale,
affected_components: parsed.affected_components,
recommended_actions: parsed.recommended_actions,
confidence,
};
}
catch (error) {
throw createError('CLAUDE_API_ERROR', `Failed to parse Claude API response for ${advisoryId}: ${error.message}`, false);
}
}
/**
* Creates a fallback analysis when Claude API fails and no cache is available
* @param advisory - Advisory that failed to analyze
* @returns Basic fallback analysis based on advisory metadata
*/
function createFallbackAnalysis(advisory) {
// Map advisory severity to priority (conservative approach)
const severityToPriority = {
critical: 'HIGH',
high: 'HIGH',
medium: 'MEDIUM',
low: 'LOW',
};
const priority = severityToPriority[advisory.severity] || 'MEDIUM';
return {
advisoryId: advisory.id,
priority,
rationale: `Fallback analysis: ${advisory.description.substring(0, 200)}... (AI analysis unavailable, using advisory metadata)`,
affected_components: advisory.affected || [],
recommended_actions: [
advisory.action || 'Review advisory and assess impact',
'Consult security team for guidance',
'Monitor for updated information',
],
confidence: 0.5, // Low confidence for fallback analysis
};
}
/**
* Create a typed AnalystError
* @param code - Error code
* @param message - Error message
* @param recoverable - Whether error is recoverable
* @returns Typed AnalystError object
*/
function createError(code, message, recoverable) {
return {
code,
message,
recoverable,
};
}
+198
View File
@@ -0,0 +1,198 @@
/**
* Result caching for offline resilience
* Caches analysis results to ~/.openclaw/clawsec-analyst-cache/
* with 7-day expiry to enable graceful degradation when Claude API is unavailable
*/
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import * as os from 'node:os';
// Cache configuration
const CACHE_DIR = path.join(os.homedir(), '.openclaw', 'clawsec-analyst-cache');
const CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
const CACHE_VERSION = '1.0';
/**
* Ensures cache directory exists
* @returns Promise that resolves when directory is ready
*/
async function ensureCacheDir() {
try {
await fs.mkdir(CACHE_DIR, { recursive: true });
}
catch (error) {
// Log but don't throw - cache is non-critical
console.warn(`Failed to create cache directory: ${error}`);
}
}
/**
* Generates safe cache file path for advisory ID
* @param advisoryId - Advisory ID (e.g., CVE-2024-12345, CLAW-2024-0001)
* @returns Absolute path to cache file
*/
function getCachePath(advisoryId) {
// Sanitize advisory ID to prevent directory traversal
const safeId = advisoryId.replace(/[^a-zA-Z0-9\-_.]/g, '_');
return path.join(CACHE_DIR, `${safeId}.json`);
}
/**
* Retrieves cached analysis for an advisory
* @param advisoryId - Advisory ID to look up
* @returns Cached analysis or null if not found/stale
*/
export async function getCachedAnalysis(advisoryId) {
try {
const cachePath = getCachePath(advisoryId);
const content = await fs.readFile(cachePath, 'utf-8');
const cached = JSON.parse(content);
// Validate cache structure
if (!cached.advisoryId || !cached.analysis || !cached.timestamp || !cached.cacheVersion) {
console.warn(`Invalid cache structure for ${advisoryId}, ignoring`);
return null;
}
// Check cache age
const cacheTimestamp = new Date(cached.timestamp).getTime();
const age = Date.now() - cacheTimestamp;
if (age > CACHE_MAX_AGE_MS) {
const ageInDays = Math.floor(age / (24 * 60 * 60 * 1000));
console.warn(`Cache for ${advisoryId} is stale (${ageInDays} days old, max 7 days)`);
return null;
}
// Warn if cache is getting old (> 5 days)
if (age > 5 * 24 * 60 * 60 * 1000) {
const ageInDays = Math.floor(age / (24 * 60 * 60 * 1000));
console.warn(`Cache for ${advisoryId} is ${ageInDays} days old (will expire in ${7 - ageInDays} days)`);
}
return cached.analysis;
}
catch (error) {
// Cache miss is expected - not an error condition
if (error.code === 'ENOENT') {
return null;
}
// Other errors are unexpected but non-critical
console.warn(`Failed to read cache for ${advisoryId}:`, error);
return null;
}
}
/**
* Stores analysis result in cache
* @param advisoryId - Advisory ID
* @param analysis - Analysis result to cache
* @returns Promise that resolves when cache is written
*/
export async function setCachedAnalysis(advisoryId, analysis) {
try {
await ensureCacheDir();
const cached = {
advisoryId,
analysis,
timestamp: new Date().toISOString(),
cacheVersion: CACHE_VERSION,
};
const cachePath = getCachePath(advisoryId);
await fs.writeFile(cachePath, JSON.stringify(cached, null, 2), 'utf-8');
}
catch (error) {
// Cache write failure is non-critical - log and continue
console.warn(`Failed to cache analysis for ${advisoryId}:`, error);
}
}
/**
* Clears stale cache entries older than 7 days
* @returns Promise with number of entries cleared
*/
export async function clearStaleCache() {
try {
const entries = await fs.readdir(CACHE_DIR);
let clearedCount = 0;
for (const entry of entries) {
// Only process .json files
if (!entry.endsWith('.json')) {
continue;
}
const filePath = path.join(CACHE_DIR, entry);
try {
const content = await fs.readFile(filePath, 'utf-8');
const cached = JSON.parse(content);
const cacheTimestamp = new Date(cached.timestamp).getTime();
const age = Date.now() - cacheTimestamp;
if (age > CACHE_MAX_AGE_MS) {
await fs.unlink(filePath);
clearedCount++;
}
}
catch (error) {
// If we can't read/parse the file, delete it
console.warn(`Removing corrupted cache file: ${entry}`);
await fs.unlink(filePath);
clearedCount++;
}
}
if (clearedCount > 0) {
console.log(`Cleared ${clearedCount} stale cache entries`);
}
return clearedCount;
}
catch (error) {
// Cache directory might not exist yet - not an error
if (error.code === 'ENOENT') {
return 0;
}
console.warn('Failed to clear stale cache:', error);
return 0;
}
}
/**
* Gets cache statistics (for debugging/monitoring)
* @returns Promise with cache stats
*/
export async function getCacheStats() {
try {
const entries = await fs.readdir(CACHE_DIR);
let totalEntries = 0;
let staleEntries = 0;
let totalSizeBytes = 0;
let oldestEntryAge = null;
for (const entry of entries) {
if (!entry.endsWith('.json')) {
continue;
}
const filePath = path.join(CACHE_DIR, entry);
try {
const stat = await fs.stat(filePath);
totalSizeBytes += stat.size;
totalEntries++;
const content = await fs.readFile(filePath, 'utf-8');
const cached = JSON.parse(content);
const cacheTimestamp = new Date(cached.timestamp).getTime();
const age = Date.now() - cacheTimestamp;
if (age > CACHE_MAX_AGE_MS) {
staleEntries++;
}
if (oldestEntryAge === null || age > oldestEntryAge) {
oldestEntryAge = age;
}
}
catch (error) {
// Skip corrupted entries
continue;
}
}
return {
totalEntries,
staleEntries,
totalSizeBytes,
oldestEntryAge,
};
}
catch (error) {
if (error.code === 'ENOENT') {
return {
totalEntries: 0,
staleEntries: 0,
totalSizeBytes: 0,
oldestEntryAge: null,
};
}
throw error;
}
}
+241
View File
@@ -0,0 +1,241 @@
/**
* Claude API client wrapper with retry logic and error handling
* Implements exponential backoff for rate limits and transient failures
*/
import Anthropic from '@anthropic-ai/sdk';
// Default configuration
const DEFAULT_MODEL = 'claude-sonnet-4-5-20250929';
const DEFAULT_MAX_TOKENS = 2048;
const DEFAULT_MAX_RETRIES = 3;
const DEFAULT_INITIAL_DELAY_MS = 1000;
/**
* Claude API client for security analysis
*/
export class ClaudeClient {
client;
config;
constructor(config = {}) {
// Get API key from config or environment
const apiKey = config.apiKey || process.env['ANTHROPIC_API_KEY'];
if (!apiKey) {
throw this.createError('MISSING_API_KEY', 'ANTHROPIC_API_KEY environment variable is required. Set it with: export ANTHROPIC_API_KEY="sk-ant-..."', false);
}
this.client = new Anthropic({ apiKey });
this.config = {
apiKey,
model: config.model || DEFAULT_MODEL,
maxTokens: config.maxTokens || DEFAULT_MAX_TOKENS,
maxRetries: config.maxRetries || DEFAULT_MAX_RETRIES,
initialDelayMs: config.initialDelayMs || DEFAULT_INITIAL_DELAY_MS,
};
}
/**
* Send a message to Claude API with retry logic
*/
async sendMessage(userMessage, options = {}) {
const model = options.model || this.config.model;
const maxTokens = options.maxTokens || this.config.maxTokens;
const messages = [
{ role: 'user', content: userMessage }
];
const requestParams = {
model,
max_tokens: maxTokens,
messages,
};
// Add system prompt if provided
if (options.systemPrompt) {
requestParams.system = options.systemPrompt;
}
// Execute with retry logic
const response = await this.callWithRetry(async () => {
return await this.client.messages.create(requestParams);
});
// Extract text from response
const textContent = response.content.find((block) => block.type === 'text');
if (!textContent) {
throw this.createError('CLAUDE_API_ERROR', 'No text content in Claude API response', false);
}
return textContent.text;
}
/**
* Analyze security advisory with structured prompt
*/
async analyzeAdvisory(advisory) {
const prompt = `Analyze this security advisory and provide a structured assessment.
Advisory Data:
${JSON.stringify(advisory, null, 2)}
Provide your analysis in the following JSON format:
{
"priority": "HIGH" | "MEDIUM" | "LOW",
"rationale": "detailed explanation of priority assessment",
"affected_components": ["list", "of", "affected", "components"],
"recommended_actions": ["prioritized", "list", "of", "remediation", "steps"],
"confidence": 0.0-1.0
}`;
return await this.sendMessage(prompt, {
systemPrompt: 'You are a security analyst specializing in vulnerability triage and risk assessment. Provide structured, actionable security analysis.',
});
}
/**
* Assess risk for skill installation
*/
async assessSkillRisk(skillMetadata) {
const prompt = `Assess the security risk of installing this skill.
Skill Metadata:
${JSON.stringify(skillMetadata, null, 2)}
Provide your assessment in the following JSON format:
{
"riskScore": 0-100,
"severity": "critical" | "high" | "medium" | "low",
"findings": [
{
"category": "filesystem" | "network" | "execution" | "dependencies" | "permissions",
"severity": "critical" | "high" | "medium" | "low",
"description": "detailed finding description",
"evidence": "specific evidence from metadata"
}
],
"recommendation": "approve" | "review" | "block",
"rationale": "detailed explanation of risk score and recommendation"
}`;
return await this.sendMessage(prompt, {
systemPrompt: 'You are a security analyst specializing in supply chain security and code review. Identify potential security risks in skill installations.',
});
}
/**
* Parse natural language security policy
*/
async parsePolicy(naturalLanguagePolicy) {
const prompt = `Parse this natural language security policy into a structured format.
Policy Statement: "${naturalLanguagePolicy}"
Provide your analysis in the following JSON format:
{
"policy": {
"type": "advisory-severity" | "filesystem-access" | "network-access" | "dependency-vulnerability" | "risk-score" | "custom",
"condition": {
"operator": "equals" | "contains" | "greater_than" | "less_than" | "matches_regex",
"field": "field name to evaluate",
"value": "value or pattern to match"
},
"action": "block" | "warn" | "require_approval" | "log" | "allow",
"description": "human-readable description of the policy"
},
"confidence": 0.0-1.0,
"ambiguities": ["list", "of", "any", "ambiguous", "aspects"]
}
If the policy statement is too ambiguous or unimplementable, set confidence < 0.7 and list specific ambiguities.`;
return await this.sendMessage(prompt, {
systemPrompt: 'You are a security policy analyst. Parse natural language policies into structured, enforceable rules.',
});
}
/**
* Execute a function with exponential backoff retry logic
*/
async callWithRetry(fn) {
let lastError;
const maxRetries = this.config.maxRetries;
const initialDelayMs = this.config.initialDelayMs;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
}
catch (error) {
lastError = error;
// Check if error is retryable
const isRetryable = this.isRetryableError(error);
if (!isRetryable || attempt === maxRetries) {
// Convert to AnalystError if it's an API error
if (error instanceof Anthropic.APIError) {
throw this.createErrorFromAPIError(error);
}
throw error;
}
// Calculate delay with exponential backoff: 1s, 2s, 4s
const delayMs = initialDelayMs * Math.pow(2, attempt);
// Log retry attempt (not to console in production)
if (process.env['NODE_ENV'] !== 'test') {
// eslint-disable-next-line no-console
console.warn(`Claude API error (attempt ${attempt + 1}/${maxRetries + 1}): ${error.message}. Retrying in ${delayMs}ms...`);
}
await this.sleep(delayMs);
}
}
throw lastError;
}
/**
* Determine if an error is retryable
*/
isRetryableError(error) {
if (!(error instanceof Anthropic.APIError)) {
// Network errors and other non-API errors are retryable
return true;
}
// Retry on rate limits (429)
if (error.status === 429) {
return true;
}
// Retry on server errors (5xx)
if (error.status && error.status >= 500 && error.status < 600) {
return true;
}
// Don't retry on client errors (4xx) except 429
// This includes 401 (auth), 400 (bad request), 403 (forbidden), etc.
return false;
}
/**
* Create an AnalystError from Anthropic APIError
*/
createErrorFromAPIError(error) {
let code = 'CLAUDE_API_ERROR';
let message = error.message;
if (error.status === 401) {
code = 'MISSING_API_KEY';
message = 'Invalid or missing API key. Check your ANTHROPIC_API_KEY.';
}
else if (error.status === 429) {
code = 'RATE_LIMIT_EXCEEDED';
message = 'Claude API rate limit exceeded. Please try again later.';
}
else if (error.status && error.status >= 500) {
code = 'NETWORK_FAILURE';
message = `Claude API server error: ${error.message}`;
}
return this.createError(code, message, error.status === 429 || (error.status !== undefined && error.status >= 500));
}
/**
* Create a typed AnalystError
*/
createError(code, message, recoverable) {
return {
code,
message,
recoverable,
};
}
/**
* Sleep for specified milliseconds
*/
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Get current configuration (for testing/debugging)
*/
getConfig() {
return { ...this.config };
}
}
/**
* Create a default Claude client instance
*/
export function createClaudeClient(config) {
return new ClaudeClient(config);
}
+474
View File
@@ -0,0 +1,474 @@
import * as crypto from "node:crypto";
import * as fs from "node:fs/promises";
import * as https from "node:https";
import * as path from "node:path";
/**
* Allowed domains for feed/signature fetching.
* Only connections to these domains are permitted for security.
*/
const ALLOWED_DOMAINS = [
"clawsec.prompt.security",
"prompt.security",
"raw.githubusercontent.com",
"github.com",
];
/**
* Custom error class for security policy violations.
* These errors should always propagate and never be silently caught.
*/
class SecurityPolicyError extends Error {
constructor(message) {
super(message);
this.name = "SecurityPolicyError";
}
}
/**
* Type guard for checking if a value is a plain object.
*/
function isObject(value) {
return typeof value === "object" && value !== null;
}
/**
* Creates a secure HTTPS agent with TLS 1.2+ enforcement and certificate validation.
*/
function createSecureAgent() {
return new https.Agent({
// Enforce minimum TLS 1.2 (eliminate TLS 1.0, 1.1)
minVersion: "TLSv1.2",
// Ensure certificate validation is enabled (reject unauthorized certificates)
rejectUnauthorized: true,
// Use strong cipher suites
ciphers: "TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256",
});
}
/**
* Validates that a URL is from an allowed domain.
*/
function isAllowedDomain(url) {
try {
const parsed = new URL(url);
// Only allow HTTPS protocol
if (parsed.protocol !== "https:") {
return false;
}
const hostname = parsed.hostname.toLowerCase();
// Check if hostname matches any allowed domain
return ALLOWED_DOMAINS.some((allowed) => hostname === allowed || hostname.endsWith(`.${allowed}`));
}
catch {
return false;
}
}
/**
* Secure wrapper around fetch with TLS enforcement and domain validation.
* @throws {SecurityPolicyError} If URL is not from an allowed domain
*/
async function secureFetch(url, options = {}) {
// Validate domain before making request
if (!isAllowedDomain(url)) {
throw new SecurityPolicyError(`Security policy violation: URL domain not allowed. ` +
`Only connections to ${ALLOWED_DOMAINS.join(", ")} are permitted. ` +
`Blocked: ${url}`);
}
// Use secure HTTPS agent with TLS 1.2+ enforcement
const agent = createSecureAgent();
return globalThis.fetch(url, {
...options,
// Attach secure agent for Node.js fetch
// @ts-ignore - agent is supported in Node.js fetch
agent,
});
}
/**
* Parse package specifier into name and version.
*/
export function parseAffectedSpecifier(rawSpecifier) {
const specifier = String(rawSpecifier ?? "").trim();
if (!specifier)
return null;
const atIndex = specifier.lastIndexOf("@");
if (atIndex <= 0) {
return { name: specifier, versionSpec: "*" };
}
return {
name: specifier.slice(0, atIndex),
versionSpec: specifier.slice(atIndex + 1),
};
}
/**
* Type guard for validating feed payload structure.
*/
export function isValidFeedPayload(raw) {
if (!isObject(raw))
return false;
if (typeof raw.version !== "string" || !raw.version.trim())
return false;
if (!Array.isArray(raw.advisories))
return false;
for (const advisory of raw.advisories) {
if (!isObject(advisory))
return false;
if (typeof advisory.id !== "string" || !advisory.id.trim())
return false;
if (typeof advisory.severity !== "string" || !advisory.severity.trim())
return false;
if (!Array.isArray(advisory.affected))
return false;
if (!advisory.affected.every((entry) => typeof entry === "string" && entry.trim()))
return false;
}
return true;
}
/**
* Decode signature from raw string (supports both plain base64 and JSON format).
*/
function decodeSignature(signatureRaw) {
const trimmed = String(signatureRaw ?? "").trim();
if (!trimmed)
return null;
let encoded = trimmed;
if (trimmed.startsWith("{")) {
try {
const parsed = JSON.parse(trimmed);
if (isObject(parsed) && typeof parsed.signature === "string") {
encoded = parsed.signature;
}
}
catch {
return null;
}
}
const normalized = encoded.replace(/\s+/g, "");
if (!normalized)
return null;
try {
return Buffer.from(normalized, "base64");
}
catch {
return null;
}
}
/**
* Verify Ed25519 signature for a payload using the public key.
*/
export function verifySignedPayload(payloadRaw, signatureRaw, publicKeyPem) {
const signature = decodeSignature(signatureRaw);
if (!signature)
return false;
const keyPem = String(publicKeyPem ?? "").trim();
if (!keyPem)
return false;
try {
const publicKey = crypto.createPublicKey(keyPem);
return crypto.verify(null, Buffer.from(payloadRaw, "utf8"), publicKey, signature);
}
catch {
return false;
}
}
/**
* Calculate SHA-256 hash of content.
*/
function sha256Hex(content) {
return crypto.createHash("sha256").update(content).digest("hex");
}
/**
* Extract SHA-256 value from various formats.
*/
function extractSha256Value(value) {
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
return /^[a-f0-9]{64}$/.test(normalized) ? normalized : null;
}
if (isObject(value) && typeof value.sha256 === "string") {
const normalized = value.sha256.trim().toLowerCase();
return /^[a-f0-9]{64}$/.test(normalized) ? normalized : null;
}
return null;
}
/**
* Parse checksum manifest JSON.
*/
function parseChecksumsManifest(manifestRaw) {
let parsed;
try {
parsed = JSON.parse(manifestRaw);
}
catch {
throw new Error("Checksum manifest is not valid JSON");
}
if (!isObject(parsed)) {
throw new Error("Checksum manifest must be an object");
}
const algorithmRaw = typeof parsed.algorithm === "string" ? parsed.algorithm.trim().toLowerCase() : "sha256";
if (algorithmRaw !== "sha256") {
throw new Error(`Unsupported checksum manifest algorithm: ${algorithmRaw || "(empty)"}`);
}
// Support legacy manifest formats:
// - New standard: schema_version field
// - skill-release.yml: version field (e.g., "0.0.1")
// - deploy-pages.yml (pre-fix): generated_at field (e.g., "2026-02-08T...")
// - Ultimate fallback: "1"
const schemaVersion = (typeof parsed.schema_version === "string" ? parsed.schema_version.trim() :
typeof parsed.version === "string" ? parsed.version.trim() :
typeof parsed.generated_at === "string" ? parsed.generated_at.trim() :
"1");
if (!schemaVersion) {
throw new Error("Checksum manifest missing schema_version");
}
if (!isObject(parsed.files)) {
throw new Error("Checksum manifest missing files object");
}
const files = {};
for (const [key, value] of Object.entries(parsed.files)) {
if (!String(key).trim())
continue;
const digest = extractSha256Value(value);
if (!digest) {
throw new Error(`Invalid checksum digest entry for ${key}`);
}
files[key] = digest;
}
if (Object.keys(files).length === 0) {
throw new Error("Checksum manifest has no usable file digests");
}
return {
schemaVersion,
algorithm: algorithmRaw,
files,
};
}
/**
* Normalize checksum entry name for consistent matching.
*/
function normalizeChecksumEntryName(entryName) {
return String(entryName ?? "")
.trim()
.replace(/\\/g, "/")
.replace(/^(?:\.\/)+/, "")
.replace(/^\/+/, "");
}
/**
* Resolve a checksum manifest entry by trying various path patterns.
*/
function resolveChecksumManifestEntry(files, entryName) {
const normalizedEntry = normalizeChecksumEntryName(entryName);
if (!normalizedEntry)
return null;
const directCandidates = [
normalizedEntry,
path.posix.basename(normalizedEntry),
`advisories/${path.posix.basename(normalizedEntry)}`,
].filter((candidate, index, all) => candidate && all.indexOf(candidate) === index);
for (const candidate of directCandidates) {
if (Object.prototype.hasOwnProperty.call(files, candidate)) {
return { key: candidate, digest: files[candidate] };
}
}
const basename = path.posix.basename(normalizedEntry);
if (!basename)
return null;
const basenameMatches = Object.entries(files).filter(([key]) => {
const normalizedKey = normalizeChecksumEntryName(key);
return path.posix.basename(normalizedKey) === basename;
});
if (basenameMatches.length > 1) {
throw new Error(`Checksum manifest entry is ambiguous for ${entryName}; ` +
`multiple manifest keys share basename ${basename}`);
}
if (basenameMatches.length === 1) {
const [resolvedKey, digest] = basenameMatches[0];
return { key: resolvedKey, digest };
}
return null;
}
/**
* Verify checksums for expected entries against manifest.
*/
function verifyChecksums(manifest, expectedEntries) {
for (const [entryName, entryContent] of Object.entries(expectedEntries)) {
if (!entryName)
continue;
const resolved = resolveChecksumManifestEntry(manifest.files, entryName);
if (!resolved) {
throw new Error(`Checksum manifest missing required entry: ${entryName}`);
}
const actualDigest = sha256Hex(entryContent);
if (actualDigest !== resolved.digest) {
throw new Error(`Checksum mismatch for ${entryName} (manifest key: ${resolved.key})`);
}
}
}
/**
* Generate default checksums URL from feed URL.
*/
export function defaultChecksumsUrl(feedUrl) {
try {
return new URL("checksums.json", feedUrl).toString();
}
catch {
const fallbackBase = String(feedUrl ?? "").replace(/\/?[^/]*$/, "");
return `${fallbackBase}/checksums.json`;
}
}
/**
* Safely extract basename from URL or file path.
*/
function safeBasename(urlOrPath, fallback) {
try {
// Try parsing as URL first
const parsed = new URL(urlOrPath);
const pathname = parsed.pathname;
const lastSlash = pathname.lastIndexOf("/");
if (lastSlash >= 0 && lastSlash < pathname.length - 1) {
return pathname.slice(lastSlash + 1);
}
}
catch {
// Not a URL, try as path
const normalized = String(urlOrPath ?? "").trim();
const lastSlash = normalized.lastIndexOf("/");
if (lastSlash >= 0 && lastSlash < normalized.length - 1) {
return normalized.slice(lastSlash + 1);
}
}
return fallback;
}
/**
* Fetch text content from URL with timeout.
*/
async function fetchText(fetchFn, targetUrl) {
const controller = new globalThis.AbortController();
const timeout = globalThis.setTimeout(() => controller.abort(), 10000);
try {
const response = await fetchFn(targetUrl, {
method: "GET",
signal: controller.signal,
headers: { accept: "application/json,text/plain;q=0.9,*/*;q=0.8" },
});
if (!response.ok)
return null;
return await response.text();
}
catch (error) {
// Re-throw security policy violations - these should never be silently caught
if (error instanceof SecurityPolicyError) {
throw error;
}
// Network errors, timeouts, etc. return null (graceful degradation)
return null;
}
finally {
globalThis.clearTimeout(timeout);
}
}
/**
* Load and verify advisory feed from local filesystem.
*/
export async function loadLocalFeed(feedPath, options = {}) {
const signaturePath = options.signaturePath ?? `${feedPath}.sig`;
const checksumsPath = options.checksumsPath ?? path.join(path.dirname(feedPath), "checksums.json");
const checksumsSignaturePath = options.checksumsSignaturePath ?? `${checksumsPath}.sig`;
const publicKeyPem = String(options.publicKeyPem ?? "");
const checksumsPublicKeyPem = String(options.checksumsPublicKeyPem ?? publicKeyPem);
const allowUnsigned = options.allowUnsigned === true;
const verifyChecksumManifest = options.verifyChecksumManifest !== false;
const payloadRaw = await fs.readFile(feedPath, "utf8");
if (!allowUnsigned) {
const signatureRaw = await fs.readFile(signaturePath, "utf8");
if (!verifySignedPayload(payloadRaw, signatureRaw, publicKeyPem)) {
throw new Error(`Feed signature verification failed for local feed: ${feedPath}`);
}
if (verifyChecksumManifest) {
const checksumsRaw = await fs.readFile(checksumsPath, "utf8");
const checksumsSignatureRaw = await fs.readFile(checksumsSignaturePath, "utf8");
if (!verifySignedPayload(checksumsRaw, checksumsSignatureRaw, checksumsPublicKeyPem)) {
throw new Error(`Checksum manifest signature verification failed: ${checksumsPath}`);
}
const checksumsManifest = parseChecksumsManifest(checksumsRaw);
const checksumFeedEntry = options.checksumFeedEntry ?? path.basename(feedPath);
const checksumSignatureEntry = options.checksumSignatureEntry ?? path.basename(signaturePath);
const expectedEntries = {
[checksumFeedEntry]: payloadRaw,
[checksumSignatureEntry]: signatureRaw,
};
if (options.checksumPublicKeyEntry) {
expectedEntries[options.checksumPublicKeyEntry] = publicKeyPem;
}
verifyChecksums(checksumsManifest, expectedEntries);
}
}
const payload = JSON.parse(payloadRaw);
if (!isValidFeedPayload(payload)) {
throw new Error(`Invalid advisory feed format: ${feedPath}`);
}
return payload;
}
/**
* Load and verify advisory feed from remote URL.
*/
export async function loadRemoteFeed(feedUrl, options = {}) {
// Use secure fetch with TLS 1.2+ enforcement and domain validation
const fetchFn = secureFetch;
const signatureUrl = options.signatureUrl ?? `${feedUrl}.sig`;
const checksumsUrl = options.checksumsUrl ?? defaultChecksumsUrl(feedUrl);
const checksumsSignatureUrl = options.checksumsSignatureUrl ?? `${checksumsUrl}.sig`;
const publicKeyPem = String(options.publicKeyPem ?? "");
const checksumsPublicKeyPem = String(options.checksumsPublicKeyPem ?? publicKeyPem);
const allowUnsigned = options.allowUnsigned === true;
const verifyChecksumManifest = options.verifyChecksumManifest !== false;
try {
const payloadRaw = await fetchText(fetchFn, feedUrl);
if (!payloadRaw)
return null;
if (!allowUnsigned) {
const signatureRaw = await fetchText(fetchFn, signatureUrl);
if (!signatureRaw)
return null;
if (!verifySignedPayload(payloadRaw, signatureRaw, publicKeyPem)) {
return null;
}
// Only verify checksums if explicitly requested AND both checksum files are available.
// Note: Many upstream workflows (e.g., GitHub raw content) don't publish checksums.json,
// so we gracefully skip verification when these files are missing.
if (verifyChecksumManifest) {
const checksumsRaw = await fetchText(fetchFn, checksumsUrl);
const checksumsSignatureRaw = await fetchText(fetchFn, checksumsSignatureUrl);
// Only proceed if BOTH checksum files are present
if (checksumsRaw && checksumsSignatureRaw) {
if (!verifySignedPayload(checksumsRaw, checksumsSignatureRaw, checksumsPublicKeyPem)) {
return null; // Fail-closed: invalid signature
}
const checksumsManifest = parseChecksumsManifest(checksumsRaw);
// Derive checksum entry names from actual URLs (supports any filename, not just feed.json)
const checksumFeedEntry = options.checksumFeedEntry ?? safeBasename(feedUrl, "feed.json");
const checksumSignatureEntry = options.checksumSignatureEntry ?? safeBasename(signatureUrl, "feed.json.sig");
verifyChecksums(checksumsManifest, {
[checksumFeedEntry]: payloadRaw,
[checksumSignatureEntry]: signatureRaw,
});
}
// If checksum files missing: continue without checksum verification
// (feed signature was already verified above)
}
}
try {
const payload = JSON.parse(payloadRaw);
if (!isValidFeedPayload(payload))
return null;
return payload;
}
catch {
return null;
}
}
catch (error) {
// Security policy violations (invalid URLs, non-HTTPS, disallowed domains) return null
// to allow graceful fallback to local feed
if (error instanceof SecurityPolicyError) {
return null;
}
// Re-throw unexpected errors
throw error;
}
}
+275
View File
@@ -0,0 +1,275 @@
/**
* Natural language policy parser
* Converts plain English security policies into structured, enforceable rules
* using Claude API for semantic understanding
*/
import * as crypto from 'node:crypto';
// Confidence threshold for policy acceptance
const CONFIDENCE_THRESHOLD = 0.7;
/**
* Parse a natural language policy statement into structured format
* @param nlPolicy - Natural language policy statement
* @param client - Claude API client instance
* @returns Promise with structured policy or error if too ambiguous
*/
export async function parsePolicy(nlPolicy, client) {
// Validate input
if (!nlPolicy || nlPolicy.trim().length === 0) {
throw createError('POLICY_AMBIGUOUS', 'Policy statement cannot be empty', false);
}
if (nlPolicy.trim().length < 10) {
throw createError('POLICY_AMBIGUOUS', 'Policy statement is too short to parse meaningfully (minimum 10 characters)', false);
}
// Call Claude API for policy parsing
try {
const responseText = await client.parsePolicy(nlPolicy);
// Parse JSON response
const parsedResponse = parsePolicyResponse(responseText);
// Check confidence threshold
if (parsedResponse.confidence < CONFIDENCE_THRESHOLD) {
return {
policy: null,
confidence: parsedResponse.confidence,
ambiguities: parsedResponse.ambiguities.length > 0
? parsedResponse.ambiguities
: ['Policy statement is too ambiguous to parse with sufficient confidence'],
};
}
// Validate parsed policy structure
validatePolicyStructure(parsedResponse.policy);
// Create structured policy with metadata
const structuredPolicy = {
id: generatePolicyId(),
type: parsedResponse.policy.type,
condition: {
operator: parsedResponse.policy.condition.operator,
field: parsedResponse.policy.condition.field,
value: parsedResponse.policy.condition.value,
},
action: parsedResponse.policy.action,
description: parsedResponse.policy.description,
createdAt: new Date().toISOString(),
};
return {
policy: structuredPolicy,
confidence: parsedResponse.confidence,
ambiguities: parsedResponse.ambiguities,
};
}
catch (error) {
// Check if it's already an AnalystError
if (isAnalystError(error)) {
throw error;
}
throw createError('CLAUDE_API_ERROR', `Failed to parse policy: ${error.message}`, false);
}
}
/**
* Parse multiple policies in batch
* @param nlPolicies - Array of natural language policy statements
* @param client - Claude API client instance
* @returns Promise with array of parse results
*/
export async function parsePolicies(nlPolicies, client) {
const results = [];
// Process policies sequentially to avoid rate limits
for (const nlPolicy of nlPolicies) {
try {
const result = await parsePolicy(nlPolicy, client);
results.push(result);
}
catch (error) {
// On error, push a null result with zero confidence
results.push({
policy: null,
confidence: 0,
ambiguities: [error.message],
});
}
}
return results;
}
/**
* Validate a policy statement without fully parsing it
* Returns suggestions for improvement if the policy is likely to fail
* @param nlPolicy - Natural language policy statement
* @param client - Claude API client instance
* @returns Promise with validation result and suggestions
*/
export async function validatePolicyStatement(nlPolicy, client) {
try {
const result = await parsePolicy(nlPolicy, client);
if (result.confidence < CONFIDENCE_THRESHOLD) {
return {
valid: false,
suggestions: [
'Policy statement is too ambiguous',
...result.ambiguities,
'Try to be more specific about:',
' - What condition triggers the policy',
' - What action should be taken',
' - What specific values or thresholds to check',
],
};
}
return {
valid: true,
suggestions: result.ambiguities.length > 0
? ['Policy is valid but has minor ambiguities:', ...result.ambiguities]
: [],
};
}
catch (error) {
return {
valid: false,
suggestions: [error.message],
};
}
}
/**
* Parse Claude API response for policy parsing
* @param responseText - Raw text response from Claude API
* @returns Parsed policy response
*/
function parsePolicyResponse(responseText) {
try {
// Extract JSON from response (may be wrapped in markdown code blocks)
const jsonMatch = responseText.match(/```json\s*([\s\S]*?)\s*```/);
const jsonText = jsonMatch ? jsonMatch[1] : responseText;
const parsed = JSON.parse(jsonText.trim());
// Validate response structure
if (!parsed.policy || typeof parsed.confidence !== 'number') {
throw new Error('Invalid response structure: missing policy or confidence');
}
if (!parsed.policy.type || !parsed.policy.condition || !parsed.policy.action) {
throw new Error('Invalid policy structure: missing type, condition, or action');
}
if (!Array.isArray(parsed.ambiguities)) {
// Ambiguities is optional, default to empty array
parsed.ambiguities = [];
}
return parsed;
}
catch (error) {
throw createError('CLAUDE_API_ERROR', `Failed to parse Claude API response: ${error.message}. Response: ${responseText.substring(0, 200)}...`, false);
}
}
/**
* Validate that parsed policy has valid structure
* @param policy - Parsed policy object
*/
function validatePolicyStructure(policy) {
const validTypes = [
'advisory-severity',
'filesystem-access',
'network-access',
'dependency-vulnerability',
'risk-score',
'custom',
];
const validOperators = [
'equals',
'contains',
'greater_than',
'less_than',
'matches_regex',
];
const validActions = [
'block',
'warn',
'require_approval',
'log',
'allow',
];
if (!validTypes.includes(policy.type)) {
throw createError('POLICY_AMBIGUOUS', `Invalid policy type: ${policy.type}. Must be one of: ${validTypes.join(', ')}`, false);
}
if (!validOperators.includes(policy.condition.operator)) {
throw createError('POLICY_AMBIGUOUS', `Invalid condition operator: ${policy.condition.operator}. Must be one of: ${validOperators.join(', ')}`, false);
}
if (!validActions.includes(policy.action)) {
throw createError('POLICY_AMBIGUOUS', `Invalid policy action: ${policy.action}. Must be one of: ${validActions.join(', ')}`, false);
}
if (!policy.condition.field || policy.condition.field.trim().length === 0) {
throw createError('POLICY_AMBIGUOUS', 'Policy condition must specify a field to evaluate', false);
}
if (policy.condition.value === undefined || policy.condition.value === null) {
throw createError('POLICY_AMBIGUOUS', 'Policy condition must specify a value to compare', false);
}
}
/**
* Generate a unique policy ID
* @returns Policy ID in format: policy-{timestamp}-{random}
*/
function generatePolicyId() {
const timestamp = Date.now().toString(36);
const random = crypto.randomBytes(4).toString('hex');
return `policy-${timestamp}-${random}`;
}
/**
* Format a policy parse result for display
* @param result - Policy parse result
* @returns Human-readable formatted string
*/
export function formatPolicyResult(result) {
const lines = [];
lines.push('=== Policy Parse Result ===');
lines.push(`Confidence: ${(result.confidence * 100).toFixed(1)}% (threshold: ${CONFIDENCE_THRESHOLD * 100}%)`);
if (result.ambiguities.length > 0) {
lines.push('\nAmbiguities:');
result.ambiguities.forEach(amb => lines.push(` - ${amb}`));
}
if (result.policy) {
lines.push('\n=== Structured Policy ===');
lines.push(`ID: ${result.policy.id}`);
lines.push(`Type: ${result.policy.type}`);
lines.push(`Action: ${result.policy.action}`);
lines.push(`Description: ${result.policy.description}`);
lines.push('\nCondition:');
lines.push(` Field: ${result.policy.condition.field}`);
lines.push(` Operator: ${result.policy.condition.operator}`);
lines.push(` Value: ${JSON.stringify(result.policy.condition.value)}`);
lines.push(`\nCreated: ${result.policy.createdAt}`);
}
else {
lines.push('\n❌ Policy failed to parse (confidence too low)');
lines.push('\nSuggestions:');
lines.push(' - Be more specific about conditions and actions');
lines.push(' - Avoid ambiguous terms like "dangerous" or "risky"');
lines.push(' - Specify exact values or thresholds');
}
return lines.join('\n');
}
/**
* Check if an error is an AnalystError
* @param error - Error to check
* @returns True if error is an AnalystError
*/
function isAnalystError(error) {
return (typeof error === 'object' &&
error !== null &&
'code' in error &&
'message' in error &&
'recoverable' in error);
}
/**
* Create a typed AnalystError
* @param code - Error code
* @param message - Error message
* @param recoverable - Whether error is recoverable
* @returns AnalystError
*/
function createError(code, message, recoverable) {
return {
code,
message,
recoverable,
};
}
/**
* Get the confidence threshold for policy acceptance
* @returns Confidence threshold (0.0 to 1.0)
*/
export function getConfidenceThreshold() {
return CONFIDENCE_THRESHOLD;
}
+392
View File
@@ -0,0 +1,392 @@
/**
* 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 { ClaudeClient } from './claude-client.js';
import { loadLocalFeed, loadRemoteFeed, parseAffectedSpecifier } from './feed-reader.js';
/**
* Risk score calculation thresholds
*/
const RISK_THRESHOLDS = {
CRITICAL: 80,
HIGH: 60,
MEDIUM: 30,
LOW: 0,
};
/**
* 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',
};
/**
* Parses skill.json file
* @param skillJsonPath - Path to skill.json file
* @returns Parsed skill metadata
*/
async function parseSkillJson(skillJsonPath) {
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;
}
catch (error) {
if (error.code === 'ENOENT') {
throw new Error(`skill.json not found: ${skillJsonPath}`);
}
throw new Error(`Failed to parse skill.json: ${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) {
try {
return await fs.readFile(skillMdPath, 'utf-8');
}
catch (error) {
if (error.code === 'ENOENT') {
return null;
}
// Log but don't fail - SKILL.md is optional for risk assessment
console.warn(`Failed to read SKILL.md: ${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) {
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.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.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, feed) {
const matches = [];
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, skillMd, advisoryMatches, claudeClient) {
// 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, skillName, advisoryMatches) {
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.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) {
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, advisoryMatches) {
const riskScore = calculateFallbackRiskScore(advisoryMatches);
let severity;
let recommendation;
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 = advisoryMatches.map(match => ({
category: 'dependencies',
severity: match.advisory.severity,
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, config = {}) {
// 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.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, config = {}) {
const assessments = [];
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.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) {
const lines = [];
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');
}
+111
View File
@@ -0,0 +1,111 @@
import * as fs from "node:fs/promises";
import * as path from "node:path";
/**
* State persistence module for clawsec-analyst
* Stores analysis history, cached results, and policies in ~/.openclaw/clawsec-analyst-state.json
*/
function isObject(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export const DEFAULT_STATE = {
schema_version: "1.0",
last_feed_check: null,
last_feed_updated: null,
cached_analyses: {},
policies: [],
analysis_history: [],
};
/**
* Validates and normalizes state object
* Ensures all fields conform to AnalystState schema
*/
export function normalizeState(raw) {
if (!isObject(raw)) {
return { ...DEFAULT_STATE };
}
// Normalize cached_analyses
const cachedAnalyses = {};
if (isObject(raw.cached_analyses)) {
for (const [key, value] of Object.entries(raw.cached_analyses)) {
if (isObject(value) && typeof value.advisoryId === "string" && value.timestamp) {
cachedAnalyses[key] = value;
}
}
}
// Normalize policies
const policies = [];
if (Array.isArray(raw.policies)) {
for (const policy of raw.policies) {
if (isObject(policy) &&
typeof policy.id === "string" &&
typeof policy.type === "string" &&
policy.condition &&
policy.action) {
policies.push(policy);
}
}
}
// Normalize analysis_history
const analysisHistory = [];
if (Array.isArray(raw.analysis_history)) {
for (const entry of raw.analysis_history) {
if (isObject(entry) &&
typeof entry.timestamp === "string" &&
typeof entry.type === "string" &&
typeof entry.targetId === "string" &&
typeof entry.result === "string") {
analysisHistory.push(entry);
}
}
}
return {
schema_version: "1.0",
last_feed_check: typeof raw.last_feed_check === "string" ? raw.last_feed_check : null,
last_feed_updated: typeof raw.last_feed_updated === "string" ? raw.last_feed_updated : null,
cached_analyses: cachedAnalyses,
policies,
analysis_history: analysisHistory,
};
}
/**
* Loads state from file, returns default state if file doesn't exist
* @param stateFile - Path to state JSON file
*/
export async function loadState(stateFile) {
try {
const raw = await fs.readFile(stateFile, "utf8");
return normalizeState(JSON.parse(raw));
}
catch {
return { ...DEFAULT_STATE };
}
}
/**
* Persists state to file atomically with secure permissions (0600)
* Uses temp file + rename for atomic write
* @param stateFile - Path to state JSON file
* @param state - State object to persist
*/
export async function persistState(stateFile, state) {
const normalized = normalizeState(state);
await fs.mkdir(path.dirname(stateFile), { recursive: true });
const tmpFile = `${stateFile}.tmp-${process.pid}-${Date.now()}`;
await fs.writeFile(tmpFile, `${JSON.stringify(normalized, null, 2)}\n`, {
encoding: "utf8",
mode: 0o600,
});
await fs.rename(tmpFile, stateFile);
try {
await fs.chmod(stateFile, 0o600);
}
catch (err) {
const code = err instanceof Error && "code" in err ? err.code : undefined;
if (code === "ENOTSUP" || code === "EPERM") {
console.warn(`Warning: chmod 0600 failed for ${stateFile} (${code}). ` +
"File permissions may not be enforced on this platform/filesystem.");
}
else {
throw err;
}
}
}
+5
View File
@@ -0,0 +1,5 @@
/**
* Type definitions for clawsec-analyst skill
* Defines types for advisory feed, policies, and analysis results
*/
export {};