New COL scanner detects skill-name collisions across plugins and
between user-level skills (~/.claude/skills/) and plugin-bundled
skills. Skill identity is the directory basename — matches how
enumerateSkills resolves names.
Detection rules (per docs/v5-namespace-research.md, confidence: medium):
- Plugin-vs-plugin same skill name → severity low (CA-COL-001)
- User-vs-plugin same skill name → severity medium (CA-COL-001)
- Plugin-vs-built-in collisions: out of scope for v5.0.0 (insufficient
verification — recorded for v5.0.1 follow-up).
Findings carry details.namespaces array with {source, name, path} for
every conflicting source — supports per-collision reporting downstream.
output.mjs: finding() helper now passes through optional `details`
field (scanner-specific structured payload).
scoring.mjs: COL → "Plugin Hygiene" (new area, 10 total). Posture test
updated from 9 → 10 area scores.
.gitignore: docs/v5-namespace-research.md is local-only (Step 22a
research output, gitignored per plan).
Fixture collision-plugins/fake-home/ has user skill `review` colliding
with plugin-a + plugin-b's `review` (medium severity), plus plugin-c's
unique `summarize` (no collision).
[skip-docs] reason: v5 plan fences off README/CLAUDE.md badge updates
to Session 5; Forgejo pre-commit-docs-gate hook requires this tag.
Tests: 617 → 625 (+8).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
126 lines
3.7 KiB
JavaScript
126 lines
3.7 KiB
JavaScript
/**
|
|
* Finding and result builders for config-audit scanners.
|
|
* Finding IDs: CA-{SCANNER}-{NNN} (e.g. CA-CML-001)
|
|
* Zero external dependencies.
|
|
*/
|
|
|
|
import { riskScore, riskBand, verdict } from './severity.mjs';
|
|
|
|
let findingCounter = 0;
|
|
|
|
/** Reset the finding counter. Call in beforeEach of tests and before each scanner run. */
|
|
export function resetCounter() {
|
|
findingCounter = 0;
|
|
}
|
|
|
|
/**
|
|
* Create a finding object with auto-incremented ID.
|
|
* @param {object} opts
|
|
* @param {string} opts.scanner - 3-letter scanner prefix (CML, SET, HKV, RUL, etc.)
|
|
* @param {string} opts.severity - critical | high | medium | low | info
|
|
* @param {string} opts.title
|
|
* @param {string} opts.description
|
|
* @param {string} [opts.file] - file path where finding was detected
|
|
* @param {number} [opts.line] - line number
|
|
* @param {string} [opts.evidence] - relevant snippet
|
|
* @param {string} [opts.category] - quality category
|
|
* @param {string} [opts.recommendation] - suggested fix
|
|
* @param {boolean} [opts.autoFixable] - can be auto-fixed
|
|
* @param {object} [opts.details] - structured details (scanner-specific shape)
|
|
* @returns {object}
|
|
*/
|
|
export function finding(opts) {
|
|
findingCounter++;
|
|
const id = `CA-${opts.scanner}-${String(findingCounter).padStart(3, '0')}`;
|
|
const result = {
|
|
id,
|
|
scanner: opts.scanner,
|
|
severity: opts.severity,
|
|
title: opts.title,
|
|
description: opts.description,
|
|
file: opts.file || null,
|
|
line: opts.line || null,
|
|
evidence: opts.evidence || null,
|
|
category: opts.category || null,
|
|
recommendation: opts.recommendation || null,
|
|
autoFixable: opts.autoFixable || false,
|
|
};
|
|
if (opts.details && typeof opts.details === 'object') {
|
|
result.details = opts.details;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Create a scanner result envelope.
|
|
* @param {string} scannerName - 3-letter prefix
|
|
* @param {'ok' | 'error' | 'skipped'} status
|
|
* @param {object[]} findings
|
|
* @param {number} filesScanned
|
|
* @param {number} durationMs
|
|
* @param {string} [errorMsg]
|
|
* @returns {object}
|
|
*/
|
|
export function scannerResult(scannerName, status, findings, filesScanned, durationMs, errorMsg) {
|
|
const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
for (const f of findings) {
|
|
if (counts[f.severity] !== undefined) {
|
|
counts[f.severity]++;
|
|
}
|
|
}
|
|
const result = {
|
|
scanner: scannerName,
|
|
status,
|
|
files_scanned: filesScanned,
|
|
duration_ms: durationMs,
|
|
findings,
|
|
counts,
|
|
};
|
|
if (errorMsg) result.error = errorMsg;
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Create the top-level output envelope combining all scanner results.
|
|
* @param {string} targetPath
|
|
* @param {object[]} scannerResults
|
|
* @param {number} totalDurationMs
|
|
* @returns {object}
|
|
*/
|
|
export function envelope(targetPath, scannerResults, totalDurationMs) {
|
|
const aggregate = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
let totalFindings = 0;
|
|
let scannersOk = 0;
|
|
let scannersError = 0;
|
|
let scannersSkipped = 0;
|
|
|
|
for (const r of scannerResults) {
|
|
for (const sev of Object.keys(aggregate)) {
|
|
aggregate[sev] += (r.counts[sev] || 0);
|
|
}
|
|
totalFindings += r.findings.length;
|
|
if (r.status === 'ok') scannersOk++;
|
|
else if (r.status === 'error') scannersError++;
|
|
else if (r.status === 'skipped') scannersSkipped++;
|
|
}
|
|
|
|
return {
|
|
meta: {
|
|
target: targetPath,
|
|
timestamp: new Date().toISOString(),
|
|
version: '2.2.0',
|
|
tool: 'config-audit',
|
|
},
|
|
scanners: scannerResults,
|
|
aggregate: {
|
|
total_findings: totalFindings,
|
|
counts: aggregate,
|
|
risk_score: riskScore(aggregate),
|
|
risk_band: riskBand(riskScore(aggregate)),
|
|
verdict: verdict(aggregate),
|
|
scanners_ok: scannersOk,
|
|
scanners_error: scannersError,
|
|
scanners_skipped: scannersSkipped,
|
|
},
|
|
};
|
|
}
|