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
185
plugins/config-audit/scanners/import-resolver.mjs
Normal file
185
plugins/config-audit/scanners/import-resolver.mjs
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
/**
|
||||
* IMP Scanner — Import Resolver
|
||||
* Resolves @import references in CLAUDE.md files: broken links, circular refs, deep chains.
|
||||
* Finding IDs: CA-IMP-NNN
|
||||
*/
|
||||
|
||||
import { resolve, dirname, basename } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { readTextFile } from './lib/file-discovery.mjs';
|
||||
import { finding, scannerResult } from './lib/output.mjs';
|
||||
import { SEVERITY } from './lib/severity.mjs';
|
||||
import { findImports } from './lib/yaml-parser.mjs';
|
||||
import { truncate } from './lib/string-utils.mjs';
|
||||
|
||||
const SCANNER = 'IMP';
|
||||
const MAX_CHAIN_DEPTH = 5;
|
||||
const HARD_LIMIT = 20;
|
||||
|
||||
/**
|
||||
* Check if a file exists.
|
||||
* @param {string} absPath
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async function fileExists(absPath) {
|
||||
try {
|
||||
await stat(absPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an import path relative to the containing file.
|
||||
* @param {string} importPath
|
||||
* @param {string} containingFile
|
||||
* @returns {{ resolved: string, hasTilde: boolean }}
|
||||
*/
|
||||
function resolveImportPath(importPath, containingFile) {
|
||||
const hasTilde = importPath.startsWith('~');
|
||||
let resolved;
|
||||
|
||||
if (hasTilde) {
|
||||
const home = process.env.HOME || process.env.USERPROFILE || tmpdir();
|
||||
resolved = resolve(importPath.replace(/^~/, home));
|
||||
} else if (importPath.startsWith('/')) {
|
||||
resolved = importPath;
|
||||
} else {
|
||||
resolved = resolve(dirname(containingFile), importPath);
|
||||
}
|
||||
|
||||
return { resolved, hasTilde };
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk imports recursively from a starting file via DFS.
|
||||
* @param {string} file - Absolute path to current file
|
||||
* @param {string[]} chain - Current chain of files (for cycle detection)
|
||||
* @param {Set<string>} reported - Set of "from::to" pairs already reported
|
||||
* @param {object[]} findings - Accumulator for findings
|
||||
*/
|
||||
async function walkImports(file, chain, reported, findings) {
|
||||
const content = await readTextFile(file);
|
||||
if (!content) return;
|
||||
|
||||
const imports = findImports(content);
|
||||
for (const imp of imports) {
|
||||
const { resolved, hasTilde } = resolveImportPath(imp.path, file);
|
||||
const reportKey = `${file}::${resolved}`;
|
||||
|
||||
// Tilde path warning
|
||||
if (hasTilde && !reported.has(`tilde::${resolved}`)) {
|
||||
reported.add(`tilde::${resolved}`);
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.medium,
|
||||
title: 'Tilde path in @import',
|
||||
description: `@${imp.path} uses ~ which may not expand correctly in all contexts.`,
|
||||
file,
|
||||
line: imp.line,
|
||||
evidence: `@${imp.path}`,
|
||||
recommendation: 'Use a relative path or absolute path without tilde expansion.',
|
||||
}));
|
||||
}
|
||||
|
||||
// Check file existence
|
||||
const exists = await fileExists(resolved);
|
||||
if (!exists) {
|
||||
if (!reported.has(reportKey)) {
|
||||
reported.add(reportKey);
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.high,
|
||||
title: 'Broken @import link',
|
||||
description: `@${imp.path} references a file that does not exist.`,
|
||||
file,
|
||||
line: imp.line,
|
||||
evidence: `@${imp.path} → ${truncate(resolved, 80)}`,
|
||||
recommendation: 'Fix the path or create the missing file.',
|
||||
}));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Circular reference detection
|
||||
if (chain.includes(resolved)) {
|
||||
if (!reported.has(reportKey)) {
|
||||
reported.add(reportKey);
|
||||
const cycleStart = chain.indexOf(resolved);
|
||||
const cycle = chain.slice(cycleStart).map(f => basename(f)).join(' → ');
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.medium,
|
||||
title: 'Circular @import reference',
|
||||
description: `@${imp.path} creates a circular import chain.`,
|
||||
file,
|
||||
line: imp.line,
|
||||
evidence: `${cycle} → ${basename(resolved)}`,
|
||||
recommendation: 'Break the circular dependency by removing one of the @imports.',
|
||||
}));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Deep chain warning
|
||||
if (chain.length >= MAX_CHAIN_DEPTH) {
|
||||
if (!reported.has(`deep::${resolved}`)) {
|
||||
reported.add(`deep::${resolved}`);
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.low,
|
||||
title: 'Deep @import chain',
|
||||
description: `@${imp.path} is at depth ${chain.length} (>${MAX_CHAIN_DEPTH} hops).`,
|
||||
file,
|
||||
line: imp.line,
|
||||
evidence: `Chain depth: ${chain.length}`,
|
||||
recommendation: 'Flatten the import hierarchy to reduce nesting.',
|
||||
}));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Hard limit safety bail
|
||||
if (chain.length >= HARD_LIMIT) continue;
|
||||
|
||||
// Recurse
|
||||
await walkImports(resolved, [...chain, resolved], reported, findings);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan all CLAUDE.md files for @import issues.
|
||||
* @param {string} targetPath
|
||||
* @param {{ files: import('./lib/file-discovery.mjs').ConfigFile[] }} discovery
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function scan(targetPath, discovery) {
|
||||
const start = Date.now();
|
||||
const claudeMdFiles = discovery.files.filter(f => f.type === 'claude-md');
|
||||
const findings = [];
|
||||
let filesScanned = 0;
|
||||
|
||||
if (claudeMdFiles.length === 0) {
|
||||
return scannerResult(SCANNER, 'skipped', [], 0, Date.now() - start);
|
||||
}
|
||||
|
||||
const reported = new Set();
|
||||
|
||||
for (const file of claudeMdFiles) {
|
||||
const content = await readTextFile(file.absPath);
|
||||
if (!content) continue;
|
||||
|
||||
const imports = findImports(content);
|
||||
if (imports.length === 0) {
|
||||
filesScanned++;
|
||||
continue;
|
||||
}
|
||||
|
||||
filesScanned++;
|
||||
await walkImports(file.absPath, [file.absPath], reported, findings);
|
||||
}
|
||||
|
||||
return scannerResult(SCANNER, 'ok', findings, filesScanned, Date.now() - start);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue