auto-claude: subtask-3-1 - Implement advisory triage analyzer

This commit is contained in:
David Abutbul
2026-02-27 21:03:22 +02:00
parent 89b763c668
commit ec632155ab
2 changed files with 247 additions and 1 deletions
@@ -0,0 +1,246 @@
/**
* Advisory triage analyzer
* Analyzes security advisories using Claude API to assess actual risk,
* identify affected components, and recommend remediation actions
*/
import { ClaudeClient } from './claude-client.js';
import { getCachedAnalysis, setCachedAnalysis } from './cache.js';
import type { Advisory, AdvisoryAnalysis, AnalystError } from './types.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: Advisory,
client: ClaudeClient
): Promise<AdvisoryAnalysis> {
// 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 as 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: Advisory[],
client: ClaudeClient
): Promise<AdvisoryAnalysis[]> {
const results: AdvisoryAnalysis[] = [];
// 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: AdvisoryAnalysis[],
minPriority: 'HIGH' | 'MEDIUM' | 'LOW' = 'MEDIUM'
): AdvisoryAnalysis[] {
const priorityOrder: Record<string, number> = {
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: string, responseText: string): AdvisoryAnalysis {
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 as 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: Advisory): AdvisoryAnalysis {
// Map advisory severity to priority (conservative approach)
const severityToPriority: Record<string, 'HIGH' | 'MEDIUM' | 'LOW'> = {
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: string,
message: string,
recoverable: boolean
): AnalystError {
return {
code,
message,
recoverable,
};
}
+1 -1
View File
@@ -2,7 +2,7 @@ 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";
import type { FeedPayload, Advisory } from "./types.js";
import type { FeedPayload } from "./types.js";
/**
* Allowed domains for feed/signature fetching.