feat(llm-security-copilot): port llm-security v5.1.0 to GitHub Copilot CLI
Full port of llm-security plugin for internal use on Windows with GitHub Copilot CLI. Protocol translation layer (copilot-hook-runner.mjs) normalizes Copilot camelCase I/O to Claude Code snake_case format — all original hook scripts run unmodified. - 8 hooks with protocol translation (stdin/stdout/exit code) - 18 SKILL.md skills (Agent Skills Open Standard) - 6 .agent.md agent definitions - 20 scanners + 14 scanner lib modules (unchanged) - 14 knowledge files (unchanged) - 39 test files including copilot-port-verify.mjs (17 tests) - Windows-ready: node:path, os.tmpdir(), process.execPath, no bash Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
901bf0ae12
commit
f418a8fe08
169 changed files with 37631 additions and 0 deletions
|
|
@ -0,0 +1,423 @@
|
|||
// memory-poisoning-scanner.mjs — Detects cognitive state poisoning in CLAUDE.md,
|
||||
// memory files, .claude/rules, and other agent configuration files.
|
||||
//
|
||||
// "Cognitive State Traps" (Franklin et al., Google DeepMind, 2025):
|
||||
// Latent Memory Poisoning + RAG Knowledge Poisoning. CLAUDE.md and memory/*.md
|
||||
// are Claude Code's equivalent of RAG corpora — loaded automatically into context,
|
||||
// potentially containing instructions the agent follows uncritically.
|
||||
//
|
||||
// Zero external dependencies — Node.js builtins only.
|
||||
// OWASP coverage: LLM01 (Prompt Injection), ASI02 (Excessive Agency)
|
||||
|
||||
import { readTextFile } from './lib/file-discovery.mjs';
|
||||
import { finding, scannerResult } from './lib/output.mjs';
|
||||
import { SEVERITY } from './lib/severity.mjs';
|
||||
import { scanForInjection } from './lib/injection-patterns.mjs';
|
||||
import { isBase64Like, isHexBlob } from './lib/string-utils.mjs';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Target file patterns — files that influence agent cognition
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Glob-like path matchers for memory/config files */
|
||||
const MEMORY_FILE_PATTERNS = [
|
||||
/(?:^|\/)CLAUDE\.md$/i,
|
||||
/(?:^|\/)\.claude\/rules\/[^/]+\.md$/,
|
||||
/(?:^|\/)memory\/[^/]+\.md$/,
|
||||
/(?:^|\/)REMEMBER\.md$/i,
|
||||
/\.local\.md$/,
|
||||
/(?:^|\/)\.claude-plugin\/plugin\.json$/,
|
||||
];
|
||||
|
||||
/** Files that are CLAUDE.md (may legitimately contain shell examples) */
|
||||
const CLAUDE_MD_PATTERN = /(?:^|\/)CLAUDE\.md$/i;
|
||||
|
||||
/** Files that should NOT contain shell commands (memory, rules, REMEMBER) */
|
||||
const STRICT_FILES_PATTERN = /(?:^|\/)(?:memory\/[^/]+\.md|REMEMBER\.md|\.claude\/rules\/[^/]+\.md)$/i;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Detection patterns
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Shell commands that should not appear in memory/rules files */
|
||||
const SHELL_COMMAND_RE = /(?:^|[`'";\s|&])(?:curl|wget|bash|sh|eval|exec|chmod\s+[+0-7]|npm\s+install|pip3?\s+install|gem\s+install|cargo\s+install)\b/i;
|
||||
|
||||
/** Credential path references */
|
||||
const CREDENTIAL_PATH_RE = /(?:~\/|\/home\/|\/root\/)?\.(?:ssh|aws|gnupg|config\/gcloud)\/|(?:^|[\s/"'`])(?:id_rsa|id_ed25519|id_ecdsa|wallet\.dat|keystore|credentials\.json|\.env(?:\.\w+)?|\.netrc|\.pgpass|kubeconfig|service[_-]account[_-]key)(?:[\s/"'`]|$)/i;
|
||||
|
||||
/** Permission expansion directives */
|
||||
const PERMISSION_EXPANSION_RE = /(?:allowed-tools\s*[=:]\s*.*(?:Bash|Write|Edit|all)|bypassPermissions\s*[=:]\s*true|dangerouslySkipPermissions|--dangerously-skip-permissions|dangerouslyAllowArbitraryPaths\s*[=:]\s*true)/i;
|
||||
|
||||
/** Suspicious exfiltration domains (subset of network-mapper) */
|
||||
const SUSPICIOUS_DOMAINS = new Set([
|
||||
'webhook.site', 'webhookinbox.com',
|
||||
'requestbin.com', 'requestbin.net',
|
||||
'pipedream.net', 'hookbin.com',
|
||||
'beeceptor.com', 'requestcatcher.com',
|
||||
'ngrok.io', 'ngrok.app', 'ngrok-free.app',
|
||||
'serveo.net', 'localtunnel.me',
|
||||
'pastebin.com', 'paste.ee',
|
||||
'transfer.sh', 'file.io',
|
||||
'temp.sh', '0x0.st',
|
||||
]);
|
||||
|
||||
/** URL extraction regex */
|
||||
const URL_RE = /https?:\/\/([a-zA-Z0-9][-a-zA-Z0-9]*(?:\.[a-zA-Z0-9][-a-zA-Z0-9]*)+)(?:\/[^\s"'`)\]]*)?/g;
|
||||
|
||||
/** Base64 token regex (40+ chars) */
|
||||
const BASE64_TOKEN_RE = /[A-Za-z0-9+/]{40,}={0,3}/g;
|
||||
|
||||
/** Hex blob regex (64+ chars) */
|
||||
const HEX_TOKEN_RE = /(?:0x)?[0-9a-fA-F]{64,}/g;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function isMemoryFile(relPath) {
|
||||
return MEMORY_FILE_PATTERNS.some(p => p.test(relPath));
|
||||
}
|
||||
|
||||
function isStrictFile(relPath) {
|
||||
return STRICT_FILES_PATTERN.test(relPath);
|
||||
}
|
||||
|
||||
function isClaudeMd(relPath) {
|
||||
return CLAUDE_MD_PATTERN.test(relPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract domain from a URL match.
|
||||
* @param {string} host - hostname from URL regex
|
||||
* @returns {string} - domain to check against suspicious set
|
||||
*/
|
||||
function getDomainForCheck(host) {
|
||||
const parts = host.toLowerCase().split('.');
|
||||
// Return last two parts (e.g., "webhook.site" from "abc.webhook.site")
|
||||
if (parts.length >= 2) {
|
||||
return parts.slice(-2).join('.');
|
||||
}
|
||||
return host.toLowerCase();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Detection functions — each returns an array of findings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Check 1: Injection patterns via shared injection-patterns.mjs
|
||||
*/
|
||||
function detectInjection(content, relPath) {
|
||||
const results = [];
|
||||
const scan = scanForInjection(content);
|
||||
|
||||
if (!scan.found) return results;
|
||||
|
||||
for (const { label, severity } of scan.patterns) {
|
||||
let sev;
|
||||
if (severity === 'critical') sev = SEVERITY.CRITICAL;
|
||||
else if (severity === 'high') sev = SEVERITY.HIGH;
|
||||
else sev = SEVERITY.MEDIUM;
|
||||
|
||||
results.push(finding({
|
||||
scanner: 'MEM',
|
||||
severity: sev,
|
||||
title: `Injection pattern in cognitive state file: ${label}`,
|
||||
description:
|
||||
`Memory/config file "${relPath}" contains a prompt injection pattern: "${label}". ` +
|
||||
'These files are loaded into agent context automatically and can manipulate agent behavior.',
|
||||
file: relPath,
|
||||
evidence: label,
|
||||
owasp: 'LLM01',
|
||||
recommendation:
|
||||
'Remove the injection pattern. Review the file for unauthorized modifications. ' +
|
||||
'Consider adding integrity checks for CLAUDE.md and memory files.',
|
||||
}));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 2: Shell commands in files that shouldn't have them
|
||||
*/
|
||||
function detectShellCommands(content, relPath) {
|
||||
const results = [];
|
||||
const lines = content.split('\n');
|
||||
const strict = isStrictFile(relPath);
|
||||
const claudeMd = isClaudeMd(relPath);
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (!SHELL_COMMAND_RE.test(line)) continue;
|
||||
|
||||
// In CLAUDE.md, shell commands in code blocks are legitimate
|
||||
if (claudeMd) {
|
||||
// Check if we're inside a code block (simple heuristic: track ``` state)
|
||||
let inCodeBlock = false;
|
||||
for (let j = 0; j <= i; j++) {
|
||||
if (lines[j].trim().startsWith('```')) inCodeBlock = !inCodeBlock;
|
||||
}
|
||||
if (inCodeBlock) continue; // legitimate code example
|
||||
|
||||
results.push(finding({
|
||||
scanner: 'MEM',
|
||||
severity: SEVERITY.LOW,
|
||||
title: 'Shell command outside code block in CLAUDE.md',
|
||||
description:
|
||||
`CLAUDE.md line ${i + 1} contains a shell command outside a code block. ` +
|
||||
'This may be a legitimate instruction or an attempt to get the agent to execute commands.',
|
||||
file: relPath,
|
||||
line: i + 1,
|
||||
evidence: line.trim().slice(0, 120),
|
||||
owasp: 'LLM01',
|
||||
recommendation:
|
||||
'Verify this shell command is intentional. If it is an instruction for the agent, ' +
|
||||
'consider whether it could be exploited via CLAUDE.md poisoning.',
|
||||
}));
|
||||
} else if (strict) {
|
||||
results.push(finding({
|
||||
scanner: 'MEM',
|
||||
severity: SEVERITY.HIGH,
|
||||
title: 'Shell command in memory/rules file',
|
||||
description:
|
||||
`Memory/rules file "${relPath}" line ${i + 1} contains a shell command. ` +
|
||||
'Memory and rules files should not contain executable commands — this is ' +
|
||||
'a strong indicator of cognitive state poisoning.',
|
||||
file: relPath,
|
||||
line: i + 1,
|
||||
evidence: line.trim().slice(0, 120),
|
||||
owasp: 'LLM01',
|
||||
recommendation:
|
||||
'Remove the shell command from this memory file. Memory files should contain ' +
|
||||
'state and context only, never executable instructions.',
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 3: Suspicious URLs (exfiltration domains)
|
||||
*/
|
||||
function detectSuspiciousUrls(content, relPath) {
|
||||
const results = [];
|
||||
const lines = content.split('\n');
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
URL_RE.lastIndex = 0;
|
||||
let match;
|
||||
while ((match = URL_RE.exec(line)) !== null) {
|
||||
const host = match[1];
|
||||
const domain = getDomainForCheck(host);
|
||||
if (SUSPICIOUS_DOMAINS.has(domain)) {
|
||||
results.push(finding({
|
||||
scanner: 'MEM',
|
||||
severity: SEVERITY.HIGH,
|
||||
title: 'Suspicious exfiltration URL in cognitive state file',
|
||||
description:
|
||||
`File "${relPath}" line ${i + 1} references "${domain}" — a known ` +
|
||||
'data exfiltration / webhook interception service. In a memory or CLAUDE.md file, ' +
|
||||
'this could instruct the agent to send data to an attacker-controlled endpoint.',
|
||||
file: relPath,
|
||||
line: i + 1,
|
||||
evidence: match[0].slice(0, 100),
|
||||
owasp: 'LLM01',
|
||||
recommendation:
|
||||
'Remove the suspicious URL. Investigate how it was introduced. ' +
|
||||
'Memory and config files should never reference webhook/tunnel services.',
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 4: Credential path references
|
||||
*/
|
||||
function detectCredentialPaths(content, relPath) {
|
||||
const results = [];
|
||||
const lines = content.split('\n');
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const match = line.match(CREDENTIAL_PATH_RE);
|
||||
if (!match) continue;
|
||||
|
||||
// In CLAUDE.md inside code blocks, credential path refs may be legitimate documentation
|
||||
if (isClaudeMd(relPath)) {
|
||||
let inCodeBlock = false;
|
||||
for (let j = 0; j <= i; j++) {
|
||||
if (lines[j].trim().startsWith('```')) inCodeBlock = !inCodeBlock;
|
||||
}
|
||||
if (inCodeBlock) continue;
|
||||
}
|
||||
|
||||
results.push(finding({
|
||||
scanner: 'MEM',
|
||||
severity: SEVERITY.HIGH,
|
||||
title: 'Credential path reference in cognitive state file',
|
||||
description:
|
||||
`File "${relPath}" line ${i + 1} references a credential path (${match[0].trim()}). ` +
|
||||
'Memory files that reference credential locations could be used to instruct the agent ' +
|
||||
'to access sensitive key material.',
|
||||
file: relPath,
|
||||
line: i + 1,
|
||||
evidence: match[0].trim().slice(0, 80),
|
||||
owasp: 'ASI02',
|
||||
recommendation:
|
||||
'Remove credential path references from memory/config files. ' +
|
||||
'If credential paths need documentation, use a separate secured document.',
|
||||
}));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 5: Permission expansion directives
|
||||
*/
|
||||
function detectPermissionExpansion(content, relPath) {
|
||||
const results = [];
|
||||
const lines = content.split('\n');
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const match = line.match(PERMISSION_EXPANSION_RE);
|
||||
if (!match) continue;
|
||||
|
||||
results.push(finding({
|
||||
scanner: 'MEM',
|
||||
severity: SEVERITY.CRITICAL,
|
||||
title: 'Permission expansion directive in cognitive state file',
|
||||
description:
|
||||
`File "${relPath}" line ${i + 1} contains a permission expansion directive: ` +
|
||||
`"${match[0].trim()}". This could grant the agent excessive capabilities through ` +
|
||||
'configuration poisoning.',
|
||||
file: relPath,
|
||||
line: i + 1,
|
||||
evidence: match[0].trim().slice(0, 100),
|
||||
owasp: 'ASI02',
|
||||
recommendation:
|
||||
'Remove the permission expansion directive. Agent permissions should be configured ' +
|
||||
'in settings.json with deny-first approach, not in memory or rules files.',
|
||||
}));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 6: Encoded payloads (base64 / hex blobs)
|
||||
*/
|
||||
function detectEncodedPayloads(content, relPath) {
|
||||
const results = [];
|
||||
const lines = content.split('\n');
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
// Check base64
|
||||
BASE64_TOKEN_RE.lastIndex = 0;
|
||||
let match;
|
||||
while ((match = BASE64_TOKEN_RE.exec(line)) !== null) {
|
||||
if (isBase64Like(match[0])) {
|
||||
results.push(finding({
|
||||
scanner: 'MEM',
|
||||
severity: SEVERITY.MEDIUM,
|
||||
title: 'Base64-encoded payload in cognitive state file',
|
||||
description:
|
||||
`File "${relPath}" line ${i + 1} contains a base64-encoded string (${match[0].length} chars). ` +
|
||||
'Encoded payloads in memory files can hide injection instructions or exfiltration commands ' +
|
||||
'that evade text-based detection.',
|
||||
file: relPath,
|
||||
line: i + 1,
|
||||
evidence: `${match[0].slice(0, 40)}... (${match[0].length} chars)`,
|
||||
owasp: 'LLM01',
|
||||
recommendation:
|
||||
'Decode and inspect the base64 content. Remove if it contains instructions or commands. ' +
|
||||
'Memory files should contain plain text only.',
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Check hex blobs
|
||||
HEX_TOKEN_RE.lastIndex = 0;
|
||||
while ((match = HEX_TOKEN_RE.exec(line)) !== null) {
|
||||
if (isHexBlob(match[0])) {
|
||||
results.push(finding({
|
||||
scanner: 'MEM',
|
||||
severity: SEVERITY.MEDIUM,
|
||||
title: 'Hex-encoded blob in cognitive state file',
|
||||
description:
|
||||
`File "${relPath}" line ${i + 1} contains a hex-encoded blob (${match[0].length} chars). ` +
|
||||
'Hex-encoded data in memory files can conceal malicious payloads.',
|
||||
file: relPath,
|
||||
line: i + 1,
|
||||
evidence: `${match[0].slice(0, 40)}... (${match[0].length} chars)`,
|
||||
owasp: 'LLM01',
|
||||
recommendation:
|
||||
'Decode and inspect the hex content. Remove if it contains instructions or commands.',
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main scanner export
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Scan all discovered files for memory/cognitive state poisoning.
|
||||
* Only processes files matching MEMORY_FILE_PATTERNS.
|
||||
*
|
||||
* @param {string} targetPath - Absolute root path being scanned
|
||||
* @param {{ files: import('./lib/file-discovery.mjs').FileInfo[] }} discovery
|
||||
* @returns {Promise<object>} - scannerResult envelope
|
||||
*/
|
||||
export async function scan(targetPath, discovery) {
|
||||
const startMs = Date.now();
|
||||
const findings = [];
|
||||
let filesScanned = 0;
|
||||
|
||||
try {
|
||||
for (const fileInfo of discovery.files) {
|
||||
// Only scan memory/config files
|
||||
if (!isMemoryFile(fileInfo.relPath)) continue;
|
||||
|
||||
const content = await readTextFile(fileInfo.absPath);
|
||||
if (content === null) continue;
|
||||
|
||||
filesScanned++;
|
||||
|
||||
// Run all 6 detectors
|
||||
findings.push(...detectInjection(content, fileInfo.relPath));
|
||||
findings.push(...detectShellCommands(content, fileInfo.relPath));
|
||||
findings.push(...detectSuspiciousUrls(content, fileInfo.relPath));
|
||||
findings.push(...detectCredentialPaths(content, fileInfo.relPath));
|
||||
findings.push(...detectPermissionExpansion(content, fileInfo.relPath));
|
||||
findings.push(...detectEncodedPayloads(content, fileInfo.relPath));
|
||||
}
|
||||
|
||||
const durationMs = Date.now() - startMs;
|
||||
return scannerResult('memory-poisoning-scanner', 'ok', findings, filesScanned, durationMs);
|
||||
|
||||
} catch (err) {
|
||||
const durationMs = Date.now() - startMs;
|
||||
return scannerResult(
|
||||
'memory-poisoning-scanner',
|
||||
'error',
|
||||
findings,
|
||||
filesScanned,
|
||||
durationMs,
|
||||
err.message,
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue