Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions src/cli/commands/audit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import type { Command } from 'commander';
import { analyzeAudit, type AuditResponse } from '../../context/audit.js';
import { assertWorkspace } from '../../storage/kgraph-paths.js';
import { mapsExist, readMaps } from '../../storage/map-store.js';
import { KGraphError, runCommand } from '../errors.js';

export function registerAuditCommand(program: Command): void {
program
.command('audit')
.description('Surface security-sensitive files and symbols by category')
.option('--json', 'Print JSON output')
.action((options: { json?: boolean }) =>
runCommand(async () => {
const workspace = await assertWorkspace(process.cwd());
if (!(await mapsExist(workspace))) {
throw new KGraphError('KGraph maps are missing. Run `kgraph scan` first.');
}
const maps = await readMaps(workspace);
const response = analyzeAudit(maps);
console.log(options.json ? JSON.stringify(response, null, 2) : renderAuditMarkdown(response));
}),
);
}

export function renderAuditMarkdown(response: AuditResponse): string {
const lines: string[] = ['# KGraph Audit', ''];

if (response.categories.length === 0) {
lines.push('No security-sensitive patterns found in the current maps.', '');
lines.push('Run `kgraph scan` to refresh maps if the repo has changed.');
return lines.join('\n');
}

for (const category of response.categories) {
lines.push(`## ${category.name}`);
lines.push(category.description, '');
for (const finding of category.findings) {
const symbolSuffix =
finding.matchedSymbols.length > 0
? ` — ${finding.matchedSymbols.join(', ')}`
: '';
lines.push(`- ${finding.filePath}${symbolSuffix}`);
}
lines.push('');
}

lines.push('---');
lines.push(
`Flagged: ${response.totalFlaggedFiles} files, ${response.totalFlaggedSymbols} symbols across ${response.categories.length} categories`,
);
lines.push('Run `kgraph impact "<symbol>"` to trace any finding deeper.');
return lines.join('\n');
}
1 change: 1 addition & 0 deletions src/cli/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export function renderRootHelp(useColor = supportsColor()): string {
'Optional: return context without scanning or updating',
),
command('impact "Button"', 'Show imports, callers, calls, cognition, and risk'),
command('audit', 'Surface security-sensitive files and symbols by category'),
command('update', 'Optional: process only .kgraph/inbox Markdown cognition notes'),
command('doctor', 'Check workspace health and next actions'),
command('doctor --quality', 'Report stale/noisy cognition references'),
Expand Down
2 changes: 2 additions & 0 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Command } from 'commander';
import { realpathSync } from 'node:fs';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { registerAuditCommand } from './commands/audit.js';
import { registerContextCommand } from './commands/context.js';
import { registerDoctorCommand } from './commands/doctor.js';
import { registerHistoryCommand } from './commands/history.js';
Expand Down Expand Up @@ -47,6 +48,7 @@ export function createProgram(): Command {
registerUpdateCommand(program);
registerContextCommand(program);
registerImpactCommand(program);
registerAuditCommand(program);
registerIntegrateCommand(program);
registerVisualizeCommand(program);
registerHistoryCommand(program);
Expand Down
147 changes: 147 additions & 0 deletions src/context/audit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import type { FileMap, SymbolMap } from '../types/maps.js';

export interface AuditFinding {
filePath: string;
matchedSymbols: string[];
reasons: string[];
}

export interface AuditCategory {
name: string;
description: string;
findings: AuditFinding[];
}

export interface AuditResponse {
categories: AuditCategory[];
totalFlaggedFiles: number;
totalFlaggedSymbols: number;
}

interface CategoryDefinition {
name: string;
description: string;
keywords: string[];
}

const AUDIT_CATEGORIES: CategoryDefinition[] = [
{
name: 'Authentication & Authorization',
description: 'Login flows, tokens, sessions, and access control',
keywords: [
'auth', 'login', 'logout', 'token', 'jwt', 'oauth', 'session',
'password', 'credential', 'permission', 'role', 'acl', 'authorize',
'authenticate', 'identity', 'bearer', 'refresh',
],
},
{
name: 'Input Handling',
description: 'Routes, handlers, and user input entry points',
keywords: [
'route', 'handler', 'endpoint', 'controller', 'middleware', 'request',
'param', 'upload', 'webhook', 'validate', 'sanitize',
],
},
{
name: 'Cryptography',
description: 'Hashing, encryption, signing, and key management',
keywords: [
'crypto', 'hash', 'encrypt', 'decrypt', 'cipher', 'sign', 'verify',
'salt', 'bcrypt', 'hmac', 'digest', 'pbkdf',
],
},
{
name: 'Data Access',
description: 'Database queries, ORM calls, and external storage',
keywords: [
'sql', 'database', 'mongo', 'postgres', 'mysql', 'redis',
'orm', 'repository', 'migration', 'schema',
],
},
{
name: 'External Connections',
description: 'HTTP clients, sockets, and third-party service calls',
keywords: ['http', 'fetch', 'axios', 'socket', 'websocket', 'client', 'webhook'],
},
{
name: 'Dangerous Patterns',
description: 'Code execution, deserialization, and shell access',
keywords: [
'eval', 'exec', 'spawn', 'shell', 'deserialize', 'subprocess',
'child_process', 'pickle', 'marshal', 'unserialize',
],
},
];

const MAX_FINDINGS_PER_CATEGORY = 20;

export function analyzeAudit(maps: { fileMap: FileMap; symbolMap: SymbolMap }): AuditResponse {
const allFlaggedFiles = new Set<string>();
const allFlaggedSymbols = new Set<string>();
const categories: AuditCategory[] = [];

for (const def of AUDIT_CATEGORIES) {
const findingMap = new Map<string, { reasons: Set<string>; symbols: Set<string> }>();

const ensure = (path: string) => {
if (!findingMap.has(path)) findingMap.set(path, { reasons: new Set(), symbols: new Set() });
return findingMap.get(path)!;
};

for (const file of maps.fileMap.files) {
const tokens = tokenizePath(file.path);
for (const kw of def.keywords) {
if (tokens.includes(kw)) {
ensure(file.path).reasons.add(`path contains "${kw}"`);
}
}
}

for (const symbol of maps.symbolMap.symbols) {
const tokens = tokenizeIdentifier(symbol.name);
for (const kw of def.keywords) {
if (tokens.includes(kw)) {
const entry = ensure(symbol.filePath);
entry.symbols.add(symbol.name);
entry.reasons.add(`symbol "${symbol.name}" matches "${kw}"`);
}
}
}

if (findingMap.size === 0) continue;

const findings: AuditFinding[] = [...findingMap.entries()]
.map(([filePath, data]) => ({
filePath,
matchedSymbols: [...data.symbols],
reasons: [...data.reasons],
}))
.sort((a, b) => b.matchedSymbols.length - a.matchedSymbols.length)
.slice(0, MAX_FINDINGS_PER_CATEGORY);

for (const finding of findings) {
allFlaggedFiles.add(finding.filePath);
for (const sym of finding.matchedSymbols) allFlaggedSymbols.add(sym);
}

categories.push({ name: def.name, description: def.description, findings });
}

return {
categories,
totalFlaggedFiles: allFlaggedFiles.size,
totalFlaggedSymbols: allFlaggedSymbols.size,
};
}

function tokenizePath(filePath: string): string[] {
return filePath.toLowerCase().split(/[/\\._-]+/).filter(Boolean);
}

function tokenizeIdentifier(name: string): string[] {
return name
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.toLowerCase()
.split(/[^a-z0-9]+/)
.filter(Boolean);
}