From 0e95d771c51fb68effa0bf6a7b261170a6ed87d2 Mon Sep 17 00:00:00 2001 From: David Abutbul Date: Fri, 27 Feb 2026 21:17:06 +0200 Subject: [PATCH] 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 --- skills/clawsec-analyst/handler.js | 264 ++++++++++ skills/clawsec-analyst/handler.ts | 65 +++ .../clawsec-analyst/lib/advisory-analyzer.js | 196 ++++++++ skills/clawsec-analyst/lib/cache.js | 198 ++++++++ skills/clawsec-analyst/lib/claude-client.js | 241 +++++++++ skills/clawsec-analyst/lib/feed-reader.js | 474 ++++++++++++++++++ skills/clawsec-analyst/lib/policy-engine.js | 275 ++++++++++ skills/clawsec-analyst/lib/risk-assessor.js | 392 +++++++++++++++ skills/clawsec-analyst/lib/state.js | 111 ++++ skills/clawsec-analyst/lib/types.js | 5 + 10 files changed, 2221 insertions(+) create mode 100644 skills/clawsec-analyst/handler.js create mode 100644 skills/clawsec-analyst/lib/advisory-analyzer.js create mode 100644 skills/clawsec-analyst/lib/cache.js create mode 100644 skills/clawsec-analyst/lib/claude-client.js create mode 100644 skills/clawsec-analyst/lib/feed-reader.js create mode 100644 skills/clawsec-analyst/lib/policy-engine.js create mode 100644 skills/clawsec-analyst/lib/risk-assessor.js create mode 100644 skills/clawsec-analyst/lib/state.js create mode 100644 skills/clawsec-analyst/lib/types.js diff --git a/skills/clawsec-analyst/handler.js b/skills/clawsec-analyst/handler.js new file mode 100644 index 0000000..7e5fd28 --- /dev/null +++ b/skills/clawsec-analyst/handler.js @@ -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 ` 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 ` 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); + } +} diff --git a/skills/clawsec-analyst/handler.ts b/skills/clawsec-analyst/handler.ts index c82bafd..870356a 100644 --- a/skills/clawsec-analyst/handler.ts +++ b/skills/clawsec-analyst/handler.ts @@ -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 => { }; 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); + } +} diff --git a/skills/clawsec-analyst/lib/advisory-analyzer.js b/skills/clawsec-analyst/lib/advisory-analyzer.js new file mode 100644 index 0000000..a0f0f07 --- /dev/null +++ b/skills/clawsec-analyst/lib/advisory-analyzer.js @@ -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, + }; +} diff --git a/skills/clawsec-analyst/lib/cache.js b/skills/clawsec-analyst/lib/cache.js new file mode 100644 index 0000000..9fbd1c0 --- /dev/null +++ b/skills/clawsec-analyst/lib/cache.js @@ -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; + } +} diff --git a/skills/clawsec-analyst/lib/claude-client.js b/skills/clawsec-analyst/lib/claude-client.js new file mode 100644 index 0000000..2e19b10 --- /dev/null +++ b/skills/clawsec-analyst/lib/claude-client.js @@ -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); +} diff --git a/skills/clawsec-analyst/lib/feed-reader.js b/skills/clawsec-analyst/lib/feed-reader.js new file mode 100644 index 0000000..e4fc132 --- /dev/null +++ b/skills/clawsec-analyst/lib/feed-reader.js @@ -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; + } +} diff --git a/skills/clawsec-analyst/lib/policy-engine.js b/skills/clawsec-analyst/lib/policy-engine.js new file mode 100644 index 0000000..972f5a4 --- /dev/null +++ b/skills/clawsec-analyst/lib/policy-engine.js @@ -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; +} diff --git a/skills/clawsec-analyst/lib/risk-assessor.js b/skills/clawsec-analyst/lib/risk-assessor.js new file mode 100644 index 0000000..9c46a59 --- /dev/null +++ b/skills/clawsec-analyst/lib/risk-assessor.js @@ -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'); +} diff --git a/skills/clawsec-analyst/lib/state.js b/skills/clawsec-analyst/lib/state.js new file mode 100644 index 0000000..697b1a8 --- /dev/null +++ b/skills/clawsec-analyst/lib/state.js @@ -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; + } + } +} diff --git a/skills/clawsec-analyst/lib/types.js b/skills/clawsec-analyst/lib/types.js new file mode 100644 index 0000000..ab2da71 --- /dev/null +++ b/skills/clawsec-analyst/lib/types.js @@ -0,0 +1,5 @@ +/** + * Type definitions for clawsec-analyst skill + * Defines types for advisory feed, policies, and analysis results + */ +export {};