chain-audit 0.6.5 → 0.6.6
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 +1 -1
- package/package.json +3 -3
- package/src/analyzer.js +40 -17
- package/src/collector.js +5 -2
- package/src/formatters.js +5 -1
- package/src/lockfile.js +4 -2
package/README.md
CHANGED
|
@@ -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.
|
|
186
|
+
chain-audit v0.6.6
|
|
187
187
|
Zero-dependency heuristic scanner CLI to detect supply chain attacks in node_modules
|
|
188
188
|
────────────────────────────────────────────────────────────
|
|
189
189
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chain-audit",
|
|
3
|
-
"version": "0.6.
|
|
4
|
-
"description": "Zero-dependency heuristic scanner CLI to detect supply chain attacks in node_modules.
|
|
3
|
+
"version": "0.6.6",
|
|
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"
|
|
@@ -52,6 +52,6 @@
|
|
|
52
52
|
"node": ">=18.0.0"
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|
|
55
|
-
"eslint": "
|
|
55
|
+
"eslint": "9.39.2"
|
|
56
56
|
}
|
|
57
57
|
}
|
package/src/analyzer.js
CHANGED
|
@@ -1010,7 +1010,8 @@ function findNativeArtifacts(pkgDir, maxDepth = 3) {
|
|
|
1010
1010
|
let entries = [];
|
|
1011
1011
|
try {
|
|
1012
1012
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
1013
|
-
} catch {
|
|
1013
|
+
} catch (err) {
|
|
1014
|
+
console.warn(`Warning: Cannot read directory ${dir} while searching for native artifacts: ${err.message}`);
|
|
1014
1015
|
continue;
|
|
1015
1016
|
}
|
|
1016
1017
|
|
|
@@ -1062,10 +1063,10 @@ function checkExecutableFiles(pkg, issues, verbose = false, config = {}) {
|
|
|
1062
1063
|
const issue = {
|
|
1063
1064
|
severity: severity,
|
|
1064
1065
|
reason: 'executable_files',
|
|
1065
|
-
detail: `Contains executable files (shell scripts, etc.): ${listed}${found.length > 5 ? `, +${found.length - 5} more` : ''}`,
|
|
1066
|
+
detail: `Contains executable files (shell scripts, binaries, etc.): ${listed}${found.length > 5 ? `, +${found.length - 5} more` : ''}`,
|
|
1066
1067
|
recommendation: suspiciousFiles.length > 0
|
|
1067
1068
|
? '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.',
|
|
1069
|
+
: 'Shell scripts and binaries in bin/ directory are less suspicious but should be reviewed for malicious content.',
|
|
1069
1070
|
};
|
|
1070
1071
|
|
|
1071
1072
|
if (verbose) {
|
|
@@ -1112,6 +1113,7 @@ function checkExecutableFiles(pkg, issues, verbose = false, config = {}) {
|
|
|
1112
1113
|
*/
|
|
1113
1114
|
function findExecutableFiles(pkgDir, maxDepth = 5) {
|
|
1114
1115
|
const found = [];
|
|
1116
|
+
const binaryFiles = []; // Track binary files that couldn't be read
|
|
1115
1117
|
const stack = [{ dir: pkgDir, depth: 0 }];
|
|
1116
1118
|
|
|
1117
1119
|
// Executable file extensions - only real executables, not .js (those are scanned by code scanner)
|
|
@@ -1135,7 +1137,8 @@ function findExecutableFiles(pkgDir, maxDepth = 5) {
|
|
|
1135
1137
|
let entries = [];
|
|
1136
1138
|
try {
|
|
1137
1139
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
1138
|
-
} catch {
|
|
1140
|
+
} catch (err) {
|
|
1141
|
+
console.warn(`Warning: Cannot read directory ${dir} while searching for executable files: ${err.message}`);
|
|
1139
1142
|
continue;
|
|
1140
1143
|
}
|
|
1141
1144
|
|
|
@@ -1186,12 +1189,13 @@ function findExecutableFiles(pkgDir, maxDepth = 5) {
|
|
|
1186
1189
|
found.push(fullPath);
|
|
1187
1190
|
}
|
|
1188
1191
|
} catch {
|
|
1189
|
-
// If we can't read content,
|
|
1190
|
-
|
|
1192
|
+
// If we can't read content, it's likely a binary file
|
|
1193
|
+
// Track it as a binary executable (no extension but executable)
|
|
1194
|
+
binaryFiles.push(fullPath);
|
|
1191
1195
|
}
|
|
1192
1196
|
}
|
|
1193
|
-
} catch {
|
|
1194
|
-
|
|
1197
|
+
} catch (err) {
|
|
1198
|
+
console.warn(`Warning: Cannot check file ${fullPath} for executable permissions: ${err.message}`);
|
|
1195
1199
|
continue;
|
|
1196
1200
|
}
|
|
1197
1201
|
}
|
|
@@ -1199,6 +1203,9 @@ function findExecutableFiles(pkgDir, maxDepth = 5) {
|
|
|
1199
1203
|
}
|
|
1200
1204
|
}
|
|
1201
1205
|
|
|
1206
|
+
// Add binary files to found list (they are executables too)
|
|
1207
|
+
found.push(...binaryFiles);
|
|
1208
|
+
|
|
1202
1209
|
return found;
|
|
1203
1210
|
}
|
|
1204
1211
|
|
|
@@ -2620,8 +2627,8 @@ function analyzeCode(pkg, config, issues, verbose = false) {
|
|
|
2620
2627
|
}
|
|
2621
2628
|
}
|
|
2622
2629
|
|
|
2623
|
-
} catch {
|
|
2624
|
-
|
|
2630
|
+
} catch (err) {
|
|
2631
|
+
console.warn(`Warning: Cannot read or analyze file ${filePath}: ${err.message}`);
|
|
2625
2632
|
continue;
|
|
2626
2633
|
}
|
|
2627
2634
|
}
|
|
@@ -2661,7 +2668,8 @@ function findJsFiles(dir, maxDepth = 2) {
|
|
|
2661
2668
|
let entries = [];
|
|
2662
2669
|
try {
|
|
2663
2670
|
entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
|
2664
|
-
} catch {
|
|
2671
|
+
} catch (err) {
|
|
2672
|
+
console.warn(`Warning: Cannot read directory ${currentDir} while searching for JS files: ${err.message}`);
|
|
2665
2673
|
continue;
|
|
2666
2674
|
}
|
|
2667
2675
|
|
|
@@ -2761,21 +2769,33 @@ function extractCodeSnippet(content, pattern, contextLines = 3) {
|
|
|
2761
2769
|
const match = lines[i].match(pattern);
|
|
2762
2770
|
if (match) {
|
|
2763
2771
|
const isMinified = isMinifiedLine(lines[i]);
|
|
2772
|
+
const obfuscationType = getObfuscationType(pattern);
|
|
2773
|
+
const isBase64 = obfuscationType === 'base64_encoding';
|
|
2774
|
+
const isLongBase64 = isBase64 && match[0].length > 200;
|
|
2764
2775
|
|
|
2765
|
-
if (isMinified) {
|
|
2766
|
-
// For minified code, show a truncated snippet around the match
|
|
2776
|
+
if (isMinified || isLongBase64) {
|
|
2777
|
+
// For minified code or long base64, show a truncated snippet around the match
|
|
2767
2778
|
const matchIndex = match.index;
|
|
2768
2779
|
const matchLength = match[0].length;
|
|
2769
2780
|
const line = lines[i];
|
|
2770
2781
|
|
|
2782
|
+
// For very long base64 matches, limit how much of the match we show
|
|
2783
|
+
const maxMatchDisplay = isBase64 ? 200 : matchLength; // Show max 200 chars of base64 match
|
|
2784
|
+
const displayMatchLength = Math.min(matchLength, maxMatchDisplay);
|
|
2785
|
+
const matchIsTruncated = isBase64 && matchLength > maxMatchDisplay;
|
|
2786
|
+
|
|
2771
2787
|
// Extract context around the match (150 chars before, 150 after)
|
|
2772
2788
|
const contextBefore = 150;
|
|
2773
2789
|
const contextAfter = 150;
|
|
2774
2790
|
const start = Math.max(0, matchIndex - contextBefore);
|
|
2775
|
-
const end = Math.min(line.length, matchIndex +
|
|
2791
|
+
const end = Math.min(line.length, matchIndex + displayMatchLength + contextAfter);
|
|
2776
2792
|
|
|
2777
2793
|
const beforeText = start > 0 ? '...' : '';
|
|
2778
|
-
|
|
2794
|
+
let afterText = '';
|
|
2795
|
+
if (matchIsTruncated || end < line.length) {
|
|
2796
|
+
afterText = '...';
|
|
2797
|
+
}
|
|
2798
|
+
|
|
2779
2799
|
const snippetText = beforeText + line.slice(start, end) + afterText;
|
|
2780
2800
|
|
|
2781
2801
|
// Calculate the position of the match marker in the snippet
|
|
@@ -2787,12 +2807,15 @@ function extractCodeSnippet(content, pattern, contextLines = 3) {
|
|
|
2787
2807
|
const snippetLines = [];
|
|
2788
2808
|
snippetLines.push(`${linePrefix}${snippetText}`);
|
|
2789
2809
|
// Add marker pointing to the match (limit to reasonable length)
|
|
2790
|
-
const markerChars = Math.min(
|
|
2810
|
+
const markerChars = Math.min(displayMatchLength, 15);
|
|
2791
2811
|
const markerLine = ' ' + ' '.repeat(markerPadding) +
|
|
2792
2812
|
'^'.repeat(markerChars) +
|
|
2793
2813
|
` (column ${matchIndex + 1})`;
|
|
2794
2814
|
snippetLines.push(markerLine);
|
|
2795
|
-
|
|
2815
|
+
const contextNote = isBase64
|
|
2816
|
+
? '[Base64 encoded data - showing context around match only]'
|
|
2817
|
+
: '[Minified code - showing context around match only]';
|
|
2818
|
+
snippetLines.push(` ${contextNote}`);
|
|
2796
2819
|
|
|
2797
2820
|
return {
|
|
2798
2821
|
lineNumber: i + 1,
|
package/src/collector.js
CHANGED
|
@@ -92,7 +92,8 @@ function collectPackages(nodeModulesPath, maxDepth = 10) {
|
|
|
92
92
|
let realDir;
|
|
93
93
|
try {
|
|
94
94
|
realDir = fs.realpathSync(dir);
|
|
95
|
-
} catch {
|
|
95
|
+
} catch (err) {
|
|
96
|
+
console.warn(`Warning: Cannot resolve real path for ${dir}: ${err.message}`);
|
|
96
97
|
continue;
|
|
97
98
|
}
|
|
98
99
|
|
|
@@ -104,6 +105,7 @@ function collectPackages(nodeModulesPath, maxDepth = 10) {
|
|
|
104
105
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
105
106
|
} catch (err) {
|
|
106
107
|
// Permission denied or other read error
|
|
108
|
+
console.warn(`Warning: Cannot read directory ${dir}: ${err.message}`);
|
|
107
109
|
continue;
|
|
108
110
|
}
|
|
109
111
|
|
|
@@ -137,7 +139,8 @@ function processScopedPackage(scopeDir, scopeRel, depth, stack, packages) {
|
|
|
137
139
|
let scopeEntries = [];
|
|
138
140
|
try {
|
|
139
141
|
scopeEntries = fs.readdirSync(scopeDir, { withFileTypes: true });
|
|
140
|
-
} catch {
|
|
142
|
+
} catch (err) {
|
|
143
|
+
console.warn(`Warning: Cannot read scoped package directory ${scopeDir}: ${err.message}`);
|
|
141
144
|
return;
|
|
142
145
|
}
|
|
143
146
|
|
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
|
-
|
|
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}`;
|
|
@@ -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;
|
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
|
}
|