Files
clawsec/skills/clawsec-analyst/handler.js
T
David Abutbul 11f217c12f auto-claude: subtask-7-2 - Run TypeScript compilation and ESLint
- Added ESLint globals for Node.js in skills/**/*.js files
- Fixed NodeJS.ErrnoException type declarations (changed from namespace to interface)
- Removed unused eslint-disable-next-line directives
- Fixed unused variables in test files (using optional catch binding where appropriate)
- Changed @ts-ignore to @ts-expect-error in feed-reader.ts
- All TypeScript compilation and ESLint checks now pass with zero warnings

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-27 21:59:32 +02:00

255 lines
9.7 KiB
JavaScript

/**
* 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') {
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') {
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') {
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') {
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) {
console.error('[clawsec-analyst] Environment validation failed:');
for (const error of validation.errors) {
console.error(` - ${error}`);
}
process.exit(1);
}
// Success - output expected message
console.log('[clawsec-analyst] Environment validation passed');
console.log('[clawsec-analyst] API key configured');
console.log('[clawsec-analyst] Ready for operation');
process.exit(0);
}
else {
console.error('[clawsec-analyst] Usage: node handler.ts --dry-run');
process.exit(1);
}
}