368 lines
12 KiB
TypeScript
368 lines
12 KiB
TypeScript
/**
|
|
* Waggle Marketplace — Cisco AI Defense Skill Scanner Adapter
|
|
*
|
|
* Wraps the Cisco `skill-scanner` CLI tool (Python) as an optional
|
|
* deep-analysis engine for marketplace security scanning.
|
|
*
|
|
* The scanner is OPTIONAL — Waggle works without it. When not installed,
|
|
* the SecurityGate falls back to its built-in JavaScript heuristics.
|
|
*
|
|
* Install the scanner: pip install cisco-ai-skill-scanner
|
|
* Reference: https://github.com/cisco-ai-defense/skill-scanner
|
|
*/
|
|
|
|
import { execFile as execFileCb } from 'child_process';
|
|
import { writeFileSync, unlinkSync, mkdirSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { tmpdir } from 'os';
|
|
import { randomBytes } from 'crypto';
|
|
import { promisify } from 'util';
|
|
|
|
const defaultExecFile = promisify(execFileCb);
|
|
|
|
/** Injectable executor for subprocess calls (overridable for tests) */
|
|
type ExecFileFn = (cmd: string, args: string[], opts: { timeout: number }) =>
|
|
Promise<{ stdout: string; stderr: string }>;
|
|
|
|
let _execFile: ExecFileFn = defaultExecFile;
|
|
|
|
/**
|
|
* Override the subprocess executor (for testing).
|
|
* Pass `null` to restore the default.
|
|
*/
|
|
export function setExecFile(fn: ExecFileFn | null): void {
|
|
_execFile = fn ?? defaultExecFile;
|
|
}
|
|
|
|
// ─── Types ──────────────────────────────────────────────────────────
|
|
|
|
export interface CiscoScanIssue {
|
|
severity: 'critical' | 'high' | 'medium' | 'low' | 'info';
|
|
type: string;
|
|
message: string;
|
|
line?: number;
|
|
rule_id?: string;
|
|
description?: string;
|
|
location?: string;
|
|
}
|
|
|
|
export interface CiscoScanResult {
|
|
/** Whether the scan passed (no critical/high findings) */
|
|
passed: boolean;
|
|
/** Security score 0-100 (100 = clean) */
|
|
score: number;
|
|
/** Individual findings from the scanner */
|
|
issues: CiscoScanIssue[];
|
|
/** Version of the Cisco scanner used */
|
|
scannerVersion: string;
|
|
/** Time taken for the scan in milliseconds */
|
|
scanDuration: number;
|
|
}
|
|
|
|
/**
|
|
* Shape of a rejected {@link ExecFileFn} call. `child_process.execFile`
|
|
* rejects with an Error augmented with `code` (`'ENOENT'` or a numeric exit
|
|
* code), `killed` (true on timeout), and the captured `stdout`/`stderr`.
|
|
*/
|
|
interface ExecFailure {
|
|
code?: string | number;
|
|
killed?: boolean;
|
|
stdout?: string;
|
|
stderr?: string;
|
|
message?: string;
|
|
}
|
|
|
|
/** Narrow an unknown caught value into an {@link ExecFailure} view. */
|
|
function asExecFailure(err: unknown): ExecFailure {
|
|
return (typeof err === 'object' && err !== null) ? err as ExecFailure : {};
|
|
}
|
|
|
|
/** Sentinel result returned when the scanner is not installed */
|
|
const SCANNER_NOT_AVAILABLE: CiscoScanResult = {
|
|
passed: true,
|
|
score: -1,
|
|
issues: [],
|
|
scannerVersion: 'not_installed',
|
|
scanDuration: 0,
|
|
};
|
|
|
|
// Scanner availability check timeout (5 seconds)
|
|
const VERSION_TIMEOUT_MS = 5_000;
|
|
|
|
// Scan execution timeout (30 seconds per file)
|
|
const SCAN_TIMEOUT_MS = 30_000;
|
|
|
|
// Cache the availability check for 60 seconds to avoid repeated subprocess calls
|
|
let _availabilityCache: { available: boolean; version: string; checkedAt: number } | null = null;
|
|
const AVAILABILITY_CACHE_TTL_MS = 60_000;
|
|
|
|
// ─── Public API ─────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Check if the Cisco skill-scanner CLI is installed and available.
|
|
* Tries `skill-scanner --version` first, then `python -m skill_scanner --version`.
|
|
* Results are cached for 60 seconds.
|
|
*/
|
|
export async function isCiscoScannerAvailable(): Promise<boolean> {
|
|
// Return cached result if still fresh
|
|
if (_availabilityCache && (Date.now() - _availabilityCache.checkedAt) < AVAILABILITY_CACHE_TTL_MS) {
|
|
return _availabilityCache.available;
|
|
}
|
|
|
|
// Try the direct CLI command first
|
|
try {
|
|
const { stdout } = await _execFile('skill-scanner', ['--version'], {
|
|
timeout: VERSION_TIMEOUT_MS,
|
|
});
|
|
const version = stdout.trim() || 'unknown';
|
|
_availabilityCache = { available: true, version, checkedAt: Date.now() };
|
|
return true;
|
|
} catch {
|
|
// Direct CLI not found — try Python module invocation
|
|
}
|
|
|
|
try {
|
|
const { stdout } = await _execFile('python', ['-m', 'skill_scanner', '--version'], {
|
|
timeout: VERSION_TIMEOUT_MS,
|
|
});
|
|
const version = stdout.trim() || 'unknown';
|
|
_availabilityCache = { available: true, version, checkedAt: Date.now() };
|
|
return true;
|
|
} catch {
|
|
// Also try python3 for Linux/macOS
|
|
}
|
|
|
|
try {
|
|
const { stdout } = await _execFile('python3', ['-m', 'skill_scanner', '--version'], {
|
|
timeout: VERSION_TIMEOUT_MS,
|
|
});
|
|
const version = stdout.trim() || 'unknown';
|
|
_availabilityCache = { available: true, version, checkedAt: Date.now() };
|
|
return true;
|
|
} catch {
|
|
// Scanner not available
|
|
}
|
|
|
|
_availabilityCache = { available: false, version: '', checkedAt: Date.now() };
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Get the cached version string of the Cisco scanner, or 'not_installed'.
|
|
*/
|
|
export function getCiscoScannerVersion(): string {
|
|
return _availabilityCache?.version || 'not_installed';
|
|
}
|
|
|
|
/**
|
|
* Reset the availability cache (useful for tests).
|
|
*/
|
|
export function resetAvailabilityCache(): void {
|
|
_availabilityCache = null;
|
|
}
|
|
|
|
/**
|
|
* Scan skill content using the Cisco AI Defense skill-scanner.
|
|
*
|
|
* 1. Writes content to a temp file
|
|
* 2. Runs: skill-scanner scan <tempfile> --format json
|
|
* 3. Parses the JSON output into CiscoScanResult
|
|
* 4. Cleans up the temp file
|
|
*
|
|
* If the scanner is not available, returns a sentinel result with
|
|
* scannerVersion='not_installed' and score=-1.
|
|
*/
|
|
export async function ciscoScan(content: string, filename: string): Promise<CiscoScanResult> {
|
|
const startTime = Date.now();
|
|
|
|
// Check availability first
|
|
const available = await isCiscoScannerAvailable();
|
|
if (!available) {
|
|
return { ...SCANNER_NOT_AVAILABLE };
|
|
}
|
|
|
|
// Write content to a temp file
|
|
const tempDir = join(tmpdir(), 'waggle-cisco-scan');
|
|
mkdirSync(tempDir, { recursive: true });
|
|
const safeName = filename.replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
const tempFile = join(tempDir, `${safeName}-${randomBytes(4).toString('hex')}.md`);
|
|
|
|
try {
|
|
writeFileSync(tempFile, content, 'utf-8');
|
|
|
|
// Build the command and args
|
|
const args = ['scan', tempFile, '--format', 'json'];
|
|
|
|
// Try the direct CLI first, fall back to python module
|
|
let stdout: string;
|
|
let exitCode = 0;
|
|
|
|
try {
|
|
const result = await _execFile('skill-scanner', args, {
|
|
timeout: SCAN_TIMEOUT_MS,
|
|
});
|
|
stdout = result.stdout;
|
|
} catch (err: unknown) {
|
|
const execErr = asExecFailure(err);
|
|
// skill-scanner exits with code 1 when findings are found — that's not an error
|
|
if (execErr.code === 'ENOENT' || execErr.killed) {
|
|
// CLI not found or timed out — try python module
|
|
try {
|
|
const result = await _execFile('python', ['-m', 'skill_scanner', ...args], {
|
|
timeout: SCAN_TIMEOUT_MS,
|
|
});
|
|
stdout = result.stdout;
|
|
} catch (pyErr: unknown) {
|
|
const pyExecErr = asExecFailure(pyErr);
|
|
if (pyExecErr.stdout) {
|
|
stdout = pyExecErr.stdout;
|
|
exitCode = typeof pyExecErr.code === 'number' ? pyExecErr.code : 1;
|
|
} else {
|
|
// Try python3 as last resort
|
|
try {
|
|
const result = await _execFile('python3', ['-m', 'skill_scanner', ...args], {
|
|
timeout: SCAN_TIMEOUT_MS,
|
|
});
|
|
stdout = result.stdout;
|
|
} catch (py3Err: unknown) {
|
|
const py3ExecErr = asExecFailure(py3Err);
|
|
if (py3ExecErr.stdout) {
|
|
stdout = py3ExecErr.stdout;
|
|
exitCode = typeof py3ExecErr.code === 'number' ? py3ExecErr.code : 1;
|
|
} else {
|
|
return {
|
|
passed: true,
|
|
score: -1,
|
|
issues: [],
|
|
scannerVersion: getCiscoScannerVersion(),
|
|
scanDuration: Date.now() - startTime,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else if (execErr.stdout) {
|
|
// Process exited with non-zero but produced output (findings found)
|
|
stdout = execErr.stdout;
|
|
exitCode = typeof execErr.code === 'number' ? execErr.code : 1;
|
|
} else {
|
|
// Unexpected error
|
|
console.warn(`[cisco-scanner] Scan failed: ${execErr.message || String(err)}`);
|
|
return {
|
|
passed: true,
|
|
score: -1,
|
|
issues: [],
|
|
scannerVersion: getCiscoScannerVersion(),
|
|
scanDuration: Date.now() - startTime,
|
|
};
|
|
}
|
|
}
|
|
|
|
// Parse the JSON output
|
|
const result = parseJsonOutput(stdout, exitCode);
|
|
result.scanDuration = Date.now() - startTime;
|
|
result.scannerVersion = getCiscoScannerVersion();
|
|
|
|
return result;
|
|
} finally {
|
|
// Clean up temp file
|
|
try {
|
|
unlinkSync(tempFile);
|
|
} catch { /* ignore cleanup failures */ }
|
|
}
|
|
}
|
|
|
|
// ─── Internal Helpers ───────────────────────────────────────────────
|
|
|
|
/**
|
|
* Parse the JSON output from skill-scanner into a CiscoScanResult.
|
|
*
|
|
* The scanner outputs a JSON object with:
|
|
* - verdict: 'PASS' | 'FAIL'
|
|
* - findings: Array of { rule_id, severity, category, title, description, line, location }
|
|
* - summary: string
|
|
* - score: number (if present)
|
|
*/
|
|
function parseJsonOutput(stdout: string, exitCode: number): CiscoScanResult {
|
|
const issues: CiscoScanIssue[] = [];
|
|
let passed = true;
|
|
let score = 100;
|
|
|
|
if (!stdout || !stdout.trim()) {
|
|
return { passed: true, score: 100, issues: [], scannerVersion: '', scanDuration: 0 };
|
|
}
|
|
|
|
try {
|
|
const data = JSON.parse(stdout.trim());
|
|
|
|
// Extract verdict
|
|
if (data.verdict) {
|
|
const verdict = data.verdict.toUpperCase();
|
|
passed = verdict === 'PASS' || verdict === 'CLEAN';
|
|
} else {
|
|
// No explicit verdict — infer from exit code
|
|
passed = exitCode === 0;
|
|
}
|
|
|
|
// Extract score if provided
|
|
if (typeof data.score === 'number') {
|
|
score = data.score;
|
|
} else if (typeof data.security_score === 'number') {
|
|
score = data.security_score;
|
|
}
|
|
|
|
// Extract findings
|
|
const rawFindings = data.findings || data.issues || data.results || [];
|
|
if (Array.isArray(rawFindings)) {
|
|
for (const f of rawFindings) {
|
|
const severity = normalizeSeverity(f.severity || f.level);
|
|
issues.push({
|
|
severity,
|
|
type: f.category || f.type || f.rule_id || 'unknown',
|
|
message: f.title || f.message || f.description || '',
|
|
line: typeof f.line === 'number' ? f.line : undefined,
|
|
rule_id: f.rule_id || f.id,
|
|
description: f.description || f.details,
|
|
location: f.location,
|
|
});
|
|
|
|
// Adjust score based on severity if no explicit score
|
|
if (typeof data.score !== 'number' && typeof data.security_score !== 'number') {
|
|
switch (severity) {
|
|
case 'critical': score = Math.min(score, 0); break;
|
|
case 'high': score = Math.min(score, 25); break;
|
|
case 'medium': score = Math.min(score, 60); break;
|
|
case 'low': score = Math.min(score, 85); break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// If verdict is FAIL but no findings, add a generic finding
|
|
if (!passed && issues.length === 0) {
|
|
issues.push({
|
|
severity: 'high',
|
|
type: 'verdict_fail',
|
|
message: data.summary || 'Cisco scanner returned FAIL verdict without specific findings.',
|
|
});
|
|
score = Math.min(score, 25);
|
|
}
|
|
} catch (parseErr) {
|
|
// JSON parse failed — treat as unparseable output
|
|
console.warn(`[cisco-scanner] Failed to parse JSON output: ${(parseErr as Error).message}`);
|
|
return { passed: true, score: -1, issues: [], scannerVersion: '', scanDuration: 0 };
|
|
}
|
|
|
|
return { passed, score, issues, scannerVersion: '', scanDuration: 0 };
|
|
}
|
|
|
|
function normalizeSeverity(level: string | undefined): CiscoScanIssue['severity'] {
|
|
const normalized = (level || '').toLowerCase().trim();
|
|
if (normalized === 'critical' || normalized === 'error') return 'critical';
|
|
if (normalized === 'high') return 'high';
|
|
if (normalized === 'medium' || normalized === 'warning') return 'medium';
|
|
if (normalized === 'low') return 'low';
|
|
if (normalized === 'info' || normalized === 'none') return 'info';
|
|
return 'medium';
|
|
}
|