mirror of
https://github.com/prompt-security/clawsec.git
synced 2026-06-21 09:21:21 +03:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Type definitions for clawsec-analyst skill
|
||||
* Defines types for advisory feed, policies, and analysis results
|
||||
*/
|
||||
export {};
|
||||
Reference in New Issue
Block a user