298 lines
10 KiB
JavaScript
298 lines
10 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* privacy-checker.cjs - Privacy pattern matching logic for sensitive file detection
|
|
*
|
|
* Extracted from privacy-block.cjs for reuse in both Claude hooks and OpenCode plugins.
|
|
* Pure logic module - no stdin/stdout, no exit codes.
|
|
*
|
|
* @module privacy-checker
|
|
*/
|
|
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// CONSTANTS
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
const APPROVED_PREFIX = 'APPROVED:';
|
|
|
|
// Safe file patterns - exempt from privacy checks (documentation/template files)
|
|
const SAFE_PATTERNS = [
|
|
/\.example$/i, // .env.example, config.example
|
|
/\.sample$/i, // .env.sample
|
|
/\.template$/i, // .env.template
|
|
];
|
|
|
|
// Privacy-sensitive patterns
|
|
const PRIVACY_PATTERNS = [
|
|
/^\.env$/, // .env
|
|
/^\.env\./, // .env.local, .env.production, etc.
|
|
/\.env$/, // path/to/.env
|
|
/\/\.env\./, // path/to/.env.local
|
|
/credentials/i, // credentials.json, etc.
|
|
/secrets?\.ya?ml$/i, // secrets.yaml, secret.yml
|
|
/\.pem$/, // Private keys
|
|
/\.key$/, // Private keys
|
|
/id_rsa/, // SSH keys
|
|
/id_ed25519/, // SSH keys
|
|
];
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// HELPER FUNCTIONS
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
/**
|
|
* Check if path is a safe file (example/sample/template)
|
|
* @param {string} testPath - Path to check
|
|
* @returns {boolean} true if file matches safe patterns
|
|
*/
|
|
function isSafeFile(testPath) {
|
|
if (!testPath) return false;
|
|
const basename = path.basename(testPath);
|
|
return SAFE_PATTERNS.some(p => p.test(basename));
|
|
}
|
|
|
|
/**
|
|
* Check if path has APPROVED: prefix
|
|
* @param {string} testPath - Path to check
|
|
* @returns {boolean} true if path starts with APPROVED:
|
|
*/
|
|
function hasApprovalPrefix(testPath) {
|
|
return testPath && testPath.startsWith(APPROVED_PREFIX);
|
|
}
|
|
|
|
/**
|
|
* Strip APPROVED: prefix from path
|
|
* @param {string} testPath - Path to process
|
|
* @returns {string} Path without APPROVED: prefix
|
|
*/
|
|
function stripApprovalPrefix(testPath) {
|
|
if (hasApprovalPrefix(testPath)) {
|
|
return testPath.slice(APPROVED_PREFIX.length);
|
|
}
|
|
return testPath;
|
|
}
|
|
|
|
/**
|
|
* Check if stripped path is suspicious (path traversal or absolute)
|
|
* @param {string} strippedPath - Path after stripping APPROVED: prefix
|
|
* @returns {boolean} true if path looks suspicious
|
|
*/
|
|
function isSuspiciousPath(strippedPath) {
|
|
return strippedPath.includes('..') || path.isAbsolute(strippedPath);
|
|
}
|
|
|
|
/**
|
|
* Check if path matches privacy patterns
|
|
* @param {string} testPath - Path to check
|
|
* @returns {boolean} true if path matches privacy-sensitive patterns
|
|
*/
|
|
function isPrivacySensitive(testPath) {
|
|
if (!testPath) return false;
|
|
|
|
// Strip prefix for pattern matching
|
|
const cleanPath = stripApprovalPrefix(testPath);
|
|
let normalized = cleanPath.replace(/\\/g, '/');
|
|
|
|
// Decode URI components to catch obfuscated paths (%2e = '.')
|
|
try {
|
|
normalized = decodeURIComponent(normalized);
|
|
} catch (e) {
|
|
// Invalid encoding, use as-is
|
|
}
|
|
|
|
// Check safe patterns first - exempt example/sample/template files
|
|
if (isSafeFile(normalized)) {
|
|
return false;
|
|
}
|
|
|
|
const basename = path.basename(normalized);
|
|
|
|
for (const pattern of PRIVACY_PATTERNS) {
|
|
if (pattern.test(basename) || pattern.test(normalized)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Extract paths from tool input
|
|
* @param {Object} toolInput - Tool input object with file_path, path, pattern, or command
|
|
* @returns {Array<{value: string, field: string}>} Array of extracted paths with field names
|
|
*/
|
|
function extractPaths(toolInput) {
|
|
const paths = [];
|
|
if (!toolInput) return paths;
|
|
|
|
if (toolInput.file_path) paths.push({ value: toolInput.file_path, field: 'file_path' });
|
|
if (toolInput.path) paths.push({ value: toolInput.path, field: 'path' });
|
|
if (toolInput.pattern) paths.push({ value: toolInput.pattern, field: 'pattern' });
|
|
|
|
// Check bash commands for file paths
|
|
if (toolInput.command) {
|
|
// Look for APPROVED:.env or .env patterns
|
|
const approvedMatch = toolInput.command.match(/APPROVED:[^\s]+/g) || [];
|
|
approvedMatch.forEach(p => paths.push({ value: p, field: 'command' }));
|
|
|
|
// Only look for .env if no APPROVED: version found
|
|
if (approvedMatch.length === 0) {
|
|
const envMatch = toolInput.command.match(/\.env[^\s]*/g) || [];
|
|
envMatch.forEach(p => paths.push({ value: p, field: 'command' }));
|
|
|
|
// Also check bash variable assignments (FILE=.env, ENV_FILE=.env.local)
|
|
const varAssignments = toolInput.command.match(/\w+=[^\s]*\.env[^\s]*/g) || [];
|
|
varAssignments.forEach(a => {
|
|
const value = a.split('=')[1];
|
|
if (value) paths.push({ value, field: 'command' });
|
|
});
|
|
|
|
// Check command substitution containing sensitive patterns - extract .env from inside
|
|
const cmdSubst = toolInput.command.match(/\$\([^)]*?(\.env[^\s)]*)[^)]*\)/g) || [];
|
|
for (const subst of cmdSubst) {
|
|
const inner = subst.match(/\.env[^\s)]*/);
|
|
if (inner) paths.push({ value: inner[0], field: 'command' });
|
|
}
|
|
}
|
|
}
|
|
|
|
return paths.filter(p => p.value);
|
|
}
|
|
|
|
/**
|
|
* Load .ck.json config to check if privacy block is disabled
|
|
* @param {string} [configDir] - Directory containing .ck.json (defaults to .claude in cwd)
|
|
* @returns {boolean} true if privacy block should be skipped
|
|
*/
|
|
function isPrivacyBlockDisabled(configDir) {
|
|
try {
|
|
const configPath = configDir
|
|
? path.join(configDir, '.ck.json')
|
|
: path.join(process.cwd(), '.claude', '.ck.json');
|
|
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
return config.privacyBlock === false;
|
|
} catch {
|
|
return false; // Default to enabled on error (file not found or invalid JSON)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build prompt data for AskUserQuestion tool
|
|
* @param {string} filePath - Blocked file path
|
|
* @returns {Object} Prompt data object
|
|
*/
|
|
function buildPromptData(filePath) {
|
|
const basename = path.basename(filePath);
|
|
return {
|
|
type: 'PRIVACY_PROMPT',
|
|
file: filePath,
|
|
basename: basename,
|
|
question: {
|
|
header: 'File Access',
|
|
text: `I need to read "${basename}" which may contain sensitive data (API keys, passwords, tokens). Do you approve?`,
|
|
options: [
|
|
{ label: 'Yes, approve access', description: `Allow reading ${basename} this time` },
|
|
{ label: 'No, skip this file', description: 'Continue without accessing this file' }
|
|
]
|
|
}
|
|
};
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// MAIN ENTRY POINT
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
/**
|
|
* Check if a tool call accesses privacy-sensitive files
|
|
*
|
|
* @param {Object} params
|
|
* @param {string} params.toolName - Name of tool (Read, Write, Bash, etc.)
|
|
* @param {Object} params.toolInput - Tool input with file_path, path, command, etc.
|
|
* @param {Object} [params.options]
|
|
* @param {boolean} [params.options.disabled] - Skip checks if true
|
|
* @param {string} [params.options.configDir] - Directory for .ck.json config
|
|
* @param {boolean} [params.options.allowBash] - Allow Bash tool without blocking (default: true)
|
|
* @returns {{
|
|
* blocked: boolean,
|
|
* filePath?: string,
|
|
* reason?: string,
|
|
* approved?: boolean,
|
|
* isBash?: boolean,
|
|
* suspicious?: boolean,
|
|
* promptData?: Object
|
|
* }}
|
|
*/
|
|
function checkPrivacy({ toolName, toolInput, options = {} }) {
|
|
const { disabled, configDir, allowBash = true } = options;
|
|
|
|
// Check if disabled via options or config
|
|
if (disabled || isPrivacyBlockDisabled(configDir)) {
|
|
return { blocked: false };
|
|
}
|
|
|
|
const isBashTool = toolName === 'Bash';
|
|
const paths = extractPaths(toolInput);
|
|
|
|
// Check each path
|
|
for (const { value: testPath } of paths) {
|
|
if (!isPrivacySensitive(testPath)) continue;
|
|
|
|
// Check for approval prefix
|
|
if (hasApprovalPrefix(testPath)) {
|
|
const strippedPath = stripApprovalPrefix(testPath);
|
|
return {
|
|
blocked: false,
|
|
approved: true,
|
|
filePath: strippedPath,
|
|
suspicious: isSuspiciousPath(strippedPath)
|
|
};
|
|
}
|
|
|
|
// For Bash: warn but don't block (allows "Yes → bash cat" flow)
|
|
if (isBashTool && allowBash) {
|
|
return {
|
|
blocked: false,
|
|
isBash: true,
|
|
filePath: testPath,
|
|
reason: `Bash command accesses sensitive file: ${testPath}`
|
|
};
|
|
}
|
|
|
|
// Block - sensitive file without approval
|
|
return {
|
|
blocked: true,
|
|
filePath: testPath,
|
|
reason: `Sensitive file access requires user approval`,
|
|
promptData: buildPromptData(testPath)
|
|
};
|
|
}
|
|
|
|
// No sensitive paths found
|
|
return { blocked: false };
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// EXPORTS
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
module.exports = {
|
|
// Main entry point
|
|
checkPrivacy,
|
|
|
|
// Helper functions (for testing and direct use)
|
|
isSafeFile,
|
|
isPrivacySensitive,
|
|
hasApprovalPrefix,
|
|
stripApprovalPrefix,
|
|
isSuspiciousPath,
|
|
extractPaths,
|
|
isPrivacyBlockDisabled,
|
|
buildPromptData,
|
|
|
|
// Constants
|
|
APPROVED_PREFIX,
|
|
SAFE_PATTERNS,
|
|
PRIVACY_PATTERNS
|
|
};
|