506 lines
15 KiB
JavaScript
506 lines
15 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* security-audit.cjs - Comprehensive security scanner for Clawdbot
|
||
* Usage: node audit.js [--full] [--json] [--credentials] [--ports] [--configs] [--permissions] [--docker]
|
||
*/
|
||
|
||
const { execSync } = require('child_process');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const crypto = require('crypto');
|
||
|
||
// Configuration
|
||
const CLAWDBOT_DIR = '/root/clawd';
|
||
const CONFIG_DIR = '/root/clawd/skills/.env';
|
||
const DOCKER_DIR = '/root/clawd';
|
||
|
||
// Results collection
|
||
const findings = [];
|
||
let checkCount = 0;
|
||
let criticalCount = 0;
|
||
let highCount = 0;
|
||
|
||
// Helper functions
|
||
function log(level, category, message, details = null) {
|
||
const emoji = {
|
||
CRITICAL: '🔴',
|
||
HIGH: '🟠',
|
||
MEDIUM: '🟡',
|
||
LOW: '🟢',
|
||
INFO: '🔵'
|
||
};
|
||
|
||
findings.push({
|
||
level,
|
||
category,
|
||
message,
|
||
details,
|
||
timestamp: new Date().toISOString()
|
||
});
|
||
|
||
checkCount++;
|
||
if (level === 'CRITICAL') criticalCount++;
|
||
if (level === 'HIGH') highCount++;
|
||
}
|
||
|
||
function checkFileExists(filePath) {
|
||
try {
|
||
return fs.existsSync(filePath);
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function scanFileForPatterns(filePath, patterns, category) {
|
||
if (!checkFileExists(filePath)) return;
|
||
|
||
try {
|
||
const content = fs.readFileSync(filePath, 'utf8');
|
||
|
||
for (const pattern of patterns) {
|
||
if (pattern.regex.test(content)) {
|
||
log(pattern.level, category, pattern.message, {
|
||
file: filePath,
|
||
match: pattern.match
|
||
});
|
||
}
|
||
}
|
||
} catch (e) {
|
||
// Ignore unreadable files
|
||
}
|
||
}
|
||
|
||
function getFilesRecursively(dir, extensions = ['.js', '.ts', '.json', '.env', '.md', '.yml', '.yaml']) {
|
||
const files = [];
|
||
|
||
function traverse(currentDir) {
|
||
try {
|
||
const entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
||
|
||
for (const entry of entries) {
|
||
const fullPath = path.join(currentDir, entry.name);
|
||
|
||
if (entry.isDirectory()) {
|
||
if (!entry.name.startsWith('.') && !entry.name.includes('node_modules')) {
|
||
traverse(fullPath);
|
||
}
|
||
} else if (extensions.some(ext => entry.name.endsWith(ext))) {
|
||
files.push(fullPath);
|
||
}
|
||
}
|
||
} catch {
|
||
// Ignore inaccessible directories
|
||
}
|
||
}
|
||
|
||
traverse(dir);
|
||
return files;
|
||
}
|
||
|
||
// === CHECKS ===
|
||
|
||
function checkCredentials() {
|
||
log('INFO', 'CREDENTIALS', 'Starting credential scan...');
|
||
|
||
const credentialPatterns = [
|
||
{
|
||
level: 'CRITICAL',
|
||
message: 'Potential API key found in file',
|
||
regex: /api[_-]?key\s*[:=]\s*['"'][a-zA-Z0-9]{20,}['"']/gi,
|
||
match: 'API key pattern'
|
||
},
|
||
{
|
||
level: 'CRITICAL',
|
||
message: 'Potential secret token found',
|
||
regex: /(secret|token|auth)[_-]?key\s*[:=]\s*['"'][a-zA-Z0-9_\-]{30,}['"']/gi,
|
||
match: 'Secret pattern'
|
||
},
|
||
{
|
||
level: 'HIGH',
|
||
message: 'Hardcoded password found',
|
||
regex: /password\s*[:=]\s*['"'][^'"']{8,}['"']/gi,
|
||
match: 'Password pattern'
|
||
},
|
||
{
|
||
level: 'HIGH',
|
||
message: 'Private key detected',
|
||
regex: /-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/g,
|
||
match: 'Private key'
|
||
},
|
||
{
|
||
level: 'MEDIUM',
|
||
message: 'URL with credentials found',
|
||
regex: /https?:\/\/[^:]+:[^@]+@/g,
|
||
match: 'URL with credentials'
|
||
}
|
||
];
|
||
|
||
// Scan key files
|
||
const keyFiles = [
|
||
CONFIG_DIR,
|
||
path.join(CLAWDBOT_DIR, 'skills/.env'),
|
||
path.join(CLAWDBOT_DIR, '.env'),
|
||
path.join(CLAWDBOT_DIR, 'config.json')
|
||
];
|
||
|
||
for (const file of keyFiles) {
|
||
scanFileForPatterns(file, credentialPatterns, 'CREDENTIALS');
|
||
}
|
||
|
||
// Scan all code files
|
||
const codeFiles = getFilesRecursively(CLAWDBOT_DIR);
|
||
|
||
for (const file of codeFiles) {
|
||
if (file.includes('node_modules') || file.includes('.git')) continue;
|
||
scanFileForPatterns(file, credentialPatterns.filter(p => p.level !== 'CRITICAL'), 'CREDENTIALS');
|
||
}
|
||
|
||
log('INFO', 'CREDENTIALS', `Scanned ${codeFiles.length} files`);
|
||
}
|
||
|
||
function checkPorts() {
|
||
log('INFO', 'PORTS', 'Checking for open ports...');
|
||
|
||
try {
|
||
// Check if ss or netstat is available
|
||
const ssResult = execSync('ss -tlnp 2>/dev/null || netstat -tlnp 2>/dev/null || echo "not available"',
|
||
{ encoding: 'utf8', timeout: 5000 });
|
||
|
||
const ports = [];
|
||
const lines = ssResult.split('\n');
|
||
|
||
for (const line of lines) {
|
||
const portMatch = line.match(/:(\d+)\s/);
|
||
if (portMatch) {
|
||
const port = parseInt(portMatch[1]);
|
||
if (port > 1024 && !ports.includes(port)) {
|
||
ports.push(port);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (ports.length > 0) {
|
||
log('MEDIUM', 'PORTS', `Found ${ports.length} open ports`, { ports });
|
||
} else {
|
||
log('INFO', 'PORTS', 'No unexpected open ports detected');
|
||
}
|
||
} catch {
|
||
log('LOW', 'PORTS', 'Could not scan ports (tool not available)');
|
||
}
|
||
}
|
||
|
||
function checkConfigs() {
|
||
log('INFO', 'CONFIGS', 'Validating configuration security...');
|
||
|
||
// Check for .env file
|
||
if (!checkFileExists(CONFIG_DIR)) {
|
||
log('HIGH', 'CONFIGS', 'No .env file found - credentials may not be configured');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const envContent = fs.readFileSync(CONFIG_DIR, 'utf8');
|
||
|
||
// Check for rate limiting config
|
||
if (!envContent.includes('RATE_LIMIT')) {
|
||
log('MEDIUM', 'CONFIGS', 'No RATE_LIMIT configuration found');
|
||
}
|
||
|
||
// Check for auth settings
|
||
if (!envContent.includes('AUTH_') && !envContent.includes('API_KEY')) {
|
||
log('HIGH', 'CONFIGS', 'No authentication configuration detected');
|
||
}
|
||
|
||
// Check for log level
|
||
if (envContent.includes('LOG_LEVEL=debug') || envContent.includes('LOG_LEVEL=DEBUG')) {
|
||
log('MEDIUM', 'CONFIGS', 'Debug logging enabled - may expose sensitive data');
|
||
}
|
||
|
||
// Check for CORS
|
||
if (envContent.includes('CORS_ORIGIN=*') || envContent.includes('CORS_ALLOW_ALL=true')) {
|
||
log('HIGH', 'CONFIGS', 'CORS configured to allow all origins');
|
||
}
|
||
|
||
} catch (e) {
|
||
log('LOW', 'CONFIGS', 'Could not read configuration file');
|
||
}
|
||
}
|
||
|
||
function checkPermissions() {
|
||
log('INFO', 'PERMISSIONS', 'Checking file permissions...');
|
||
|
||
const sensitivePatterns = [
|
||
{ pattern: /\.env$/, level: 'CRITICAL', message: 'World-readable .env file' },
|
||
{ pattern: /\.json$/, level: 'HIGH', message: 'World-readable JSON config' },
|
||
{ pattern: /\.key$/, level: 'CRITICAL', message: 'World-readable key file' },
|
||
{ pattern: /\.pem$/, level: 'CRITICAL', message: 'World-readable PEM file' }
|
||
];
|
||
|
||
const files = getFilesRecursively(CLAWDBOT_DIR);
|
||
|
||
for (const file of files) {
|
||
try {
|
||
const stats = fs.statSync(file);
|
||
const mode = stats.mode & 0o777;
|
||
|
||
// Check if world-readable
|
||
if ((mode & 0o004) !== 0) {
|
||
for (const sp of sensitivePatterns) {
|
||
if (sp.pattern.test(file)) {
|
||
log(sp.level, 'PERMISSIONS', sp.message, { file, mode: mode.toString(8) });
|
||
}
|
||
}
|
||
}
|
||
|
||
// Check if executable by all
|
||
if ((mode & 0o001) !== 0 && file.endsWith('.js')) {
|
||
log('MEDIUM', 'PERMISSIONS', `Executable JS file: ${path.basename(file)}`);
|
||
}
|
||
} catch {
|
||
// Ignore inaccessible files
|
||
}
|
||
}
|
||
}
|
||
|
||
function checkDocker() {
|
||
log('INFO', 'DOCKER', 'Checking Docker security...');
|
||
|
||
const dockerFile = path.join(CLAWDBOT_DIR, 'Dockerfile');
|
||
|
||
if (!checkFileExists(dockerFile)) {
|
||
log('INFO', 'DOCKER', 'No Dockerfile found - skipping Docker checks');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const dockerContent = fs.readFileSync(dockerFile, 'utf8');
|
||
|
||
if (dockerContent.includes('USER root') || !dockerContent.includes('USER ')) {
|
||
log('HIGH', 'DOCKER', 'Container may run as root user');
|
||
}
|
||
|
||
if (dockerContent.includes('--privileged')) {
|
||
log('CRITICAL', 'DOCKER', 'Container has privileged mode enabled');
|
||
}
|
||
|
||
if (!dockerContent.includes('HEALTHCHECK')) {
|
||
log('LOW', 'DOCKER', 'No HEALTHCHECK instruction found');
|
||
}
|
||
|
||
if (dockerContent.includes(':latest') && !dockerContent.includes('BUILDARG')) {
|
||
log('MEDIUM', 'DOCKER', 'Using floating tag :latest - consider specific version');
|
||
}
|
||
|
||
} catch (e) {
|
||
log('LOW', 'DOCKER', 'Could not analyze Dockerfile');
|
||
}
|
||
}
|
||
|
||
function checkGit() {
|
||
log('INFO', 'GIT', 'Checking for exposed Git information...');
|
||
|
||
const gitDir = path.join(CLAWDBOT_DIR, '.git');
|
||
|
||
if (checkFileExists(gitDir)) {
|
||
log('MEDIUM', 'GIT', '.git directory exists - ensure it is not web-accessible');
|
||
}
|
||
|
||
const gitIgnore = path.join(CLAWDBOT_DIR, '.gitignore');
|
||
if (!checkFileExists(gitIgnore)) {
|
||
log('LOW', 'GIT', 'No .gitignore file found');
|
||
}
|
||
}
|
||
|
||
function checkRecentCommits() {
|
||
log('INFO', 'HISTORY', 'Checking for credential exposure in recent commits...');
|
||
|
||
try {
|
||
const logOutput = execSync('git log --oneline -20 2>/dev/null || echo "not a git repo"',
|
||
{ encoding: 'utf8', timeout: 5000 });
|
||
|
||
// Check for secrets in commit messages (paranoid check)
|
||
if (/secret|token|password|key|auth/i.test(logOutput)) {
|
||
log('LOW', 'HISTORY', 'Recent commits contain security-related keywords in messages');
|
||
}
|
||
} catch {
|
||
log('INFO', 'HISTORY', 'Not a Git repository or Git not available');
|
||
}
|
||
}
|
||
|
||
// === MAIN ===
|
||
|
||
async function runAudit(options = {}) {
|
||
const { full = false, json = false, credentials = false, ports = false,
|
||
configs = false, permissions = false, docker = false } = options;
|
||
|
||
const runAll = full || (!credentials && !ports && !configs && !permissions && !docker);
|
||
|
||
console.log('\n╔════════════════════════════════════════════════════════════╗');
|
||
console.log('║ CLAWDBOT SECURITY AUDIT v1.0 ║');
|
||
console.log('╚════════════════════════════════════════════════════════════╝\n');
|
||
|
||
const startTime = Date.now();
|
||
|
||
if (runAll || credentials) checkCredentials();
|
||
if (runAll || ports) checkPorts();
|
||
if (runAll || configs) checkConfigs();
|
||
if (runAll || permissions) checkPermissions();
|
||
if (runAll || docker) checkDocker();
|
||
checkGit();
|
||
checkRecentCommits();
|
||
|
||
const duration = Date.now() - startTime;
|
||
|
||
// Summary
|
||
console.log('\n╔════════════════════════════════════════════════════════════╗');
|
||
console.log('║ AUDIT SUMMARY ║');
|
||
console.log('╚════════════════════════════════════════════════════════════╝\n');
|
||
|
||
console.log(`Checks performed: ${checkCount}`);
|
||
console.log(`🔴 Critical: ${criticalCount}`);
|
||
console.log(`🟠 High: ${highCount}`);
|
||
console.log(`Total findings: ${findings.length}`);
|
||
console.log(`Duration: ${duration}ms\n`);
|
||
|
||
// Critical issues first
|
||
const criticalFindings = findings.filter(f => f.level === 'CRITICAL');
|
||
if (criticalFindings.length > 0) {
|
||
console.log('🔴 CRITICAL ISSUES (Immediate action required):');
|
||
for (const f of criticalFindings) {
|
||
console.log(` • ${f.message}`);
|
||
if (f.details?.file) console.log(` File: ${f.details.file}`);
|
||
}
|
||
console.log('');
|
||
}
|
||
|
||
if (json) {
|
||
console.log('\n=== JSON REPORT ===');
|
||
console.log(JSON.stringify({
|
||
summary: {
|
||
checks: checkCount,
|
||
critical: criticalCount,
|
||
high: highCount,
|
||
total: findings.length,
|
||
duration_ms: duration,
|
||
timestamp: new Date().toISOString()
|
||
},
|
||
findings
|
||
}, null, 2));
|
||
}
|
||
|
||
// Recommendation
|
||
if (criticalCount > 0) {
|
||
console.log('\n⚠️ CRITICAL ISSUES FOUND - Do not deploy until fixed!');
|
||
process.exitCode = 1;
|
||
} else if (highCount > 0) {
|
||
console.log('\n⚠️ High-risk issues found - Review recommended before deployment.');
|
||
} else {
|
||
console.log('\n✅ No critical issues found. Security posture looks reasonable.');
|
||
}
|
||
|
||
return { findings, criticalCount, highCount, checkCount };
|
||
}
|
||
|
||
// Auto-fix function
|
||
async function runAutoFix() {
|
||
console.log('\n╔════════════════════════════════════════════════════════════╗');
|
||
console.log('║ AUTO-FIX MODE ║');
|
||
console.log('╚════════════════════════════════════════════════════════════╝\n');
|
||
|
||
let fixedCount = 0;
|
||
|
||
// Fix 1: Secure .env file
|
||
const envFile = '/root/clawd/skills/.env';
|
||
if (checkFileExists(envFile)) {
|
||
try {
|
||
const stats = fs.statSync(envFile);
|
||
const mode = stats.mode & 0o777;
|
||
if ((mode & 0o077) !== 0) {
|
||
fs.chmodSync(envFile, 0o600);
|
||
console.log('✅ Fixed: Set 600 permissions on .env');
|
||
fixedCount++;
|
||
}
|
||
} catch (e) {
|
||
console.log('❌ Failed to fix .env permissions:', e.message);
|
||
}
|
||
}
|
||
|
||
// Fix 2: Secure other sensitive files
|
||
const sensitivePatterns = [
|
||
{ pattern: /\.env$/, perms: 0o600 },
|
||
{ pattern: /\.json$/, perms: 0o600 },
|
||
{ pattern: /\.key$/, perms: 0o600 },
|
||
{ pattern: /\.pem$/, perms: 0o600 }
|
||
];
|
||
|
||
const files = getFilesRecursively(CLAWDBOT_DIR);
|
||
for (const file of files) {
|
||
for (const sp of sensitivePatterns) {
|
||
if (sp.pattern.test(file)) {
|
||
try {
|
||
const stats = fs.statSync(file);
|
||
const mode = stats.mode & 0o777;
|
||
if (mode !== sp.perms) {
|
||
fs.chmodSync(file, sp.perms);
|
||
console.log(`✅ Fixed: Set ${sp.perms.toString(8)} on ${path.basename(file)}`);
|
||
fixedCount++;
|
||
}
|
||
} catch {
|
||
// Ignore
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Fix 3: Create .gitignore if missing
|
||
const gitignorePath = path.join(CLAWDBOT_DIR, '.gitignore');
|
||
if (!checkFileExists(gitignorePath)) {
|
||
const defaultGitignore = `# Clawdbot
|
||
.env
|
||
*.log
|
||
node_modules/
|
||
.DS_Store
|
||
*.pem
|
||
*.key
|
||
`;
|
||
fs.writeFileSync(gitignorePath, defaultGitignore);
|
||
console.log('✅ Fixed: Created .gitignore');
|
||
fixedCount++;
|
||
}
|
||
|
||
console.log(`\n✅ Auto-fix complete! ${fixedCount} issues resolved.`);
|
||
|
||
// Re-run audit to confirm
|
||
console.log('\n🔍 Re-running audit to verify...\n');
|
||
return fixedCount;
|
||
}
|
||
|
||
// Run if called directly
|
||
if (require.main === module) {
|
||
const args = process.argv.slice(2);
|
||
|
||
const shouldFix = args.includes('--fix');
|
||
|
||
if (shouldFix) {
|
||
runAutoFix().catch(e => {
|
||
console.error('Auto-fix error:', e.message);
|
||
process.exit(1);
|
||
});
|
||
} else {
|
||
runAudit({
|
||
full: args.includes('--full'),
|
||
json: args.includes('--json'),
|
||
credentials: args.includes('--credentials'),
|
||
ports: args.includes('--ports'),
|
||
configs: args.includes('--configs'),
|
||
permissions: args.includes('--permissions'),
|
||
docker: args.includes('--docker')
|
||
}).catch(e => {
|
||
console.error('Audit error:', e.message);
|
||
process.exit(1);
|
||
});
|
||
}
|
||
}
|
||
|
||
module.exports = { runAudit, checkCredentials, checkPorts, checkConfigs, checkPermissions, checkDocker };
|