feat(scanner): add SARIF 2.1.0 output format to scan-orchestrator (--format sarif)
New sarif-formatter.mjs converts scan envelope to OASIS SARIF 2.1.0 standard. Maps severity to SARIF levels, findings to results with locations and rules. scan-orchestrator accepts --format sarif|json (default: json). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
51b5371d6f
commit
2116e702df
3 changed files with 305 additions and 3 deletions
129
plugins/llm-security/scanners/lib/sarif-formatter.mjs
Normal file
129
plugins/llm-security/scanners/lib/sarif-formatter.mjs
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
// 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/claude-code-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';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: f.owasp ? [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 = [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,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ import { tmpdir } from 'node:os';
|
|||
import { discoverFiles } from './lib/file-discovery.mjs';
|
||||
import { envelope, resetCounter } from './lib/output.mjs';
|
||||
import { saveBaseline, diffAgainstBaseline, extractFindings } from './lib/diff-engine.mjs';
|
||||
import { toSARIF } from './lib/sarif-formatter.mjs';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// .llm-security-ignore support
|
||||
|
|
@ -122,12 +123,14 @@ const SCANNERS = [
|
|||
// CLI arg parsing — supports --log-file <path>
|
||||
// ---------------------------------------------------------------------------
|
||||
function parseArgs(argv) {
|
||||
const args = { target: null, logFile: null, outputFile: null, baseline: false, saveBaseline: false };
|
||||
const args = { target: null, logFile: null, outputFile: null, baseline: false, saveBaseline: false, format: 'json' };
|
||||
for (let i = 2; i < argv.length; i++) {
|
||||
if (argv[i] === '--log-file' && argv[i + 1]) {
|
||||
args.logFile = argv[++i];
|
||||
} else if (argv[i] === '--output-file' && argv[i + 1]) {
|
||||
args.outputFile = argv[++i];
|
||||
} else if (argv[i] === '--format' && argv[i + 1]) {
|
||||
args.format = argv[++i];
|
||||
} else if (argv[i] === '--baseline') {
|
||||
args.baseline = true;
|
||||
} else if (argv[i] === '--save-baseline') {
|
||||
|
|
@ -245,8 +248,9 @@ async function main() {
|
|||
log(`[deep-scan] Baseline saved: ${savedPath}\n`);
|
||||
}
|
||||
|
||||
// Output JSON: to file (--output-file) or stdout
|
||||
const jsonStr = JSON.stringify(output, null, 2) + '\n';
|
||||
// Output: SARIF or JSON, to file (--output-file) or stdout
|
||||
const finalOutput = args.format === 'sarif' ? toSARIF(output) : output;
|
||||
const jsonStr = JSON.stringify(finalOutput, null, 2) + '\n';
|
||||
if (args.outputFile) {
|
||||
writeFileSync(args.outputFile, jsonStr);
|
||||
output.output_file = args.outputFile;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue