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 +8 -8
- package/package.json +6 -6
- package/src/analyzer.js +149 -139
- package/src/collector.js +20 -8
- package/src/formatters.js +7 -3
- package/src/index.js +30 -7
- package/src/lockfile.js +4 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# chain-audit
|
|
2
2
|
|
|
3
|
-
[](https://github.com/HubertKasperek/chain-audit/actions/workflows/ci.yml)
|
|
4
4
|
[](https://www.npmjs.com/package/chain-audit)
|
|
5
5
|
[](https://opensource.org/licenses/MIT)
|
|
6
6
|
[](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/
|
|
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/
|
|
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.
|
|
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:
|
|
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/
|
|
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/
|
|
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/
|
|
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.
|
|
4
|
-
"description": "Zero-dependency heuristic scanner CLI to detect supply chain attacks in node_modules.
|
|
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/
|
|
45
|
+
"url": "git+https://github.com/HubertKasperek/chain-audit.git"
|
|
46
46
|
},
|
|
47
47
|
"bugs": {
|
|
48
|
-
"url": "https://github.com/
|
|
48
|
+
"url": "https://github.com/HubertKasperek/chain-audit/issues"
|
|
49
49
|
},
|
|
50
|
-
"homepage": "https://github.com/
|
|
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": "
|
|
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
|
|
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
|
|
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(
|
|
369
|
-
if (pkg.name && pkg.name !== expectedName
|
|
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
|
|
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 (
|
|
443
|
-
return `${
|
|
454
|
+
if (first && first.startsWith('@') && second) {
|
|
455
|
+
return `${first}/${second}`;
|
|
444
456
|
}
|
|
445
|
-
|
|
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,
|
|
1190
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
1498
|
-
*
|
|
1522
|
+
* Get lexical context right before a character index.
|
|
1523
|
+
* Tracks whether parser is currently in comment or string.
|
|
1499
1524
|
*/
|
|
1500
|
-
function
|
|
1501
|
-
|
|
1502
|
-
|
|
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
|
-
|
|
1552
|
-
|
|
1553
|
-
const
|
|
1554
|
-
|
|
1555
|
-
|
|
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
|
-
|
|
1559
|
-
|
|
1543
|
+
|
|
1544
|
+
if (inBlockComment) {
|
|
1545
|
+
if (char === '*' && nextChar === '/') {
|
|
1546
|
+
inBlockComment = false;
|
|
1547
|
+
i++;
|
|
1548
|
+
}
|
|
1560
1549
|
continue;
|
|
1561
1550
|
}
|
|
1562
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1574
|
-
//
|
|
1575
|
-
//
|
|
1576
|
-
const
|
|
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
|
-
|
|
1590
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 +
|
|
2778
|
+
const end = Math.min(line.length, matchIndex + displayMatchLength + contextAfter);
|
|
2776
2779
|
|
|
2777
2780
|
const beforeText = start > 0 ? '...' : '';
|
|
2778
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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/
|
|
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/
|
|
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(
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
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 &&
|
|
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/
|
|
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
|
}
|