/ scripts / security-scan.js
security-scan.js
  1  #!/usr/bin/env node
  2  /**
  3   * Comprehensive Security Scan Script
  4   *
  5   * Runs all security checks and generates a report.
  6   * Designed to be run by cron or CI/CD.
  7   * Can auto-fix certain issues with --fix flag.
  8   *
  9   * Usage:
 10   *   node scripts/security-scan.js [--fix] [--verbose]
 11   */
 12  
 13  // Safe: all commands are hardcoded npm scripts; file paths are from known report directories
 14  /* eslint-disable security/detect-non-literal-fs-filename */
 15  import { execSync } from 'child_process';
 16  import { existsSync, mkdirSync, writeFileSync } from 'fs';
 17  import { join } from 'path';
 18  
 19  const REPORTS_DIR = '.security-reports';
 20  const TIMESTAMP = new Date().toISOString().replace(/[:.]/g, '-');
 21  
 22  // Parse CLI args
 23  const args = process.argv.slice(2);
 24  const autoFix = args.includes('--fix');
 25  const verbose = args.includes('--verbose');
 26  
 27  // Ensure reports directory exists
 28  if (!existsSync(REPORTS_DIR)) {
 29    mkdirSync(REPORTS_DIR, { recursive: true });
 30  }
 31  
 32  const results = {
 33    timestamp: new Date().toISOString(),
 34    autofix: autoFix,
 35    checks: [],
 36    summary: {
 37      total: 0,
 38      passed: 0,
 39      failed: 0,
 40      fixed: 0,
 41    },
 42  };
 43  
 44  /**
 45   * Run a command and capture output
 46   */
 47  function runCheck(name, command, options = {}) {
 48    const { critical = false, canFix = false } = options;
 49  
 50    console.log(`\nšŸ” Running ${name}...`);
 51  
 52    const check = {
 53      name,
 54      command,
 55      critical,
 56      canFix,
 57      status: 'pending',
 58      output: '',
 59      error: '',
 60      exitCode: 0,
 61    };
 62  
 63    try {
 64      // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process
 65      const output = execSync(command, {
 66        encoding: 'utf8',
 67        stdio: verbose ? 'inherit' : 'pipe',
 68        cwd: process.cwd(),
 69      });
 70  
 71      check.output = output;
 72      check.status = 'passed';
 73      check.exitCode = 0;
 74  
 75      console.log(`āœ… ${name} passed`);
 76      results.summary.passed++;
 77    } catch (error) {
 78      check.error = error.stderr || error.stdout || error.message;
 79      check.exitCode = error.status || 1;
 80      check.status = 'failed';
 81  
 82      console.error(`āŒ ${name} failed (exit code: ${check.exitCode})`);
 83  
 84      if (verbose) {
 85        console.error(check.error);
 86      }
 87  
 88      // Try to fix if auto-fix is enabled and this check supports it
 89      if (autoFix && canFix) {
 90        const fixCommand = getFixCommand(name);
 91        if (fixCommand) {
 92          console.log(`šŸ”§ Attempting to fix ${name}...`);
 93          try {
 94            // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process
 95            execSync(fixCommand, { stdio: 'inherit' });
 96            check.status = 'fixed';
 97            check.fixCommand = fixCommand;
 98            results.summary.fixed++;
 99            console.log(`āœ… ${name} fixed`);
100          } catch {
101            console.error(`āš ļø  Could not auto-fix ${name}`);
102            results.summary.failed++;
103          }
104        }
105      } else {
106        results.summary.failed++;
107      }
108    }
109  
110    results.checks.push(check);
111    results.summary.total++;
112  
113    return check.status === 'passed' || check.status === 'fixed';
114  }
115  
116  /**
117   * Get fix command for a specific check
118   */
119  function getFixCommand(checkName) {
120    const fixes = {
121      'ESLint Security': 'npm run lint:fix',
122      'npm Audit': 'npm audit fix',
123      Snyk: 'snyk fix',
124    };
125  
126    // Safe: checkName is from our hardcoded check names, not user input
127    // eslint-disable-next-line security/detect-object-injection
128    return fixes[checkName] || null;
129  }
130  
131  /**
132   * Main security scan routine
133   */
134  function main() {
135    console.log('šŸ›”ļø  Starting Security Scan');
136    console.log(`Auto-fix: ${autoFix ? 'enabled' : 'disabled'}`);
137    console.log(`Timestamp: ${results.timestamp}`);
138  
139    // 1. ESLint Security Checks (fast, can fix)
140    runCheck('ESLint Security', 'npm run security:lint', { critical: true, canFix: true });
141  
142    // 2. npm Audit (fast, can fix)
143    runCheck('npm Audit', 'npm run security:audit', { critical: true, canFix: true });
144  
145    // 3. Snyk (medium speed, can fix)
146    // Only run if snyk is installed
147    try {
148      execSync('which snyk', { stdio: 'ignore' });
149      runCheck('Snyk', 'npm run security:snyk', { critical: false, canFix: true });
150    } catch {
151      console.log('ā­ļø  Skipping Snyk (not installed)');
152      console.log('   Install with: npm install -g snyk && snyk auth');
153    }
154  
155    // 4. Semgrep (slower, manual fix)
156    // Only run if semgrep is installed
157    try {
158      execSync('which semgrep', { stdio: 'ignore' });
159      runCheck('Semgrep', 'npm run security:semgrep', { critical: false, canFix: false });
160    } catch {
161      console.log('ā­ļø  Skipping Semgrep (not installed)');
162      console.log('   Install with: pip install semgrep OR brew install semgrep');
163    }
164  
165    // 5. Check for hardcoded secrets (fast, manual fix)
166    console.log('\nšŸ” Scanning for hardcoded secrets...');
167    try {
168      // Simple keyword-based secret detection
169      // Look for common secret-related keywords
170      const keywords = ['API_KEY', 'SECRET', 'PASSWORD', 'PRIVATE_KEY', 'TOKEN'];
171  
172      let secretsFound = '';
173      for (const keyword of keywords) {
174        try {
175          // Simple grep for the keyword followed by = and a quoted string
176          const result = execSync(
177            `grep -r "${keyword}.*=" src/ scripts/ --exclude-dir=node_modules --exclude-dir=.git --exclude="*.test.js" 2>/dev/null || true`,
178            {
179              encoding: 'utf8',
180            }
181          );
182  
183          // Filter out .env references and process.env usage (those are safe)
184          const lines = result
185            .split('\n')
186            .filter(
187              line =>
188                line &&
189                !line.includes('process.env') &&
190                !line.includes('.env') &&
191                !line.includes('// ')
192            );
193  
194          if (lines.length > 0) {
195            secretsFound += `${lines.join('\n')}\n`;
196          }
197        } catch {
198          // Ignore grep errors
199        }
200      }
201  
202      if (secretsFound.trim()) {
203        console.error('āš ļø  Potential secrets found in code:');
204        console.error(secretsFound);
205        results.checks.push({
206          name: 'Secret Detection',
207          status: 'failed',
208          output: secretsFound,
209          critical: true,
210          canFix: false,
211        });
212        results.summary.failed++;
213      } else {
214        console.log('āœ… No hardcoded secrets detected');
215        results.checks.push({
216          name: 'Secret Detection',
217          status: 'passed',
218          critical: true,
219        });
220        results.summary.passed++;
221      }
222      results.summary.total++;
223    } catch (error) {
224      console.error('āš ļø  Could not scan for secrets:', error.message);
225    }
226  
227    // Generate report
228    const reportPath = join(REPORTS_DIR, `security-scan-${TIMESTAMP}.json`);
229    writeFileSync(reportPath, JSON.stringify(results, null, 2));
230  
231    // Generate summary
232    console.log('\nšŸ“Š Security Scan Summary');
233    console.log('═'.repeat(50));
234    console.log(`Total checks: ${results.summary.total}`);
235    console.log(`āœ… Passed: ${results.summary.passed}`);
236    console.log(`āŒ Failed: ${results.summary.failed}`);
237    if (autoFix) {
238      console.log(`šŸ”§ Fixed: ${results.summary.fixed}`);
239    }
240    console.log(`\nšŸ“„ Full report: ${reportPath}`);
241  
242    // Exit with error if critical checks failed
243    const criticalFailures = results.checks.filter(c => c.critical && c.status === 'failed');
244  
245    if (criticalFailures.length > 0) {
246      console.error(`\nāŒ ${criticalFailures.length} critical security issues found!`);
247      criticalFailures.forEach(check => {
248        console.error(`   - ${check.name}`);
249      });
250      process.exit(1);
251    }
252  
253    console.log('\nāœ… Security scan complete');
254    process.exit(0);
255  }
256  
257  // Run the scan
258  main().catch(error => {
259    console.error('Fatal error during security scan:', error);
260    process.exit(1);
261  });