'use strict';
* pre-commit 失败日志按工具解析(兜底 SARIF 数据源)。
* 第一批支持:clang-tidy / bandit / gosec / spotbugs / rust-clippy / eslint / gitleaks / detect-secrets。
* 每种工具实现一个行解析器:输入失败钩子的输出块行(trimmed 非空),输出结构化 findings。
* 供 pre-commit-action 在用户未提供 *.sarif 时生成 <tool>.sarif。
*
* 【安全合规说明】本文件中的公网地址(SARIF $schema 与各工具 helpUri)为 SARIF 2.1.0 标准
* 格式声明字段与工具官方文档链接,属功能必需,已登记白名单作为合规例外,不得删除或替换。
*/
const crypto = require('crypto');
const LEVEL_ERROR = 'error';
const LEVEL_WARNING = 'warning';
const LEVEL_NOTE = 'note';
function _locationHash(toolName, ruleId, file, line, column) {
const raw = [toolName, ruleId, file || '', line || '', column || ''].join('|');
return crypto.createHash('sha256').update(raw, 'utf8').digest('hex');
}
function buildToolSarif(toolName, findings) {
const rules = [];
const ruleMap = {};
const results = [];
for (const f of findings) {
const ruleId = f.ruleId || toolName;
if (!ruleMap[ruleId]) {
ruleMap[ruleId] = true;
rules.push({
id: ruleId,
name: ruleId,
shortDescription: { text: f.ruleDesc || ruleId },
helpUri: f.helpUri || ''
});
}
const region = {};
if (f.line) region.startLine = f.line;
if (f.column) region.startColumn = f.column;
results.push({
ruleId,
level: f.level || LEVEL_ERROR,
message: { text: f.message || ruleId },
locations: [{
physicalLocation: {
artifactLocation: { uri: f.file || toolName },
region
}
}],
partialFingerprints: {
primaryLocationLineHash: _locationHash(toolName, ruleId, f.file, f.line, f.column)
}
});
}
if (rules.length === 0) return null;
return {
version: '2.1.0',
$schema: 'https://json.schemastore.org/sarif-2.1.0.json',
runs: [{
tool: {
driver: {
name: toolName,
rules
}
},
results
}]
};
}
function parseClangTidy(lines) {
const findings = [];
const re = /^(.+?):(\d+)(?::(\d+))?:\s*(warning|error|note):\s*(.+?)\s*(?:\[([^\]]+)\])?$/;
for (const line of lines) {
const m = line.match(re);
if (!m) continue;
const lvl = m[4];
findings.push({
ruleId: m[6] || 'clang-tidy',
ruleDesc: m[6] || 'clang-tidy diagnostic',
level: lvl === 'error' ? LEVEL_ERROR : (lvl === 'warning' ? LEVEL_WARNING : LEVEL_NOTE),
message: m[5],
file: m[1],
line: parseInt(m[2], 10),
column: m[3] ? parseInt(m[3], 10) : undefined,
helpUri: 'https://clang.llvm.org/extra/clang-tidy/'
});
}
return findings;
}
function parseBandit(lines) {
const findings = [];
let cur = null;
for (const line of lines) {
let m = line.match(/^>>\s*Issue:\s*\[([A-Z]\d+):([\w-]+)\]\s*(.+)$/);
if (m) {
cur = { ruleId: m[1], ruleDesc: m[2], message: m[3], severity: '' };
findings.push(cur);
continue;
}
if (!cur) continue;
m = line.match(/^Severity:\s*(\w+)/);
if (m) { cur.severity = m[1]; continue; }
m = line.match(/^Location:\s*(.+?):(\d+)(?::(\d+))?$/);
if (m) {
cur.file = m[1];
cur.line = parseInt(m[2], 10);
cur.column = m[3] ? parseInt(m[3], 10) : undefined;
continue;
}
}
for (const line of lines) {
const m = line.match(/^Issue:\s*\[([A-Z]\d+):([\w-]+)\]\s*(.+?)\s+in file\s+(\S+)\s+at line\s+(\d+)/);
if (m) {
findings.push({
ruleId: m[1],
ruleDesc: m[2],
message: m[3],
severity: '',
file: m[4],
line: parseInt(m[5], 10)
});
}
}
return findings.map(f => ({
...f,
level: f.severity === 'High' ? LEVEL_ERROR : LEVEL_WARNING,
helpUri: 'https://bandit.readthedocs.io/'
}));
}
function parseGosec(lines) {
const findings = [];
const re = /^\[(.+):(\d+)\]\s*-\s*([A-Z]\d+):\s*(.+?)\s*\(Confidence: \w+, Severity: (\w+)\)/;
for (const line of lines) {
const m = line.match(re);
if (!m) continue;
const severity = m[5];
findings.push({
ruleId: m[3],
ruleDesc: m[3],
message: m[4],
level: severity === 'MEDIUM' || severity === 'HIGH' ? LEVEL_ERROR : LEVEL_WARNING,
file: m[1],
line: parseInt(m[2], 10),
helpUri: 'https://github.com/securego/gosec'
});
}
return findings;
}
function parseSpotbugs(lines) {
const findings = [];
const re = /^([HML])\s+([\w.]+):\s*(.+?)\s+In class\s+(\S+)\s+In method\s+(.+?)\s+At\s+(\S+):\[line\s+(\d+)(?:-(\d+))?\]/;
for (const line of lines) {
const m = line.match(re);
if (!m) continue;
findings.push({
ruleId: m[2],
ruleDesc: m[2],
message: m[3],
level: m[1] === 'H' ? LEVEL_ERROR : LEVEL_WARNING,
file: m[6],
line: parseInt(m[7], 10),
helpUri: 'https://spotbugs.readthedocs.io/'
});
}
return findings;
}
function parseClippy(lines) {
const findings = [];
let cur = null;
const startRe = /^(warning|error|note):\s*(.+)$/;
const locRe = /^-->\s*(.+):(\d+):(\d+)/;
const noteRe = /^= note:\s*`#\[warn\(([\w:]+)\)\]/;
for (const line of lines) {
const m = line.match(startRe);
if (m) {
cur = {
ruleId: 'clippy',
ruleDesc: 'clippy lint',
message: m[2],
level: m[1] === 'error' ? LEVEL_ERROR : (m[1] === 'warning' ? LEVEL_WARNING : LEVEL_NOTE)
};
findings.push(cur);
continue;
}
if (!cur) continue;
const nm = line.match(noteRe);
if (nm) { cur.ruleId = nm[1]; cur.ruleDesc = nm[1]; continue; }
const lm = line.match(locRe);
if (lm) {
cur.file = lm[1];
cur.line = parseInt(lm[2], 10);
cur.column = parseInt(lm[3], 10);
}
}
return findings.map(f => ({ ...f, helpUri: 'https://rust-lang.github.io/rust-clippy/' }));
}
function parseEslint(lines) {
const findings = [];
let currentFile = '';
const resultRe = /^\s*(\d+):(\d+)\s+(error|warning)\s+(.+?)\s+([\w-./\\@]+)\s*$/;
for (const line of lines) {
if (!line.trim()) continue;
const m = line.match(resultRe);
if (m) {
findings.push({
ruleId: m[5],
ruleDesc: m[5],
message: m[4],
level: m[3] === 'error' ? LEVEL_ERROR : LEVEL_WARNING,
file: currentFile,
line: parseInt(m[1], 10),
column: parseInt(m[2], 10),
helpUri: 'https://eslint.org/'
});
continue;
}
if (!/^\s*\d+:\d+/.test(line) && !/^✖/.test(line) && !/^\d+\s+problems/.test(line) && !line.startsWith(' ')) {
currentFile = line.trim();
}
}
return findings;
}
function parseGitleaks(lines) {
const findings = [];
const singleRe = /^○\s*\[([\w-]+)\]\s*(.+?)\s+in\s+(\S+):(\d+)/;
let cur = null;
for (const line of lines) {
const m = line.match(singleRe);
if (m) {
findings.push({
ruleId: m[1],
ruleDesc: m[1],
message: m[2],
level: LEVEL_ERROR,
file: m[3],
line: parseInt(m[4], 10)
});
cur = null;
continue;
}
let f = line.match(/^RuleID:\s*(\S+)/);
if (f) {
cur = { ruleId: f[1], ruleDesc: f[1], message: '', level: LEVEL_ERROR };
findings.push(cur);
continue;
}
if (!cur) continue;
f = line.match(/^(File|Line|Secret|Finding|Description|Match):\s*(.+)/);
if (f) {
const k = f[1];
const v = f[2].trim();
if (k === 'File') cur.file = v;
else if (k === 'Line') cur.line = parseInt(v, 10);
else if (k === 'Secret' || k === 'Match') cur.message = (cur.message ? cur.message + ' ' : '') + v.slice(0, 60);
else if (k === 'Description' || k === 'Finding') cur.message = cur.message || v;
}
}
return findings.map(f => ({ ...f, helpUri: 'https://github.com/gitleaks/gitleaks' }));
}
function parseDetectSecrets(lines) {
const findings = [];
let cur = null;
const typeRe = /^Secret Type:\s*(.+)$/;
const locRe = /^Location:\s*(\S+):(\d+)(?::(\d+))?/;
for (const line of lines) {
const m = line.match(typeRe);
if (m) {
const type = m[1].trim();
cur = {
ruleId: type,
ruleDesc: type,
message: `Potential secret detected: ${type}`,
level: LEVEL_ERROR
};
findings.push(cur);
continue;
}
if (!cur) continue;
const lm = line.match(locRe);
if (lm) {
cur.file = lm[1];
cur.line = parseInt(lm[2], 10);
cur.column = lm[3] ? parseInt(lm[3], 10) : undefined;
}
}
return findings.map(f => ({ ...f, helpUri: 'https://github.com/Yelp/detect-secrets' }));
}
const TOOL_PARSERS = [
{ toolName: 'clang-tidy', hooks: ['clang-tidy', 'clang_tidy'], parse: parseClangTidy },
{ toolName: 'bandit', hooks: ['bandit'], parse: parseBandit },
{ toolName: 'gosec', hooks: ['gosec'], parse: parseGosec },
{ toolName: 'spotbugs', hooks: ['spotbugs', 'spot-bugs'], parse: parseSpotbugs },
{ toolName: 'rust-clippy', hooks: ['rust-clippy', 'rust_clippy', 'clippy'], parse: parseClippy },
{ toolName: 'eslint', hooks: ['eslint'], parse: parseEslint },
{ toolName: 'gitleaks', hooks: ['gitleaks'], parse: parseGitleaks },
{ toolName: 'detect-secrets', hooks: ['detect-secrets', 'detect-secrets-report'], parse: parseDetectSecrets }
];
function matchToolParser(hookId) {
const id = String(hookId || '').toLowerCase();
for (const t of TOOL_PARSERS) {
if (t.hooks.includes(id)) return t;
}
return null;
}
module.exports = {
buildToolSarif,
matchToolParser,
parseClangTidy,
parseBandit,
parseGosec,
parseSpotbugs,
parseClippy,
parseEslint,
parseGitleaks,
parseDetectSecrets,
TOOL_PARSERS
};