feat: initial open marketplace with llm-security, config-audit, ultraplan-local
This commit is contained in:
commit
f93d6abdae
380 changed files with 65935 additions and 0 deletions
666
plugins/config-audit/scanners/fix-engine.mjs
Normal file
666
plugins/config-audit/scanners/fix-engine.mjs
Normal file
|
|
@ -0,0 +1,666 @@
|
|||
/**
|
||||
* Config-Audit Fix Engine
|
||||
* Deterministic fix engine: maps scanner findings to concrete file changes.
|
||||
* Zero external dependencies.
|
||||
*/
|
||||
|
||||
import { readFile, writeFile, rename, stat } from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
import { parseJson, parseFrontmatter } from './lib/yaml-parser.mjs';
|
||||
import { createBackup } from './lib/backup.mjs';
|
||||
import { runAllScanners } from './scan-orchestrator.mjs';
|
||||
|
||||
/**
|
||||
* Fix type constants.
|
||||
*/
|
||||
const FIX_TYPES = {
|
||||
JSON_KEY_ADD: 'json-key-add',
|
||||
JSON_KEY_REMOVE: 'json-key-remove',
|
||||
JSON_KEY_TYPE_FIX: 'json-key-type-fix',
|
||||
JSON_RESTRUCTURE: 'json-restructure',
|
||||
FRONTMATTER_RENAME: 'frontmatter-rename',
|
||||
FILE_RENAME: 'file-rename',
|
||||
};
|
||||
|
||||
/** Valid effortLevel values for nearest-match */
|
||||
const VALID_EFFORT_LEVELS = ['low', 'medium', 'high', 'max'];
|
||||
|
||||
/**
|
||||
* Plan fixes from a scanner envelope.
|
||||
* @param {object} envelope - Full scanner envelope from scan-orchestrator
|
||||
* @returns {{ fixes: object[], skipped: object[], manual: object[] }}
|
||||
*/
|
||||
export function planFixes(envelope) {
|
||||
const fixes = [];
|
||||
const skipped = [];
|
||||
const manual = [];
|
||||
|
||||
for (const scanner of envelope.scanners) {
|
||||
for (const finding of scanner.findings) {
|
||||
if (!finding.autoFixable) {
|
||||
manual.push({
|
||||
findingId: finding.id,
|
||||
title: finding.title,
|
||||
file: finding.file,
|
||||
recommendation: finding.recommendation,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const fixPlan = createFixPlan(finding);
|
||||
if (fixPlan) {
|
||||
fixes.push(fixPlan);
|
||||
} else {
|
||||
skipped.push(finding);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort fixes by severity weight (critical first)
|
||||
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
|
||||
fixes.sort((a, b) => (severityOrder[a.severity] || 4) - (severityOrder[b.severity] || 4));
|
||||
|
||||
return { fixes, skipped, manual };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a fix plan for a single finding.
|
||||
* @param {object} finding
|
||||
* @returns {object|null}
|
||||
*/
|
||||
function createFixPlan(finding) {
|
||||
if (!finding.file) return null;
|
||||
|
||||
const base = {
|
||||
findingId: finding.id,
|
||||
file: finding.file,
|
||||
severity: finding.severity,
|
||||
description: '',
|
||||
before: null,
|
||||
after: null,
|
||||
type: null,
|
||||
};
|
||||
|
||||
const scanner = finding.scanner;
|
||||
const title = finding.title;
|
||||
|
||||
// --- SET scanner fixes ---
|
||||
if (scanner === 'SET') {
|
||||
if (title === 'Missing $schema reference') {
|
||||
return {
|
||||
...base,
|
||||
type: FIX_TYPES.JSON_KEY_ADD,
|
||||
description: 'Add $schema reference for IDE autocomplete',
|
||||
before: '(no $schema key)',
|
||||
after: '"$schema": "https://json.schemastore.org/claude-code-settings.json"',
|
||||
key: '$schema',
|
||||
value: 'https://json.schemastore.org/claude-code-settings.json',
|
||||
};
|
||||
}
|
||||
|
||||
if (title === 'Deprecated settings key') {
|
||||
const key = extractKeyFromEvidence(finding.evidence);
|
||||
if (!key) return null;
|
||||
return {
|
||||
...base,
|
||||
type: FIX_TYPES.JSON_KEY_REMOVE,
|
||||
description: `Remove deprecated key "${key}"`,
|
||||
before: finding.evidence,
|
||||
after: '(key removed)',
|
||||
key,
|
||||
};
|
||||
}
|
||||
|
||||
if (title === 'Type mismatch in settings') {
|
||||
const key = extractKeyFromEvidence(finding.evidence);
|
||||
if (!key) return null;
|
||||
const expectedType = extractExpectedType(finding.description);
|
||||
return {
|
||||
...base,
|
||||
type: FIX_TYPES.JSON_KEY_TYPE_FIX,
|
||||
description: `Fix type of "${key}" to ${expectedType}`,
|
||||
before: finding.evidence,
|
||||
after: `(converted to ${expectedType})`,
|
||||
key,
|
||||
expectedType,
|
||||
};
|
||||
}
|
||||
|
||||
if (title === 'Invalid effortLevel value') {
|
||||
return {
|
||||
...base,
|
||||
type: FIX_TYPES.JSON_KEY_TYPE_FIX,
|
||||
description: 'Fix effortLevel to nearest valid value',
|
||||
before: finding.evidence,
|
||||
after: '(nearest valid effortLevel)',
|
||||
key: 'effortLevel',
|
||||
expectedType: 'effortLevel',
|
||||
};
|
||||
}
|
||||
|
||||
if (title === 'Hooks configured as array instead of object') {
|
||||
return {
|
||||
...base,
|
||||
type: FIX_TYPES.JSON_RESTRUCTURE,
|
||||
description: 'Convert hooks from array to object format',
|
||||
before: '"hooks": [...]',
|
||||
after: '"hooks": { ... }',
|
||||
restructureType: 'hooks-array-to-object',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// --- HKV scanner fixes ---
|
||||
if (scanner === 'HKV') {
|
||||
if (title === 'Matcher must be a string, not an object') {
|
||||
return {
|
||||
...base,
|
||||
type: FIX_TYPES.JSON_RESTRUCTURE,
|
||||
description: 'Convert matcher from object to string',
|
||||
before: finding.evidence,
|
||||
after: '"matcher": "ToolName"',
|
||||
restructureType: 'matcher-object-to-string',
|
||||
event: extractEventFromDescription(finding.description),
|
||||
};
|
||||
}
|
||||
|
||||
if (title === 'Hook timeout must be a number') {
|
||||
return {
|
||||
...base,
|
||||
type: FIX_TYPES.JSON_KEY_TYPE_FIX,
|
||||
description: 'Convert timeout to number',
|
||||
before: finding.evidence,
|
||||
after: '(parsed to number)',
|
||||
key: 'timeout',
|
||||
expectedType: 'number',
|
||||
event: extractEventFromDescription(finding.description),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// --- RUL scanner fixes ---
|
||||
if (scanner === 'RUL') {
|
||||
if (title === 'Rule uses deprecated "globs" field') {
|
||||
return {
|
||||
...base,
|
||||
type: FIX_TYPES.FRONTMATTER_RENAME,
|
||||
description: 'Rename "globs" to "paths" in frontmatter',
|
||||
before: 'globs:',
|
||||
after: 'paths:',
|
||||
oldField: 'globs',
|
||||
newField: 'paths',
|
||||
};
|
||||
}
|
||||
|
||||
if (title === 'Rule file is not .md') {
|
||||
const newPath = finding.file.replace(/\.[^.]+$/, '.md');
|
||||
return {
|
||||
...base,
|
||||
type: FIX_TYPES.FILE_RENAME,
|
||||
description: `Rename to .md extension`,
|
||||
before: finding.file,
|
||||
after: newPath,
|
||||
newPath,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply planned fixes to files.
|
||||
* @param {object[]} fixPlans - Array of fix plans from planFixes()
|
||||
* @param {object} opts
|
||||
* @param {boolean} [opts.dryRun=false]
|
||||
* @param {string} [opts.backupDir] - Required if not dryRun
|
||||
* @returns {Promise<{ applied: object[], failed: object[] }>}
|
||||
*/
|
||||
export async function applyFixes(fixPlans, opts = {}) {
|
||||
const applied = [];
|
||||
const failed = [];
|
||||
|
||||
if (!opts.dryRun && !opts.backupDir) {
|
||||
throw new Error('backupDir is required when not in dryRun mode');
|
||||
}
|
||||
|
||||
for (const plan of fixPlans) {
|
||||
if (opts.dryRun) {
|
||||
applied.push({
|
||||
findingId: plan.findingId,
|
||||
file: plan.file,
|
||||
status: 'dry-run',
|
||||
type: plan.type,
|
||||
description: plan.description,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await applyFix(plan);
|
||||
applied.push({
|
||||
findingId: plan.findingId,
|
||||
file: plan.file,
|
||||
status: 'applied',
|
||||
type: plan.type,
|
||||
description: plan.description,
|
||||
});
|
||||
} catch (err) {
|
||||
failed.push({
|
||||
findingId: plan.findingId,
|
||||
file: plan.file,
|
||||
status: 'failed',
|
||||
error: err.message,
|
||||
type: plan.type,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { applied, failed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a single fix.
|
||||
* @param {object} plan
|
||||
*/
|
||||
async function applyFix(plan) {
|
||||
switch (plan.type) {
|
||||
case FIX_TYPES.JSON_KEY_ADD:
|
||||
await applyJsonKeyAdd(plan);
|
||||
break;
|
||||
case FIX_TYPES.JSON_KEY_REMOVE:
|
||||
await applyJsonKeyRemove(plan);
|
||||
break;
|
||||
case FIX_TYPES.JSON_KEY_TYPE_FIX:
|
||||
await applyJsonKeyTypeFix(plan);
|
||||
break;
|
||||
case FIX_TYPES.JSON_RESTRUCTURE:
|
||||
await applyJsonRestructure(plan);
|
||||
break;
|
||||
case FIX_TYPES.FRONTMATTER_RENAME:
|
||||
await applyFrontmatterRename(plan);
|
||||
break;
|
||||
case FIX_TYPES.FILE_RENAME:
|
||||
await applyFileRename(plan);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown fix type: ${plan.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a key to a JSON file (as first key for $schema).
|
||||
*/
|
||||
async function applyJsonKeyAdd(plan) {
|
||||
const content = await readFile(plan.file, 'utf-8');
|
||||
const parsed = parseJson(content);
|
||||
if (parsed === null) throw new Error('Invalid JSON');
|
||||
|
||||
// For $schema, insert as first key
|
||||
if (plan.key === '$schema') {
|
||||
const newObj = { $schema: plan.value, ...parsed };
|
||||
await writeJsonFile(plan.file, newObj);
|
||||
} else {
|
||||
parsed[plan.key] = plan.value;
|
||||
await writeJsonFile(plan.file, parsed);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a key from a JSON file.
|
||||
*/
|
||||
async function applyJsonKeyRemove(plan) {
|
||||
const content = await readFile(plan.file, 'utf-8');
|
||||
const parsed = parseJson(content);
|
||||
if (parsed === null) throw new Error('Invalid JSON');
|
||||
|
||||
delete parsed[plan.key];
|
||||
await writeJsonFile(plan.file, parsed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix the type of a JSON key value.
|
||||
*/
|
||||
async function applyJsonKeyTypeFix(plan) {
|
||||
const content = await readFile(plan.file, 'utf-8');
|
||||
const parsed = parseJson(content);
|
||||
if (parsed === null) throw new Error('Invalid JSON');
|
||||
|
||||
// Handle nested hook timeout fixes
|
||||
if (plan.key === 'timeout' && plan.event) {
|
||||
fixTimeoutInHooks(parsed, plan.event);
|
||||
await writeJsonFile(plan.file, parsed);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle effortLevel special case
|
||||
if (plan.key === 'effortLevel' && plan.expectedType === 'effortLevel') {
|
||||
parsed.effortLevel = findNearestEffortLevel(parsed.effortLevel);
|
||||
await writeJsonFile(plan.file, parsed);
|
||||
return;
|
||||
}
|
||||
|
||||
// Generic type conversion
|
||||
if (parsed[plan.key] !== undefined) {
|
||||
parsed[plan.key] = convertType(parsed[plan.key], plan.expectedType);
|
||||
await writeJsonFile(plan.file, parsed);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restructure JSON (hooks array→object, matcher object→string).
|
||||
*/
|
||||
async function applyJsonRestructure(plan) {
|
||||
const content = await readFile(plan.file, 'utf-8');
|
||||
const parsed = parseJson(content);
|
||||
if (parsed === null) throw new Error('Invalid JSON');
|
||||
|
||||
if (plan.restructureType === 'hooks-array-to-object') {
|
||||
restructureHooksArrayToObject(parsed);
|
||||
await writeJsonFile(plan.file, parsed);
|
||||
return;
|
||||
}
|
||||
|
||||
if (plan.restructureType === 'matcher-object-to-string') {
|
||||
restructureMatcherObjectToString(parsed, plan.event);
|
||||
await writeJsonFile(plan.file, parsed);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`Unknown restructure type: ${plan.restructureType}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a frontmatter field in a markdown file.
|
||||
*/
|
||||
async function applyFrontmatterRename(plan) {
|
||||
const content = await readFile(plan.file, 'utf-8');
|
||||
|
||||
// Replace the field name in the frontmatter section only
|
||||
const fmMatch = content.match(/^(---\r?\n)([\s\S]*?)(\r?\n---)/);
|
||||
if (!fmMatch) throw new Error('No frontmatter found');
|
||||
|
||||
const before = fmMatch[2];
|
||||
const regex = new RegExp(`^(${plan.oldField})(\\s*:)`, 'gm');
|
||||
const after = before.replace(regex, `${plan.newField}$2`);
|
||||
|
||||
if (before === after) throw new Error(`Field "${plan.oldField}" not found in frontmatter`);
|
||||
|
||||
const newContent = fmMatch[1] + after + fmMatch[3] + content.slice(fmMatch[0].length);
|
||||
await writeFile(plan.file, newContent, 'utf-8');
|
||||
|
||||
// Validate frontmatter still parses
|
||||
const { frontmatter } = parseFrontmatter(newContent);
|
||||
if (!frontmatter) throw new Error('Frontmatter parse failed after rename');
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a file (change extension to .md).
|
||||
*/
|
||||
async function applyFileRename(plan) {
|
||||
try {
|
||||
await stat(plan.file);
|
||||
} catch {
|
||||
throw new Error(`Source file not found: ${plan.file}`);
|
||||
}
|
||||
|
||||
// Check target doesn't already exist
|
||||
try {
|
||||
await stat(plan.newPath);
|
||||
throw new Error(`Target already exists: ${plan.newPath}`);
|
||||
} catch (err) {
|
||||
if (err.message.startsWith('Target already exists')) throw err;
|
||||
// File doesn't exist — good
|
||||
}
|
||||
|
||||
await rename(plan.file, plan.newPath);
|
||||
}
|
||||
|
||||
// --- Helper functions ---
|
||||
|
||||
/**
|
||||
* Write a JSON object to a file with 2-space indent.
|
||||
*/
|
||||
async function writeJsonFile(filePath, obj) {
|
||||
const json = JSON.stringify(obj, null, 2) + '\n';
|
||||
// Validate the JSON we're about to write
|
||||
const reparsed = parseJson(json);
|
||||
if (reparsed === null) throw new Error('Generated invalid JSON');
|
||||
await writeFile(filePath, json, 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a value to the expected type.
|
||||
*/
|
||||
function convertType(value, expectedType) {
|
||||
switch (expectedType) {
|
||||
case 'boolean':
|
||||
if (typeof value === 'string') {
|
||||
if (value.toLowerCase() === 'true' || value === '1') return true;
|
||||
if (value.toLowerCase() === 'false' || value === '0') return false;
|
||||
}
|
||||
if (typeof value === 'number') return value !== 0;
|
||||
return Boolean(value);
|
||||
case 'number':
|
||||
if (typeof value === 'string') {
|
||||
const num = Number(value);
|
||||
return isNaN(num) ? 10000 : num; // default 10000 for timeouts
|
||||
}
|
||||
return Number(value);
|
||||
case 'string':
|
||||
return String(value);
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the nearest valid effortLevel.
|
||||
*/
|
||||
function findNearestEffortLevel(value) {
|
||||
if (typeof value !== 'string') return 'medium';
|
||||
const lower = value.toLowerCase();
|
||||
// Simple distance-based matching
|
||||
let best = 'medium';
|
||||
let bestDist = Infinity;
|
||||
for (const level of VALID_EFFORT_LEVELS) {
|
||||
const dist = levenshtein(lower, level);
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist;
|
||||
best = level;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple Levenshtein distance.
|
||||
*/
|
||||
function levenshtein(a, b) {
|
||||
const m = a.length, n = b.length;
|
||||
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
||||
for (let i = 0; i <= m; i++) dp[i][0] = i;
|
||||
for (let j = 0; j <= n; j++) dp[0][j] = j;
|
||||
for (let i = 1; i <= m; i++) {
|
||||
for (let j = 1; j <= n; j++) {
|
||||
dp[i][j] = Math.min(
|
||||
dp[i - 1][j] + 1,
|
||||
dp[i][j - 1] + 1,
|
||||
dp[i - 1][j - 1] + (a[i - 1] !== b[j - 1] ? 1 : 0),
|
||||
);
|
||||
}
|
||||
}
|
||||
return dp[m][n];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert hooks array to object format.
|
||||
* Best-effort: wraps array items under "PreToolUse" if they have event fields,
|
||||
* otherwise groups by event property.
|
||||
*/
|
||||
function restructureHooksArrayToObject(parsed) {
|
||||
if (!Array.isArray(parsed.hooks)) return;
|
||||
|
||||
const hooksObj = {};
|
||||
for (const item of parsed.hooks) {
|
||||
const event = item.event || 'PreToolUse';
|
||||
if (!hooksObj[event]) hooksObj[event] = [];
|
||||
|
||||
// Build the handler group
|
||||
const group = {};
|
||||
if (item.matcher) group.matcher = typeof item.matcher === 'string' ? item.matcher : String(item.matcher);
|
||||
group.hooks = [];
|
||||
|
||||
if (item.command) {
|
||||
group.hooks.push({
|
||||
type: item.type || 'command',
|
||||
command: item.command,
|
||||
...(item.timeout !== undefined ? { timeout: typeof item.timeout === 'number' ? item.timeout : Number(item.timeout) || 10000 } : {}),
|
||||
});
|
||||
} else if (item.hooks && Array.isArray(item.hooks)) {
|
||||
group.hooks = item.hooks;
|
||||
}
|
||||
|
||||
hooksObj[event].push(group);
|
||||
}
|
||||
|
||||
parsed.hooks = hooksObj;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert matcher from object to string in hooks config.
|
||||
*/
|
||||
function restructureMatcherObjectToString(parsed, event) {
|
||||
const hooks = parsed.hooks || parsed;
|
||||
if (typeof hooks !== 'object' || Array.isArray(hooks)) return;
|
||||
|
||||
for (const [eventKey, handlers] of Object.entries(hooks)) {
|
||||
if (event && eventKey !== event) continue;
|
||||
if (!Array.isArray(handlers)) continue;
|
||||
|
||||
for (const group of handlers) {
|
||||
if (group.matcher && typeof group.matcher === 'object') {
|
||||
// Extract tool name from object — common patterns: { tool: "Bash" }, { name: "Bash" }
|
||||
const tool = group.matcher.tool || group.matcher.name || group.matcher.type || Object.values(group.matcher)[0];
|
||||
group.matcher = typeof tool === 'string' ? tool : 'Bash';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix timeout type in nested hooks config.
|
||||
*/
|
||||
function fixTimeoutInHooks(parsed, event) {
|
||||
const hooks = parsed.hooks || parsed;
|
||||
if (typeof hooks !== 'object' || Array.isArray(hooks)) return;
|
||||
|
||||
for (const [eventKey, handlers] of Object.entries(hooks)) {
|
||||
if (event && eventKey !== event) continue;
|
||||
if (!Array.isArray(handlers)) continue;
|
||||
|
||||
for (const group of handlers) {
|
||||
if (!group.hooks || !Array.isArray(group.hooks)) continue;
|
||||
for (const hook of group.hooks) {
|
||||
if (hook.timeout !== undefined && typeof hook.timeout !== 'number') {
|
||||
const num = Number(hook.timeout);
|
||||
hook.timeout = isNaN(num) ? 10000 : num;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract key name from evidence string like: 'someKey: "value"'
|
||||
*/
|
||||
function extractKeyFromEvidence(evidence) {
|
||||
if (!evidence) return null;
|
||||
const match = evidence.match(/^(\w+)\s*:/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract expected type from description string like: 'should be boolean, got string'
|
||||
*/
|
||||
function extractExpectedType(description) {
|
||||
const match = description.match(/should be (\w+)/);
|
||||
return match ? match[1] : 'string';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract event name from description like: '"PreToolUse" has a matcher...'
|
||||
*/
|
||||
function extractEventFromDescription(description) {
|
||||
const match = description.match(/"(\w+)"/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify fixes by re-running affected scanners.
|
||||
* @param {object} originalEnvelope - Original scanner envelope
|
||||
* @param {object[]} appliedResults - Results from applyFixes()
|
||||
* @returns {Promise<{ verified: string[], regressions: string[], newFindings: object[] }>}
|
||||
*/
|
||||
export async function verifyFixes(originalEnvelope, appliedResults) {
|
||||
const targetPath = originalEnvelope.meta.target;
|
||||
const verified = [];
|
||||
const regressions = [];
|
||||
const newFindings = [];
|
||||
|
||||
// Re-scan the target
|
||||
const newEnvelope = await runAllScanners(targetPath, { includeGlobal: false });
|
||||
|
||||
// Build set of original finding IDs that were fixed
|
||||
const fixedIds = new Set(
|
||||
appliedResults.filter(r => r.status === 'applied').map(r => r.findingId),
|
||||
);
|
||||
|
||||
// Build set of new finding titles for comparison
|
||||
const newFindingMap = new Map();
|
||||
for (const scanner of newEnvelope.scanners) {
|
||||
for (const f of scanner.findings) {
|
||||
newFindingMap.set(`${f.scanner}:${f.title}:${f.file}`, f);
|
||||
}
|
||||
}
|
||||
|
||||
// Check that fixed findings are gone
|
||||
for (const scanner of originalEnvelope.scanners) {
|
||||
for (const f of scanner.findings) {
|
||||
if (!fixedIds.has(f.id)) continue;
|
||||
|
||||
const key = `${f.scanner}:${f.title}:${f.file}`;
|
||||
// For file-rename fixes, the original file path won't exist anymore
|
||||
const fixResult = appliedResults.find(r => r.findingId === f.id);
|
||||
if (fixResult && fixResult.type === 'file-rename') {
|
||||
// Check that the finding doesn't reappear at the new path
|
||||
verified.push(f.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (newFindingMap.has(key)) {
|
||||
regressions.push(f.id);
|
||||
} else {
|
||||
verified.push(f.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for any completely new findings not in original
|
||||
const originalKeys = new Set();
|
||||
for (const scanner of originalEnvelope.scanners) {
|
||||
for (const f of scanner.findings) {
|
||||
originalKeys.add(`${f.scanner}:${f.title}:${f.file}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, f] of newFindingMap) {
|
||||
if (!originalKeys.has(key)) {
|
||||
newFindings.push(f);
|
||||
}
|
||||
}
|
||||
|
||||
return { verified, regressions, newFindings };
|
||||
}
|
||||
|
||||
export { FIX_TYPES };
|
||||
Loading…
Add table
Add a link
Reference in a new issue