ktg-plugin-marketplace/plugins/llm-security/scanners/posture-scanner.mjs
Kjell Tore Guttormsen d83424a782 feat(llm-security)!: v7.0.0 commit 1 — severity-dominated log-scaled risk score
Replace sum-and-cap formula (every non-trivial scan → 100/Extreme) with
severity-dominated, log-scaled-within-tier model. Discriminates actual
risk: 1 critical = 80, 2 critical = 86, 17 high = 65. Hyperframes-class
rendering codebases no longer collapse to Extreme just from shader noise.

Changes:
- scanners/lib/severity.mjs: new riskScore() v2; keep riskScoreV1() for
  reference; riskBand() cutoffs aligned (14/39/64/84).
- scanners/posture-scanner.mjs: delete inline duplicate formula, import
  riskScore/riskBand/verdict from severity.mjs. Single source of truth.

Breaking: aggregate.risk_score semantics change. Batched with entropy
suppression (Commit 2+) under v7.0.0 bump in Commit 6. Do not release
individually — JSON consumers depend on scoring band stability.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 22:00:29 +02:00

1554 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, riskScore, riskBand, verdict, 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 (delegated to severity.mjs — single source of truth, v7.0.0+)
const riskScoreValue = riskScore(counts);
const riskBandValue = riskBand(riskScoreValue);
const verdictValue = verdict(counts);
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);
}
}