From 03f5009adaca7d249ad78c66db13b234964713ba Mon Sep 17 00:00:00 2001 From: Kanatat Asipong Date: Mon, 11 May 2026 02:26:39 +0700 Subject: [PATCH] Added audit skill for security and further penetration testing purpose. --- src/cli/commands/audit.ts | 53 ++++++++++++++ src/cli/help.ts | 1 + src/cli/index.ts | 2 + src/context/audit.ts | 147 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 203 insertions(+) create mode 100644 src/cli/commands/audit.ts create mode 100644 src/context/audit.ts diff --git a/src/cli/commands/audit.ts b/src/cli/commands/audit.ts new file mode 100644 index 0000000..46daf5b --- /dev/null +++ b/src/cli/commands/audit.ts @@ -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 ""` to trace any finding deeper.'); + return lines.join('\n'); +} diff --git a/src/cli/help.ts b/src/cli/help.ts index 703513f..843da47 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -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'), diff --git a/src/cli/index.ts b/src/cli/index.ts index fc6ba2c..9a2fb6c 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -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'; @@ -47,6 +48,7 @@ export function createProgram(): Command { registerUpdateCommand(program); registerContextCommand(program); registerImpactCommand(program); + registerAuditCommand(program); registerIntegrateCommand(program); registerVisualizeCommand(program); registerHistoryCommand(program); diff --git a/src/context/audit.ts b/src/context/audit.ts new file mode 100644 index 0000000..6a3e08f --- /dev/null +++ b/src/context/audit.ts @@ -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(); + const allFlaggedSymbols = new Set(); + const categories: AuditCategory[] = []; + + for (const def of AUDIT_CATEGORIES) { + const findingMap = new Map; symbols: Set }>(); + + 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); +}