scan-compromised 1.0.1
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 +78 -0
- package/index.js +224 -0
- package/package.json +15 -0
- package/threats.json +39 -0
package/README.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# scan-compromised
|
|
2
|
+
|
|
3
|
+
๐ A CLI tool to detect known compromised npm packages in your project.
|
|
4
|
+
|
|
5
|
+
This scanner checks your `package.json`, `package-lock.json`, `yarn.lock`, and `pnpm-lock.yaml` files for any packages that were compromised in recent supply chain attacks โ including the September 2025 Shai-Hulud incident.
|
|
6
|
+
|
|
7
|
+
It flags:
|
|
8
|
+
- โ Known malicious versions (fails the scan)
|
|
9
|
+
- โ ๏ธ Safe versions of previously compromised packages (warns but does not fail)
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## ๐ Installation
|
|
14
|
+
|
|
15
|
+
### Run directly with `npx` (no install)
|
|
16
|
+
```bash
|
|
17
|
+
npx scan-compromised
|
|
18
|
+
```
|
|
19
|
+
Or install globally
|
|
20
|
+
```bash
|
|
21
|
+
npm install -g scan-compromised
|
|
22
|
+
scan-compromised
|
|
23
|
+
```
|
|
24
|
+
## ๐ฆ Usage
|
|
25
|
+
### Basic scan
|
|
26
|
+
```bash
|
|
27
|
+
scan-compromised
|
|
28
|
+
```
|
|
29
|
+
### JSON output (for CI integration)
|
|
30
|
+
```bash
|
|
31
|
+
scan-compromised --json
|
|
32
|
+
```
|
|
33
|
+
## ๐ Threat List
|
|
34
|
+
The tool uses a local `threats.json` file located in the root of the CLI package. This file contains a list of known compromised packages and their malicious versions.
|
|
35
|
+
|
|
36
|
+
Example `threats.json`
|
|
37
|
+
```json
|
|
38
|
+
{
|
|
39
|
+
"@ctrl/tinycolor": ["4.1.1", "4.1.2"],
|
|
40
|
+
"ngx-toastr": ["19.0.1", "19.0.2"]
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
You can update this file manually as new threats are discovered. Trusted sources include:
|
|
44
|
+
|
|
45
|
+
StepSecurity
|
|
46
|
+
|
|
47
|
+
GitHub Security Advisories
|
|
48
|
+
|
|
49
|
+
Snyk Vulnerability Database
|
|
50
|
+
|
|
51
|
+
## ๐งช GitHub Actions Integration
|
|
52
|
+
You can run this tool automatically on every push or pull request using GitHub Actions.
|
|
53
|
+
|
|
54
|
+
`.github/workflows/scan.yml`
|
|
55
|
+
```yaml
|
|
56
|
+
name: Scan for Compromised Packages
|
|
57
|
+
|
|
58
|
+
on:
|
|
59
|
+
push:
|
|
60
|
+
branches: [main]
|
|
61
|
+
pull_request:
|
|
62
|
+
|
|
63
|
+
jobs:
|
|
64
|
+
scan:
|
|
65
|
+
runs-on: ubuntu-latest
|
|
66
|
+
steps:
|
|
67
|
+
- uses: actions/checkout@v3
|
|
68
|
+
- name: Setup Node
|
|
69
|
+
uses: actions/setup-node@v3
|
|
70
|
+
with:
|
|
71
|
+
node-version: '18'
|
|
72
|
+
- name: Install scanner
|
|
73
|
+
run: npm install scan-compromised
|
|
74
|
+
- name: Run scan
|
|
75
|
+
run: npx scan-compromised
|
|
76
|
+
```
|
|
77
|
+
## ๐ก๏ธ License
|
|
78
|
+
MIT ยฉ Jonathan Blades
|
package/index.js
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const fs = require("fs");
|
|
3
|
+
const path = require("path");
|
|
4
|
+
const https = require("https");
|
|
5
|
+
|
|
6
|
+
function loadThreats() {
|
|
7
|
+
const threatPath = path.join(__dirname, "threats.json");
|
|
8
|
+
if (!fs.existsSync(threatPath)) {
|
|
9
|
+
console.error("โ threats.json not found in CLI directory.");
|
|
10
|
+
process.exit(1);
|
|
11
|
+
}
|
|
12
|
+
try {
|
|
13
|
+
const raw = fs.readFileSync(threatPath, "utf8");
|
|
14
|
+
return JSON.parse(raw);
|
|
15
|
+
} catch (err) {
|
|
16
|
+
console.error("โ Failed to parse threats.json:", err.message);
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const compromised = loadThreats();
|
|
22
|
+
|
|
23
|
+
const filesToCheck = [
|
|
24
|
+
"package.json",
|
|
25
|
+
"package-lock.json",
|
|
26
|
+
"yarn.lock",
|
|
27
|
+
"pnpm-lock.yaml"
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
function escRe(s) {
|
|
31
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function getCompromisedMap() {
|
|
35
|
+
const map = new Map();
|
|
36
|
+
for (const [pkg, versions] of Object.entries(compromised)) {
|
|
37
|
+
map.set(pkg, new Set(versions));
|
|
38
|
+
}
|
|
39
|
+
return map;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function recordFinding(findings, type, file, pkg, version, where) {
|
|
43
|
+
findings.push({ type, file, pkg, version, where });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// -------- Scanners --------
|
|
47
|
+
|
|
48
|
+
// package.json
|
|
49
|
+
function scanPackageJson(content, bad) {
|
|
50
|
+
const findings = [];
|
|
51
|
+
try {
|
|
52
|
+
const json = JSON.parse(content);
|
|
53
|
+
const allDeps = {
|
|
54
|
+
...(json.dependencies || {}),
|
|
55
|
+
...(json.devDependencies || {}),
|
|
56
|
+
...(json.peerDependencies || {}),
|
|
57
|
+
...(json.optionalDependencies || {})
|
|
58
|
+
};
|
|
59
|
+
for (const [pkg, range] of Object.entries(allDeps)) {
|
|
60
|
+
if (!bad.has(pkg)) continue;
|
|
61
|
+
let type = "warn";
|
|
62
|
+
for (const v of bad.get(pkg)) {
|
|
63
|
+
if (range === v || range === `=${v}`) {
|
|
64
|
+
type = "bad";
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
recordFinding(findings, type, "package.json", pkg, range, "declared dependency");
|
|
69
|
+
}
|
|
70
|
+
} catch {}
|
|
71
|
+
return findings;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// package-lock.json
|
|
75
|
+
function scanPackageLock(content, bad) {
|
|
76
|
+
const findings = [];
|
|
77
|
+
try {
|
|
78
|
+
const lock = JSON.parse(content);
|
|
79
|
+
const stack = [];
|
|
80
|
+
|
|
81
|
+
function visitDeps(obj, pathArr = []) {
|
|
82
|
+
if (!obj) return;
|
|
83
|
+
const deps = obj.dependencies || {};
|
|
84
|
+
for (const [name, meta] of Object.entries(deps)) {
|
|
85
|
+
const version = meta.version;
|
|
86
|
+
if (bad.has(name)) {
|
|
87
|
+
const type = bad.get(name).has(version) ? "bad" : "warn";
|
|
88
|
+
recordFinding(findings, type, "package-lock.json", name, version, pathArr.concat(name).join(" > "));
|
|
89
|
+
}
|
|
90
|
+
visitDeps(meta, pathArr.concat(name));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (lock.packages && typeof lock.packages === "object") {
|
|
95
|
+
for (const [pkgPath, meta] of Object.entries(lock.packages)) {
|
|
96
|
+
if (!meta || !meta.version) continue;
|
|
97
|
+
const name = meta.name || (pkgPath.includes("node_modules/") ? pkgPath.split("node_modules/").pop() : null);
|
|
98
|
+
if (!name) continue;
|
|
99
|
+
if (bad.has(name)) {
|
|
100
|
+
const type = bad.get(name).has(meta.version) ? "bad" : "warn";
|
|
101
|
+
recordFinding(findings, type, "package-lock.json", name, meta.version, pkgPath || "(root)");
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
} else {
|
|
105
|
+
visitDeps(lock, []);
|
|
106
|
+
}
|
|
107
|
+
} catch {}
|
|
108
|
+
return findings;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// yarn.lock v1
|
|
112
|
+
function scanYarnLockV1(content, bad) {
|
|
113
|
+
const findings = [];
|
|
114
|
+
const entries = content.split(/\n{2,}/g);
|
|
115
|
+
for (const entry of entries) {
|
|
116
|
+
const headerMatch = entry.match(/^"([^"]+)"\s*:\s*$/m);
|
|
117
|
+
if (!headerMatch) continue;
|
|
118
|
+
const header = headerMatch[1];
|
|
119
|
+
const versionMatch = entry.match(/^\s*version\s+"([^"]+)"/m);
|
|
120
|
+
if (!versionMatch) continue;
|
|
121
|
+
const version = versionMatch[1];
|
|
122
|
+
const keys = header.split(/,\s*/g);
|
|
123
|
+
const packageNames = new Set();
|
|
124
|
+
for (const key of keys) {
|
|
125
|
+
if (key.startsWith("@")) {
|
|
126
|
+
const parts = key.split("@");
|
|
127
|
+
if (parts.length >= 2) packageNames.add("@" + parts[1]);
|
|
128
|
+
} else {
|
|
129
|
+
const at = key.lastIndexOf("@");
|
|
130
|
+
if (at > 0) packageNames.add(key.slice(0, at));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
for (const name of packageNames) {
|
|
134
|
+
if (bad.has(name)) {
|
|
135
|
+
const type = bad.get(name).has(version) ? "bad" : "warn";
|
|
136
|
+
recordFinding(findings, type, "yarn.lock", name, version, header);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return findings;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Yarn Berry (v2+)
|
|
144
|
+
function scanYarnBerryLock(content, bad) {
|
|
145
|
+
const findings = [];
|
|
146
|
+
const blockRe = /^("?([^"\n]+)"?):\n((?: {2}.+\n)+)/gm;
|
|
147
|
+
let m;
|
|
148
|
+
while ((m = blockRe.exec(content))) {
|
|
149
|
+
const key = m[2];
|
|
150
|
+
const block = m[3];
|
|
151
|
+
const v = block.match(/^\s{2}version:\s+("?)([^"\n]+)\1/m);
|
|
152
|
+
if (!v) continue;
|
|
153
|
+
const version = v[2];
|
|
154
|
+
let name = key;
|
|
155
|
+
const protoAt = key.indexOf("@npm:");
|
|
156
|
+
if (protoAt !== -1) name = key.slice(0, protoAt);
|
|
157
|
+
else {
|
|
158
|
+
const at = key.lastIndexOf("@");
|
|
159
|
+
if (at > 0) name = key.slice(0, at);
|
|
160
|
+
}
|
|
161
|
+
if (bad.has(name)) {
|
|
162
|
+
const type = bad.get(name).has(version) ? "bad" : "warn";
|
|
163
|
+
recordFinding(findings, type, "yarn.lock", name, version, key);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return findings;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// pnpm-lock.yaml
|
|
170
|
+
function scanPnpmLock(content, bad) {
|
|
171
|
+
const findings = [];
|
|
172
|
+
const re = /^\s*\/?(@?[^@\s/][^@:\s/]*\/?[^@:\s/]*)@([0-9][^:\s]+):/gm;
|
|
173
|
+
let m;
|
|
174
|
+
while ((m = re.exec(content))) {
|
|
175
|
+
const name = m[1];
|
|
176
|
+
const version = m[2];
|
|
177
|
+
if (bad.has(name)) {
|
|
178
|
+
const type = bad.get(name).has(version) ? "bad" : "warn";
|
|
179
|
+
recordFinding(findings, type, "pnpm-lock.yaml", name, version, `${name}@${version}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return findings;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// -------- Runner --------
|
|
186
|
+
(function main() {
|
|
187
|
+
const bad = getCompromisedMap();
|
|
188
|
+
const allFindings = [];
|
|
189
|
+
|
|
190
|
+
for (const file of filesToCheck) {
|
|
191
|
+
const filePath = path.join(process.cwd(), file);
|
|
192
|
+
if (!fs.existsSync(filePath)) continue;
|
|
193
|
+
const content = fs.readFileSync(filePath, "utf8");
|
|
194
|
+
|
|
195
|
+
if (file === "package.json") {
|
|
196
|
+
allFindings.push(...scanPackageJson(content, bad));
|
|
197
|
+
} else if (file === "package-lock.json") {
|
|
198
|
+
allFindings.push(...scanPackageLock(content, bad));
|
|
199
|
+
} else if (file === "yarn.lock") {
|
|
200
|
+
const f1 = scanYarnLockV1(content, bad);
|
|
201
|
+
const f2 = f1.length ? [] : scanYarnBerryLock(content, bad);
|
|
202
|
+
allFindings.push(...f1, ...f2);
|
|
203
|
+
} else if (file === "pnpm-lock.yaml") {
|
|
204
|
+
allFindings.push(...scanPnpmLock(content, bad));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const badFindings = allFindings.filter(f => f.type === "bad");
|
|
209
|
+
const warnFindings = allFindings.filter(f => f.type === "warn");
|
|
210
|
+
|
|
211
|
+
warnFindings.forEach(f =>
|
|
212
|
+
console.log(`โ ๏ธ WARNING: ${f.pkg}@${f.version} in ${f.file} (${f.where}) โ package was targeted in past attack, but version is not flagged as malicious`)
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
badFindings.forEach(f =>
|
|
216
|
+
console.log(`โ ALERT: ${f.pkg}@${f.version} in ${f.file} (${f.where}) โ known malicious version`)
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
if (badFindings.length === 0) {
|
|
220
|
+
console.log("โ
No known malicious versions detected.");
|
|
221
|
+
} else {
|
|
222
|
+
process.exit(1);
|
|
223
|
+
}
|
|
224
|
+
})();
|
package/package.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "scan-compromised",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "A simple npm CLI tool (starter template)",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"scan-compromised": "./index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": ["index.js", "threats.json"],
|
|
10
|
+
"scripts": {
|
|
11
|
+
"start": "node index.js"
|
|
12
|
+
},
|
|
13
|
+
"author": "",
|
|
14
|
+
"license": "MIT"
|
|
15
|
+
}
|
package/threats.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"@ctrl/deluge": ["7.2.1", "7.2.2"],
|
|
3
|
+
"@ctrl/golang-template": ["1.4.2", "1.4.3"],
|
|
4
|
+
"@ctrl/magnet-link": ["4.0.3", "4.0.4"],
|
|
5
|
+
"@ctrl/ngx-codemirror": ["7.0.1", "7.0.2"],
|
|
6
|
+
"@ctrl/ngx-csv": ["6.0.1", "6.0.2"],
|
|
7
|
+
"@ctrl/ngx-emoji-mart": ["9.2.1", "9.2.2"],
|
|
8
|
+
"@ctrl/ngx-rightclick": ["4.0.1", "4.0.2"],
|
|
9
|
+
"@ctrl/qbittorrent": ["9.7.1", "9.7.2"],
|
|
10
|
+
"@ctrl/react-adsense": ["2.0.1", "2.0.2"],
|
|
11
|
+
"@ctrl/shared-torrent": ["6.3.1", "6.3.2"],
|
|
12
|
+
"@ctrl/tinycolor": ["4.1.1", "4.1.2"],
|
|
13
|
+
"@ctrl/torrent-file": ["4.1.1", "4.1.2"],
|
|
14
|
+
"@ctrl/transmission": ["7.3.1"],
|
|
15
|
+
"@ctrl/ts-base32": ["4.0.1", "4.0.2"],
|
|
16
|
+
"angulartics2": ["14.1.1", "14.1.2"],
|
|
17
|
+
"json-rules-engine-simplified": ["0.2.1", "0.2.4"],
|
|
18
|
+
"koa2-swagger-ui": ["5.11.1", "5.11.2"],
|
|
19
|
+
"@nativescript-community/gesturehandler": ["2.0.35"],
|
|
20
|
+
"@nativescript-community/sentry": ["4.6.43"],
|
|
21
|
+
"@nativescript-community/text": ["1.6.9", "1.6.10", "1.6.11", "1.6.12", "1.6.13"],
|
|
22
|
+
"@nativescript-community/ui-collectionview": ["6.0.6"],
|
|
23
|
+
"@nativescript-community/ui-drawer": ["0.1.30"],
|
|
24
|
+
"@nativescript-community/ui-image": ["4.5.6"],
|
|
25
|
+
"@nativescript-community/ui-material-bottomsheet": ["7.2.72"],
|
|
26
|
+
"@nativescript-community/ui-material-core": ["7.2.72", "7.2.73", "7.2.74", "7.2.75", "7.2.76"],
|
|
27
|
+
"@nativescript-community/ui-material-core-tabs": ["7.2.72", "7.2.73", "7.2.74", "7.2.75", "7.2.76"],
|
|
28
|
+
"ngx-color": ["10.0.1", "10.0.2"],
|
|
29
|
+
"ngx-toastr": ["19.0.1", "19.0.2"],
|
|
30
|
+
"ngx-trend": ["8.0.1"],
|
|
31
|
+
"react-complaint-image": ["0.0.32", "0.0.35"],
|
|
32
|
+
"react-jsonschema-form-conditionals": ["0.3.18", "0.3.21"],
|
|
33
|
+
"react-jsonschema-form-extras": ["1.0.4"],
|
|
34
|
+
"rxnt-authentication": ["0.0.3", "0.0.4", "0.0.5", "0.0.6"],
|
|
35
|
+
"rxnt-healthchecks-nestjs": ["1.0.2", "1.0.3", "1.0.4", "1.0.5"],
|
|
36
|
+
"rxnt-kue": ["1.0.4", "1.0.5", "1.0.6", "1.0.7"],
|
|
37
|
+
"swc-plugin-component-annotate": ["1.9.1", "1.9.2"],
|
|
38
|
+
"ts-gaussian": ["3.0.5", "3.0.6"]
|
|
39
|
+
}
|