llm-security/scanners/lib/sarif-formatter.mjs
Kjell Tore Guttormsen e97c23246e fix(llm-security): sarif-formatter splits comma-separated owasp string into multiple tags
buildRules() and toSARIF() wrapped a multi-mapping f.owasp string
(e.g. 'MCP03, MCP06', emitted by mcp-live-inspect.mjs and
ide-extension-scanner.mjs) as a single-element tags array instead of
splitting it, silently dropping the second OWASP mapping in SARIF
output. Added owaspTags() helper; test asserts a multi-entry tags
array for both rule and result properties.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WbQmoLxcAAsJAkFxBeCx6z
2026-08-18 16:52:52 +02:00

138 lines
3.6 KiB
JavaScript

// sarif-formatter.mjs — Converts scan-orchestrator envelope to SARIF 2.1.0
// OASIS SARIF standard: https://docs.oasis-open.org/sarif/sarif/v2.1.0/
// Zero external dependencies.
const SARIF_SCHEMA = 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json';
const SARIF_VERSION = '2.1.0';
const TOOL_NAME = 'llm-security';
const TOOL_URI = 'https://git.fromaitochitta.com/open/llm-security';
/**
* Map finding severity to SARIF level.
* @param {string} severity - critical|high|medium|low|info
* @returns {string} SARIF level: error|warning|note
*/
function toLevel(severity) {
switch (severity) {
case 'critical':
case 'high':
return 'error';
case 'medium':
return 'warning';
case 'low':
case 'info':
default:
return 'note';
}
}
/**
* Split a possibly comma-separated OWASP string (e.g. 'MCP03, MCP06') into tags.
* @param {string} [owasp]
* @returns {string[]}
*/
function owaspTags(owasp) {
return owasp ? owasp.split(',').map(s => s.trim()).filter(Boolean) : [];
}
/**
* Build SARIF rules array from unique finding scanner+title combos.
* @param {object[]} findings
* @returns {{ rules: object[], ruleIndex: Map<string, number> }}
*/
function buildRules(findings) {
const ruleIndex = new Map();
const rules = [];
for (const f of findings) {
const ruleId = `${f.scanner}/${f.title.replace(/\s+/g, '-').toLowerCase()}`;
if (!ruleIndex.has(ruleId)) {
ruleIndex.set(ruleId, rules.length);
rules.push({
id: ruleId,
name: f.title,
shortDescription: { text: f.title },
fullDescription: { text: f.description || f.title },
defaultConfiguration: { level: toLevel(f.severity) },
properties: {
tags: owaspTags(f.owasp),
},
});
}
}
return { rules, ruleIndex };
}
/**
* Convert scan-orchestrator envelope JSON to SARIF 2.1.0 format.
* @param {object} envelopeData - The full scan-orchestrator output
* @param {string} [version='6.0.0'] - Tool version
* @returns {object} SARIF 2.1.0 JSON
*/
export function toSARIF(envelopeData, version = '6.0.0') {
// Collect all findings from all scanners
const allFindings = [];
if (envelopeData.scanners) {
for (const scannerResult of Object.values(envelopeData.scanners)) {
if (scannerResult.findings) {
allFindings.push(...scannerResult.findings);
}
}
}
const { rules, ruleIndex } = buildRules(allFindings);
// Build SARIF results
const results = allFindings.map(f => {
const ruleId = `${f.scanner}/${f.title.replace(/\s+/g, '-').toLowerCase()}`;
const result = {
ruleId,
ruleIndex: ruleIndex.get(ruleId),
level: toLevel(f.severity),
message: { text: f.description || f.title },
properties: {},
};
// Add OWASP tags
if (f.owasp) {
result.properties.tags = owaspTags(f.owasp);
}
// Add recommendation
if (f.recommendation) {
result.properties.recommendation = f.recommendation;
}
// Add location if file is present
if (f.file) {
const location = {
physicalLocation: {
artifactLocation: { uri: f.file },
},
};
if (f.line) {
location.physicalLocation.region = { startLine: f.line };
}
result.locations = [location];
}
return result;
});
return {
$schema: SARIF_SCHEMA,
version: SARIF_VERSION,
runs: [{
tool: {
driver: {
name: TOOL_NAME,
version,
informationUri: TOOL_URI,
rules,
},
},
results,
}],
};
}