chain-audit 0.6.5 → 0.6.8

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.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # chain-audit
2
2
 
3
- [![CI](https://github.com/hukasx0/chain-audit/actions/workflows/ci.yml/badge.svg)](https://github.com/hukasx0/chain-audit/actions/workflows/ci.yml)
3
+ [![CI](https://github.com/HubertKasperek/chain-audit/actions/workflows/ci.yml/badge.svg)](https://github.com/HubertKasperek/chain-audit/actions/workflows/ci.yml)
4
4
  [![npm version](https://img.shields.io/npm/v/chain-audit.svg)](https://www.npmjs.com/package/chain-audit)
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
6
  [![Node.js Version](https://img.shields.io/node/v/chain-audit.svg)](https://nodejs.org)
@@ -50,7 +50,7 @@ bun add -d chain-audit
50
50
 
51
51
  > **Note:** In 99.99% of cases, `npm install -g chain-audit` is sufficient. Standalone executables are only for special cases where Node.js, npm, Bun, or other package managers are unavailable or installation is restricted.
52
52
 
53
- Pre-built standalone executables are available in the [GitHub Releases](https://github.com/hukasx0/chain-audit/releases) for Linux (x64 and ARM64). These are self-contained binaries that don't require Node.js or Bun to be installed.
53
+ Pre-built standalone executables are available in the [GitHub Releases](https://github.com/HubertKasperek/chain-audit/releases) for Linux (x64 and ARM64). These are self-contained binaries that don't require Node.js or Bun to be installed.
54
54
 
55
55
  **Use cases for standalone executables:**
56
56
  - CI/CD environments without Node.js
@@ -62,7 +62,7 @@ You can also compile chain-audit to a standalone binary yourself (For Linux, Win
62
62
 
63
63
  ```bash
64
64
  # Clone the repository
65
- git clone https://github.com/hukasx0/chain-audit.git
65
+ git clone https://github.com/HubertKasperek/chain-audit.git
66
66
  cd chain-audit
67
67
 
68
68
  # Compile to single executable
@@ -183,7 +183,7 @@ Issues will be displayed sorted by severity (highest first), then by package nam
183
183
  ## Example Output
184
184
 
185
185
  ```
186
- chain-audit v0.6.5
186
+ chain-audit v0.6.8
187
187
  Zero-dependency heuristic scanner CLI to detect supply chain attacks in node_modules
188
188
  ────────────────────────────────────────────────────────────
189
189
 
@@ -431,7 +431,7 @@ on: [push, pull_request]
431
431
 
432
432
  jobs:
433
433
  scan:
434
- uses: hukasx0/chain-audit/.github/workflows/scan.yml@main
434
+ uses: HubertKasperek/chain-audit/.github/workflows/scan.yml@main
435
435
  with:
436
436
  fail-on: high
437
437
  scan-code: false
@@ -591,11 +591,11 @@ npm rebuild
591
591
 
592
592
  ## Contributing
593
593
 
594
- **Repository:** [github.com/hukasx0/chain-audit](https://github.com/hukasx0/chain-audit)
594
+ **Repository:** [github.com/HubertKasperek/chain-audit](https://github.com/HubertKasperek/chain-audit)
595
595
 
596
596
  ```bash
597
597
  # Clone and install
598
- git clone https://github.com/hukasx0/chain-audit.git
598
+ git clone https://github.com/HubertKasperek/chain-audit.git
599
599
  cd chain-audit
600
600
  npm install
601
601
 
@@ -613,7 +613,7 @@ node src/index.js --node-modules /path/to/project/node_modules
613
613
 
614
614
  Hubert Kasperek
615
615
 
616
- [MIT License](https://github.com/hukasx0/chain-audit/blob/main/LICENSE)
616
+ [MIT License](https://github.com/HubertKasperek/chain-audit/blob/main/LICENSE)
617
617
 
618
618
  ---
619
619
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "chain-audit",
3
- "version": "0.6.5",
4
- "description": "Zero-dependency heuristic scanner CLI to detect supply chain attacks in node_modules. Scans for malicious install scripts, typosquatting, extraneous packages, lockfile integrity, obfuscated code, executable files, and suspicious code patterns.",
3
+ "version": "0.6.8",
4
+ "description": "Zero-dependency heuristic scanner CLI to detect supply chain attacks in node_modules. Detects malicious install scripts, network access, env exfiltration, lockfile integrity, obfuscated code, and suspicious patterns. Works fully offline.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
7
7
  "chain-audit": "src/index.js"
@@ -42,16 +42,16 @@
42
42
  "license": "MIT",
43
43
  "repository": {
44
44
  "type": "git",
45
- "url": "git+https://github.com/hukasx0/chain-audit.git"
45
+ "url": "git+https://github.com/HubertKasperek/chain-audit.git"
46
46
  },
47
47
  "bugs": {
48
- "url": "https://github.com/hukasx0/chain-audit/issues"
48
+ "url": "https://github.com/HubertKasperek/chain-audit/issues"
49
49
  },
50
- "homepage": "https://github.com/hukasx0/chain-audit#readme",
50
+ "homepage": "https://github.com/HubertKasperek/chain-audit#readme",
51
51
  "engines": {
52
52
  "node": ">=18.0.0"
53
53
  },
54
54
  "devDependencies": {
55
- "eslint": "^8.57.0"
55
+ "eslint": "9.39.2"
56
56
  }
57
57
  }
package/src/analyzer.js CHANGED
@@ -286,7 +286,8 @@ function checkLockfileIntegrity(pkg, lockIndex, issues, verbose = false, config
286
286
  if (!config.checkLockfile) return;
287
287
  if (!lockIndex.lockPresent) return;
288
288
 
289
- const lockByPath = lockIndex.indexByPath.get(pkg.relativePath);
289
+ const normalizedRelativePath = normalizePackageRelativePath(pkg.relativePath);
290
+ const lockByPath = lockIndex.indexByPath.get(normalizedRelativePath);
290
291
  const lockByName = lockIndex.indexByName.get(pkg.name);
291
292
 
292
293
  if (!lockByPath && !lockByName) {
@@ -358,15 +359,16 @@ function checkLockfileIntegrity(pkg, lockIndex, issues, verbose = false, config
358
359
  function checkPackageStructureIntegrity(pkg, lockIndex, issues, verbose = false) {
359
360
  if (!lockIndex.lockPresent) return;
360
361
 
361
- const lockByPath = lockIndex.indexByPath.get(pkg.relativePath);
362
+ const normalizedRelativePath = normalizePackageRelativePath(pkg.relativePath);
363
+ const lockByPath = lockIndex.indexByPath.get(normalizedRelativePath);
362
364
  const lockByName = lockIndex.indexByName.get(pkg.name);
363
365
  const lockEntry = lockByPath || lockByName;
364
366
 
365
367
  if (!lockEntry) return; // Already flagged as extraneous in checkLockfileIntegrity
366
368
 
367
369
  // Check 1: Package should have a name matching the expected name
368
- const expectedName = lockByPath ? extractPackageNameFromPath(pkg.relativePath) : pkg.name;
369
- if (pkg.name && pkg.name !== expectedName && !pkg.name.startsWith('@')) {
370
+ const expectedName = lockByPath ? extractPackageNameFromPath(normalizedRelativePath) : pkg.name;
371
+ if (pkg.name && pkg.name !== expectedName) {
370
372
  const issue = {
371
373
  severity: 'high',
372
374
  reason: 'package_name_mismatch',
@@ -437,12 +439,28 @@ function checkPackageStructureIntegrity(pkg, lockIndex, issues, verbose = false)
437
439
  * Extract expected package name from relative path
438
440
  */
439
441
  function extractPackageNameFromPath(relativePath) {
440
- const parts = relativePath.split('/');
442
+ const normalized = normalizePackageRelativePath(relativePath);
443
+ const parts = normalized.split('/').filter(Boolean);
444
+ if (parts.length === 0) return '';
445
+
446
+ // Use the last package segment after the last "node_modules/".
447
+ // Example: "foo/node_modules/bar" -> "bar".
448
+ const nodeModulesIndex = parts.lastIndexOf('node_modules');
449
+ const start = nodeModulesIndex >= 0 ? nodeModulesIndex + 1 : 0;
450
+ const first = parts[start];
451
+ const second = parts[start + 1];
452
+
441
453
  // Handle scoped packages (@scope/name)
442
- if (parts[0] && parts[0].startsWith('@') && parts.length >= 2) {
443
- return `${parts[0]}/${parts[1]}`;
454
+ if (first && first.startsWith('@') && second) {
455
+ return `${first}/${second}`;
444
456
  }
445
- return parts[0];
457
+
458
+ return first || '';
459
+ }
460
+
461
+ function normalizePackageRelativePath(relativePath) {
462
+ if (!relativePath) return '';
463
+ return String(relativePath).replace(/\\/g, '/');
446
464
  }
447
465
 
448
466
  /**
@@ -1010,7 +1028,8 @@ function findNativeArtifacts(pkgDir, maxDepth = 3) {
1010
1028
  let entries = [];
1011
1029
  try {
1012
1030
  entries = fs.readdirSync(dir, { withFileTypes: true });
1013
- } catch {
1031
+ } catch (err) {
1032
+ console.warn(`Warning: Cannot read directory ${dir} while searching for native artifacts: ${err.message}`);
1014
1033
  continue;
1015
1034
  }
1016
1035
 
@@ -1062,10 +1081,10 @@ function checkExecutableFiles(pkg, issues, verbose = false, config = {}) {
1062
1081
  const issue = {
1063
1082
  severity: severity,
1064
1083
  reason: 'executable_files',
1065
- detail: `Contains executable files (shell scripts, etc.): ${listed}${found.length > 5 ? `, +${found.length - 5} more` : ''}`,
1084
+ detail: `Contains executable files (shell scripts, binaries, etc.): ${listed}${found.length > 5 ? `, +${found.length - 5} more` : ''}`,
1066
1085
  recommendation: suspiciousFiles.length > 0
1067
1086
  ? 'Executable files outside bin/ directory are suspicious and may indicate a supply chain attack. Review immediately.'
1068
- : 'Shell scripts in bin/ directory are less suspicious but should be reviewed for malicious content.',
1087
+ : 'Shell scripts and binaries in bin/ directory are less suspicious but should be reviewed for malicious content.',
1069
1088
  };
1070
1089
 
1071
1090
  if (verbose) {
@@ -1112,6 +1131,7 @@ function checkExecutableFiles(pkg, issues, verbose = false, config = {}) {
1112
1131
  */
1113
1132
  function findExecutableFiles(pkgDir, maxDepth = 5) {
1114
1133
  const found = [];
1134
+ const binaryFiles = []; // Track binary files that couldn't be read
1115
1135
  const stack = [{ dir: pkgDir, depth: 0 }];
1116
1136
 
1117
1137
  // Executable file extensions - only real executables, not .js (those are scanned by code scanner)
@@ -1135,7 +1155,8 @@ function findExecutableFiles(pkgDir, maxDepth = 5) {
1135
1155
  let entries = [];
1136
1156
  try {
1137
1157
  entries = fs.readdirSync(dir, { withFileTypes: true });
1138
- } catch {
1158
+ } catch (err) {
1159
+ console.warn(`Warning: Cannot read directory ${dir} while searching for executable files: ${err.message}`);
1139
1160
  continue;
1140
1161
  }
1141
1162
 
@@ -1186,12 +1207,13 @@ function findExecutableFiles(pkgDir, maxDepth = 5) {
1186
1207
  found.push(fullPath);
1187
1208
  }
1188
1209
  } catch {
1189
- // If we can't read content, skip (might be binary)
1190
- continue;
1210
+ // If we can't read content, it's likely a binary file
1211
+ // Track it as a binary executable (no extension but executable)
1212
+ binaryFiles.push(fullPath);
1191
1213
  }
1192
1214
  }
1193
- } catch {
1194
- // Skip files we can't check
1215
+ } catch (err) {
1216
+ console.warn(`Warning: Cannot check file ${fullPath} for executable permissions: ${err.message}`);
1195
1217
  continue;
1196
1218
  }
1197
1219
  }
@@ -1199,6 +1221,9 @@ function findExecutableFiles(pkgDir, maxDepth = 5) {
1199
1221
  }
1200
1222
  }
1201
1223
 
1224
+ // Add binary files to found list (they are executables too)
1225
+ found.push(...binaryFiles);
1226
+
1202
1227
  return found;
1203
1228
  }
1204
1229
 
@@ -1494,137 +1519,106 @@ function checkMetadataAnomalies(pkg, issues, verbose = false) {
1494
1519
  }
1495
1520
 
1496
1521
  /**
1497
- * Check if a match is in a comment, string literal, or regex pattern definition
1498
- * This helps reduce false positives when scanning code that defines detection patterns
1522
+ * Get lexical context right before a character index.
1523
+ * Tracks whether parser is currently in comment or string.
1499
1524
  */
1500
- function isMatchInNonExecutableContext(content, matchIndex, matchLength) {
1501
- // Get the line containing the match
1502
- const lines = content.split('\n');
1503
- let charCount = 0;
1504
- let lineIndex = 0;
1505
- let lineStart = 0;
1506
-
1507
- for (let i = 0; i < lines.length; i++) {
1508
- const lineEnd = charCount + lines[i].length;
1509
- if (matchIndex >= charCount && matchIndex < lineEnd) {
1510
- lineIndex = i;
1511
- lineStart = charCount;
1512
- break;
1513
- }
1514
- charCount = lineEnd + 1; // +1 for newline
1515
- }
1516
-
1517
- const line = lines[lineIndex];
1518
- const matchInLine = matchIndex - lineStart;
1519
- const lineBeforeMatch = line.slice(0, matchInLine);
1520
-
1521
- // Check if it's in a multi-line comment (need to check entire content, not just line)
1522
- const contentBeforeMatch = content.slice(0, matchIndex);
1523
- const lastCommentStart = contentBeforeMatch.lastIndexOf('/*');
1524
- if (lastCommentStart !== -1) {
1525
- const lastCommentEnd = contentBeforeMatch.lastIndexOf('*/');
1526
- if (lastCommentEnd < lastCommentStart) {
1527
- // Comment started but not closed - match is after comment start
1528
- return true; // In multi-line comment
1529
- }
1530
- }
1531
-
1532
- // Check if it's in a single-line comment
1533
- const singleLineCommentIndex = lineBeforeMatch.lastIndexOf('//');
1534
- if (singleLineCommentIndex !== -1) {
1535
- // Check if there's no newline between comment and match (same line)
1536
- const afterComment = lineBeforeMatch.slice(singleLineCommentIndex + 2);
1537
- if (!afterComment.includes('\n')) {
1538
- // No newline means we're still on the same line as the comment
1539
- // Check if there's an unclosed string (which would mean we're not in comment)
1540
- // But simpler: if // is before match on same line and no newline, we're in comment
1541
- return true; // In comment
1542
- }
1543
- }
1544
-
1545
- // Check if it's in a string literal (single, double, or template string)
1546
- // Need to check entire content before match, not just the line (for multiline strings)
1525
+ function getLexicalContextBeforeIndex(content, targetIndex) {
1526
+ let inLineComment = false;
1527
+ let inBlockComment = false;
1547
1528
  let inString = false;
1548
1529
  let stringChar = null;
1530
+ let stringStart = -1;
1549
1531
  let escaped = false;
1550
-
1551
- // Check entire content before match for string context
1552
- for (let i = 0; i < contentBeforeMatch.length; i++) {
1553
- const char = contentBeforeMatch[i];
1554
- if (escaped) {
1555
- escaped = false;
1532
+
1533
+ for (let i = 0; i < targetIndex; i++) {
1534
+ const char = content[i];
1535
+ const nextChar = content[i + 1];
1536
+
1537
+ if (inLineComment) {
1538
+ if (char === '\n') {
1539
+ inLineComment = false;
1540
+ }
1556
1541
  continue;
1557
1542
  }
1558
- if (char === '\\') {
1559
- escaped = true;
1543
+
1544
+ if (inBlockComment) {
1545
+ if (char === '*' && nextChar === '/') {
1546
+ inBlockComment = false;
1547
+ i++;
1548
+ }
1560
1549
  continue;
1561
1550
  }
1562
- if ((char === '"' || char === "'" || char === '`') && !inString) {
1551
+
1552
+ if (inString) {
1553
+ if (escaped) {
1554
+ escaped = false;
1555
+ continue;
1556
+ }
1557
+ if (char === '\\') {
1558
+ escaped = true;
1559
+ continue;
1560
+ }
1561
+ if (char === stringChar) {
1562
+ inString = false;
1563
+ stringChar = null;
1564
+ stringStart = -1;
1565
+ }
1566
+ continue;
1567
+ }
1568
+
1569
+ if (char === '/' && nextChar === '/') {
1570
+ inLineComment = true;
1571
+ i++;
1572
+ continue;
1573
+ }
1574
+
1575
+ if (char === '/' && nextChar === '*') {
1576
+ inBlockComment = true;
1577
+ i++;
1578
+ continue;
1579
+ }
1580
+
1581
+ if (char === '"' || char === '\'' || char === '`') {
1563
1582
  inString = true;
1564
1583
  stringChar = char;
1565
- } else if (char === stringChar && inString) {
1566
- inString = false;
1567
- stringChar = null;
1584
+ stringStart = i;
1568
1585
  }
1569
1586
  }
1587
+
1588
+ return {
1589
+ inLineComment,
1590
+ inBlockComment,
1591
+ inString,
1592
+ stringChar,
1593
+ stringStart,
1594
+ };
1595
+ }
1596
+
1597
+ /**
1598
+ * Check if a match is in a comment, string literal, or regex pattern definition
1599
+ * This helps reduce false positives when scanning code that defines detection patterns
1600
+ */
1601
+ function isMatchInNonExecutableContext(content, matchIndex, matchLength) {
1602
+ const contentBeforeMatch = content.slice(0, matchIndex);
1603
+ const lexicalContext = getLexicalContextBeforeIndex(content, matchIndex);
1604
+
1605
+ if (lexicalContext.inBlockComment || lexicalContext.inLineComment) {
1606
+ return true;
1607
+ }
1570
1608
 
1571
1609
  // If we're in a string, check if match is also in the string
1572
- if (inString) {
1573
- // CRITICAL: Don't ignore patterns in strings if they're passed to code execution functions
1574
- // Malware often uses: eval("process.env.SECRET"), new Function("return process.env.TOKEN")
1575
- // Check if the string is passed to eval, Function, require, setTimeout, etc.
1576
- const dangerousContexts = [
1577
- /\beval\s*\(/,
1578
- /\bnew\s+Function\s*\(/,
1579
- /\bFunction\s*\(/,
1580
- /\brequire\s*\(/,
1581
- /\bsetTimeout\s*\(/,
1582
- /\bsetInterval\s*\(/,
1583
- /\bvm\.runInContext\s*\(/,
1584
- /\bvm\.runInNewContext\s*\(/,
1585
- /\bvm\.runInThisContext\s*\(/,
1586
- /\bvm\.compileFunction\s*\(/,
1587
- ];
1610
+ if (lexicalContext.inString) {
1611
+ const stringChar = lexicalContext.stringChar;
1612
+ // Don't ignore only when the string is an immediate argument to a dangerous call.
1613
+ // This avoids false positives from descriptive text like "uses eval(), new Function()".
1614
+ const immediateDangerousCall = /(?:\beval|\bFunction|\bnew\s+Function|\brequire|\bsetTimeout|\bsetInterval|\bvm\.(?:runInContext|runInNewContext|runInThisContext|compileFunction))\s*\(\s*$/;
1588
1615
 
1589
- // Find the start of the string by looking backwards from match
1590
- let stringStartIndex = -1;
1591
- let foundQuote = false;
1592
- for (let i = matchInLine - 1; i >= 0; i--) {
1593
- if (lineBeforeMatch[i] === stringChar && (i === 0 || lineBeforeMatch[i - 1] !== '\\')) {
1594
- stringStartIndex = lineStart + i + 1; // +1 to get position after quote
1595
- foundQuote = true;
1596
- break;
1597
- }
1598
- }
1599
-
1600
- if (foundQuote && stringStartIndex !== -1) {
1616
+ const stringStartIndex = lexicalContext.stringStart >= 0 ? lexicalContext.stringStart + 1 : -1;
1617
+ if (stringStartIndex !== -1) {
1601
1618
  // Look backwards from string start to find if it's passed to dangerous function
1602
1619
  const beforeString = content.slice(Math.max(0, stringStartIndex - 200), stringStartIndex);
1603
-
1604
- // Check if string is passed to dangerous function
1605
- for (const dangerousPattern of dangerousContexts) {
1606
- const dangerousMatch = beforeString.match(dangerousPattern);
1607
- if (dangerousMatch) {
1608
- // Check if the dangerous function call is before the string (within reasonable distance)
1609
- const dangerousIndex = stringStartIndex - beforeString.length + dangerousMatch.index;
1610
- const distance = stringStartIndex - dangerousIndex;
1611
- // If dangerous function is within 100 chars before string, don't ignore
1612
- if (distance < 100 && distance > 0) {
1613
- return false; // DON'T ignore - this is dangerous!
1614
- }
1615
- }
1616
- }
1617
-
1618
- // Also check for require("https://...") pattern specifically
1619
- if (stringChar === '"' || stringChar === "'") {
1620
- const requireMatch = beforeString.match(/\brequire\s*\(\s*$/);
1621
- if (requireMatch) {
1622
- // String is passed to require() - check if it's a URL
1623
- const afterMatch = content.slice(matchIndex + matchLength, matchIndex + matchLength + 20);
1624
- if (/https?:\/\//.test(afterMatch)) {
1625
- return false; // DON'T ignore - require("https://...") is suspicious!
1626
- }
1627
- }
1620
+ if (immediateDangerousCall.test(beforeString)) {
1621
+ return false; // DON'T ignore - string is directly executed
1628
1622
  }
1629
1623
  }
1630
1624
 
@@ -2620,8 +2614,8 @@ function analyzeCode(pkg, config, issues, verbose = false) {
2620
2614
  }
2621
2615
  }
2622
2616
 
2623
- } catch {
2624
- // Skip files that can't be read
2617
+ } catch (err) {
2618
+ console.warn(`Warning: Cannot read or analyze file ${filePath}: ${err.message}`);
2625
2619
  continue;
2626
2620
  }
2627
2621
  }
@@ -2661,7 +2655,8 @@ function findJsFiles(dir, maxDepth = 2) {
2661
2655
  let entries = [];
2662
2656
  try {
2663
2657
  entries = fs.readdirSync(currentDir, { withFileTypes: true });
2664
- } catch {
2658
+ } catch (err) {
2659
+ console.warn(`Warning: Cannot read directory ${currentDir} while searching for JS files: ${err.message}`);
2665
2660
  continue;
2666
2661
  }
2667
2662
 
@@ -2761,21 +2756,33 @@ function extractCodeSnippet(content, pattern, contextLines = 3) {
2761
2756
  const match = lines[i].match(pattern);
2762
2757
  if (match) {
2763
2758
  const isMinified = isMinifiedLine(lines[i]);
2759
+ const obfuscationType = getObfuscationType(pattern);
2760
+ const isBase64 = obfuscationType === 'base64_encoding';
2761
+ const isLongBase64 = isBase64 && match[0].length > 200;
2764
2762
 
2765
- if (isMinified) {
2766
- // For minified code, show a truncated snippet around the match
2763
+ if (isMinified || isLongBase64) {
2764
+ // For minified code or long base64, show a truncated snippet around the match
2767
2765
  const matchIndex = match.index;
2768
2766
  const matchLength = match[0].length;
2769
2767
  const line = lines[i];
2770
2768
 
2769
+ // For very long base64 matches, limit how much of the match we show
2770
+ const maxMatchDisplay = isBase64 ? 200 : matchLength; // Show max 200 chars of base64 match
2771
+ const displayMatchLength = Math.min(matchLength, maxMatchDisplay);
2772
+ const matchIsTruncated = isBase64 && matchLength > maxMatchDisplay;
2773
+
2771
2774
  // Extract context around the match (150 chars before, 150 after)
2772
2775
  const contextBefore = 150;
2773
2776
  const contextAfter = 150;
2774
2777
  const start = Math.max(0, matchIndex - contextBefore);
2775
- const end = Math.min(line.length, matchIndex + matchLength + contextAfter);
2778
+ const end = Math.min(line.length, matchIndex + displayMatchLength + contextAfter);
2776
2779
 
2777
2780
  const beforeText = start > 0 ? '...' : '';
2778
- const afterText = end < line.length ? '...' : '';
2781
+ let afterText = '';
2782
+ if (matchIsTruncated || end < line.length) {
2783
+ afterText = '...';
2784
+ }
2785
+
2779
2786
  const snippetText = beforeText + line.slice(start, end) + afterText;
2780
2787
 
2781
2788
  // Calculate the position of the match marker in the snippet
@@ -2787,12 +2794,15 @@ function extractCodeSnippet(content, pattern, contextLines = 3) {
2787
2794
  const snippetLines = [];
2788
2795
  snippetLines.push(`${linePrefix}${snippetText}`);
2789
2796
  // Add marker pointing to the match (limit to reasonable length)
2790
- const markerChars = Math.min(matchLength, 15);
2797
+ const markerChars = Math.min(displayMatchLength, 15);
2791
2798
  const markerLine = ' ' + ' '.repeat(markerPadding) +
2792
2799
  '^'.repeat(markerChars) +
2793
2800
  ` (column ${matchIndex + 1})`;
2794
2801
  snippetLines.push(markerLine);
2795
- snippetLines.push(` [Minified code - showing context around match only]`);
2802
+ const contextNote = isBase64
2803
+ ? '[Base64 encoded data - showing context around match only]'
2804
+ : '[Minified code - showing context around match only]';
2805
+ snippetLines.push(` ${contextNote}`);
2796
2806
 
2797
2807
  return {
2798
2808
  lineNumber: i + 1,
package/src/collector.js CHANGED
@@ -13,6 +13,15 @@ const JSON_READ_ERROR = {
13
13
  UNKNOWN: 'UNKNOWN',
14
14
  };
15
15
 
16
+ /**
17
+ * Normalize relative package paths for lockfile lookups across platforms.
18
+ * lockfile paths use "/" separators even on Windows.
19
+ */
20
+ function normalizeRelativePath(relativePath) {
21
+ if (!relativePath) return '';
22
+ return String(relativePath).replace(/\\/g, '/');
23
+ }
24
+
16
25
  /**
17
26
  * Safely read and parse a JSON file with detailed error information
18
27
  * @param {string} filePath - Path to JSON file
@@ -92,7 +101,8 @@ function collectPackages(nodeModulesPath, maxDepth = 10) {
92
101
  let realDir;
93
102
  try {
94
103
  realDir = fs.realpathSync(dir);
95
- } catch {
104
+ } catch (err) {
105
+ console.warn(`Warning: Cannot resolve real path for ${dir}: ${err.message}`);
96
106
  continue;
97
107
  }
98
108
 
@@ -104,6 +114,7 @@ function collectPackages(nodeModulesPath, maxDepth = 10) {
104
114
  entries = fs.readdirSync(dir, { withFileTypes: true });
105
115
  } catch (err) {
106
116
  // Permission denied or other read error
117
+ console.warn(`Warning: Cannot read directory ${dir}: ${err.message}`);
107
118
  continue;
108
119
  }
109
120
 
@@ -113,7 +124,7 @@ function collectPackages(nodeModulesPath, maxDepth = 10) {
113
124
  if (entry.name === '.bin') continue;
114
125
 
115
126
  const entryPath = path.join(dir, entry.name);
116
- const relPath = relative ? path.join(relative, entry.name) : entry.name;
127
+ const relPath = normalizeRelativePath(relative ? path.join(relative, entry.name) : entry.name);
117
128
 
118
129
  if (!entry.isDirectory()) continue;
119
130
 
@@ -137,7 +148,8 @@ function processScopedPackage(scopeDir, scopeRel, depth, stack, packages) {
137
148
  let scopeEntries = [];
138
149
  try {
139
150
  scopeEntries = fs.readdirSync(scopeDir, { withFileTypes: true });
140
- } catch {
151
+ } catch (err) {
152
+ console.warn(`Warning: Cannot read scoped package directory ${scopeDir}: ${err.message}`);
141
153
  return;
142
154
  }
143
155
 
@@ -146,7 +158,7 @@ function processScopedPackage(scopeDir, scopeRel, depth, stack, packages) {
146
158
  if (scoped.name.startsWith('.')) continue;
147
159
 
148
160
  const scopedDir = path.join(scopeDir, scoped.name);
149
- const scopedRel = path.join(scopeRel, scoped.name);
161
+ const scopedRel = normalizeRelativePath(path.join(scopeRel, scoped.name));
150
162
 
151
163
  processPackage(scopedDir, scopedRel, depth, stack, packages);
152
164
  }
@@ -169,13 +181,13 @@ function processPackage(pkgDir, pkgRel, depth, stack, packages) {
169
181
  if (fs.existsSync(nestedNodeModules)) {
170
182
  stack.push({
171
183
  dir: nestedNodeModules,
172
- relative: path.join(pkgRel, 'node_modules'),
184
+ relative: normalizeRelativePath(path.join(pkgRel, 'node_modules')),
173
185
  depth: depth + 1,
174
186
  });
175
187
  }
176
188
  } else {
177
189
  // Not a package, might be a directory containing packages
178
- stack.push({ dir: pkgDir, relative: pkgRel, depth });
190
+ stack.push({ dir: pkgDir, relative: normalizeRelativePath(pkgRel), depth });
179
191
  }
180
192
  }
181
193
 
@@ -216,7 +228,7 @@ function readPackage(dir, relativePath) {
216
228
  license: null,
217
229
  publishConfig: null,
218
230
  dir,
219
- relativePath,
231
+ relativePath: normalizeRelativePath(relativePath),
220
232
  // Error information for security analysis
221
233
  _parseError: true,
222
234
  _errorType: result.errorType,
@@ -247,7 +259,7 @@ function readPackage(dir, relativePath) {
247
259
  publishConfig: pkg.publishConfig || null,
248
260
  // Directory info
249
261
  dir,
250
- relativePath,
262
+ relativePath: normalizeRelativePath(relativePath),
251
263
  };
252
264
  }
253
265
 
package/src/formatters.js CHANGED
@@ -84,7 +84,8 @@ function formatText(issues, summary, context) {
84
84
  for (const issue of sorted) {
85
85
  if (issue.severity !== currentSeverity) {
86
86
  currentSeverity = issue.severity;
87
- lines.push(color(`── ${currentSeverity.toUpperCase()} ──`, getSeverityColor(issue.severity)));
87
+ const severityLabel = currentSeverity ? currentSeverity.toUpperCase() : 'UNKNOWN';
88
+ lines.push(color(`── ${severityLabel} ──`, getSeverityColor(issue.severity)));
88
89
  }
89
90
 
90
91
  const pkgInfo = `${issue.package}@${issue.version}`;
@@ -301,7 +302,7 @@ function formatText(issues, summary, context) {
301
302
  lines.push('');
302
303
  lines.push(color('─'.repeat(60), colors.dim));
303
304
  lines.push(color('chain-audit by Hubert Kasperek • MIT License', colors.dim));
304
- lines.push(color('https://github.com/hukasx0/chain-audit', colors.dim));
305
+ lines.push(color('https://github.com/HubertKasperek/chain-audit', colors.dim));
305
306
  lines.push('');
306
307
  lines.push(color('Disclaimer: Licensed under MIT License, provided "AS IS" without warranty.', colors.dim));
307
308
  lines.push(color('The author makes no guarantees and takes no responsibility for false', colors.dim));
@@ -315,6 +316,9 @@ function formatText(issues, summary, context) {
315
316
  * Get ANSI color code for severity
316
317
  */
317
318
  function getSeverityColor(severity) {
319
+ if (severity === null || severity === undefined) {
320
+ return colors.dim;
321
+ }
318
322
  switch (severity) {
319
323
  case 'critical': return colors.magenta;
320
324
  case 'high': return colors.red;
@@ -414,7 +418,7 @@ function formatSarif(issues, summary, context) {
414
418
  driver: {
415
419
  name: 'chain-audit',
416
420
  version: context.version || '1.0.0',
417
- informationUri: 'https://github.com/hukasx0/chain-audit',
421
+ informationUri: 'https://github.com/HubertKasperek/chain-audit',
418
422
  rules: generateSarifRules(),
419
423
  },
420
424
  },
package/src/index.js CHANGED
@@ -25,7 +25,7 @@ const { color, colors } = require('./utils');
25
25
 
26
26
  const pkgMeta = (safeReadJSONWithDetails(path.join(__dirname, '..', 'package.json')).data) || {};
27
27
 
28
- function detectDefaultLockfile(cwd) {
28
+ function detectDefaultLockfile(searchStartDirs) {
29
29
  const candidates = [
30
30
  'package-lock.json',
31
31
  'npm-shrinkwrap.json',
@@ -33,10 +33,31 @@ function detectDefaultLockfile(cwd) {
33
33
  'pnpm-lock.yaml',
34
34
  'bun.lock',
35
35
  ];
36
- for (const candidate of candidates) {
37
- const full = path.resolve(cwd, candidate);
38
- if (fs.existsSync(full)) return full;
36
+
37
+ const starts = Array.isArray(searchStartDirs) ? searchStartDirs : [searchStartDirs];
38
+ const visited = new Set();
39
+
40
+ for (const start of starts) {
41
+ if (!start) continue;
42
+
43
+ let currentDir = path.resolve(start);
44
+ while (true) {
45
+ if (!visited.has(currentDir)) {
46
+ visited.add(currentDir);
47
+ for (const candidate of candidates) {
48
+ const full = path.join(currentDir, candidate);
49
+ if (fs.existsSync(full) && fs.statSync(full).isFile()) {
50
+ return full;
51
+ }
52
+ }
53
+ }
54
+
55
+ const parent = path.dirname(currentDir);
56
+ if (parent === currentDir) break;
57
+ currentDir = parent;
58
+ }
39
59
  }
60
+
40
61
  return null;
41
62
  }
42
63
 
@@ -120,7 +141,8 @@ function run(argv = process.argv) {
120
141
  }
121
142
 
122
143
  // Resolve lockfile
123
- const resolvedLock = config.lockPath || detectDefaultLockfile(process.cwd());
144
+ const scanRoot = path.dirname(config.nodeModules);
145
+ const resolvedLock = config.lockPath || detectDefaultLockfile([scanRoot, process.cwd()]);
124
146
  const lockIndex = buildLockIndex(resolvedLock);
125
147
 
126
148
  // Collect and analyze packages
@@ -157,6 +179,7 @@ function run(argv = process.argv) {
157
179
  }
158
180
 
159
181
  const summary = summarize(filteredIssues);
182
+ const overallSummary = summarize(issues);
160
183
  const context = {
161
184
  nodeModules: config.nodeModules,
162
185
  lockfile: lockIndex.lockPresent ? resolvedLock : null,
@@ -187,7 +210,7 @@ function run(argv = process.argv) {
187
210
  const severityOrder = ['info', 'low', 'medium', 'high', 'critical'];
188
211
  const rankSeverity = (level) => level === null ? -1 : severityOrder.indexOf(level);
189
212
 
190
- if (config.failOn && summary.maxSeverity !== null && rankSeverity(summary.maxSeverity) >= rankSeverity(config.failOn)) {
213
+ if (config.failOn && overallSummary.maxSeverity !== null && rankSeverity(overallSummary.maxSeverity) >= rankSeverity(config.failOn)) {
191
214
  return { exitCode: 1, issues, summary };
192
215
  }
193
216
 
@@ -315,7 +338,7 @@ ${color('DISCLAIMER:', colors.bold)}
315
338
  manually and use as part of defense-in-depth.
316
339
 
317
340
  ${color('MORE INFO:', colors.bold)}
318
- https://github.com/hukasx0/chain-audit
341
+ https://github.com/HubertKasperek/chain-audit
319
342
  `;
320
343
  console.log(text);
321
344
  }
package/src/lockfile.js CHANGED
@@ -594,7 +594,8 @@ function computePackageIntegrity(pkgDir, algorithm = 'sha512') {
594
594
  const hash = crypto.createHash(algorithm);
595
595
  hash.update(content);
596
596
  return hash.digest('base64');
597
- } catch {
597
+ } catch (err) {
598
+ console.warn(`Warning: Cannot compute package integrity for ${pkgDir}: ${err.message}`);
598
599
  return null;
599
600
  }
600
601
  }
@@ -657,7 +658,8 @@ function computeFileIntegrity(filePath, algorithm = 'sha512') {
657
658
  const hash = crypto.createHash(algorithm);
658
659
  hash.update(content);
659
660
  return `${algorithm}-${hash.digest('base64')}`;
660
- } catch {
661
+ } catch (err) {
662
+ console.warn(`Warning: Cannot compute file integrity for ${filePath}: ${err.message}`);
661
663
  return null;
662
664
  }
663
665
  }