Extends posture scanner from 13 to 16 categories with three governance/compliance checks. New categories are advisory (not in CRITICAL_CATEGORIES) — existing Grade A projects remain Grade A. VERSION bumped to 6.0.0. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1565 lines
58 KiB
JavaScript
1565 lines
58 KiB
JavaScript
#!/usr/bin/env node
|
|
// posture-scanner.mjs — Deterministic security posture assessment
|
|
// Replaces the Opus-based posture-assessor-agent for quick posture checks.
|
|
// 13 categories aligned with OWASP LLM Top 10 + Claude Code Security Baseline v1.0.
|
|
//
|
|
// Standalone CLI: node scanners/posture-scanner.mjs [path] → JSON stdout
|
|
// Library: import { scan } from './posture-scanner.mjs'
|
|
//
|
|
// Scanner prefix: PST
|
|
// Zero external dependencies — Node.js builtins only.
|
|
|
|
import { readFile, readdir, stat, access } from 'node:fs/promises';
|
|
import { join, resolve, relative, extname } from 'node:path';
|
|
import { homedir } from 'node:os';
|
|
import { scanForInjection } from './lib/injection-patterns.mjs';
|
|
import { gradeFromPassRate, SEVERITY } from './lib/severity.mjs';
|
|
import { finding, scannerResult, resetCounter } from './lib/output.mjs';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Constants
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const VERSION = '6.0.0';
|
|
|
|
/** Minimum lines for a hook script to be considered non-stub */
|
|
const NON_STUB_THRESHOLD = 5;
|
|
|
|
/** Status values */
|
|
const STATUS = Object.freeze({ PASS: 'PASS', PARTIAL: 'PARTIAL', FAIL: 'FAIL', NA: 'N_A' });
|
|
|
|
/** Category definitions */
|
|
const CATEGORIES = [
|
|
{ id: 1, name: 'Deny-First Configuration', owasp: 'ASI02, ASI03' },
|
|
{ id: 2, name: 'Secrets Protection', owasp: 'ASI03, ASI05' },
|
|
{ id: 3, name: 'Path Guarding', owasp: 'ASI05, ASI10' },
|
|
{ id: 4, name: 'MCP Server Trust', owasp: 'ASI04, ASI07' },
|
|
{ id: 5, name: 'Destructive Command Blocking', owasp: 'ASI02, ASI05' },
|
|
{ id: 6, name: 'Sandbox Configuration', owasp: 'ASI02, ASI05' },
|
|
{ id: 7, name: 'Human Review Requirements', owasp: 'ASI09' },
|
|
{ id: 8, name: 'Skill and Plugin Sources', owasp: 'ASI04' },
|
|
{ id: 9, name: 'Session Isolation', owasp: 'ASI06, ASI08' },
|
|
{ id: 10, name: 'Cognitive State Security', owasp: 'LLM01, ASI02' },
|
|
{ id: 11, name: 'Prompt Injection Hardening', owasp: 'LLM01, ASI01' },
|
|
{ id: 12, name: 'Rule of Two', owasp: 'ASI02, ASI05' },
|
|
{ id: 13, name: 'Long-Horizon Monitoring', owasp: 'ASI06, ASI08' },
|
|
{ id: 14, name: 'EU AI Act Compliance', owasp: 'Governance' },
|
|
{ id: 15, name: 'NIST AI RMF Alignment', owasp: 'Governance' },
|
|
{ id: 16, name: 'ISO 42001 Readiness', owasp: 'Governance' },
|
|
];
|
|
|
|
// Critical categories: FAIL in these prevents Grade A
|
|
const CRITICAL_CATEGORIES = new Set([1, 2, 5]);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// File reading helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function readJson(filePath) {
|
|
try {
|
|
const raw = await readFile(filePath, 'utf-8');
|
|
return JSON.parse(raw);
|
|
} catch { return null; }
|
|
}
|
|
|
|
async function readText(filePath) {
|
|
try { return await readFile(filePath, 'utf-8'); }
|
|
catch { return null; }
|
|
}
|
|
|
|
async function fileExists(filePath) {
|
|
try { await access(filePath); return true; }
|
|
catch { return false; }
|
|
}
|
|
|
|
async function listDir(dirPath) {
|
|
try { return await readdir(dirPath); }
|
|
catch { return []; }
|
|
}
|
|
|
|
/**
|
|
* Count non-empty, non-comment lines in a file.
|
|
* @param {string} filePath
|
|
* @returns {Promise<number>}
|
|
*/
|
|
async function countCodeLines(filePath) {
|
|
const content = await readText(filePath);
|
|
if (!content) return 0;
|
|
return content.split('\n').filter(l => {
|
|
const t = l.trim();
|
|
return t.length > 0 && !t.startsWith('//') && !t.startsWith('#') && !t.startsWith('<!--');
|
|
}).length;
|
|
}
|
|
|
|
/**
|
|
* Read YAML-ish frontmatter from a markdown file (simplified).
|
|
* Returns the raw text between --- delimiters.
|
|
*/
|
|
function extractFrontmatter(content) {
|
|
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
return match ? match[1] : '';
|
|
}
|
|
|
|
/**
|
|
* Find all .md files in a directory (non-recursive).
|
|
*/
|
|
async function listMdFiles(dirPath) {
|
|
const entries = await listDir(dirPath);
|
|
return entries.filter(e => e.endsWith('.md'));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Category check functions
|
|
// Each returns { status, findings, evidence }
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Category 1: Deny-First Configuration (ASI02, ASI03)
|
|
*/
|
|
async function checkDenyFirst(projectRoot, globalSettings, projectSettings) {
|
|
const evidence = [];
|
|
const findings = [];
|
|
let hasSettingsFile = false;
|
|
let denyFirst = false;
|
|
let hasBroadAllow = false;
|
|
let claudeMdGuardrails = false;
|
|
let commandsMissingTools = 0;
|
|
let totalCommands = 0;
|
|
|
|
// Check settings
|
|
for (const [label, settings] of [['global', globalSettings], ['project', projectSettings]]) {
|
|
if (!settings) continue;
|
|
hasSettingsFile = true;
|
|
|
|
// Check permissions
|
|
const perms = settings.permissions || {};
|
|
if (perms.defaultPermissionLevel === 'deny' || perms.defaultPermissionLevel === 'deny-all') {
|
|
denyFirst = true;
|
|
evidence.push(`${label}: defaultPermissionLevel = ${perms.defaultPermissionLevel}`);
|
|
}
|
|
|
|
// Check for broad allows
|
|
const allow = perms.allow || [];
|
|
if (allow.includes('*') || allow.some(a => typeof a === 'string' && a === '*')) {
|
|
hasBroadAllow = true;
|
|
evidence.push(`${label}: broad allow wildcard found`);
|
|
}
|
|
|
|
// Check scoped allows
|
|
const scopedAllow = Array.isArray(allow) ? allow : [];
|
|
if (scopedAllow.length > 0 && !hasBroadAllow) {
|
|
evidence.push(`${label}: ${scopedAllow.length} scoped allow rules`);
|
|
}
|
|
}
|
|
|
|
// Check CLAUDE.md for guardrails
|
|
const claudeMd = await readText(join(projectRoot, 'CLAUDE.md'));
|
|
if (claudeMd) {
|
|
const guardrailKeywords = /\b(?:deny|block|restrict|scope[- ]guard|override|security\s+boundar)/i;
|
|
if (guardrailKeywords.test(claudeMd)) {
|
|
claudeMdGuardrails = true;
|
|
evidence.push('CLAUDE.md has scope/override guardrails');
|
|
}
|
|
}
|
|
|
|
// Check commands/agents for allowed-tools
|
|
for (const dir of ['commands', 'agents']) {
|
|
const mdFiles = await listMdFiles(join(projectRoot, dir));
|
|
for (const file of mdFiles) {
|
|
totalCommands++;
|
|
const content = await readText(join(projectRoot, dir, file));
|
|
if (!content) continue;
|
|
const fm = extractFrontmatter(content);
|
|
if (!fm.includes('allowed-tools') && !fm.includes('tools:')) {
|
|
commandsMissingTools++;
|
|
findings.push(finding({
|
|
scanner: 'PST',
|
|
severity: SEVERITY.LOW,
|
|
title: `No allowed-tools declared: ${dir}/${file}`,
|
|
description: `${dir}/${file} has no allowed-tools in frontmatter. Explicit tool lists enforce least-privilege.`,
|
|
file: `${dir}/${file}`,
|
|
owasp: 'ASI02',
|
|
recommendation: 'Add allowed-tools to frontmatter listing only required tools.',
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Determine status
|
|
if (!hasSettingsFile) {
|
|
findings.push(finding({
|
|
scanner: 'PST',
|
|
severity: SEVERITY.HIGH,
|
|
title: 'No settings.json found',
|
|
description: 'No global or project settings.json found. Cannot verify deny-first configuration.',
|
|
owasp: 'ASI02',
|
|
recommendation: 'Create .claude/settings.json with defaultPermissionLevel: deny.',
|
|
}));
|
|
return { status: STATUS.FAIL, findings, evidence };
|
|
}
|
|
|
|
if (hasBroadAllow) {
|
|
findings.push(finding({
|
|
scanner: 'PST',
|
|
severity: SEVERITY.HIGH,
|
|
title: 'Broad allow wildcard in permissions',
|
|
description: 'Settings contain allow: ["*"] or similar broad wildcard, defeating deny-first.',
|
|
owasp: 'ASI02',
|
|
recommendation: 'Replace broad wildcards with explicit, scoped allow rules.',
|
|
}));
|
|
return { status: STATUS.FAIL, findings, evidence };
|
|
}
|
|
|
|
if (denyFirst && claudeMdGuardrails && commandsMissingTools === 0) {
|
|
return { status: STATUS.PASS, findings, evidence };
|
|
}
|
|
|
|
if (denyFirst || (!hasBroadAllow && hasSettingsFile)) {
|
|
if (!claudeMdGuardrails) {
|
|
findings.push(finding({
|
|
scanner: 'PST',
|
|
severity: SEVERITY.LOW,
|
|
title: 'CLAUDE.md lacks scope/override guardrails',
|
|
description: 'CLAUDE.md does not contain deny-first language or scope guard instructions.',
|
|
file: 'CLAUDE.md',
|
|
owasp: 'ASI02',
|
|
recommendation: 'Add security boundary instructions to CLAUDE.md.',
|
|
}));
|
|
}
|
|
return { status: STATUS.PARTIAL, findings, evidence };
|
|
}
|
|
|
|
return { status: STATUS.FAIL, findings, evidence };
|
|
}
|
|
|
|
/**
|
|
* Category 2: Secrets Protection (ASI03, ASI05)
|
|
*/
|
|
async function checkSecretsProtection(projectRoot, hooksJson) {
|
|
const evidence = [];
|
|
const findings = [];
|
|
let hookActive = false;
|
|
let hookNonStub = false;
|
|
let gitignoreCoversSecrets = false;
|
|
|
|
// Check hooks.json for pre-edit-secrets
|
|
if (hooksJson) {
|
|
const preToolUse = hooksJson.hooks?.PreToolUse || hooksJson.PreToolUse || [];
|
|
const entries = Array.isArray(preToolUse) ? preToolUse : [];
|
|
for (const entry of entries) {
|
|
const matcher = entry.matcher || '';
|
|
const hooks = entry.hooks || [];
|
|
if (/edit|write/i.test(matcher)) {
|
|
for (const h of hooks) {
|
|
if (h.command && /pre-edit-secrets/i.test(h.command)) {
|
|
hookActive = true;
|
|
evidence.push(`Hook registered: ${h.command}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check if hook script is non-stub
|
|
if (hookActive) {
|
|
const scriptPath = join(projectRoot, 'hooks', 'scripts', 'pre-edit-secrets.mjs');
|
|
const lines = await countCodeLines(scriptPath);
|
|
hookNonStub = lines > NON_STUB_THRESHOLD;
|
|
evidence.push(`pre-edit-secrets.mjs: ${lines} code lines (${hookNonStub ? 'non-stub' : 'stub'})`);
|
|
}
|
|
|
|
// Check .gitignore
|
|
const gitignore = await readText(join(projectRoot, '.gitignore'));
|
|
if (gitignore) {
|
|
const secretPatterns = ['.env', '*.key', '*.pem', 'credentials', 'secrets'];
|
|
const covered = secretPatterns.filter(p => gitignore.includes(p));
|
|
gitignoreCoversSecrets = covered.length >= 3;
|
|
evidence.push(`.gitignore covers: ${covered.join(', ')}`);
|
|
if (!gitignoreCoversSecrets) {
|
|
findings.push(finding({
|
|
scanner: 'PST',
|
|
severity: SEVERITY.MEDIUM,
|
|
title: 'Incomplete secrets coverage in .gitignore',
|
|
description: `.gitignore covers only ${covered.length}/5 secret patterns: ${covered.join(', ')}`,
|
|
file: '.gitignore',
|
|
owasp: 'ASI03',
|
|
recommendation: 'Add .env, *.key, *.pem, credentials.*, secrets.* to .gitignore.',
|
|
}));
|
|
}
|
|
} else {
|
|
findings.push(finding({
|
|
scanner: 'PST',
|
|
severity: SEVERITY.MEDIUM,
|
|
title: 'No .gitignore found',
|
|
description: 'No .gitignore file found. Secrets may be committed to the repository.',
|
|
owasp: 'ASI03',
|
|
recommendation: 'Create .gitignore with standard secret exclusions.',
|
|
}));
|
|
}
|
|
|
|
// Check for embedded secrets in md files (quick scan)
|
|
const secretPatterns = /(?:sk-[a-zA-Z0-9]{20,}|Bearer\s+[a-zA-Z0-9._-]{20,}|password\s*=\s*["'][^"']+["']|(?:api[_-]?key|token)\s*=\s*["'][^"']+["'])/;
|
|
for (const mdFile of ['CLAUDE.md', 'REMEMBER.md']) {
|
|
const content = await readText(join(projectRoot, mdFile));
|
|
if (content && secretPatterns.test(content)) {
|
|
findings.push(finding({
|
|
scanner: 'PST',
|
|
severity: SEVERITY.CRITICAL,
|
|
title: `Embedded secret in ${mdFile}`,
|
|
description: `${mdFile} contains what appears to be a hardcoded secret.`,
|
|
file: mdFile,
|
|
owasp: 'ASI05',
|
|
recommendation: 'Remove the secret immediately. Use environment variables or a secret manager.',
|
|
}));
|
|
}
|
|
}
|
|
|
|
if (!hookActive) {
|
|
findings.push(finding({
|
|
scanner: 'PST',
|
|
severity: SEVERITY.HIGH,
|
|
title: 'No secrets protection hook registered',
|
|
description: 'No pre-edit-secrets hook found in hooks.json. Secrets can be written to files unchecked.',
|
|
owasp: 'ASI03',
|
|
recommendation: 'Register pre-edit-secrets.mjs under PreToolUse with Edit|Write matcher.',
|
|
}));
|
|
return { status: STATUS.FAIL, findings, evidence };
|
|
}
|
|
|
|
if (hookNonStub && gitignoreCoversSecrets) {
|
|
return { status: STATUS.PASS, findings, evidence };
|
|
}
|
|
|
|
return { status: STATUS.PARTIAL, findings, evidence };
|
|
}
|
|
|
|
/**
|
|
* Category 3: Path Guarding (ASI05, ASI10)
|
|
*/
|
|
async function checkPathGuarding(projectRoot, hooksJson) {
|
|
const evidence = [];
|
|
const findings = [];
|
|
let hookActive = false;
|
|
let hookNonStub = false;
|
|
const requiredPaths = ['.env', '.ssh', '.aws', 'credentials', 'key', 'pem'];
|
|
let coveredPaths = [];
|
|
|
|
// Check hooks.json for pre-write-pathguard
|
|
if (hooksJson) {
|
|
const preToolUse = hooksJson.hooks?.PreToolUse || hooksJson.PreToolUse || [];
|
|
const entries = Array.isArray(preToolUse) ? preToolUse : [];
|
|
for (const entry of entries) {
|
|
const matcher = entry.matcher || '';
|
|
const hooks = entry.hooks || [];
|
|
if (/write/i.test(matcher)) {
|
|
for (const h of hooks) {
|
|
if (h.command && /pathguard/i.test(h.command)) {
|
|
hookActive = true;
|
|
evidence.push(`Hook registered: ${h.command}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check script content for path coverage
|
|
if (hookActive) {
|
|
const scriptPath = join(projectRoot, 'hooks', 'scripts', 'pre-write-pathguard.mjs');
|
|
const content = await readText(scriptPath);
|
|
if (content) {
|
|
const lines = content.split('\n').filter(l => l.trim() && !l.trim().startsWith('//')).length;
|
|
hookNonStub = lines > NON_STUB_THRESHOLD;
|
|
coveredPaths = requiredPaths.filter(p => content.includes(p));
|
|
evidence.push(`pathguard covers: ${coveredPaths.join(', ')}`);
|
|
evidence.push(`${lines} code lines (${hookNonStub ? 'non-stub' : 'stub'})`);
|
|
|
|
const missing = requiredPaths.filter(p => !coveredPaths.includes(p));
|
|
if (missing.length > 0) {
|
|
findings.push(finding({
|
|
scanner: 'PST',
|
|
severity: SEVERITY.MEDIUM,
|
|
title: 'Pathguard missing sensitive path patterns',
|
|
description: `Pathguard hook does not cover: ${missing.join(', ')}`,
|
|
file: 'hooks/scripts/pre-write-pathguard.mjs',
|
|
owasp: 'ASI05',
|
|
recommendation: `Add protection for: ${missing.join(', ')}`,
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!hookActive) {
|
|
findings.push(finding({
|
|
scanner: 'PST',
|
|
severity: SEVERITY.HIGH,
|
|
title: 'No path guard hook registered',
|
|
description: 'No pre-write-pathguard hook found. Sensitive paths (.env, .ssh, .aws) are unprotected.',
|
|
owasp: 'ASI05',
|
|
recommendation: 'Register pre-write-pathguard.mjs under PreToolUse with Write matcher.',
|
|
}));
|
|
return { status: STATUS.FAIL, findings, evidence };
|
|
}
|
|
|
|
if (hookNonStub && coveredPaths.length >= 4) {
|
|
return { status: STATUS.PASS, findings, evidence };
|
|
}
|
|
|
|
return { status: STATUS.PARTIAL, findings, evidence };
|
|
}
|
|
|
|
/**
|
|
* Category 4: MCP Server Trust (ASI04, ASI07)
|
|
*/
|
|
async function checkMcpTrust(projectRoot, globalSettings, projectSettings) {
|
|
const evidence = [];
|
|
const findings = [];
|
|
let mcpServers = {};
|
|
|
|
// Collect MCP configs from all sources
|
|
const mcpJson = await readJson(join(projectRoot, '.mcp.json'));
|
|
if (mcpJson?.mcpServers) Object.assign(mcpServers, mcpJson.mcpServers);
|
|
|
|
for (const settings of [globalSettings, projectSettings]) {
|
|
if (settings?.mcpServers) Object.assign(mcpServers, settings.mcpServers);
|
|
}
|
|
|
|
const desktopConfig = await readJson(join(projectRoot, 'claude_desktop_config.json'));
|
|
if (desktopConfig?.mcpServers) Object.assign(mcpServers, desktopConfig.mcpServers);
|
|
|
|
const serverNames = Object.keys(mcpServers);
|
|
if (serverNames.length === 0) {
|
|
evidence.push('No MCP servers configured');
|
|
return { status: STATUS.NA, findings, evidence };
|
|
}
|
|
|
|
evidence.push(`${serverNames.length} MCP server(s): ${serverNames.join(', ')}`);
|
|
|
|
let unpinned = 0;
|
|
let noAuth = 0;
|
|
|
|
for (const [name, config] of Object.entries(mcpServers)) {
|
|
const cmd = config.command || '';
|
|
const args = (config.args || []).join(' ');
|
|
const full = `${cmd} ${args}`;
|
|
|
|
// Check version pinning
|
|
if (!/@\d+\.\d+/.test(full) && !/:\d+\.\d+/.test(full)) {
|
|
unpinned++;
|
|
findings.push(finding({
|
|
scanner: 'PST',
|
|
severity: SEVERITY.LOW,
|
|
title: `MCP server "${name}" version not pinned`,
|
|
description: `MCP server "${name}" does not appear to have a pinned version.`,
|
|
owasp: 'ASI04',
|
|
recommendation: 'Pin the MCP server to a specific version.',
|
|
}));
|
|
}
|
|
|
|
// Check for HTTP/SSE without auth
|
|
if (/https?:\/\//.test(full) && !/auth|apikey|token|bearer/i.test(JSON.stringify(config))) {
|
|
noAuth++;
|
|
findings.push(finding({
|
|
scanner: 'PST',
|
|
severity: SEVERITY.HIGH,
|
|
title: `MCP server "${name}" has network access without auth`,
|
|
description: `MCP server "${name}" connects to HTTP endpoint without visible auth configuration.`,
|
|
owasp: 'ASI07',
|
|
recommendation: 'Configure authentication for network-accessible MCP servers.',
|
|
}));
|
|
}
|
|
}
|
|
|
|
// Check for post-mcp-verify hook (via hooksJson passed from parent)
|
|
// We check it at the caller level — here just note if we found servers
|
|
|
|
if (noAuth > 0) return { status: STATUS.FAIL, findings, evidence };
|
|
if (unpinned > 0) return { status: STATUS.PARTIAL, findings, evidence };
|
|
return { status: STATUS.PASS, findings, evidence };
|
|
}
|
|
|
|
/**
|
|
* Category 5: Destructive Command Blocking (ASI02, ASI05)
|
|
*/
|
|
async function checkDestructiveBlocking(projectRoot, hooksJson) {
|
|
const evidence = [];
|
|
const findings = [];
|
|
let hookActive = false;
|
|
let hookNonStub = false;
|
|
// Use single keywords that must appear in any real blocklist script
|
|
const requiredPatterns = ['rm', 'force', 'DROP', 'curl', 'wget', 'mkfs'];
|
|
let coveredPatterns = [];
|
|
|
|
if (hooksJson) {
|
|
const preToolUse = hooksJson.hooks?.PreToolUse || hooksJson.PreToolUse || [];
|
|
const entries = Array.isArray(preToolUse) ? preToolUse : [];
|
|
for (const entry of entries) {
|
|
const matcher = entry.matcher || '';
|
|
const hooks = entry.hooks || [];
|
|
if (/bash/i.test(matcher)) {
|
|
for (const h of hooks) {
|
|
if (h.command && /destructive/i.test(h.command)) {
|
|
hookActive = true;
|
|
evidence.push(`Hook registered: ${h.command}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (hookActive) {
|
|
const scriptPath = join(projectRoot, 'hooks', 'scripts', 'pre-bash-destructive.mjs');
|
|
const content = await readText(scriptPath);
|
|
if (content) {
|
|
const lines = content.split('\n').filter(l => l.trim() && !l.trim().startsWith('//')).length;
|
|
hookNonStub = lines > NON_STUB_THRESHOLD;
|
|
coveredPatterns = requiredPatterns.filter(p => content.toLowerCase().includes(p.toLowerCase()));
|
|
evidence.push(`Blocklist covers: ${coveredPatterns.join(', ')}`);
|
|
|
|
const missing = requiredPatterns.filter(p => !coveredPatterns.includes(p));
|
|
if (missing.length > 0 && missing.length <= 2) {
|
|
findings.push(finding({
|
|
scanner: 'PST',
|
|
severity: SEVERITY.MEDIUM,
|
|
title: 'Destructive command blocklist incomplete',
|
|
description: `Blocklist missing: ${missing.join(', ')}`,
|
|
file: 'hooks/scripts/pre-bash-destructive.mjs',
|
|
owasp: 'ASI05',
|
|
recommendation: `Add patterns for: ${missing.join(', ')}`,
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!hookActive) {
|
|
findings.push(finding({
|
|
scanner: 'PST',
|
|
severity: SEVERITY.HIGH,
|
|
title: 'No destructive command hook registered',
|
|
description: 'No pre-bash-destructive hook found. rm -rf, curl|sh, DROP TABLE etc. are not blocked.',
|
|
owasp: 'ASI05',
|
|
recommendation: 'Register pre-bash-destructive.mjs under PreToolUse with Bash matcher.',
|
|
}));
|
|
return { status: STATUS.FAIL, findings, evidence };
|
|
}
|
|
|
|
if (hookNonStub && coveredPatterns.length >= 5) {
|
|
return { status: STATUS.PASS, findings, evidence };
|
|
}
|
|
|
|
if (hookNonStub) {
|
|
return { status: STATUS.PARTIAL, findings, evidence };
|
|
}
|
|
|
|
findings.push(finding({
|
|
scanner: 'PST',
|
|
severity: SEVERITY.HIGH,
|
|
title: 'Destructive command hook is a stub',
|
|
description: 'pre-bash-destructive.mjs exists but has insufficient code to function.',
|
|
file: 'hooks/scripts/pre-bash-destructive.mjs',
|
|
owasp: 'ASI05',
|
|
recommendation: 'Implement the destructive command blocklist.',
|
|
}));
|
|
return { status: STATUS.FAIL, findings, evidence };
|
|
}
|
|
|
|
/**
|
|
* Category 6: Sandbox Configuration (ASI02, ASI05)
|
|
*/
|
|
async function checkSandboxConfig(projectRoot, globalSettings, projectSettings) {
|
|
const evidence = [];
|
|
const findings = [];
|
|
let dangerousFlags = 0;
|
|
|
|
for (const [label, settings] of [['global', globalSettings], ['project', projectSettings]]) {
|
|
if (!settings) continue;
|
|
|
|
// Check for dangerous flags
|
|
if (settings.dangerouslyAllowArbitraryPaths === true) {
|
|
dangerousFlags++;
|
|
findings.push(finding({
|
|
scanner: 'PST',
|
|
severity: SEVERITY.CRITICAL,
|
|
title: `dangerouslyAllowArbitraryPaths enabled (${label})`,
|
|
description: `${label} settings.json has dangerouslyAllowArbitraryPaths: true.`,
|
|
owasp: 'ASI05',
|
|
recommendation: 'Remove dangerouslyAllowArbitraryPaths or set to false.',
|
|
}));
|
|
}
|
|
|
|
if (settings.skipDangerousModePermissionPrompt === true) {
|
|
dangerousFlags++;
|
|
findings.push(finding({
|
|
scanner: 'PST',
|
|
severity: SEVERITY.HIGH,
|
|
title: `skipDangerousModePermissionPrompt enabled (${label})`,
|
|
description: `${label} settings.json skips dangerous mode permission prompts.`,
|
|
owasp: 'ASI02',
|
|
recommendation: 'Set skipDangerousModePermissionPrompt to false.',
|
|
}));
|
|
}
|
|
|
|
// Check network access
|
|
if (settings.sandbox?.network === 'unrestricted' || settings.network === 'unrestricted') {
|
|
findings.push(finding({
|
|
scanner: 'PST',
|
|
severity: SEVERITY.HIGH,
|
|
title: `Unrestricted network access (${label})`,
|
|
description: `${label} settings.json has network: unrestricted.`,
|
|
owasp: 'ASI05',
|
|
recommendation: 'Restrict network access to required domains only.',
|
|
}));
|
|
dangerousFlags++;
|
|
}
|
|
}
|
|
|
|
// Check for bypassPermissions in commands/agents
|
|
let bypassCount = 0;
|
|
for (const dir of ['commands', 'agents']) {
|
|
const mdFiles = await listMdFiles(join(projectRoot, dir));
|
|
for (const file of mdFiles) {
|
|
const content = await readText(join(projectRoot, dir, file));
|
|
if (content && /bypass[Pp]ermissions|--dangerously-skip-permissions/i.test(content)) {
|
|
bypassCount++;
|
|
findings.push(finding({
|
|
scanner: 'PST',
|
|
severity: SEVERITY.HIGH,
|
|
title: `Permission bypass in ${dir}/${file}`,
|
|
description: `${dir}/${file} references bypassPermissions or --dangerously-skip-permissions.`,
|
|
file: `${dir}/${file}`,
|
|
owasp: 'ASI02',
|
|
recommendation: 'Remove permission bypass references. Use explicit allow rules instead.',
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
|
|
evidence.push(`dangerous flags: ${dangerousFlags}, bypass refs: ${bypassCount}`);
|
|
|
|
if (dangerousFlags === 0 && bypassCount === 0) {
|
|
return { status: STATUS.PASS, findings, evidence };
|
|
}
|
|
|
|
if (dangerousFlags >= 2 || bypassCount >= 2) {
|
|
return { status: STATUS.FAIL, findings, evidence };
|
|
}
|
|
|
|
return { status: STATUS.PARTIAL, findings, evidence };
|
|
}
|
|
|
|
/**
|
|
* Category 7: Human Review Requirements (ASI09)
|
|
*/
|
|
async function checkHumanReview(projectRoot) {
|
|
const evidence = [];
|
|
const findings = [];
|
|
let commandsWithConfirmation = 0;
|
|
let totalHighImpactCommands = 0;
|
|
let claudeMdHitl = false;
|
|
|
|
// Check CLAUDE.md for human-in-the-loop policy
|
|
const claudeMd = await readText(join(projectRoot, 'CLAUDE.md'));
|
|
if (claudeMd) {
|
|
if (/human[- ]in[- ]the[- ]loop|confirm|approval|ask.*user|user.*confirm/i.test(claudeMd)) {
|
|
claudeMdHitl = true;
|
|
evidence.push('CLAUDE.md has human-in-the-loop policy');
|
|
}
|
|
}
|
|
|
|
// Check commands for confirmation gates
|
|
const cmdFiles = await listMdFiles(join(projectRoot, 'commands'));
|
|
for (const file of cmdFiles) {
|
|
const content = await readText(join(projectRoot, 'commands', file));
|
|
if (!content) continue;
|
|
|
|
// Commands that involve write/delete/push are high-impact
|
|
const isHighImpact = /\b(?:delete|remove|push|deploy|publish|force|drop|reset|clean)\b/i.test(content);
|
|
if (isHighImpact) {
|
|
totalHighImpactCommands++;
|
|
if (/AskUserQuestion|confirm|approval|user.*confirm/i.test(content)) {
|
|
commandsWithConfirmation++;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check agents for AskUserQuestion
|
|
const agentFiles = await listMdFiles(join(projectRoot, 'agents'));
|
|
let agentsWithAsk = 0;
|
|
for (const file of agentFiles) {
|
|
const content = await readText(join(projectRoot, 'agents', file));
|
|
if (content && /AskUserQuestion/.test(content)) {
|
|
agentsWithAsk++;
|
|
}
|
|
}
|
|
|
|
evidence.push(`high-impact commands: ${totalHighImpactCommands}, with confirmation: ${commandsWithConfirmation}`);
|
|
evidence.push(`agents with AskUserQuestion: ${agentsWithAsk}`);
|
|
|
|
if (totalHighImpactCommands === 0) {
|
|
// No high-impact commands found — PASS if CLAUDE.md has policy
|
|
if (claudeMdHitl) return { status: STATUS.PASS, findings, evidence };
|
|
return { status: STATUS.PARTIAL, findings, evidence };
|
|
}
|
|
|
|
if (commandsWithConfirmation >= totalHighImpactCommands && claudeMdHitl) {
|
|
return { status: STATUS.PASS, findings, evidence };
|
|
}
|
|
|
|
if (commandsWithConfirmation > 0 || claudeMdHitl) {
|
|
if (commandsWithConfirmation < totalHighImpactCommands) {
|
|
findings.push(finding({
|
|
scanner: 'PST',
|
|
severity: SEVERITY.MEDIUM,
|
|
title: 'Some high-impact commands lack confirmation gates',
|
|
description: `${commandsWithConfirmation}/${totalHighImpactCommands} high-impact commands have confirmation steps.`,
|
|
owasp: 'ASI09',
|
|
recommendation: 'Add AskUserQuestion or explicit confirmation before destructive operations.',
|
|
}));
|
|
}
|
|
return { status: STATUS.PARTIAL, findings, evidence };
|
|
}
|
|
|
|
findings.push(finding({
|
|
scanner: 'PST',
|
|
severity: SEVERITY.MEDIUM,
|
|
title: 'No human review gates found',
|
|
description: 'No confirmation steps found in commands and no HITL policy in CLAUDE.md.',
|
|
owasp: 'ASI09',
|
|
recommendation: 'Add confirmation gates for irreversible operations.',
|
|
}));
|
|
return { status: STATUS.FAIL, findings, evidence };
|
|
}
|
|
|
|
/**
|
|
* Category 8: Skill and Plugin Sources (ASI04)
|
|
*/
|
|
async function checkPluginSources(projectRoot, globalSettings) {
|
|
const evidence = [];
|
|
const findings = [];
|
|
|
|
// Check plugin.json
|
|
const pluginJson = await readJson(join(projectRoot, '.claude-plugin', 'plugin.json'))
|
|
|| await readJson(join(projectRoot, 'plugin.json'));
|
|
|
|
if (!pluginJson) {
|
|
evidence.push('Not a plugin project (no plugin.json)');
|
|
// Check if there are enabled plugins in global settings
|
|
const enabledPlugins = globalSettings?.enabledPlugins || {};
|
|
const pluginCount = Object.keys(enabledPlugins).length;
|
|
if (pluginCount === 0) {
|
|
return { status: STATUS.NA, findings, evidence };
|
|
}
|
|
evidence.push(`${pluginCount} enabled plugins in global settings`);
|
|
return { status: STATUS.PASS, findings, evidence };
|
|
}
|
|
|
|
evidence.push(`plugin: ${pluginJson.name || 'unnamed'}`);
|
|
|
|
// Check command tool permissions
|
|
let overPermissioned = 0;
|
|
const cmdFiles = await listMdFiles(join(projectRoot, 'commands'));
|
|
for (const file of cmdFiles) {
|
|
const content = await readText(join(projectRoot, 'commands', file));
|
|
if (!content) continue;
|
|
const fm = extractFrontmatter(content);
|
|
|
|
// Flag commands with Bash + Write that don't seem to need it
|
|
if (/allowed-tools:.*Bash/i.test(fm) && /allowed-tools:.*Write/i.test(fm)) {
|
|
// Only flag if description suggests read-only
|
|
if (/\b(?:scan|audit|assess|check|review|analyze|report|posture)\b/i.test(fm)) {
|
|
overPermissioned++;
|
|
findings.push(finding({
|
|
scanner: 'PST',
|
|
severity: SEVERITY.LOW,
|
|
title: `Possibly over-permissioned command: ${file}`,
|
|
description: `${file} appears to be a read/analysis command but has both Bash and Write tools.`,
|
|
file: `commands/${file}`,
|
|
owasp: 'ASI04',
|
|
recommendation: 'Review if Bash and Write are both needed for this command.',
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
|
|
if (overPermissioned === 0) {
|
|
return { status: STATUS.PASS, findings, evidence };
|
|
}
|
|
|
|
return { status: STATUS.PARTIAL, findings, evidence };
|
|
}
|
|
|
|
/**
|
|
* Category 9: Session Isolation (ASI06, ASI08)
|
|
*/
|
|
async function checkSessionIsolation(projectRoot) {
|
|
const evidence = [];
|
|
const findings = [];
|
|
let secretsInState = false;
|
|
|
|
// Check state files for credentials
|
|
const secretRe = /(?:sk-[a-zA-Z0-9]{20,}|Bearer\s+[a-zA-Z0-9._-]{20,}|password\s*=\s*["'][^"']+["']|(?:api[_-]?key|token)\s*=\s*["'][^"']+["'])/;
|
|
|
|
const stateFiles = [
|
|
'REMEMBER.md',
|
|
...await listMdFiles(join(projectRoot, 'memory')).then(f => f.map(n => `memory/${n}`)),
|
|
];
|
|
|
|
for (const file of stateFiles) {
|
|
const content = await readText(join(projectRoot, file));
|
|
if (content && secretRe.test(content)) {
|
|
secretsInState = true;
|
|
findings.push(finding({
|
|
scanner: 'PST',
|
|
severity: SEVERITY.CRITICAL,
|
|
title: `Credentials found in state file: ${file}`,
|
|
description: `${file} contains what appears to be credentials or API keys.`,
|
|
file,
|
|
owasp: 'ASI06',
|
|
recommendation: 'Remove credentials from state files immediately.',
|
|
}));
|
|
}
|
|
}
|
|
|
|
// Check .gitignore for state files
|
|
const gitignore = await readText(join(projectRoot, '.gitignore'));
|
|
const stateGitignored = gitignore && /\*\.local\.md|REMEMBER\.md|memory\//i.test(gitignore);
|
|
evidence.push(`state files gitignored: ${stateGitignored ? 'yes' : 'no'}`);
|
|
|
|
if (!stateGitignored && stateFiles.length > 0) {
|
|
findings.push(finding({
|
|
scanner: 'PST',
|
|
severity: SEVERITY.LOW,
|
|
title: 'State files not covered by .gitignore',
|
|
description: 'REMEMBER.md or memory/ files may not be gitignored.',
|
|
file: '.gitignore',
|
|
owasp: 'ASI08',
|
|
recommendation: 'Add *.local.md, REMEMBER.md, and memory/ to .gitignore.',
|
|
}));
|
|
}
|
|
|
|
if (secretsInState) return { status: STATUS.FAIL, findings, evidence };
|
|
if (stateGitignored) return { status: STATUS.PASS, findings, evidence };
|
|
return { status: STATUS.PARTIAL, findings, evidence };
|
|
}
|
|
|
|
/**
|
|
* Category 10: Cognitive State Security (LLM01, ASI02)
|
|
*/
|
|
async function checkCognitiveStateSecurity(projectRoot) {
|
|
const evidence = [];
|
|
const findings = [];
|
|
let injectionFound = false;
|
|
let shellInMemory = false;
|
|
let credPathInMemory = false;
|
|
let permExpansionFound = false;
|
|
let suspiciousUrlFound = false;
|
|
|
|
// Files to scan
|
|
const filesToScan = [];
|
|
|
|
// CLAUDE.md
|
|
if (await fileExists(join(projectRoot, 'CLAUDE.md'))) {
|
|
filesToScan.push('CLAUDE.md');
|
|
}
|
|
if (await fileExists(join(projectRoot, 'REMEMBER.md'))) {
|
|
filesToScan.push('REMEMBER.md');
|
|
}
|
|
|
|
// .claude/rules/*.md
|
|
const rulesDir = join(projectRoot, '.claude', 'rules');
|
|
const ruleFiles = await listMdFiles(rulesDir);
|
|
for (const f of ruleFiles) filesToScan.push(`.claude/rules/${f}`);
|
|
|
|
// memory/*.md
|
|
const memoryFiles = await listMdFiles(join(projectRoot, 'memory'));
|
|
for (const f of memoryFiles) filesToScan.push(`memory/${f}`);
|
|
|
|
// *.local.md
|
|
const rootFiles = await listDir(projectRoot);
|
|
for (const f of rootFiles) {
|
|
if (f.endsWith('.local.md')) filesToScan.push(f);
|
|
}
|
|
|
|
evidence.push(`cognitive state files: ${filesToScan.length}`);
|
|
|
|
const shellCommandRe = /(?:^|[`'";\s|&])(?:curl|wget|bash|sh|eval|exec|chmod\s+[+0-7]|npm\s+install|pip3?\s+install)\b/i;
|
|
const credPathRe = /(?:~\/|\/home\/|\/root\/)?\.(?:ssh|aws|gnupg|config\/gcloud)\/|(?:^|[\s/"'`])(?:id_rsa|id_ed25519|wallet\.dat|credentials\.json|\.env(?:\.\w+)?|\.netrc|kubeconfig)(?:[\s/"'`]|$)/i;
|
|
const permExpansionRe = /(?:allowed-tools\s*[=:]\s*.*(?:Bash|Write|Edit|all)|bypassPermissions\s*[=:]\s*true|dangerouslySkipPermissions|--dangerously-skip-permissions|dangerouslyAllowArbitraryPaths\s*[=:]\s*true)/i;
|
|
const suspiciousDomains = /(?:webhook\.site|requestbin\.com|pipedream\.net|ngrok\.io|ngrok\.app|pastebin\.com|transfer\.sh|file\.io|0x0\.st)/i;
|
|
|
|
const isStrictFile = (f) => /^(?:memory\/|REMEMBER\.md|\.claude\/rules\/)/i.test(f);
|
|
const isClaudeMd = (f) => /CLAUDE\.md$/i.test(f);
|
|
|
|
for (const file of filesToScan) {
|
|
const content = await readText(join(projectRoot, file));
|
|
if (!content) continue;
|
|
|
|
// 1. Injection patterns (reuse shared library)
|
|
const scan = scanForInjection(content);
|
|
if (scan.found) {
|
|
injectionFound = true;
|
|
for (const { label, severity } of scan.patterns) {
|
|
findings.push(finding({
|
|
scanner: 'PST',
|
|
severity: severity === 'critical' ? SEVERITY.CRITICAL : severity === 'high' ? SEVERITY.HIGH : SEVERITY.MEDIUM,
|
|
title: `Injection pattern in ${file}: ${label}`,
|
|
description: `Cognitive state file "${file}" contains injection pattern: "${label}".`,
|
|
file,
|
|
owasp: 'LLM01',
|
|
recommendation: 'Remove the injection pattern and review for unauthorized modifications.',
|
|
}));
|
|
}
|
|
}
|
|
|
|
// 2. Shell commands in strict files
|
|
if (isStrictFile(file)) {
|
|
const lines = content.split('\n');
|
|
for (let i = 0; i < lines.length; i++) {
|
|
if (shellCommandRe.test(lines[i])) {
|
|
shellInMemory = true;
|
|
findings.push(finding({
|
|
scanner: 'PST',
|
|
severity: SEVERITY.HIGH,
|
|
title: `Shell command in memory/rules file: ${file}`,
|
|
description: `${file} line ${i + 1} contains a shell command. Memory files should not have executable instructions.`,
|
|
file,
|
|
line: i + 1,
|
|
evidence: lines[i].trim().slice(0, 80),
|
|
owasp: 'LLM01',
|
|
recommendation: 'Remove shell commands from memory files.',
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Credential path references (skip code blocks in CLAUDE.md)
|
|
const lines = content.split('\n');
|
|
let inCodeBlock = false;
|
|
for (let i = 0; i < lines.length; i++) {
|
|
if (lines[i].trim().startsWith('```')) inCodeBlock = !inCodeBlock;
|
|
if (inCodeBlock && isClaudeMd(file)) continue;
|
|
|
|
if (credPathRe.test(lines[i]) && isStrictFile(file)) {
|
|
credPathInMemory = true;
|
|
findings.push(finding({
|
|
scanner: 'PST',
|
|
severity: SEVERITY.HIGH,
|
|
title: `Credential path reference in ${file}`,
|
|
description: `${file} line ${i + 1} references a credential path.`,
|
|
file,
|
|
line: i + 1,
|
|
owasp: 'ASI02',
|
|
recommendation: 'Remove credential path references from memory/rules files.',
|
|
}));
|
|
}
|
|
}
|
|
|
|
// 4. Permission expansion directives
|
|
if (permExpansionRe.test(content) && (isStrictFile(file) || /\.local\.md$/.test(file))) {
|
|
permExpansionFound = true;
|
|
findings.push(finding({
|
|
scanner: 'PST',
|
|
severity: SEVERITY.CRITICAL,
|
|
title: `Permission expansion in ${file}`,
|
|
description: `${file} contains permission expansion directives.`,
|
|
file,
|
|
owasp: 'ASI02',
|
|
recommendation: 'Remove permission expansion from cognitive state files.',
|
|
}));
|
|
}
|
|
|
|
// 5. Suspicious URLs
|
|
if (suspiciousDomains.test(content)) {
|
|
suspiciousUrlFound = true;
|
|
findings.push(finding({
|
|
scanner: 'PST',
|
|
severity: SEVERITY.HIGH,
|
|
title: `Suspicious URL in ${file}`,
|
|
description: `${file} references a known exfiltration/webhook domain.`,
|
|
file,
|
|
owasp: 'LLM01',
|
|
recommendation: 'Remove suspicious URLs from cognitive state files.',
|
|
}));
|
|
}
|
|
}
|
|
|
|
if (injectionFound || permExpansionFound) return { status: STATUS.FAIL, findings, evidence };
|
|
if (shellInMemory || credPathInMemory || suspiciousUrlFound) return { status: STATUS.PARTIAL, findings, evidence };
|
|
if (findings.length === 0) return { status: STATUS.PASS, findings, evidence };
|
|
return { status: STATUS.PARTIAL, findings, evidence };
|
|
}
|
|
|
|
|
|
/**
|
|
* Category 11: Prompt Injection Hardening (LLM01, ASI01)
|
|
* v5.0: Checks MEDIUM advisory support, Unicode Tag detection, bash expansion normalization.
|
|
*/
|
|
async function checkPromptInjectionHardening(projectRoot, hooksJson) {
|
|
const evidence = [];
|
|
const findings = [];
|
|
let hookActive = false;
|
|
let hasMediumAdvisory = false;
|
|
let hasUnicodeTagDetection = false;
|
|
let hasBashNormalization = false;
|
|
|
|
// Check hooks.json for pre-prompt-inject-scan in UserPromptSubmit
|
|
if (hooksJson) {
|
|
const userPromptSubmit = hooksJson.hooks?.UserPromptSubmit || hooksJson.UserPromptSubmit || [];
|
|
const entries = Array.isArray(userPromptSubmit) ? userPromptSubmit : [];
|
|
for (const entry of entries) {
|
|
const hooks = entry.hooks || [];
|
|
for (const h of hooks) {
|
|
if (h.command && /prompt.?inject/i.test(h.command)) {
|
|
hookActive = true;
|
|
evidence.push(`Hook registered: ${h.command}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (hookActive) {
|
|
// Check script for MEDIUM advisory support
|
|
const scriptPath = join(projectRoot, 'hooks', 'scripts', 'pre-prompt-inject-scan.mjs');
|
|
const content = await readText(scriptPath);
|
|
if (content) {
|
|
if (/medium/i.test(content)) {
|
|
hasMediumAdvisory = true;
|
|
evidence.push('MEDIUM advisory support: yes');
|
|
}
|
|
if (/unicode.?tag|U\+E00|decodeUnicodeTags/i.test(content)) {
|
|
hasUnicodeTagDetection = true;
|
|
evidence.push('Unicode Tag detection: yes');
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check bash-destructive for bash expansion normalization
|
|
const bashScriptPath = join(projectRoot, 'hooks', 'scripts', 'pre-bash-destructive.mjs');
|
|
const bashContent = await readText(bashScriptPath);
|
|
if (bashContent && /normalizeBash|bash.?normalize/i.test(bashContent)) {
|
|
hasBashNormalization = true;
|
|
evidence.push('Bash expansion normalization: yes');
|
|
}
|
|
|
|
if (!hookActive) {
|
|
findings.push(finding({
|
|
scanner: 'PST',
|
|
severity: SEVERITY.HIGH,
|
|
title: 'No prompt injection scanning hook',
|
|
description: 'No pre-prompt-inject-scan hook found in UserPromptSubmit. User input is not scanned for injection patterns.',
|
|
owasp: 'LLM01',
|
|
recommendation: 'Register pre-prompt-inject-scan.mjs under UserPromptSubmit in hooks.json.',
|
|
}));
|
|
return { status: STATUS.FAIL, findings, evidence };
|
|
}
|
|
|
|
const features = [hasMediumAdvisory, hasUnicodeTagDetection, hasBashNormalization].filter(Boolean).length;
|
|
if (features === 3) return { status: STATUS.PASS, findings, evidence };
|
|
|
|
if (!hasMediumAdvisory) {
|
|
findings.push(finding({
|
|
scanner: 'PST',
|
|
severity: SEVERITY.MEDIUM,
|
|
title: 'Prompt injection hook lacks MEDIUM advisory support',
|
|
description: 'pre-prompt-inject-scan does not emit advisories for MEDIUM-severity obfuscation patterns (leetspeak, homoglyphs, zero-width).',
|
|
file: 'hooks/scripts/pre-prompt-inject-scan.mjs',
|
|
owasp: 'LLM01',
|
|
recommendation: 'Upgrade to v5.0 hook with MEDIUM advisory support.',
|
|
}));
|
|
}
|
|
if (!hasUnicodeTagDetection) {
|
|
findings.push(finding({
|
|
scanner: 'PST',
|
|
severity: SEVERITY.MEDIUM,
|
|
title: 'No Unicode Tag steganography detection',
|
|
description: 'Prompt injection hook does not detect Unicode Tags (U+E0000-E007F) steganography.',
|
|
file: 'hooks/scripts/pre-prompt-inject-scan.mjs',
|
|
owasp: 'LLM01',
|
|
recommendation: 'Add Unicode Tag decoding to the injection scanning pipeline.',
|
|
}));
|
|
}
|
|
if (!hasBashNormalization) {
|
|
findings.push(finding({
|
|
scanner: 'PST',
|
|
severity: SEVERITY.MEDIUM,
|
|
title: 'Bash destructive hook lacks expansion normalization',
|
|
description: 'pre-bash-destructive does not normalize bash parameter expansion evasion.',
|
|
file: 'hooks/scripts/pre-bash-destructive.mjs',
|
|
owasp: 'ASI01',
|
|
recommendation: 'Import and apply normalizeBashExpansion() before command matching.',
|
|
}));
|
|
}
|
|
|
|
if (features >= 1) return { status: STATUS.PARTIAL, findings, evidence };
|
|
return { status: STATUS.FAIL, findings, evidence };
|
|
}
|
|
|
|
/**
|
|
* Category 12: Rule of Two (ASI02, ASI05)
|
|
* v5.0: Checks that the trifecta (untrusted input + sensitive data + state change)
|
|
* is monitored with configurable enforcement mode.
|
|
*/
|
|
async function checkRuleOfTwo(projectRoot, hooksJson) {
|
|
const evidence = [];
|
|
const findings = [];
|
|
let hookActive = false;
|
|
let hasTrifectaMode = false;
|
|
|
|
// Check hooks.json for post-session-guard in PostToolUse
|
|
if (hooksJson) {
|
|
const postToolUse = hooksJson.hooks?.PostToolUse || hooksJson.PostToolUse || [];
|
|
const entries = Array.isArray(postToolUse) ? postToolUse : [];
|
|
for (const entry of entries) {
|
|
const hooks = entry.hooks || [];
|
|
for (const h of hooks) {
|
|
if (h.command && /session.?guard/i.test(h.command)) {
|
|
hookActive = true;
|
|
evidence.push(`Hook registered: ${h.command}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (hookActive) {
|
|
const scriptPath = join(projectRoot, 'hooks', 'scripts', 'post-session-guard.mjs');
|
|
const content = await readText(scriptPath);
|
|
if (content && /TRIFECTA_MODE/i.test(content)) {
|
|
hasTrifectaMode = true;
|
|
evidence.push('TRIFECTA_MODE configurable: yes');
|
|
}
|
|
}
|
|
|
|
if (!hookActive) {
|
|
findings.push(finding({
|
|
scanner: 'PST',
|
|
severity: SEVERITY.HIGH,
|
|
title: 'No Rule of Two enforcement hook',
|
|
description: 'No post-session-guard hook found. The lethal trifecta (untrusted input + sensitive data + state change) is not monitored.',
|
|
owasp: 'ASI02',
|
|
recommendation: 'Register post-session-guard.mjs under PostToolUse in hooks.json.',
|
|
}));
|
|
return { status: STATUS.FAIL, findings, evidence };
|
|
}
|
|
|
|
if (hasTrifectaMode) return { status: STATUS.PASS, findings, evidence };
|
|
|
|
findings.push(finding({
|
|
scanner: 'PST',
|
|
severity: SEVERITY.MEDIUM,
|
|
title: 'Session guard lacks configurable trifecta mode',
|
|
description: 'post-session-guard does not support LLM_SECURITY_TRIFECTA_MODE (block/warn/off).',
|
|
file: 'hooks/scripts/post-session-guard.mjs',
|
|
owasp: 'ASI02',
|
|
recommendation: 'Upgrade to v5.0 session guard with configurable enforcement mode.',
|
|
}));
|
|
return { status: STATUS.PARTIAL, findings, evidence };
|
|
}
|
|
|
|
/**
|
|
* Category 13: Long-Horizon Monitoring (ASI06, ASI08)
|
|
* v5.0: Checks that the session guard monitors a 100-call window alongside
|
|
* the standard 20-call window for slow-burn attack detection.
|
|
*/
|
|
async function checkLongHorizonMonitoring(projectRoot, hooksJson) {
|
|
const evidence = [];
|
|
const findings = [];
|
|
let hookActive = false;
|
|
let hasLongHorizon = false;
|
|
let hasBehavioralDrift = false;
|
|
|
|
// Check hooks.json for post-session-guard in PostToolUse
|
|
if (hooksJson) {
|
|
const postToolUse = hooksJson.hooks?.PostToolUse || hooksJson.PostToolUse || [];
|
|
const entries = Array.isArray(postToolUse) ? postToolUse : [];
|
|
for (const entry of entries) {
|
|
const hooks = entry.hooks || [];
|
|
for (const h of hooks) {
|
|
if (h.command && /session.?guard/i.test(h.command)) {
|
|
hookActive = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (hookActive) {
|
|
const scriptPath = join(projectRoot, 'hooks', 'scripts', 'post-session-guard.mjs');
|
|
const content = await readText(scriptPath);
|
|
if (content) {
|
|
if (/LONG_HORIZON|long.?horizon|100.?call/i.test(content)) {
|
|
hasLongHorizon = true;
|
|
evidence.push('Long-horizon window (100-call): yes');
|
|
}
|
|
if (/behavioral.?drift|jensen.?shannon|divergence/i.test(content)) {
|
|
hasBehavioralDrift = true;
|
|
evidence.push('Behavioral drift detection: yes');
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!hookActive) {
|
|
findings.push(finding({
|
|
scanner: 'PST',
|
|
severity: SEVERITY.HIGH,
|
|
title: 'No long-horizon session monitoring',
|
|
description: 'No post-session-guard hook found. Long-horizon attacks (slow-burn trifecta over 50+ calls) are not detected.',
|
|
owasp: 'ASI06',
|
|
recommendation: 'Register post-session-guard.mjs under PostToolUse in hooks.json.',
|
|
}));
|
|
return { status: STATUS.FAIL, findings, evidence };
|
|
}
|
|
|
|
if (hasLongHorizon && hasBehavioralDrift) return { status: STATUS.PASS, findings, evidence };
|
|
|
|
if (!hasLongHorizon) {
|
|
findings.push(finding({
|
|
scanner: 'PST',
|
|
severity: SEVERITY.MEDIUM,
|
|
title: 'Session guard lacks long-horizon window',
|
|
description: 'post-session-guard does not implement a 100-call monitoring window. Slow-burn attacks may evade the standard 20-call window.',
|
|
file: 'hooks/scripts/post-session-guard.mjs',
|
|
owasp: 'ASI06',
|
|
recommendation: 'Upgrade to v5.0 session guard with 100-call long-horizon window.',
|
|
}));
|
|
}
|
|
|
|
if (!hasBehavioralDrift) {
|
|
findings.push(finding({
|
|
scanner: 'PST',
|
|
severity: SEVERITY.LOW,
|
|
title: 'No behavioral drift detection',
|
|
description: 'post-session-guard does not track tool-class distribution changes (Jensen-Shannon divergence) for behavioral drift.',
|
|
file: 'hooks/scripts/post-session-guard.mjs',
|
|
owasp: 'ASI08',
|
|
recommendation: 'Add behavioral drift detection using Jensen-Shannon divergence on tool-class distribution.',
|
|
}));
|
|
}
|
|
|
|
if (hasLongHorizon || hasBehavioralDrift) return { status: STATUS.PARTIAL, findings, evidence };
|
|
return { status: STATUS.FAIL, findings, evidence };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Category 14: EU AI Act Compliance (Governance)
|
|
// Checks for evidence that supports EU AI Act requirements:
|
|
// Art. 9 — risk management system
|
|
// Art. 14 — human oversight
|
|
// Art. 15 — accuracy, robustness, cybersecurity
|
|
// Art. 17 — quality management system
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function checkEUAIActCompliance(projectRoot, hooksJson) {
|
|
const evidence = [];
|
|
const findings = [];
|
|
let score = 0;
|
|
const maxScore = 4;
|
|
|
|
// Art. 9: Risk management — look for structured risk/security documentation
|
|
const claudeMd = await readText(join(projectRoot, 'CLAUDE.md'));
|
|
const hasRiskDoc = claudeMd && claudeMd.length > 100 &&
|
|
/security.*boundar|risk.*manage|threat.*model|security.*polic/i.test(claudeMd);
|
|
if (hasRiskDoc) {
|
|
score++;
|
|
evidence.push('Art. 9: risk management documentation found in CLAUDE.md');
|
|
}
|
|
|
|
// Art. 14: Human oversight — hooks or CLAUDE.md mention human-in-the-loop
|
|
const hasHitl = claudeMd && /human[- ](?:in[- ]the[- ]loop|oversight|review|confirm)|AskUserQuestion/i.test(claudeMd);
|
|
const hasHumanHooks = hooksJson && JSON.stringify(hooksJson).includes('UserPromptSubmit');
|
|
if (hasHitl || hasHumanHooks) {
|
|
score++;
|
|
evidence.push('Art. 14: human oversight mechanism present');
|
|
}
|
|
|
|
// Art. 15: Robustness/cybersecurity — security hooks registered
|
|
const hookCount = hooksJson ? Object.keys(hooksJson.hooks || {}).length : 0;
|
|
if (hookCount >= 2) {
|
|
score++;
|
|
evidence.push(`Art. 15: ${hookCount} hook event types registered for robustness`);
|
|
}
|
|
|
|
// Art. 17: Quality management — test suite or scan reports exist
|
|
const hasTests = await fileExists(join(projectRoot, 'tests')) || await fileExists(join(projectRoot, 'test'));
|
|
const hasReports = await fileExists(join(projectRoot, 'reports'));
|
|
if (hasTests || hasReports) {
|
|
score++;
|
|
evidence.push('Art. 17: quality management evidence (tests or reports)');
|
|
}
|
|
|
|
if (score === 0) {
|
|
findings.push(finding({
|
|
scanner: 'PST',
|
|
severity: SEVERITY.INFO,
|
|
title: 'No EU AI Act compliance evidence',
|
|
description: 'No risk management, human oversight, robustness hooks, or quality management evidence found.',
|
|
owasp: 'Governance',
|
|
recommendation: 'Add security documentation to CLAUDE.md, register security hooks, and maintain test suites.',
|
|
}));
|
|
return { status: STATUS.FAIL, findings, evidence };
|
|
}
|
|
|
|
if (score >= maxScore) return { status: STATUS.PASS, findings, evidence };
|
|
return { status: STATUS.PARTIAL, findings, evidence };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Category 15: NIST AI RMF Alignment (Governance)
|
|
// Maps to four NIST AI RMF functions:
|
|
// Govern — governance controls (deny-first config, policies)
|
|
// Map — risk mapping documentation (threat models, risk assessments)
|
|
// Measure — measurement tooling (scanners, posture assessment)
|
|
// Manage — risk management actions (hooks, remediation capabilities)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function checkNISTAlignment(projectRoot, hooksJson, projectSettings) {
|
|
const evidence = [];
|
|
const findings = [];
|
|
let functionsPresent = 0;
|
|
const totalFunctions = 4;
|
|
|
|
// Govern: deny-first configuration or policy documentation
|
|
const settingsJson = projectSettings || await readJson(join(projectRoot, '.claude', 'settings.json'));
|
|
const hasDenyFirst = settingsJson?.permissions?.defaultPermissionLevel === 'deny';
|
|
const hasPolicyFile = await fileExists(join(projectRoot, '.llm-security', 'policy.json'));
|
|
if (hasDenyFirst || hasPolicyFile) {
|
|
functionsPresent++;
|
|
evidence.push('Govern: deny-first config or policy file present');
|
|
}
|
|
|
|
// Map: risk documentation (threat-model reports, CLAUDE.md with threat/risk mentions)
|
|
const claudeMd = await readText(join(projectRoot, 'CLAUDE.md'));
|
|
const hasRiskDoc = claudeMd && /threat|risk.*assess|security.*boundar/i.test(claudeMd);
|
|
const hasReports = await fileExists(join(projectRoot, 'reports'));
|
|
if (hasRiskDoc || hasReports) {
|
|
functionsPresent++;
|
|
evidence.push('Map: risk documentation or reports present');
|
|
}
|
|
|
|
// Measure: measurement tooling (scanners, tests)
|
|
const hasScanners = await fileExists(join(projectRoot, 'scanners'));
|
|
const hasTests = await fileExists(join(projectRoot, 'tests')) || await fileExists(join(projectRoot, 'test'));
|
|
if (hasScanners || hasTests) {
|
|
functionsPresent++;
|
|
evidence.push('Measure: measurement tooling present (scanners or tests)');
|
|
}
|
|
|
|
// Manage: risk management actions (hooks registered)
|
|
const hookCount = hooksJson ? Object.keys(hooksJson.hooks || {}).length : 0;
|
|
if (hookCount >= 2) {
|
|
functionsPresent++;
|
|
evidence.push(`Manage: ${hookCount} hook event types for active risk management`);
|
|
}
|
|
|
|
if (functionsPresent === 0) {
|
|
findings.push(finding({
|
|
scanner: 'PST',
|
|
severity: SEVERITY.INFO,
|
|
title: 'No NIST AI RMF alignment evidence',
|
|
description: 'No evidence for any of the four NIST AI RMF functions: Govern, Map, Measure, Manage.',
|
|
owasp: 'Governance',
|
|
recommendation: 'Implement deny-first permissions (Govern), add risk documentation (Map), enable scanners (Measure), register hooks (Manage).',
|
|
}));
|
|
return { status: STATUS.FAIL, findings, evidence };
|
|
}
|
|
|
|
if (functionsPresent >= totalFunctions) return { status: STATUS.PASS, findings, evidence };
|
|
return { status: STATUS.PARTIAL, findings, evidence };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Category 16: ISO 42001 Readiness (Governance)
|
|
// ISO/IEC 42001:2023 AI Management System indicators:
|
|
// Cl. 6 — planning and risk assessment
|
|
// Cl. 8 — operational controls
|
|
// Cl. 9 — performance evaluation and monitoring
|
|
// Cl. 10 — continual improvement
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function checkISO42001Readiness(projectRoot, hooksJson) {
|
|
const evidence = [];
|
|
const findings = [];
|
|
let indicators = 0;
|
|
const totalIndicators = 4;
|
|
|
|
// Cl. 6: Planning and risk — documented processes (CLAUDE.md with structure)
|
|
const claudeMd = await readText(join(projectRoot, 'CLAUDE.md'));
|
|
if (claudeMd && claudeMd.length > 100) {
|
|
indicators++;
|
|
evidence.push('Cl. 6: documented AI management processes in CLAUDE.md');
|
|
}
|
|
|
|
// Cl. 8: Operational controls — hooks and settings providing runtime controls
|
|
const hookCount = hooksJson ? Object.keys(hooksJson.hooks || {}).length : 0;
|
|
if (hookCount >= 2) {
|
|
indicators++;
|
|
evidence.push(`Cl. 8: ${hookCount} operational control hook event types`);
|
|
}
|
|
|
|
// Cl. 9: Performance evaluation — monitoring and measurement capabilities
|
|
const hasReports = await fileExists(join(projectRoot, 'reports'));
|
|
const hasTests = await fileExists(join(projectRoot, 'tests')) || await fileExists(join(projectRoot, 'test'));
|
|
if (hasReports || hasTests) {
|
|
indicators++;
|
|
evidence.push('Cl. 9: performance evaluation evidence (reports or tests)');
|
|
}
|
|
|
|
// Cl. 10: Continual improvement — baseline diff capability, scan history
|
|
const hasBaselines = await fileExists(join(projectRoot, 'reports', 'baselines'));
|
|
const hasChangelog = await fileExists(join(projectRoot, 'CHANGELOG.md'));
|
|
if (hasBaselines || hasChangelog) {
|
|
indicators++;
|
|
evidence.push('Cl. 10: continual improvement evidence (baselines or changelog)');
|
|
}
|
|
|
|
if (indicators === 0) {
|
|
findings.push(finding({
|
|
scanner: 'PST',
|
|
severity: SEVERITY.INFO,
|
|
title: 'No ISO 42001 readiness evidence',
|
|
description: 'No evidence for ISO/IEC 42001 AI management system requirements.',
|
|
owasp: 'Governance',
|
|
recommendation: 'Document AI processes in CLAUDE.md, register operational hooks, maintain reports, track improvement via baselines.',
|
|
}));
|
|
return { status: STATUS.FAIL, findings, evidence };
|
|
}
|
|
|
|
if (indicators >= totalIndicators) return { status: STATUS.PASS, findings, evidence };
|
|
return { status: STATUS.PARTIAL, findings, evidence };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main scan function
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Run deterministic posture assessment on a project.
|
|
* @param {string} targetPath - Absolute path to project root
|
|
* @returns {Promise<object>} - Full posture result with categories, grade, and findings
|
|
*/
|
|
export async function scan(targetPath) {
|
|
const startMs = Date.now();
|
|
resetCounter();
|
|
|
|
const projectRoot = resolve(targetPath);
|
|
|
|
// Load shared config files once
|
|
const globalSettingsPath = join(homedir(), '.claude', 'settings.json');
|
|
const projectSettingsPath = join(projectRoot, '.claude', 'settings.json');
|
|
const hooksJsonPath = join(projectRoot, 'hooks', 'hooks.json');
|
|
|
|
const globalSettings = await readJson(globalSettingsPath);
|
|
const projectSettings = await readJson(projectSettingsPath);
|
|
const hooksJson = await readJson(hooksJsonPath);
|
|
|
|
// Run all 16 category checks (13 security + 3 compliance)
|
|
const results = [];
|
|
results.push({ ...CATEGORIES[0], ...(await checkDenyFirst(projectRoot, globalSettings, projectSettings)) });
|
|
results.push({ ...CATEGORIES[1], ...(await checkSecretsProtection(projectRoot, hooksJson)) });
|
|
results.push({ ...CATEGORIES[2], ...(await checkPathGuarding(projectRoot, hooksJson)) });
|
|
results.push({ ...CATEGORIES[3], ...(await checkMcpTrust(projectRoot, globalSettings, projectSettings)) });
|
|
results.push({ ...CATEGORIES[4], ...(await checkDestructiveBlocking(projectRoot, hooksJson)) });
|
|
results.push({ ...CATEGORIES[5], ...(await checkSandboxConfig(projectRoot, globalSettings, projectSettings)) });
|
|
results.push({ ...CATEGORIES[6], ...(await checkHumanReview(projectRoot)) });
|
|
results.push({ ...CATEGORIES[7], ...(await checkPluginSources(projectRoot, globalSettings)) });
|
|
results.push({ ...CATEGORIES[8], ...(await checkSessionIsolation(projectRoot)) });
|
|
results.push({ ...CATEGORIES[9], ...(await checkCognitiveStateSecurity(projectRoot)) });
|
|
results.push({ ...CATEGORIES[10], ...(await checkPromptInjectionHardening(projectRoot, hooksJson)) });
|
|
results.push({ ...CATEGORIES[11], ...(await checkRuleOfTwo(projectRoot, hooksJson)) });
|
|
results.push({ ...CATEGORIES[12], ...(await checkLongHorizonMonitoring(projectRoot, hooksJson)) });
|
|
results.push({ ...CATEGORIES[13], ...(await checkEUAIActCompliance(projectRoot, hooksJson)) });
|
|
results.push({ ...CATEGORIES[14], ...(await checkNISTAlignment(projectRoot, hooksJson, projectSettings)) });
|
|
results.push({ ...CATEGORIES[15], ...(await checkISO42001Readiness(projectRoot, hooksJson)) });
|
|
|
|
// Compute grade
|
|
const applicable = results.filter(r => r.status !== STATUS.NA);
|
|
const passCount = results.filter(r => r.status === STATUS.PASS).length;
|
|
const partialCount = results.filter(r => r.status === STATUS.PARTIAL).length;
|
|
const failCount = results.filter(r => r.status === STATUS.FAIL).length;
|
|
const naCount = results.filter(r => r.status === STATUS.NA).length;
|
|
const score = passCount + (partialCount * 0.5);
|
|
const passRate = applicable.length > 0 ? score / applicable.length : 0;
|
|
|
|
// Count fails in critical categories (1, 2, 5)
|
|
const failsInCritCats = results.filter(r => CRITICAL_CATEGORIES.has(r.id) && r.status === STATUS.FAIL).length;
|
|
|
|
// Collect all findings and count by severity
|
|
const allFindings = results.flatMap(r => r.findings);
|
|
const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
for (const f of allFindings) {
|
|
if (counts[f.severity] !== undefined) counts[f.severity]++;
|
|
}
|
|
|
|
const grade = gradeFromPassRate(passRate, failsInCritCats, counts.critical);
|
|
|
|
// Risk score
|
|
const riskScoreValue = Math.min(
|
|
counts.critical * 25 + counts.high * 10 + counts.medium * 4 + counts.low * 1,
|
|
100,
|
|
);
|
|
|
|
const riskBandValue =
|
|
riskScoreValue <= 20 ? 'Low' :
|
|
riskScoreValue <= 40 ? 'Medium' :
|
|
riskScoreValue <= 60 ? 'High' :
|
|
riskScoreValue <= 80 ? 'Critical' : 'Extreme';
|
|
|
|
const verdictValue =
|
|
counts.critical >= 1 || riskScoreValue >= 61 ? 'BLOCK' :
|
|
counts.high >= 1 || riskScoreValue >= 21 ? 'WARNING' : 'ALLOW';
|
|
|
|
const durationMs = Date.now() - startMs;
|
|
|
|
return {
|
|
scanner: 'posture-scanner',
|
|
version: VERSION,
|
|
status: 'ok',
|
|
target: projectRoot,
|
|
timestamp: new Date().toISOString(),
|
|
duration_ms: durationMs,
|
|
categories: results.map(r => ({
|
|
id: r.id,
|
|
name: r.name,
|
|
owasp: r.owasp,
|
|
status: r.status,
|
|
findings_count: r.findings.length,
|
|
evidence: r.evidence,
|
|
})),
|
|
findings: allFindings,
|
|
counts,
|
|
scoring: {
|
|
pass: passCount,
|
|
partial: partialCount,
|
|
fail: failCount,
|
|
na: naCount,
|
|
applicable: applicable.length,
|
|
score,
|
|
pass_rate: Math.round(passRate * 100) / 100,
|
|
grade,
|
|
},
|
|
risk: {
|
|
score: riskScoreValue,
|
|
band: riskBandValue,
|
|
verdict: verdictValue,
|
|
},
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CLI entry point
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const isMain = process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
|
|
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
if (isMain) {
|
|
const target = process.argv[2] || process.cwd();
|
|
const absTarget = resolve(target);
|
|
|
|
try {
|
|
const result = await scan(absTarget);
|
|
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
process.exit(result.scoring.grade === 'F' ? 1 : 0);
|
|
} catch (err) {
|
|
process.stderr.write(`Error: ${err.message}\n`);
|
|
process.exit(2);
|
|
}
|
|
}
|