hackmyagent-core 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -40,6 +40,45 @@ Object.defineProperty(exports, "__esModule", { value: true });
40
40
  exports.HardeningScanner = void 0;
41
41
  const fs = __importStar(require("fs/promises"));
42
42
  const path = __importStar(require("path"));
43
+ /**
44
+ * Defines which checks apply to which project types
45
+ * Key: check ID prefix or full ID
46
+ * Value: array of project types this check applies to
47
+ *
48
+ * If a check ID is not in this map, it applies to 'all' project types
49
+ */
50
+ const CHECK_PROJECT_TYPES = {
51
+ // Core security checks - apply to all projects
52
+ 'CRED-': ['all'], // Credential exposure - always critical
53
+ 'GIT-': ['all'], // Git security - always important
54
+ 'PERM-': ['all'], // File permissions - always important
55
+ 'DEP-': ['all'], // Dependencies - always important
56
+ // Environment checks - API/webapp mostly
57
+ 'ENV-': ['webapp', 'api', 'mcp'],
58
+ // AI-specific checks - apply to MCP servers and AI-integrated projects
59
+ 'CLAUDE-': ['all'], // Claude-specific (if files exist)
60
+ 'MCP-': ['mcp'], // MCP configuration - only MCP servers
61
+ 'PROMPT-': ['mcp', 'api'], // Prompt injection - MCP and APIs
62
+ 'TOOL-': ['mcp'], // MCP tool boundaries
63
+ // Web-specific checks - only for web apps and APIs
64
+ 'AUTH-': ['webapp', 'api'], // Authentication/authorization
65
+ 'SESSION-': ['webapp', 'api'], // Session management
66
+ 'NET-': ['webapp', 'api'], // Network security (HTTPS, etc.)
67
+ 'IO-': ['webapp', 'api'], // Input/output (XSS, etc.)
68
+ 'API-': ['api'], // API security headers
69
+ 'RATE-': ['webapp', 'api'], // Rate limiting
70
+ 'PROC-': ['webapp', 'api'], // Process security (headers, etc.)
71
+ // Database/encryption - only for apps with data storage
72
+ 'INJ-': ['webapp', 'api'], // SQL injection, input validation
73
+ 'ENCRYPT-': ['webapp', 'api'], // Encryption, password hashing
74
+ // Logging/audit - servers and MCP
75
+ 'LOG-': ['webapp', 'api', 'mcp'],
76
+ 'AUDIT-': ['webapp', 'api'],
77
+ // Sandboxing - containerized apps
78
+ 'SANDBOX-': ['webapp', 'api', 'mcp'],
79
+ // Secret management - primarily for apps with secrets
80
+ 'SEC-': ['webapp', 'api', 'mcp'],
81
+ };
43
82
  // Patterns for detecting exposed credentials
44
83
  // Each pattern is carefully tuned to minimize false positives
45
84
  const CREDENTIAL_PATTERNS = [
@@ -86,8 +125,9 @@ class HardeningScanner {
86
125
  }
87
126
  // Track if any fix fails for atomic rollback
88
127
  let fixFailed = false;
89
- // Detect platform
128
+ // Detect platform and project type
90
129
  const platform = await this.detectPlatform(targetDir);
130
+ const projectType = await this.detectProjectType(targetDir);
91
131
  // Run all checks
92
132
  const findings = [];
93
133
  // Credential exposure checks
@@ -183,11 +223,24 @@ class HardeningScanner {
183
223
  // Tool boundary checks
184
224
  const toolFindings = await this.checkToolBoundaries(targetDir, shouldFix);
185
225
  findings.push(...toolFindings);
186
- // Filter out ignored checks
187
- const filteredFindings = ignoredChecks.size > 0
188
- ? findings.filter((f) => !ignoredChecks.has(f.checkId.toUpperCase()))
189
- : findings;
190
- // Calculate score (only on non-ignored findings)
226
+ // Filter findings to only show real, actionable issues:
227
+ // 1. Only failed checks (passed: false)
228
+ // 2. Only checks with a file path (concrete findings, not generic advice)
229
+ // 3. Filter out ignored checks
230
+ let filteredFindings = findings.filter((f) => {
231
+ // Keep fixed findings (so users can see what was fixed)
232
+ // Otherwise, only show failed checks
233
+ if (!f.fixed && f.passed)
234
+ return false;
235
+ // Only show concrete findings (has a file path)
236
+ if (!f.file)
237
+ return false;
238
+ // Filter out ignored checks
239
+ if (ignoredChecks.has(f.checkId.toUpperCase()))
240
+ return false;
241
+ return true;
242
+ });
243
+ // Calculate score (only on applicable, non-ignored findings)
191
244
  const { score, maxScore } = this.calculateScore(filteredFindings);
192
245
  // In dry-run mode, mark fixable failed findings with wouldFix
193
246
  if (dryRun && autoFix) {
@@ -203,6 +256,7 @@ class HardeningScanner {
203
256
  return {
204
257
  timestamp: new Date(),
205
258
  platform,
259
+ projectType,
206
260
  findings: filteredFindings,
207
261
  score,
208
262
  maxScore,
@@ -239,21 +293,104 @@ class HardeningScanner {
239
293
  }
240
294
  return platforms.join('+');
241
295
  }
296
+ /**
297
+ * Detect the project type based on package.json and project structure
298
+ */
299
+ async detectProjectType(targetDir) {
300
+ try {
301
+ const pkgPath = path.join(targetDir, 'package.json');
302
+ const content = await fs.readFile(pkgPath, 'utf-8');
303
+ const pkg = JSON.parse(content);
304
+ // Check if it's a CLI tool (has bin field)
305
+ if (pkg.bin) {
306
+ return 'cli';
307
+ }
308
+ // Check dependencies for framework detection
309
+ const allDeps = {
310
+ ...pkg.dependencies,
311
+ ...pkg.devDependencies,
312
+ };
313
+ // Check for MCP server
314
+ if (allDeps['@modelcontextprotocol/sdk'] ||
315
+ allDeps['mcp'] ||
316
+ pkg.name?.includes('mcp')) {
317
+ return 'mcp';
318
+ }
319
+ // Check for web frameworks
320
+ if (allDeps['react'] ||
321
+ allDeps['vue'] ||
322
+ allDeps['svelte'] ||
323
+ allDeps['@angular/core'] ||
324
+ allDeps['next'] ||
325
+ allDeps['nuxt']) {
326
+ return 'webapp';
327
+ }
328
+ // Check for API frameworks
329
+ if (allDeps['express'] ||
330
+ allDeps['fastify'] ||
331
+ allDeps['koa'] ||
332
+ allDeps['hapi'] ||
333
+ allDeps['@hapi/hapi'] ||
334
+ allDeps['restify']) {
335
+ return 'api';
336
+ }
337
+ // Default to library if it has main/exports but no clear type
338
+ if (pkg.main || pkg.exports || pkg.module) {
339
+ return 'library';
340
+ }
341
+ }
342
+ catch {
343
+ // No package.json or invalid JSON
344
+ }
345
+ // Check for Python projects
346
+ try {
347
+ const setupPath = path.join(targetDir, 'setup.py');
348
+ await fs.access(setupPath);
349
+ return 'library';
350
+ }
351
+ catch { }
352
+ try {
353
+ const pyprojectPath = path.join(targetDir, 'pyproject.toml');
354
+ const content = await fs.readFile(pyprojectPath, 'utf-8');
355
+ if (content.includes('fastapi') || content.includes('flask') || content.includes('django')) {
356
+ return 'api';
357
+ }
358
+ return 'library';
359
+ }
360
+ catch { }
361
+ // Default to library for generic projects
362
+ return 'library';
363
+ }
364
+ /**
365
+ * Check if a finding applies to the given project type
366
+ */
367
+ findingAppliesTo(finding, projectType) {
368
+ // Find the matching rule based on check ID prefix
369
+ for (const [prefix, types] of Object.entries(CHECK_PROJECT_TYPES)) {
370
+ if (finding.checkId.startsWith(prefix.replace('-', ''))) {
371
+ // Check if 'all' is in the types array
372
+ if (types.includes('all')) {
373
+ return true;
374
+ }
375
+ return types.includes(projectType);
376
+ }
377
+ }
378
+ // Default: applies to all if no rule found
379
+ return true;
380
+ }
242
381
  async checkCredentialExposure(targetDir, autoFix) {
243
382
  const findings = [];
244
- const exposedKeys = [];
245
- const fixedFiles = [];
246
383
  const envVarsToAdd = new Set();
247
384
  // Credential patterns with their env var names (stricter to avoid false positives)
248
385
  const credentialPatterns = [
249
- { name: 'ANTHROPIC_API_KEY', pattern: /sk-ant-api\d{2}-[a-zA-Z0-9_-]{20,}/g, envVar: 'ANTHROPIC_API_KEY' },
250
- { name: 'OPENAI_API_KEY', pattern: /sk-proj-[a-zA-Z0-9]{20,}/g, envVar: 'OPENAI_API_KEY' },
251
- { name: 'OPENAI_API_KEY', pattern: /sk-[a-zA-Z0-9]{48,}/g, envVar: 'OPENAI_API_KEY' },
252
- { name: 'AWS_ACCESS_KEY', pattern: /AKIA[0-9A-Z]{16}/g, envVar: 'AWS_ACCESS_KEY_ID' },
253
- { name: 'GITHUB_TOKEN', pattern: /ghp_[a-zA-Z0-9]{36}/g, envVar: 'GITHUB_TOKEN' },
254
- { name: 'GITHUB_TOKEN', pattern: /github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59}/g, envVar: 'GITHUB_TOKEN' },
255
- { name: 'GOOGLE_API_KEY', pattern: /AIza[0-9A-Za-z_-]{35}/g, envVar: 'GOOGLE_API_KEY' },
256
- { name: 'STRIPE_KEY', pattern: /sk_live_[0-9a-zA-Z]{24,}/g, envVar: 'STRIPE_SECRET_KEY' },
386
+ { name: 'Anthropic API Key', pattern: /sk-ant-api\d{2}-[a-zA-Z0-9_-]{20,}/g, envVar: 'ANTHROPIC_API_KEY' },
387
+ { name: 'OpenAI API Key', pattern: /sk-proj-[a-zA-Z0-9]{20,}/g, envVar: 'OPENAI_API_KEY' },
388
+ { name: 'OpenAI API Key', pattern: /sk-[a-zA-Z0-9]{48,}/g, envVar: 'OPENAI_API_KEY' },
389
+ { name: 'AWS Access Key', pattern: /AKIA[0-9A-Z]{16}/g, envVar: 'AWS_ACCESS_KEY_ID' },
390
+ { name: 'GitHub Token', pattern: /ghp_[a-zA-Z0-9]{36}/g, envVar: 'GITHUB_TOKEN' },
391
+ { name: 'GitHub Token', pattern: /github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59}/g, envVar: 'GITHUB_TOKEN' },
392
+ { name: 'Google API Key', pattern: /AIza[0-9A-Za-z_-]{35}/g, envVar: 'GOOGLE_API_KEY' },
393
+ { name: 'Stripe Key', pattern: /sk_live_[0-9a-zA-Z]{24,}/g, envVar: 'STRIPE_SECRET_KEY' },
257
394
  ];
258
395
  // Files to check for credentials
259
396
  const filesToCheck = [
@@ -264,34 +401,52 @@ class HardeningScanner {
264
401
  'settings.json',
265
402
  '.env',
266
403
  '.env.local',
404
+ 'CLAUDE.md',
267
405
  ];
268
406
  for (const filename of filesToCheck) {
269
407
  const filePath = path.join(targetDir, filename);
270
408
  try {
271
409
  let content = await fs.readFile(filePath, 'utf-8');
410
+ const lines = content.split('\n');
272
411
  let fileModified = false;
412
+ const keysFoundInFile = [];
273
413
  for (const { name, pattern, envVar } of credentialPatterns) {
274
- // Reset pattern lastIndex for global regex
275
- pattern.lastIndex = 0;
276
- if (pattern.test(content)) {
277
- // Check if it's already an env var reference
414
+ // Check each line for credentials
415
+ for (let i = 0; i < lines.length; i++) {
278
416
  pattern.lastIndex = 0;
279
- const match = content.match(pattern);
280
- if (match && !content.includes('${' + envVar + '}')) {
281
- exposedKeys.push(name);
417
+ if (pattern.test(lines[i]) && !lines[i].includes('${' + envVar + '}')) {
418
+ keysFoundInFile.push({ name, line: i + 1 });
282
419
  if (autoFix) {
283
- // Replace the credential with env var reference
284
420
  pattern.lastIndex = 0;
285
- content = content.replace(pattern, '${' + envVar + '}');
421
+ lines[i] = lines[i].replace(pattern, '${' + envVar + '}');
286
422
  fileModified = true;
287
423
  envVarsToAdd.add(envVar);
288
424
  }
289
425
  }
290
426
  }
291
427
  }
292
- if (fileModified) {
293
- await fs.writeFile(filePath, content);
294
- fixedFiles.push(filename);
428
+ // Report one finding per file with exposed credentials
429
+ if (keysFoundInFile.length > 0) {
430
+ const keyNames = [...new Set(keysFoundInFile.map((k) => k.name))];
431
+ const firstLine = keysFoundInFile[0].line;
432
+ if (fileModified) {
433
+ content = lines.join('\n');
434
+ await fs.writeFile(filePath, content);
435
+ }
436
+ findings.push({
437
+ checkId: 'CRED-001',
438
+ name: 'Exposed Credential',
439
+ description: `${keyNames.join(', ')} found in plaintext`,
440
+ category: 'credentials',
441
+ severity: 'critical',
442
+ passed: fileModified, // Fixed if we replaced it
443
+ message: keyNames.join(', '),
444
+ file: filename,
445
+ line: firstLine,
446
+ fixable: true,
447
+ fixed: fileModified,
448
+ fix: `Run \`hackmyagent secure --fix\` to replace the hardcoded credential with a \${ENV_VAR} reference, then store the actual value in your .env file`,
449
+ });
295
450
  }
296
451
  }
297
452
  catch {
@@ -301,31 +456,12 @@ class HardeningScanner {
301
456
  // Create .env.example if we fixed any credentials
302
457
  if (autoFix && envVarsToAdd.size > 0) {
303
458
  const envExamplePath = path.join(targetDir, '.env.example');
304
- let envExampleContent = '# Environment variables for this project\n# Copy to .env and fill in your values\n\n';
459
+ let envExampleContent = '# Environment variables\n\n';
305
460
  for (const envVar of envVarsToAdd) {
306
- envExampleContent += `${envVar}=your_${envVar.toLowerCase()}_here\n`;
461
+ envExampleContent += `${envVar}=\n`;
307
462
  }
308
463
  await fs.writeFile(envExamplePath, envExampleContent);
309
464
  }
310
- const passed = exposedKeys.length === 0;
311
- const fixed = fixedFiles.length > 0;
312
- findings.push({
313
- checkId: 'CRED-001',
314
- name: 'Exposed API Keys',
315
- description: 'API keys or secrets found in plaintext configuration files',
316
- category: 'credentials',
317
- severity: 'critical',
318
- passed: passed || fixed,
319
- message: fixed
320
- ? `Replaced credentials with env var references in: ${fixedFiles.join(', ')}`
321
- : passed
322
- ? 'No exposed API keys detected'
323
- : `Found exposed credentials: ${[...new Set(exposedKeys)].join(', ')}`,
324
- fixable: true,
325
- fixed,
326
- fixMessage: fixed ? `Created .env.example with: ${[...envVarsToAdd].join(', ')}` : undefined,
327
- details: passed && !fixed ? undefined : { keys: [...new Set(exposedKeys)], fixedFiles },
328
- });
329
465
  return findings;
330
466
  }
331
467
  async checkClaudeMd(targetDir, autoFix) {
@@ -333,39 +469,40 @@ class HardeningScanner {
333
469
  const claudeMdPath = path.join(targetDir, 'CLAUDE.md');
334
470
  try {
335
471
  const content = await fs.readFile(claudeMdPath, 'utf-8');
336
- let hasSecrets = false;
472
+ const lines = content.split('\n');
473
+ let credentialLine;
474
+ let credentialType;
337
475
  // Check for credentials in CLAUDE.md
338
- for (const { pattern } of CREDENTIAL_PATTERNS) {
339
- if (pattern.test(content)) {
340
- hasSecrets = true;
341
- break;
476
+ for (const { name, pattern } of CREDENTIAL_PATTERNS) {
477
+ for (let i = 0; i < lines.length; i++) {
478
+ if (pattern.test(lines[i])) {
479
+ credentialLine = i + 1;
480
+ credentialType = name;
481
+ break;
482
+ }
342
483
  }
484
+ if (credentialLine)
485
+ break;
486
+ }
487
+ // Only report if credentials found
488
+ if (credentialLine) {
489
+ findings.push({
490
+ checkId: 'CLAUDE-001',
491
+ name: 'Credential in CLAUDE.md',
492
+ description: `${credentialType} found in CLAUDE.md`,
493
+ category: 'claude-code',
494
+ severity: 'critical',
495
+ passed: false,
496
+ message: 'Remove credentials from CLAUDE.md',
497
+ file: 'CLAUDE.md',
498
+ line: credentialLine,
499
+ fixable: false,
500
+ fix: 'Manually move the credential to a .env file and reference it as ${ENV_VAR}. CLAUDE.md may be committed to git and exposed publicly',
501
+ });
343
502
  }
344
- findings.push({
345
- checkId: 'CLAUDE-001',
346
- name: 'CLAUDE.md Sensitive Content',
347
- description: 'CLAUDE.md file contains sensitive information like API keys',
348
- category: 'claude-code',
349
- severity: 'critical',
350
- passed: !hasSecrets,
351
- message: hasSecrets
352
- ? 'CLAUDE.md contains exposed credentials'
353
- : 'CLAUDE.md does not contain sensitive credentials',
354
- fixable: false,
355
- });
356
503
  }
357
504
  catch {
358
- // CLAUDE.md doesn't exist, that's fine
359
- findings.push({
360
- checkId: 'CLAUDE-001',
361
- name: 'CLAUDE.md Sensitive Content',
362
- description: 'CLAUDE.md file contains sensitive information like API keys',
363
- category: 'claude-code',
364
- severity: 'critical',
365
- passed: true,
366
- message: 'No CLAUDE.md file found (OK)',
367
- fixable: false,
368
- });
505
+ // CLAUDE.md doesn't exist, that's fine - no finding needed
369
506
  }
370
507
  return findings;
371
508
  }
@@ -413,57 +550,39 @@ class HardeningScanner {
413
550
  if (mcp001Fixed) {
414
551
  await fs.writeFile(mcpConfigPath, JSON.stringify(config, null, 2));
415
552
  }
416
- findings.push({
417
- checkId: 'MCP-001',
418
- name: 'MCP Root Filesystem Access',
419
- description: 'MCP server configured with root or home directory access',
420
- category: 'mcp',
421
- severity: 'high',
422
- passed: !hasRootAccess || mcp001Fixed,
423
- message: mcp001Fixed
424
- ? 'Changed dangerous filesystem paths to scoped directories'
425
- : hasRootAccess
426
- ? 'MCP server has dangerous filesystem access (/ or ~)'
427
- : 'MCP filesystem access is scoped appropriately',
428
- fixable: true,
429
- fixed: mcp001Fixed,
430
- fixMessage: mcp001Fixed ? 'Replaced "/" with "./data" and "~" with "./"' : undefined,
431
- });
432
- findings.push({
433
- checkId: 'MCP-002',
434
- name: 'MCP Unrestricted Shell',
435
- description: 'MCP shell server without command restrictions',
436
- category: 'mcp',
437
- severity: 'critical',
438
- passed: !hasUnrestrictedShell,
439
- message: hasUnrestrictedShell
440
- ? 'MCP shell server has no command restrictions'
441
- : 'MCP shell server is properly restricted or not present',
442
- fixable: false,
443
- });
553
+ // Only report if there's an issue
554
+ if (hasRootAccess) {
555
+ findings.push({
556
+ checkId: 'MCP-001',
557
+ name: 'MCP Root Filesystem Access',
558
+ description: 'Server has access to / or ~ directory',
559
+ category: 'mcp',
560
+ severity: 'high',
561
+ passed: mcp001Fixed,
562
+ message: 'Restrict filesystem access to specific directories',
563
+ file: 'mcp.json',
564
+ fixable: true,
565
+ fixed: mcp001Fixed,
566
+ fix: 'Run `hackmyagent secure --fix` to restrict filesystem access from / or ~ to project-relative paths (./data or ./)',
567
+ });
568
+ }
569
+ if (hasUnrestrictedShell) {
570
+ findings.push({
571
+ checkId: 'MCP-002',
572
+ name: 'Unrestricted Shell Server',
573
+ description: 'Shell server has no command restrictions',
574
+ category: 'mcp',
575
+ severity: 'critical',
576
+ passed: false,
577
+ message: 'Add allowedCommands to restrict shell access',
578
+ file: 'mcp.json',
579
+ fixable: false,
580
+ fix: 'Manually add an "allowedCommands" array to your shell server config in mcp.json to whitelist specific commands (e.g., ["ls", "cat", "grep"])',
581
+ });
582
+ }
444
583
  }
445
584
  catch {
446
- // mcp.json doesn't exist or is invalid
447
- findings.push({
448
- checkId: 'MCP-001',
449
- name: 'MCP Root Filesystem Access',
450
- description: 'MCP server configured with root or home directory access',
451
- category: 'mcp',
452
- severity: 'high',
453
- passed: true,
454
- message: 'No mcp.json found (OK)',
455
- fixable: true,
456
- });
457
- findings.push({
458
- checkId: 'MCP-002',
459
- name: 'MCP Unrestricted Shell',
460
- description: 'MCP shell server without command restrictions',
461
- category: 'mcp',
462
- severity: 'critical',
463
- passed: true,
464
- message: 'No mcp.json found (OK)',
465
- fixable: false,
466
- });
585
+ // mcp.json doesn't exist - no findings needed
467
586
  }
468
587
  return findings;
469
588
  }
@@ -550,22 +669,22 @@ dist/
550
669
  gitignoreExists = true;
551
670
  git001Fixed = true;
552
671
  }
553
- findings.push({
554
- checkId: 'GIT-001',
555
- name: 'Missing .gitignore',
556
- description: 'No .gitignore file found to prevent accidental commits of sensitive files',
557
- category: 'git',
558
- severity: 'medium',
559
- passed: gitignoreExists,
560
- message: git001Fixed
561
- ? '.gitignore file created with recommended patterns'
562
- : gitignoreExists
563
- ? '.gitignore file present'
564
- : 'No .gitignore file found - sensitive files may be accidentally committed',
565
- fixable: true,
566
- fixed: git001Fixed,
567
- fixMessage: git001Fixed ? 'Created .gitignore with secure defaults' : undefined,
568
- });
672
+ // Only report if .gitignore is missing
673
+ if (!gitignoreExists || git001Fixed) {
674
+ findings.push({
675
+ checkId: 'GIT-001',
676
+ name: 'Missing .gitignore',
677
+ description: 'No .gitignore file to prevent accidental commits',
678
+ category: 'git',
679
+ severity: 'medium',
680
+ passed: git001Fixed,
681
+ message: 'Create .gitignore to protect sensitive files',
682
+ file: '.gitignore',
683
+ fixable: true,
684
+ fixed: git001Fixed,
685
+ fix: 'Run `hackmyagent secure --fix` to create a .gitignore with security patterns (.env, secrets.json, *.pem, *.key) to prevent accidental commits',
686
+ });
687
+ }
569
688
  // GIT-002: Check for missing sensitive patterns in .gitignore
570
689
  const sensitivePatterns = ['.env', 'secrets.json', '*.pem', '*.key'];
571
690
  const missingPatterns = [];
@@ -581,23 +700,22 @@ dist/
581
700
  await fs.writeFile(gitignorePath, gitignoreContent);
582
701
  git002Fixed = true;
583
702
  }
584
- findings.push({
585
- checkId: 'GIT-002',
586
- name: 'Incomplete .gitignore',
587
- description: '.gitignore missing patterns for sensitive files',
588
- category: 'git',
589
- severity: 'high',
590
- passed: missingPatterns.length === 0 || git002Fixed,
591
- message: git002Fixed
592
- ? `Added missing patterns to .gitignore: ${missingPatterns.join(', ')}`
593
- : missingPatterns.length === 0
594
- ? '.gitignore has all recommended sensitive file patterns'
595
- : `Missing patterns in .gitignore: ${missingPatterns.join(', ')}`,
596
- fixable: true,
597
- fixed: git002Fixed,
598
- fixMessage: git002Fixed ? `Added: ${missingPatterns.join(', ')}` : undefined,
599
- details: missingPatterns.length > 0 && !git002Fixed ? { missing: missingPatterns } : undefined,
600
- });
703
+ // Only report if patterns are missing
704
+ if (missingPatterns.length > 0) {
705
+ findings.push({
706
+ checkId: 'GIT-002',
707
+ name: 'Incomplete .gitignore',
708
+ description: `Missing: ${missingPatterns.join(', ')}`,
709
+ category: 'git',
710
+ severity: 'high',
711
+ passed: git002Fixed,
712
+ message: `Add patterns: ${missingPatterns.join(', ')}`,
713
+ file: '.gitignore',
714
+ fixable: true,
715
+ fixed: git002Fixed,
716
+ fix: `Run \`hackmyagent secure --fix\` to add ${missingPatterns.join(', ')} to .gitignore so sensitive files won't be accidentally committed`,
717
+ });
718
+ }
601
719
  // GIT-003: Check if .env exists but not in .gitignore
602
720
  let envExists = false;
603
721
  try {
@@ -618,23 +736,22 @@ dist/
618
736
  await fs.writeFile(gitignorePath, gitignoreContent);
619
737
  git003Fixed = true;
620
738
  }
621
- findings.push({
622
- checkId: 'GIT-003',
623
- name: '.env File at Risk',
624
- description: '.env file exists but may not be ignored by git',
625
- category: 'git',
626
- severity: 'critical',
627
- passed: !envAtRisk || git003Fixed,
628
- message: git003Fixed
629
- ? 'Added .env to .gitignore'
630
- : envAtRisk
631
- ? '.env file exists but is not in .gitignore - secrets may be committed!'
632
- : envExists
633
- ? '.env file is properly ignored'
634
- : 'No .env file present',
635
- fixable: true,
636
- fixed: git003Fixed,
637
- });
739
+ // Only report if .env is at risk
740
+ if (envAtRisk) {
741
+ findings.push({
742
+ checkId: 'GIT-003',
743
+ name: '.env Not Ignored',
744
+ description: '.env exists but not in .gitignore - secrets may be committed',
745
+ category: 'git',
746
+ severity: 'critical',
747
+ passed: git003Fixed,
748
+ message: 'Add .env to .gitignore',
749
+ file: '.env',
750
+ fixable: true,
751
+ fixed: git003Fixed,
752
+ fix: 'Run `hackmyagent secure --fix` to add .env to .gitignore so your environment variables won\'t be accidentally committed',
753
+ });
754
+ }
638
755
  return findings;
639
756
  }
640
757
  async checkNetworkSecurity(targetDir, autoFix) {
@@ -664,22 +781,22 @@ dist/
664
781
  await fs.writeFile(mcpConfigPath, fixedContent);
665
782
  net001Fixed = true;
666
783
  }
667
- findings.push({
668
- checkId: 'NET-001',
669
- name: 'Server Bound to All Interfaces',
670
- description: 'MCP server bound to 0.0.0.0 exposes it to all network interfaces',
671
- category: 'network',
672
- severity: 'critical',
673
- passed: !boundToAllInterfaces || net001Fixed,
674
- message: net001Fixed
675
- ? 'Changed 0.0.0.0 to 127.0.0.1 in mcp.json'
676
- : boundToAllInterfaces
677
- ? 'MCP server bound to 0.0.0.0 - accessible from any network interface'
678
- : 'No servers bound to 0.0.0.0',
679
- fixable: true,
680
- fixed: net001Fixed,
681
- fixMessage: net001Fixed ? 'Replaced 0.0.0.0 with 127.0.0.1' : undefined,
682
- });
784
+ // Only report if bound to 0.0.0.0
785
+ if (boundToAllInterfaces) {
786
+ findings.push({
787
+ checkId: 'NET-001',
788
+ name: 'Server Bound to All Interfaces',
789
+ description: 'Server bound to 0.0.0.0 - accessible from any network',
790
+ category: 'network',
791
+ severity: 'critical',
792
+ passed: net001Fixed,
793
+ message: 'Change 0.0.0.0 to 127.0.0.1',
794
+ file: 'mcp.json',
795
+ fixable: true,
796
+ fixed: net001Fixed,
797
+ fix: 'Run `hackmyagent secure --fix` to change 0.0.0.0 to 127.0.0.1 so the server only accepts local connections instead of being exposed to the network',
798
+ });
799
+ }
683
800
  // NET-002: Check for remote MCP servers without TLS
684
801
  let hasInsecureRemote = false;
685
802
  if (mcpConfig?.servers) {
@@ -690,18 +807,21 @@ dist/
690
807
  }
691
808
  }
692
809
  }
693
- findings.push({
694
- checkId: 'NET-002',
695
- name: 'Remote MCP Without TLS',
696
- description: 'Remote MCP server configured without HTTPS',
697
- category: 'network',
698
- severity: 'high',
699
- passed: !hasInsecureRemote,
700
- message: hasInsecureRemote
701
- ? 'Remote MCP server using HTTP instead of HTTPS - traffic is unencrypted'
702
- : 'All remote MCP servers use HTTPS or no remote servers configured',
703
- fixable: false,
704
- });
810
+ // Only report if insecure remote found
811
+ if (hasInsecureRemote) {
812
+ findings.push({
813
+ checkId: 'NET-002',
814
+ name: 'Remote MCP Without TLS',
815
+ description: 'Remote server using HTTP instead of HTTPS',
816
+ category: 'network',
817
+ severity: 'high',
818
+ passed: false,
819
+ message: 'Change http:// to https://',
820
+ file: 'mcp.json',
821
+ fixable: false,
822
+ fix: 'Manually change http:// to https:// in mcp.json to encrypt traffic and prevent man-in-the-middle attacks',
823
+ });
824
+ }
705
825
  return findings;
706
826
  }
707
827
  async checkMcpAdvanced(targetDir, autoFix) {
@@ -754,22 +874,22 @@ dist/
754
874
  await fs.writeFile(mcpConfigPath, JSON.stringify(mcpConfig, null, 2));
755
875
  }
756
876
  }
757
- findings.push({
758
- checkId: 'MCP-003',
759
- name: 'MCP Hardcoded Secrets',
760
- description: 'MCP server configuration contains hardcoded secrets in environment variables',
761
- category: 'mcp',
762
- severity: 'critical',
763
- passed: !hasHardcodedSecrets || mcp003Fixed,
764
- message: mcp003Fixed
765
- ? 'Replaced hardcoded secrets with environment variable references'
766
- : hasHardcodedSecrets
767
- ? 'MCP server has hardcoded secrets in env vars - use environment variable references instead'
768
- : 'No hardcoded secrets in MCP env vars',
769
- fixable: true,
770
- fixed: mcp003Fixed,
771
- fixMessage: mcp003Fixed ? 'Replaced with ${ENV_VAR} references' : undefined,
772
- });
877
+ // Only report if hardcoded secrets found
878
+ if (hasHardcodedSecrets) {
879
+ findings.push({
880
+ checkId: 'MCP-003',
881
+ name: 'Hardcoded Secrets in MCP',
882
+ description: 'Secrets found in MCP env vars',
883
+ category: 'mcp',
884
+ severity: 'critical',
885
+ passed: mcp003Fixed,
886
+ message: 'Use ${ENV_VAR} references instead',
887
+ file: 'mcp.json',
888
+ fixable: true,
889
+ fixed: mcp003Fixed,
890
+ fix: 'Run `hackmyagent secure --fix` to replace hardcoded API keys with ${ENV_VAR} references, then store actual values in .env file',
891
+ });
892
+ }
773
893
  // MCP-004: Check for default credentials
774
894
  const defaultPasswords = ['postgres', 'password', 'admin', 'root', '123456', 'default'];
775
895
  let hasDefaultCreds = false;
@@ -786,18 +906,21 @@ dist/
786
906
  }
787
907
  }
788
908
  }
789
- findings.push({
790
- checkId: 'MCP-004',
791
- name: 'MCP Default Credentials',
792
- description: 'MCP server using default or weak credentials',
793
- category: 'mcp',
794
- severity: 'critical',
795
- passed: !hasDefaultCreds,
796
- message: hasDefaultCreds
797
- ? 'MCP server using default credentials - change to strong unique passwords'
798
- : 'No default credentials detected in MCP config',
799
- fixable: false,
800
- });
909
+ // Only report if default credentials found
910
+ if (hasDefaultCreds) {
911
+ findings.push({
912
+ checkId: 'MCP-004',
913
+ name: 'Default Credentials',
914
+ description: 'MCP server using default password',
915
+ category: 'mcp',
916
+ severity: 'critical',
917
+ passed: false,
918
+ message: 'Change to strong unique password',
919
+ file: 'mcp.json',
920
+ fixable: false,
921
+ fix: 'Manually change the default password in mcp.json to a strong, unique password (use `openssl rand -base64 24` to generate one)',
922
+ });
923
+ }
801
924
  // MCP-005: Check for wildcard tool access
802
925
  let hasWildcardTools = false;
803
926
  if (mcpConfig?.servers) {
@@ -808,18 +931,21 @@ dist/
808
931
  }
809
932
  }
810
933
  }
811
- findings.push({
812
- checkId: 'MCP-005',
813
- name: 'MCP Wildcard Tools',
814
- description: 'MCP server allows all tools without restrictions',
815
- category: 'mcp',
816
- severity: 'high',
817
- passed: !hasWildcardTools,
818
- message: hasWildcardTools
819
- ? 'MCP server allows all tools (*) - restrict to specific tools needed'
820
- : 'MCP tools are properly scoped',
821
- fixable: false,
822
- });
934
+ // Only report if wildcard tools found
935
+ if (hasWildcardTools) {
936
+ findings.push({
937
+ checkId: 'MCP-005',
938
+ name: 'Wildcard Tool Access',
939
+ description: 'Server allows all tools (*)',
940
+ category: 'mcp',
941
+ severity: 'high',
942
+ passed: false,
943
+ message: 'Restrict to specific tools needed',
944
+ file: 'mcp.json',
945
+ fixable: false,
946
+ fix: 'Manually replace "*" with a list of specific tool names you need (e.g., ["read_file", "list_directory"]) to limit what the AI can access',
947
+ });
948
+ }
823
949
  return findings;
824
950
  }
825
951
  async checkClaudeAdvanced(targetDir, autoFix) {
@@ -842,18 +968,21 @@ dist/
842
968
  }
843
969
  }
844
970
  }
845
- findings.push({
846
- checkId: 'CLAUDE-002',
847
- name: 'Overly Permissive Claude Permissions',
848
- description: 'Claude Code settings allow unrestricted tool access',
849
- category: 'claude-code',
850
- severity: 'high',
851
- passed: !hasOverlyPermissive,
852
- message: hasOverlyPermissive
853
- ? 'Claude Code has overly permissive permissions (wildcards) - scope to specific paths/commands'
854
- : 'Claude Code permissions are appropriately scoped',
855
- fixable: false,
856
- });
971
+ // Only report if overly permissive
972
+ if (hasOverlyPermissive) {
973
+ findings.push({
974
+ checkId: 'CLAUDE-002',
975
+ name: 'Overly Permissive Permissions',
976
+ description: 'Settings allow unrestricted tool access',
977
+ category: 'claude-code',
978
+ severity: 'high',
979
+ passed: false,
980
+ message: 'Scope permissions to specific paths',
981
+ file: '.claude/settings.json',
982
+ fixable: false,
983
+ fix: 'Manually replace wildcards like Bash(*) or Read(*) with specific paths (e.g., Bash(npm test) or Read(/src/**)) to limit AI access',
984
+ });
985
+ }
857
986
  // CLAUDE-003: Check for dangerous Bash patterns
858
987
  let hasDangerousBash = false;
859
988
  const dangerousPatterns = ['rm -rf', 'rm -r', 'chmod 777', 'curl | sh', 'wget | sh', 'sudo'];
@@ -869,18 +998,21 @@ dist/
869
998
  }
870
999
  }
871
1000
  }
872
- findings.push({
873
- checkId: 'CLAUDE-003',
874
- name: 'Dangerous Bash Permissions',
875
- description: 'Claude Code allows dangerous shell commands',
876
- category: 'claude-code',
877
- severity: 'critical',
878
- passed: !hasDangerousBash,
879
- message: hasDangerousBash
880
- ? 'Claude Code allows dangerous Bash commands (rm -rf, sudo, etc.) - remove or deny these'
881
- : 'No dangerous Bash patterns in Claude permissions',
882
- fixable: false,
883
- });
1001
+ // Only report if dangerous Bash patterns found
1002
+ if (hasDangerousBash) {
1003
+ findings.push({
1004
+ checkId: 'CLAUDE-003',
1005
+ name: 'Dangerous Bash Permissions',
1006
+ description: 'Allows destructive shell commands',
1007
+ category: 'claude-code',
1008
+ severity: 'critical',
1009
+ passed: false,
1010
+ message: 'Remove rm -rf, sudo, etc.',
1011
+ file: '.claude/settings.json',
1012
+ fixable: false,
1013
+ fix: 'Manually remove dangerous commands (rm -rf, sudo, chmod 777, etc.) from the allow list in .claude/settings.json to prevent accidental destructive operations',
1014
+ });
1015
+ }
884
1016
  return findings;
885
1017
  }
886
1018
  async checkCursorConfig(targetDir, autoFix) {
@@ -3276,10 +3408,9 @@ dist/
3276
3408
  }
3277
3409
  calculateScore(findings) {
3278
3410
  let score = 100;
3279
- let maxDeduction = 0;
3411
+ // All findings passed in are concrete issues (already filtered)
3280
3412
  for (const finding of findings) {
3281
3413
  const weight = SEVERITY_WEIGHTS[finding.severity];
3282
- maxDeduction += weight;
3283
3414
  if (!finding.passed && !finding.fixed) {
3284
3415
  score -= weight;
3285
3416
  }