hackmyagent 0.11.3 → 0.11.5

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.
@@ -104,6 +104,8 @@ const CHECK_PROJECT_TYPES = {
104
104
  'DNA-': ['all'],
105
105
  // Skill memory manipulation checks
106
106
  'SKILL-MEM-': ['openclaw', 'mcp'],
107
+ // NemoClaw/sandbox static analysis checks
108
+ 'NEMO-': ['all'],
107
109
  };
108
110
  // Patterns for detecting exposed credentials
109
111
  // Each pattern is carefully tuned to minimize false positives
@@ -218,6 +220,11 @@ const SEVERITY_WEIGHTS = {
218
220
  };
219
221
  const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB max file size to prevent memory exhaustion
220
222
  const MAX_LINE_LENGTH = 10000; // 10KB max line length for regex safety
223
+ /** Shell-escape a string for safe interpolation into advisory fix commands. */
224
+ function shellEscape(s) {
225
+ // Wrap in single quotes and escape embedded single quotes: ' -> '\''
226
+ return "'" + s.replace(/'/g, "'\\''") + "'";
227
+ }
221
228
  class HardeningScanner {
222
229
  constructor() {
223
230
  this.cliName = 'hackmyagent';
@@ -378,6 +385,9 @@ class HardeningScanner {
378
385
  // Skill memory manipulation checks
379
386
  const skillMemFindings = await this.checkSkillMemory(targetDir, shouldFix);
380
387
  findings.push(...skillMemFindings);
388
+ // NemoClaw codebase pattern checks
389
+ const nemoFindings = await this.checkNemoClawPatterns(targetDir, shouldFix);
390
+ findings.push(...nemoFindings);
381
391
  // Enrich findings with attack taxonomy mapping
382
392
  (0, taxonomy_1.enrichWithTaxonomy)(findings);
383
393
  // Layer 2: Structural analysis (always on)
@@ -3756,7 +3766,7 @@ dist/
3756
3766
  await fs.access(backupBaseDir);
3757
3767
  }
3758
3768
  catch {
3759
- throw new Error('No backup found. Run hackmyagent harden --fix <dir> first to create a backup.');
3769
+ throw new Error('No backup found. Run hackmyagent secure --fix <dir> first to create a backup.');
3760
3770
  }
3761
3771
  // Find the most recent backup
3762
3772
  const backups = await fs.readdir(backupBaseDir);
@@ -3765,7 +3775,7 @@ dist/
3765
3775
  .sort()
3766
3776
  .reverse();
3767
3777
  if (sortedBackups.length === 0) {
3768
- throw new Error('No backup found. Run hackmyagent harden --fix <dir> first to create a backup.');
3778
+ throw new Error('No backup found. Run hackmyagent secure --fix <dir> first to create a backup.');
3769
3779
  }
3770
3780
  const latestBackup = sortedBackups[0];
3771
3781
  const backupDir = path.join(backupBaseDir, latestBackup);
@@ -4883,8 +4893,8 @@ dist/
4883
4893
  if (!this.isPathWithinDirectory(fullPath, targetDir)) {
4884
4894
  continue;
4885
4895
  }
4886
- // Skip node_modules and .git directories
4887
- if (entryName === 'node_modules' || entryName === '.git') {
4896
+ // Skip node_modules, .git, and backup directories
4897
+ if (entryName === 'node_modules' || entryName === '.git' || entryName === '.hackmyagent-backup') {
4888
4898
  continue;
4889
4899
  }
4890
4900
  let stat;
@@ -6224,7 +6234,9 @@ dist/
6224
6234
  */
6225
6235
  async checkUnicodeSteganography(targetDir, _autoFix) {
6226
6236
  const findings = [];
6227
- const sourceFiles = await this.findSourceFiles(targetDir, targetDir);
6237
+ // Scan expanded file types beyond JS/TS (configs, docs, and Python are attack surfaces too)
6238
+ const stegoExtensions = ['.ts', '.js', '.mjs', '.cjs', '.tsx', '.jsx', '.py', '.md', '.txt', '.yaml', '.yml', '.json', '.toml'];
6239
+ const sourceFiles = await this.walkDirectory(targetDir, stegoExtensions);
6228
6240
  for (const filePath of sourceFiles) {
6229
6241
  const relativePath = path.relative(targetDir, filePath);
6230
6242
  let rawBuffer;
@@ -6238,12 +6250,22 @@ dist/
6238
6250
  if (rawBuffer.length > MAX_FILE_SIZE)
6239
6251
  continue;
6240
6252
  // UNICODE-STEGO-001: Invisible Codepoint Detection
6241
- // Scan for variation selectors U+FE00-FE0F (UTF-8: EF B8 80-8F)
6242
- // and tag characters U+E0100-E01EF (UTF-8: F3 A0 84 80 - F3 A0 87 AF)
6253
+ // Scan for:
6254
+ // - Variation selectors U+FE00-FE0F (UTF-8: EF B8 80-8F)
6255
+ // - Tag characters U+E0100-E01EF (UTF-8: F3 A0 84 80 - F3 A0 87 AF)
6256
+ // - Zero-width chars: U+200B (E2 80 8B), U+200C (E2 80 8C), U+200D (E2 80 8D)
6257
+ // - Mid-file BOM: U+FEFF (EF BB BF) -- skip offset 0
6258
+ // - Bidi overrides: U+202A-202E (E2 80 AA-AE), U+2066-2069 (E2 81 A6-A9)
6243
6259
  let hasVariationSelectors = false;
6244
6260
  let variationSelectorLine = 1;
6245
6261
  let hasTagCharsIn001 = false;
6246
6262
  let tagCharLine001 = 1;
6263
+ let hasZeroWidth = false;
6264
+ let zeroWidthLine = 1;
6265
+ let hasMidFileBom = false;
6266
+ let midFileBomLine = 1;
6267
+ let hasBidiOverride = false;
6268
+ let bidiOverrideLine = 1;
6247
6269
  let currentLine = 1;
6248
6270
  for (let i = 0; i < rawBuffer.length; i++) {
6249
6271
  if (rawBuffer[i] === 0x0A) {
@@ -6272,25 +6294,86 @@ dist/
6272
6294
  tagCharLine001 = currentLine;
6273
6295
  }
6274
6296
  }
6297
+ // Zero-width chars: U+200B/200C/200D = E2 80 8B/8C/8D
6298
+ if (rawBuffer[i] === 0xE2 &&
6299
+ i + 2 < rawBuffer.length &&
6300
+ rawBuffer[i + 1] === 0x80 &&
6301
+ rawBuffer[i + 2] >= 0x8B &&
6302
+ rawBuffer[i + 2] <= 0x8D) {
6303
+ if (!hasZeroWidth) {
6304
+ hasZeroWidth = true;
6305
+ zeroWidthLine = currentLine;
6306
+ }
6307
+ }
6308
+ // Mid-file BOM: U+FEFF = EF BB BF (skip if at offset 0)
6309
+ if (i > 0 &&
6310
+ rawBuffer[i] === 0xEF &&
6311
+ i + 2 < rawBuffer.length &&
6312
+ rawBuffer[i + 1] === 0xBB &&
6313
+ rawBuffer[i + 2] === 0xBF) {
6314
+ if (!hasMidFileBom) {
6315
+ hasMidFileBom = true;
6316
+ midFileBomLine = currentLine;
6317
+ }
6318
+ }
6319
+ // Bidi overrides: U+202A-202E = E2 80 AA-AE
6320
+ if (rawBuffer[i] === 0xE2 &&
6321
+ i + 2 < rawBuffer.length &&
6322
+ rawBuffer[i + 1] === 0x80 &&
6323
+ rawBuffer[i + 2] >= 0xAA &&
6324
+ rawBuffer[i + 2] <= 0xAE) {
6325
+ if (!hasBidiOverride) {
6326
+ hasBidiOverride = true;
6327
+ bidiOverrideLine = currentLine;
6328
+ }
6329
+ }
6330
+ // Bidi isolates: U+2066-2069 = E2 81 A6-A9
6331
+ if (rawBuffer[i] === 0xE2 &&
6332
+ i + 2 < rawBuffer.length &&
6333
+ rawBuffer[i + 1] === 0x81 &&
6334
+ rawBuffer[i + 2] >= 0xA6 &&
6335
+ rawBuffer[i + 2] <= 0xA9) {
6336
+ if (!hasBidiOverride) {
6337
+ hasBidiOverride = true;
6338
+ bidiOverrideLine = currentLine;
6339
+ }
6340
+ }
6275
6341
  }
6276
- if (hasVariationSelectors || hasTagCharsIn001) {
6342
+ // Bidi and variation/tag chars are critical; zero-width-only is high
6343
+ const hasCriticalInvisible = hasVariationSelectors || hasTagCharsIn001 || hasBidiOverride;
6344
+ const hasAnyInvisible = hasCriticalInvisible || hasZeroWidth || hasMidFileBom;
6345
+ if (hasAnyInvisible) {
6277
6346
  const detectedTypes = [];
6278
6347
  if (hasVariationSelectors)
6279
6348
  detectedTypes.push('variation selectors (U+FE00-FE0F)');
6280
6349
  if (hasTagCharsIn001)
6281
6350
  detectedTypes.push('tag characters (U+E0100-E01EF)');
6351
+ if (hasZeroWidth)
6352
+ detectedTypes.push('zero-width characters (U+200B-200D)');
6353
+ if (hasMidFileBom)
6354
+ detectedTypes.push('mid-file BOM (U+FEFF)');
6355
+ if (hasBidiOverride)
6356
+ detectedTypes.push('bidi overrides (U+202A-202E, U+2066-2069)');
6357
+ // Determine first line hit for reporting
6358
+ const firstLine = Math.min(...[
6359
+ hasVariationSelectors ? variationSelectorLine : Infinity,
6360
+ hasTagCharsIn001 ? tagCharLine001 : Infinity,
6361
+ hasZeroWidth ? zeroWidthLine : Infinity,
6362
+ hasMidFileBom ? midFileBomLine : Infinity,
6363
+ hasBidiOverride ? bidiOverrideLine : Infinity,
6364
+ ]);
6282
6365
  findings.push({
6283
6366
  checkId: 'UNICODE-STEGO-001',
6284
6367
  name: 'Invisible Unicode Codepoints Detected',
6285
6368
  description: 'Source file contains invisible Unicode codepoints that can hide malicious payloads (GlassWorm attack vector)',
6286
6369
  category: 'unicode-stego',
6287
- severity: 'critical',
6370
+ severity: hasCriticalInvisible ? 'critical' : 'high',
6288
6371
  passed: false,
6289
6372
  message: `Found ${detectedTypes.join(' and ')} in ${relativePath}`,
6290
6373
  file: relativePath,
6291
- line: hasVariationSelectors ? variationSelectorLine : tagCharLine001,
6374
+ line: firstLine,
6292
6375
  fixable: false,
6293
- fix: 'Inspect the file with a hex editor (e.g., xxd) to identify and remove invisible Unicode codepoints. Run: xxd ' + relativePath + ' | grep -E "fe0[0-9a-f]|f3a0"',
6376
+ fix: 'Inspect the file with a hex editor (e.g., xxd) to identify and remove invisible Unicode codepoints. Run: xxd ' + shellEscape(relativePath) + ' | grep -iE "e280[8-9a-e]|efbb|efb8|f3a0"',
6294
6377
  });
6295
6378
  }
6296
6379
  // UNICODE-STEGO-002: GlassWorm Decoder Pattern
@@ -6380,7 +6463,7 @@ dist/
6380
6463
  file: relativePath,
6381
6464
  line: evalLine,
6382
6465
  fixable: false,
6383
- fix: 'Remove the eval/Function call and inspect the string argument with a hex editor. The string likely contains invisible Unicode characters encoding a malicious payload. Run: node -e "const fs=require(\'fs\'); const s=fs.readFileSync(\'' + relativePath + '\',\'utf8\'); console.log([...s].filter(c=>c.codePointAt(0)>0x200).map(c=>c.codePointAt(0).toString(16)))"',
6466
+ fix: 'Remove the eval/Function call and inspect the string argument with a hex editor. The string likely contains invisible Unicode characters encoding a malicious payload. Run: node -e "const fs=require(\'fs\'); const s=fs.readFileSync(' + JSON.stringify(relativePath) + ',\'utf8\'); console.log([...s].filter(c=>c.codePointAt(0)>0x200).map(c=>c.codePointAt(0).toString(16)))"',
6384
6467
  });
6385
6468
  break; // One finding per file
6386
6469
  }
@@ -6422,10 +6505,654 @@ dist/
6422
6505
  file: relativePath,
6423
6506
  line: tagBlockLine,
6424
6507
  fixable: false,
6425
- fix: 'Inspect the file with a hex editor to identify tag block characters (byte sequence starting with F3 A0). These characters are invisible and have no legitimate use in source code. Run: xxd ' + relativePath + ' | grep "f3a0"',
6508
+ fix: 'Inspect the file with a hex editor to identify tag block characters (byte sequence starting with F3 A0). These characters are invisible and have no legitimate use in source code. Run: xxd ' + shellEscape(relativePath) + ' | grep "f3a0"',
6426
6509
  });
6427
6510
  }
6428
6511
  }
6512
+ // UNICODE-STEGO-005: Homoglyph Confusable Detection
6513
+ // Detect Cyrillic/Greek characters that look identical to Latin but have different codepoints.
6514
+ // These can be used to bypass code review and hide malicious identifiers.
6515
+ const homoglyphCodepoints = new Set([
6516
+ // Cyrillic uppercase that look like Latin: A, B, C, E, H, K, M, O, P, T, X
6517
+ 0x0410, 0x0412, 0x0421, 0x0415, 0x041D, 0x041A, 0x041C, 0x041E, 0x0420, 0x0422, 0x0425,
6518
+ // Cyrillic lowercase that look like Latin: a, e, o, p, c, x
6519
+ 0x0430, 0x0435, 0x043E, 0x0440, 0x0441, 0x0445,
6520
+ // Fullwidth Latin (U+FF21-FF3A, U+FF41-FF5A) -- spot-check common ones
6521
+ 0xFF21, 0xFF22, 0xFF41, 0xFF42,
6522
+ ]);
6523
+ let homoglyphFound = false;
6524
+ let homoglyphLine = 1;
6525
+ let homoglyphChar = '';
6526
+ const contentForHomoglyph = content || rawBuffer.toString('utf-8');
6527
+ const homoglyphLines = contentForHomoglyph.split('\n');
6528
+ for (let lineIdx = 0; lineIdx < homoglyphLines.length; lineIdx++) {
6529
+ const line = homoglyphLines[lineIdx];
6530
+ if (line.length > MAX_LINE_LENGTH)
6531
+ continue;
6532
+ // Skip comment lines
6533
+ const trimmed = line.trimStart();
6534
+ if (trimmed.startsWith('//') || trimmed.startsWith('#') || trimmed.startsWith('*'))
6535
+ continue;
6536
+ for (const ch of line) {
6537
+ const cp = ch.codePointAt(0);
6538
+ if (homoglyphCodepoints.has(cp)) {
6539
+ homoglyphFound = true;
6540
+ homoglyphLine = lineIdx + 1;
6541
+ homoglyphChar = `U+${cp.toString(16).toUpperCase().padStart(4, '0')}`;
6542
+ break;
6543
+ }
6544
+ }
6545
+ if (homoglyphFound)
6546
+ break;
6547
+ }
6548
+ if (homoglyphFound) {
6549
+ findings.push({
6550
+ checkId: 'UNICODE-STEGO-005',
6551
+ name: 'Homoglyph Confusable Characters Detected',
6552
+ description: 'Source file contains characters from non-Latin scripts (Cyrillic, Greek, Fullwidth) that visually resemble Latin letters. These can be used to create identifiers that look identical in code review but behave differently at runtime.',
6553
+ category: 'unicode-stego',
6554
+ severity: 'high',
6555
+ passed: false,
6556
+ message: `Found homoglyph confusable character (${homoglyphChar}) in ${relativePath} at line ${homoglyphLine}`,
6557
+ file: relativePath,
6558
+ line: homoglyphLine,
6559
+ fixable: false,
6560
+ fix: 'Inspect the file for characters that look like Latin letters but are actually Cyrillic, Greek, or Fullwidth. Replace them with their ASCII equivalents. Run: node -e "const fs=require(\'fs\'); [...fs.readFileSync(' + JSON.stringify(relativePath) + ',\'utf8\')].forEach((c,i)=>{const cp=c.codePointAt(0); if(cp>0x7F && cp<0xFFFF) console.log(i, cp.toString(16), c)})"',
6561
+ });
6562
+ }
6563
+ }
6564
+ return findings;
6565
+ }
6566
+ /**
6567
+ * NemoClaw static analysis checks (NEMO-001 through NEMO-010)
6568
+ * Detects vulnerability patterns in any codebase: unsafe installs, missing
6569
+ * digest verification, injection vectors, secret leaks, deserialization, and
6570
+ * egress policy gaps.
6571
+ */
6572
+ async checkNemoClawPatterns(targetDir, _shouldFix) {
6573
+ const findings = [];
6574
+ // Collect source files by extension (max depth 5, skips node_modules/.git/dist/build)
6575
+ const shFiles = await this.walkDirectory(targetDir, ['.sh'], 0, 5);
6576
+ const tsJsFiles = await this.walkDirectory(targetDir, ['.ts', '.js'], 0, 5);
6577
+ const pyFiles = await this.walkDirectory(targetDir, ['.py'], 0, 5);
6578
+ const yamlFiles = await this.walkDirectory(targetDir, ['.yaml', '.yml'], 0, 5);
6579
+ // Cap file counts to avoid scanning enormous repos
6580
+ const maxFiles = 200;
6581
+ const cappedSh = shFiles.slice(0, maxFiles);
6582
+ const cappedTsJs = tsJsFiles.slice(0, maxFiles);
6583
+ const cappedPy = pyFiles.slice(0, maxFiles);
6584
+ const cappedYaml = yamlFiles.slice(0, maxFiles);
6585
+ // ---------- NEMO-001: Curl-pipe install without checksum ----------
6586
+ let nemo001Found = false;
6587
+ for (const file of cappedSh) {
6588
+ try {
6589
+ const content = await fs.readFile(file, 'utf-8');
6590
+ const lines = content.split('\n');
6591
+ for (let i = 0; i < lines.length; i++) {
6592
+ const line = lines[i];
6593
+ if (/curl.*\|\s*(ba)?sh/i.test(line) || /curl.*\|\s*sudo/i.test(line)) {
6594
+ // Check surrounding 20 lines for checksum verification
6595
+ const windowStart = Math.max(0, i - 10);
6596
+ const windowEnd = Math.min(lines.length, i + 11);
6597
+ const window = lines.slice(windowStart, windowEnd).join('\n').toLowerCase();
6598
+ if (!window.includes('sha256') && !window.includes('checksum') && !window.includes('gpg --verify')) {
6599
+ nemo001Found = true;
6600
+ findings.push({
6601
+ checkId: 'NEMO-001',
6602
+ name: 'Curl-pipe install without checksum',
6603
+ description: 'A shell script pipes curl output directly into a shell interpreter without verifying a checksum or GPG signature. An attacker who compromises the remote host can inject arbitrary code.',
6604
+ category: 'nemo-install',
6605
+ severity: 'critical',
6606
+ passed: false,
6607
+ message: `Curl-pipe install without integrity check at line ${i + 1}`,
6608
+ fixable: false,
6609
+ file: path.relative(targetDir, file),
6610
+ line: i + 1,
6611
+ fix: 'Download to temp file, verify checksum, then execute: curl -o /tmp/install.sh URL && echo "EXPECTED_SHA256 /tmp/install.sh" | sha256sum -c && bash /tmp/install.sh',
6612
+ });
6613
+ }
6614
+ }
6615
+ }
6616
+ }
6617
+ catch { /* skip unreadable */ }
6618
+ }
6619
+ if (!nemo001Found && cappedSh.length > 0) {
6620
+ findings.push({
6621
+ checkId: 'NEMO-001',
6622
+ name: 'Curl-pipe install without checksum',
6623
+ description: 'No curl-pipe-to-shell patterns found without checksum verification.',
6624
+ category: 'nemo-install',
6625
+ severity: 'critical',
6626
+ passed: true,
6627
+ message: 'No unsafe curl-pipe installs detected',
6628
+ fixable: false,
6629
+ });
6630
+ }
6631
+ // ---------- NEMO-002: Blueprint/artifact digest verification gap ----------
6632
+ let nemo002Found = false;
6633
+ // Check YAML files for empty digest fields
6634
+ for (const file of cappedYaml) {
6635
+ try {
6636
+ const content = await fs.readFile(file, 'utf-8');
6637
+ const lines = content.split('\n');
6638
+ for (let i = 0; i < lines.length; i++) {
6639
+ const line = lines[i];
6640
+ if (/digest:\s*["']?\s*["']?\s*$/.test(line) || /digest:\s*["']{2}/.test(line)) {
6641
+ nemo002Found = true;
6642
+ findings.push({
6643
+ checkId: 'NEMO-002',
6644
+ name: 'Empty digest field in blueprint/artifact',
6645
+ description: 'A YAML manifest declares a digest field with an empty value. Artifacts without digests cannot be verified for integrity, enabling supply-chain injection.',
6646
+ category: 'nemo-integrity',
6647
+ severity: 'critical',
6648
+ passed: false,
6649
+ message: `Empty digest field at line ${i + 1}`,
6650
+ fixable: false,
6651
+ file: path.relative(targetDir, file),
6652
+ line: i + 1,
6653
+ fix: 'Require non-empty digest; fail verification if digest is missing. Compute with: sha256sum <artifact>',
6654
+ });
6655
+ }
6656
+ }
6657
+ }
6658
+ catch { /* skip */ }
6659
+ }
6660
+ // Check TS/JS files for digest skip logic
6661
+ for (const file of cappedTsJs) {
6662
+ try {
6663
+ const content = await fs.readFile(file, 'utf-8');
6664
+ const lines = content.split('\n');
6665
+ for (let i = 0; i < lines.length; i++) {
6666
+ const line = lines[i];
6667
+ if (/if\s*\(\s*!?.*digest\s*&&/.test(line) || /if\s*\(\s*!.*digest\s*\)/.test(line)) {
6668
+ nemo002Found = true;
6669
+ findings.push({
6670
+ checkId: 'NEMO-002',
6671
+ name: 'Digest verification skipped on falsy value',
6672
+ description: 'Code skips digest verification when the digest field is falsy. An attacker who removes the digest from a manifest bypasses integrity checks entirely.',
6673
+ category: 'nemo-integrity',
6674
+ severity: 'critical',
6675
+ passed: false,
6676
+ message: `Digest verification skipped when falsy at line ${i + 1}`,
6677
+ fixable: false,
6678
+ file: path.relative(targetDir, file),
6679
+ line: i + 1,
6680
+ fix: 'Require non-empty digest; fail verification if digest is missing instead of skipping the check.',
6681
+ });
6682
+ }
6683
+ }
6684
+ }
6685
+ catch { /* skip */ }
6686
+ }
6687
+ if (!nemo002Found && (cappedYaml.length > 0 || cappedTsJs.length > 0)) {
6688
+ findings.push({
6689
+ checkId: 'NEMO-002',
6690
+ name: 'Blueprint/artifact digest verification gap',
6691
+ description: 'No empty digest fields or digest-skip logic found.',
6692
+ category: 'nemo-integrity',
6693
+ severity: 'critical',
6694
+ passed: true,
6695
+ message: 'No digest verification gaps detected',
6696
+ fixable: false,
6697
+ });
6698
+ }
6699
+ // ---------- NEMO-003: Hot-reload policy paths reachable from user input ----------
6700
+ let nemo003Found = false;
6701
+ for (const file of cappedTsJs) {
6702
+ try {
6703
+ const content = await fs.readFile(file, 'utf-8');
6704
+ const lines = content.split('\n');
6705
+ for (let i = 0; i < lines.length; i++) {
6706
+ const line = lines[i];
6707
+ if (/policy.*reload|reload.*policy|hot.*reload/i.test(line)) {
6708
+ // Check surrounding 10 lines for user input references
6709
+ const windowStart = Math.max(0, i - 5);
6710
+ const windowEnd = Math.min(lines.length, i + 6);
6711
+ const window = lines.slice(windowStart, windowEnd).join('\n');
6712
+ if (/req\.|request\.|input\.|user\./i.test(window)) {
6713
+ nemo003Found = true;
6714
+ findings.push({
6715
+ checkId: 'NEMO-003',
6716
+ name: 'Hot-reload policy path reachable from user input',
6717
+ description: 'A policy reload mechanism is within code proximity of user input handling. An attacker could trigger policy changes through crafted requests.',
6718
+ category: 'nemo-policy',
6719
+ severity: 'high',
6720
+ passed: false,
6721
+ message: `Policy reload near user input handling at line ${i + 1}`,
6722
+ fixable: false,
6723
+ file: path.relative(targetDir, file),
6724
+ line: i + 1,
6725
+ fix: 'Gate policy reload behind operator authentication, not agent output or user requests.',
6726
+ });
6727
+ }
6728
+ }
6729
+ }
6730
+ }
6731
+ catch { /* skip */ }
6732
+ }
6733
+ if (!nemo003Found && cappedTsJs.length > 0) {
6734
+ findings.push({
6735
+ checkId: 'NEMO-003',
6736
+ name: 'Hot-reload policy paths reachable from user input',
6737
+ description: 'No policy reload paths reachable from user input found.',
6738
+ category: 'nemo-policy',
6739
+ severity: 'high',
6740
+ passed: true,
6741
+ message: 'No unsafe policy reload paths detected',
6742
+ fixable: false,
6743
+ });
6744
+ }
6745
+ // ---------- NEMO-004: API key passed as CLI argument ----------
6746
+ let nemo004Found = false;
6747
+ const nemo004Files = [...cappedTsJs, ...cappedPy];
6748
+ for (const file of nemo004Files) {
6749
+ try {
6750
+ const content = await fs.readFile(file, 'utf-8');
6751
+ const lines = content.split('\n');
6752
+ for (let i = 0; i < lines.length; i++) {
6753
+ const line = lines[i];
6754
+ if (/--credential.*\$\{.*key/i.test(line) ||
6755
+ /--api-key.*\$\{/i.test(line) ||
6756
+ /--token.*\$\{/i.test(line) ||
6757
+ /execSync.*--credential/i.test(line) ||
6758
+ /spawn.*--credential/i.test(line) ||
6759
+ /subprocess.*--credential/i.test(line)) {
6760
+ nemo004Found = true;
6761
+ findings.push({
6762
+ checkId: 'NEMO-004',
6763
+ name: 'API key passed as CLI argument',
6764
+ description: 'Credentials are passed as command-line arguments to a subprocess. CLI arguments are visible in process listings (ps aux) and shell history.',
6765
+ category: 'nemo-secrets',
6766
+ severity: 'high',
6767
+ passed: false,
6768
+ message: `Credential passed as CLI argument at line ${i + 1}`,
6769
+ fixable: false,
6770
+ file: path.relative(targetDir, file),
6771
+ line: i + 1,
6772
+ fix: 'Pass credentials via environment variables or stdin, not command-line arguments.',
6773
+ });
6774
+ }
6775
+ }
6776
+ }
6777
+ catch { /* skip */ }
6778
+ }
6779
+ if (!nemo004Found && nemo004Files.length > 0) {
6780
+ findings.push({
6781
+ checkId: 'NEMO-004',
6782
+ name: 'API key passed as CLI argument',
6783
+ description: 'No credentials passed as CLI arguments detected.',
6784
+ category: 'nemo-secrets',
6785
+ severity: 'high',
6786
+ passed: true,
6787
+ message: 'No CLI credential exposure detected',
6788
+ fixable: false,
6789
+ });
6790
+ }
6791
+ // ---------- NEMO-005: exec() with user-controlled string interpolation ----------
6792
+ let nemo005Found = false;
6793
+ for (const file of cappedTsJs) {
6794
+ try {
6795
+ const content = await fs.readFile(file, 'utf-8');
6796
+ const lines = content.split('\n');
6797
+ for (let i = 0; i < lines.length; i++) {
6798
+ const line = lines[i];
6799
+ // Match exec( or execSync( with template literal containing user-controlled vars
6800
+ // Exclude execFile (safe) patterns
6801
+ if (/\bexec(Sync)?\s*\(/.test(line) &&
6802
+ !/\bexecFile/.test(line) &&
6803
+ /`[^`]*\$\{[^}]*(name|Name|id|Id|input|arg|param|flag|option)/i.test(line)) {
6804
+ nemo005Found = true;
6805
+ findings.push({
6806
+ checkId: 'NEMO-005',
6807
+ name: 'exec() with user-controlled string interpolation',
6808
+ description: 'exec() or execSync() is called with a template literal containing user-controlled variables. This enables command injection.',
6809
+ category: 'nemo-injection',
6810
+ severity: 'critical',
6811
+ passed: false,
6812
+ message: `exec() with user-controlled interpolation at line ${i + 1}`,
6813
+ fixable: false,
6814
+ file: path.relative(targetDir, file),
6815
+ line: i + 1,
6816
+ fix: 'Use execFile() or spawn() with array arguments instead of exec() with string interpolation.',
6817
+ });
6818
+ }
6819
+ }
6820
+ }
6821
+ catch { /* skip */ }
6822
+ }
6823
+ if (!nemo005Found && cappedTsJs.length > 0) {
6824
+ findings.push({
6825
+ checkId: 'NEMO-005',
6826
+ name: 'exec() with user-controlled string interpolation',
6827
+ description: 'No exec() calls with user-controlled string interpolation found.',
6828
+ category: 'nemo-injection',
6829
+ severity: 'critical',
6830
+ passed: true,
6831
+ message: 'No command injection via exec() detected',
6832
+ fixable: false,
6833
+ });
6834
+ }
6835
+ // ---------- NEMO-006: Predictable /tmp paths without mktemp ----------
6836
+ let nemo006Found = false;
6837
+ for (const file of cappedSh) {
6838
+ try {
6839
+ const content = await fs.readFile(file, 'utf-8');
6840
+ const lines = content.split('\n');
6841
+ for (let i = 0; i < lines.length; i++) {
6842
+ const line = lines[i];
6843
+ // Skip lines that ARE mktemp commands
6844
+ if (/mktemp/.test(line))
6845
+ continue;
6846
+ // Match hardcoded /tmp/ writes
6847
+ if (/\/tmp\//.test(line) &&
6848
+ (/>/.test(line) || />>/.test(line) || /-o\s+\/tmp\//.test(line) || /install.*\/tmp\//.test(line))) {
6849
+ nemo006Found = true;
6850
+ findings.push({
6851
+ checkId: 'NEMO-006',
6852
+ name: 'Predictable /tmp path without mktemp',
6853
+ description: 'A shell script writes to a hardcoded /tmp path instead of using mktemp. Predictable temp file names enable symlink attacks (CWE-377).',
6854
+ category: 'nemo-filesystem',
6855
+ severity: 'high',
6856
+ passed: false,
6857
+ message: `Hardcoded /tmp path at line ${i + 1}`,
6858
+ fixable: false,
6859
+ file: path.relative(targetDir, file),
6860
+ line: i + 1,
6861
+ fix: 'Use mktemp for all temporary files; add trap EXIT for cleanup: TMPDIR=$(mktemp -d) && trap "rm -rf $TMPDIR" EXIT',
6862
+ });
6863
+ }
6864
+ }
6865
+ }
6866
+ catch { /* skip */ }
6867
+ }
6868
+ if (!nemo006Found && cappedSh.length > 0) {
6869
+ findings.push({
6870
+ checkId: 'NEMO-006',
6871
+ name: 'Predictable /tmp paths without mktemp',
6872
+ description: 'No hardcoded /tmp paths found in shell scripts.',
6873
+ category: 'nemo-filesystem',
6874
+ severity: 'high',
6875
+ passed: true,
6876
+ message: 'No predictable temp file paths detected',
6877
+ fixable: false,
6878
+ });
6879
+ }
6880
+ // ---------- NEMO-007: Full process.env passthrough to subprocess ----------
6881
+ let nemo007Found = false;
6882
+ for (const file of cappedTsJs) {
6883
+ try {
6884
+ const content = await fs.readFile(file, 'utf-8');
6885
+ const lines = content.split('\n');
6886
+ for (let i = 0; i < lines.length; i++) {
6887
+ const line = lines[i];
6888
+ if (/env:\s*\{[^}]*\.\.\.process\.env/.test(line)) {
6889
+ nemo007Found = true;
6890
+ findings.push({
6891
+ checkId: 'NEMO-007',
6892
+ name: 'Full process.env passthrough to subprocess',
6893
+ description: 'process.env is spread into subprocess options, leaking all environment variables (including secrets) to child processes.',
6894
+ category: 'nemo-secrets',
6895
+ severity: 'high',
6896
+ passed: false,
6897
+ message: `Full process.env spread into subprocess at line ${i + 1}`,
6898
+ fixable: false,
6899
+ file: path.relative(targetDir, file),
6900
+ line: i + 1,
6901
+ fix: 'Use an explicit env var allowlist instead of spreading process.env: env: { PATH: process.env.PATH, NODE_ENV: process.env.NODE_ENV }',
6902
+ });
6903
+ }
6904
+ }
6905
+ }
6906
+ catch { /* skip */ }
6907
+ }
6908
+ if (!nemo007Found && cappedTsJs.length > 0) {
6909
+ findings.push({
6910
+ checkId: 'NEMO-007',
6911
+ name: 'Full process.env passthrough to subprocess',
6912
+ description: 'No full process.env passthrough to subprocesses found.',
6913
+ category: 'nemo-secrets',
6914
+ severity: 'high',
6915
+ passed: true,
6916
+ message: 'No process.env leakage to subprocesses detected',
6917
+ fixable: false,
6918
+ });
6919
+ }
6920
+ // ---------- NEMO-008: TOCTOU race between verify and apply ----------
6921
+ let nemo008Found = false;
6922
+ // Heuristic: look for verify/validate in one file AND exec/spawn in the same directory tree
6923
+ const dirVerifyMap = new Map();
6924
+ const dirExecMap = new Map();
6925
+ for (const file of cappedTsJs) {
6926
+ try {
6927
+ const content = await fs.readFile(file, 'utf-8');
6928
+ const lines = content.split('\n');
6929
+ const dir = path.dirname(file);
6930
+ for (let i = 0; i < lines.length; i++) {
6931
+ if (/verify.*digest|validate.*hash|check.*integrity/i.test(lines[i])) {
6932
+ if (!dirVerifyMap.has(dir)) {
6933
+ dirVerifyMap.set(dir, { verifyFile: file, verifyLine: i + 1 });
6934
+ }
6935
+ }
6936
+ if (/\bspawn\s*\(|\bexec\s*\(|\bexecSync\s*\(|\bexecFile\s*\(/.test(lines[i])) {
6937
+ if (!dirExecMap.has(dir)) {
6938
+ dirExecMap.set(dir, { execFile: file, execLine: i + 1 });
6939
+ }
6940
+ }
6941
+ }
6942
+ }
6943
+ catch { /* skip */ }
6944
+ }
6945
+ for (const [dir, verify] of dirVerifyMap) {
6946
+ const exec = dirExecMap.get(dir);
6947
+ if (exec && verify.verifyFile !== exec.execFile) {
6948
+ nemo008Found = true;
6949
+ findings.push({
6950
+ checkId: 'NEMO-008',
6951
+ name: 'TOCTOU race between verify and apply',
6952
+ description: 'Integrity verification and execution happen in separate files, creating a time-of-check-time-of-use window where the artifact can be swapped between verification and use.',
6953
+ category: 'nemo-integrity',
6954
+ severity: 'high',
6955
+ passed: false,
6956
+ message: `Verify in ${path.relative(targetDir, verify.verifyFile)}:${verify.verifyLine}, exec in ${path.relative(targetDir, exec.execFile)}:${exec.execLine}`,
6957
+ fixable: false,
6958
+ file: path.relative(targetDir, verify.verifyFile),
6959
+ line: verify.verifyLine,
6960
+ fix: 'Copy artifact to temp dir, verify the copy, execute from the copy (atomic verify-then-execute in the same function).',
6961
+ });
6962
+ }
6963
+ }
6964
+ if (!nemo008Found && cappedTsJs.length > 0) {
6965
+ findings.push({
6966
+ checkId: 'NEMO-008',
6967
+ name: 'TOCTOU race between verify and apply',
6968
+ description: 'No verify/apply TOCTOU patterns detected.',
6969
+ category: 'nemo-integrity',
6970
+ severity: 'high',
6971
+ passed: true,
6972
+ message: 'No TOCTOU race conditions detected',
6973
+ fixable: false,
6974
+ });
6975
+ }
6976
+ // ---------- NEMO-009: Unsafe deserialization of untrusted data ----------
6977
+ let nemo009Found = false;
6978
+ // Python files: pickle.load, yaml.load without SafeLoader, eval(), exec()
6979
+ for (const file of cappedPy) {
6980
+ try {
6981
+ const content = await fs.readFile(file, 'utf-8');
6982
+ const lines = content.split('\n');
6983
+ for (let i = 0; i < lines.length; i++) {
6984
+ const line = lines[i];
6985
+ if (/pickle\.load/i.test(line)) {
6986
+ nemo009Found = true;
6987
+ findings.push({
6988
+ checkId: 'NEMO-009',
6989
+ name: 'Unsafe deserialization: pickle.load',
6990
+ description: 'pickle.load() deserializes arbitrary Python objects, enabling remote code execution if the data source is untrusted.',
6991
+ category: 'nemo-deserialization',
6992
+ severity: 'critical',
6993
+ passed: false,
6994
+ message: `pickle.load() at line ${i + 1}`,
6995
+ fixable: false,
6996
+ file: path.relative(targetDir, file),
6997
+ line: i + 1,
6998
+ fix: 'Use json.load() or a restricted deserializer instead of pickle for untrusted data.',
6999
+ });
7000
+ }
7001
+ if (/yaml\.load\s*\(/.test(line) && !/Loader\s*=\s*SafeLoader/.test(line) && !/safe_load/.test(line)) {
7002
+ nemo009Found = true;
7003
+ findings.push({
7004
+ checkId: 'NEMO-009',
7005
+ name: 'Unsafe deserialization: yaml.load without SafeLoader',
7006
+ description: 'yaml.load() without SafeLoader can execute arbitrary Python code embedded in YAML documents.',
7007
+ category: 'nemo-deserialization',
7008
+ severity: 'high',
7009
+ passed: false,
7010
+ message: `yaml.load() without SafeLoader at line ${i + 1}`,
7011
+ fixable: false,
7012
+ file: path.relative(targetDir, file),
7013
+ line: i + 1,
7014
+ fix: 'Use yaml.safe_load() or yaml.load(data, Loader=yaml.SafeLoader).',
7015
+ });
7016
+ }
7017
+ if (/\beval\s*\(/.test(line) || /\bexec\s*\(/.test(line)) {
7018
+ nemo009Found = true;
7019
+ findings.push({
7020
+ checkId: 'NEMO-009',
7021
+ name: 'Unsafe deserialization: eval/exec in Python',
7022
+ description: 'eval() or exec() executes arbitrary code. If the input originates from untrusted sources, this enables code injection.',
7023
+ category: 'nemo-deserialization',
7024
+ severity: 'critical',
7025
+ passed: false,
7026
+ message: `eval()/exec() at line ${i + 1}`,
7027
+ fixable: false,
7028
+ file: path.relative(targetDir, file),
7029
+ line: i + 1,
7030
+ fix: 'Replace eval/exec with ast.literal_eval() for data parsing, or use a safe DSL.',
7031
+ });
7032
+ }
7033
+ }
7034
+ }
7035
+ catch { /* skip */ }
7036
+ }
7037
+ // TS/JS files: eval(), new Function(), JSON5.parse
7038
+ for (const file of cappedTsJs) {
7039
+ try {
7040
+ const content = await fs.readFile(file, 'utf-8');
7041
+ const lines = content.split('\n');
7042
+ for (let i = 0; i < lines.length; i++) {
7043
+ const line = lines[i];
7044
+ if (/\beval\s*\(/.test(line)) {
7045
+ nemo009Found = true;
7046
+ findings.push({
7047
+ checkId: 'NEMO-009',
7048
+ name: 'Unsafe deserialization: eval()',
7049
+ description: 'eval() executes arbitrary JavaScript code. If the input comes from untrusted sources, this enables code injection.',
7050
+ category: 'nemo-deserialization',
7051
+ severity: 'critical',
7052
+ passed: false,
7053
+ message: `eval() at line ${i + 1}`,
7054
+ fixable: false,
7055
+ file: path.relative(targetDir, file),
7056
+ line: i + 1,
7057
+ fix: 'Use JSON.parse() for data, or a sandboxed evaluator for expressions.',
7058
+ });
7059
+ }
7060
+ if (/new\s+Function\s*\(/.test(line)) {
7061
+ nemo009Found = true;
7062
+ findings.push({
7063
+ checkId: 'NEMO-009',
7064
+ name: 'Unsafe deserialization: new Function()',
7065
+ description: 'new Function() creates executable code from strings, equivalent to eval() for code injection risks.',
7066
+ category: 'nemo-deserialization',
7067
+ severity: 'critical',
7068
+ passed: false,
7069
+ message: `new Function() at line ${i + 1}`,
7070
+ fixable: false,
7071
+ file: path.relative(targetDir, file),
7072
+ line: i + 1,
7073
+ fix: 'Use JSON.parse() for data, or a sandboxed evaluator for expressions.',
7074
+ });
7075
+ }
7076
+ if (/JSON5\.parse/.test(line)) {
7077
+ nemo009Found = true;
7078
+ findings.push({
7079
+ checkId: 'NEMO-009',
7080
+ name: 'Unsafe deserialization: JSON5.parse',
7081
+ description: 'JSON5.parse() is more lenient than JSON.parse(), accepting comments, trailing commas, and unquoted keys. This expanded surface can introduce parsing ambiguities.',
7082
+ category: 'nemo-deserialization',
7083
+ severity: 'high',
7084
+ passed: false,
7085
+ message: `JSON5.parse() at line ${i + 1}`,
7086
+ fixable: false,
7087
+ file: path.relative(targetDir, file),
7088
+ line: i + 1,
7089
+ fix: 'Use JSON.parse() instead of JSON5.parse() for untrusted data.',
7090
+ });
7091
+ }
7092
+ }
7093
+ }
7094
+ catch { /* skip */ }
7095
+ }
7096
+ if (!nemo009Found && (cappedPy.length > 0 || cappedTsJs.length > 0)) {
7097
+ findings.push({
7098
+ checkId: 'NEMO-009',
7099
+ name: 'Unsafe deserialization of untrusted data',
7100
+ description: 'No unsafe deserialization patterns detected.',
7101
+ category: 'nemo-deserialization',
7102
+ severity: 'critical',
7103
+ passed: true,
7104
+ message: 'No unsafe deserialization detected',
7105
+ fixable: false,
7106
+ });
7107
+ }
7108
+ // ---------- NEMO-010: Network egress policy allows data exfiltration ----------
7109
+ let nemo010Found = false;
7110
+ const egressEndpoints = [
7111
+ 'api.telegram.org',
7112
+ 'discord.com/api',
7113
+ 'hooks.slack.com',
7114
+ 'webhook.site',
7115
+ 'requestbin',
7116
+ ];
7117
+ for (const file of cappedYaml) {
7118
+ try {
7119
+ const content = await fs.readFile(file, 'utf-8');
7120
+ const lines = content.split('\n');
7121
+ for (let i = 0; i < lines.length; i++) {
7122
+ const line = lines[i].toLowerCase();
7123
+ for (const endpoint of egressEndpoints) {
7124
+ if (line.includes(endpoint.toLowerCase())) {
7125
+ nemo010Found = true;
7126
+ findings.push({
7127
+ checkId: 'NEMO-010',
7128
+ name: 'Messaging API in egress policy',
7129
+ description: `Sandbox policy pre-allows access to ${endpoint}. Agents can exfiltrate data via messaging APIs without explicit operator approval.`,
7130
+ category: 'nemo-egress',
7131
+ severity: 'high',
7132
+ passed: false,
7133
+ message: `Messaging endpoint "${endpoint}" in egress policy at line ${i + 1}`,
7134
+ fixable: false,
7135
+ file: path.relative(targetDir, file),
7136
+ line: i + 1,
7137
+ fix: 'Remove messaging APIs from base sandbox policy; require explicit operator opt-in per deployment.',
7138
+ });
7139
+ }
7140
+ }
7141
+ }
7142
+ }
7143
+ catch { /* skip */ }
7144
+ }
7145
+ if (!nemo010Found && cappedYaml.length > 0) {
7146
+ findings.push({
7147
+ checkId: 'NEMO-010',
7148
+ name: 'Network egress policy allows data exfiltration',
7149
+ description: 'No messaging API endpoints found in egress policies.',
7150
+ category: 'nemo-egress',
7151
+ severity: 'high',
7152
+ passed: true,
7153
+ message: 'No exfiltration-prone egress endpoints detected',
7154
+ fixable: false,
7155
+ });
6429
7156
  }
6430
7157
  return findings;
6431
7158
  }