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.
Files changed (4) hide show
  1. package/README.md +78 -0
  2. package/index.js +224 -0
  3. package/package.json +15 -0
  4. 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
+ }