@lateos/npm-scan 0.13.1 → 0.15.0

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.de.md CHANGED
@@ -11,7 +11,7 @@
11
11
  [![Node](https://img.shields.io/badge/node-%3E%3D18-brightgreen?style=flat-square)](package.json)
12
12
  [![Tests](https://img.shields.io/badge/tests-222%20passing-brightgreen?style=flat-square)](https://github.com/lateos-ai/npm-scan)
13
13
  [![Coverage](https://img.shields.io/badge/coverage-85%25-yellowgreen?style=flat-square)](https://github.com/lateos-ai/npm-scan)
14
- [![Docker](https://img.shields.io/badge/docker-ghcr.io%2Flateos%2Fnpm--scan-2496ED?style=flat-square&logo=docker)](https://github.com/lateos-ai/npm-scan/pkgs/container/npm-scan)
14
+ [![Docker](https://img.shields.io/badge/docker-lateos%2Fnpm--scan-2496ED?style=flat-square&logo=docker)](https://hub.docker.com/r/lateos/npm-scan)
15
15
  [![Sigstore](https://img.shields.io/static/v1?label=Sigstore&message=Provenance&color=green&style=flat-square&logo=sigstore)](https://github.com/lateos-ai/npm-scan/actions/workflows/publish.yml)
16
16
 
17
17
  **Moderne Lieferkettensicherheit für das npm-Ökosystem.**
@@ -100,7 +100,7 @@ npx @lateos/npm-scan scan commander
100
100
 
101
101
  ```bash
102
102
  # Einmaligen Scan pullen und ausführen — kein Node.js oder npm erforderlich
103
- docker run --rm ghcr.io/lateos/npm-scan:cli scan lodash
103
+ docker run --rm lateos/npm-scan:cli scan lodash
104
104
 
105
105
  # Vollständige Pipeline mit persistentem Speicher und Compose
106
106
  docker compose --profile pipeline up -d
package/README.fr.md CHANGED
@@ -11,7 +11,7 @@
11
11
  [![Node](https://img.shields.io/badge/node-%3E%3D18-brightgreen?style=flat-square)](package.json)
12
12
  [![Tests](https://img.shields.io/badge/tests-222%20passing-brightgreen?style=flat-square)](https://github.com/lateos-ai/npm-scan)
13
13
  [![Coverage](https://img.shields.io/badge/coverage-85%25-yellowgreen?style=flat-square)](https://github.com/lateos-ai/npm-scan)
14
- [![Docker](https://img.shields.io/badge/docker-ghcr.io%2Flateos%2Fnpm--scan-2496ED?style=flat-square&logo=docker)](https://github.com/lateos-ai/npm-scan/pkgs/container/npm-scan)
14
+ [![Docker](https://img.shields.io/badge/docker-lateos%2Fnpm--scan-2496ED?style=flat-square&logo=docker)](https://hub.docker.com/r/lateos/npm-scan)
15
15
  [![Sigstore](https://img.shields.io/static/v1?label=Sigstore&message=Provenance&color=green&style=flat-square&logo=sigstore)](https://github.com/lateos-ai/npm-scan/actions/workflows/publish.yml)
16
16
 
17
17
  **Sécurité moderne de la chaîne d'approvisionnement pour l'écosystème npm.**
@@ -100,7 +100,7 @@ npx @lateos/npm-scan scan commander
100
100
 
101
101
  ```bash
102
102
  # Tirez et exécutez un scan unique — pas de Node.js ni npm requis
103
- docker run --rm ghcr.io/lateos/npm-scan:cli scan lodash
103
+ docker run --rm lateos/npm-scan:cli scan lodash
104
104
 
105
105
  # Pipeline complet avec stockage persistant et Compose
106
106
  docker compose --profile pipeline up -d
package/README.ja.md CHANGED
@@ -11,7 +11,7 @@
11
11
  [![Node](https://img.shields.io/badge/node-%3E%3D18-brightgreen?style=flat-square)](package.json)
12
12
  [![Tests](https://img.shields.io/badge/tests-222%20passing-brightgreen?style=flat-square)](https://github.com/lateos-ai/npm-scan)
13
13
  [![Coverage](https://img.shields.io/badge/coverage-85%25-yellowgreen?style=flat-square)](https://github.com/lateos-ai/npm-scan)
14
- [![Docker](https://img.shields.io/badge/docker-ghcr.io%2Flateos%2Fnpm--scan-2496ED?style=flat-square&logo=docker)](https://github.com/lateos-ai/npm-scan/pkgs/container/npm-scan)
14
+ [![Docker](https://img.shields.io/badge/docker-lateos%2Fnpm--scan-2496ED?style=flat-square&logo=docker)](https://hub.docker.com/r/lateos/npm-scan)
15
15
  [![Sigstore](https://img.shields.io/static/v1?label=Sigstore&message=Provenance&color=green&style=flat-square&logo=sigstore)](https://github.com/lateos-ai/npm-scan/actions/workflows/publish.yml)
16
16
 
17
17
  **npmエコシステムのためのモダンなサプライチェーンセキュリティ。**
@@ -100,7 +100,7 @@ npx @lateos/npm-scan scan commander
100
100
 
101
101
  ```bash
102
102
  # 単一スキャンをプルして実行 — Node.jsやnpmは不要
103
- docker run --rm ghcr.io/lateos/npm-scan:cli scan lodash
103
+ docker run --rm lateos/npm-scan:cli scan lodash
104
104
 
105
105
  # 永続ストレージとComposeを使用した完全パイプライン
106
106
  docker compose --profile pipeline up -d
package/README.md CHANGED
@@ -3,9 +3,9 @@
3
3
  [![npm version](https://img.shields.io/npm/v/@lateos/npm-scan?style=flat-square)](https://www.npmjs.com/package/@lateos/npm-scan)
4
4
  [![License](https://img.shields.io/badge/license-Apache%202.0%20%2B%20Commons%20Clause-blue?style=flat-square)](LICENSING.md)
5
5
  [![Node](https://img.shields.io/badge/node-%3E%3D18-brightgreen?style=flat-square)](package.json)
6
- [![Tests](https://img.shields.io/badge/tests-222%20passing-brightgreen?style=flat-square)](https://github.com/lateos-ai/npm-scan)
7
- [![Coverage](https://img.shields.io/badge/coverage-85%25-yellowgreen?style=flat-square)](https://github.com/lateos-ai/npm-scan)
8
- [![Docker](https://img.shields.io/badge/docker-ghcr.io%2Flateos%2Fnpm--scan-2496ED?style=flat-square&logo=docker)](https://github.com/lateos-ai/npm-scan/pkgs/container/npm-scan)
6
+ [![Tests](https://img.shields.io/badge/tests-324%20passing-brightgreen?style=flat-square)](https://github.com/lateos-ai/npm-scan)
7
+ [![Coverage](https://img.shields.io/badge/coverage-90%25-brightgreen?style=flat-square)](https://github.com/lateos-ai/npm-scan)
8
+ [![Docker](https://img.shields.io/badge/docker-lateos%2Fnpm--scan-2496ED?style=flat-square&logo=docker)](https://hub.docker.com/r/lateos/npm-scan)
9
9
  [![Sigstore](https://img.shields.io/static/v1?label=Sigstore&message=Provenance&color=green&style=flat-square&logo=sigstore)](https://github.com/lateos-ai/npm-scan/actions/workflows/publish.yml)
10
10
 
11
11
  [![中文](https://img.shields.io/badge/lang-zh--CN-red?style=flat-square)](https://github.com/lateos-ai/npm-scan/blob/main/README.zh.md)
@@ -71,6 +71,7 @@ Attackers have moved past simple typosquatting. They now ship **obfuscated prein
71
71
  | 🛡️ | **Zero telemetry** | No data leaves your machine. No cloud. No callbacks. |
72
72
  | 💾 | **Local scan history** | SQLite-backed persistence, zero external dependencies |
73
73
  | 🪝 | **Pre-commit hook** | Block threats before commit — one-liner install, scans `package-lock.json` changes |
74
+ | 📎 | **Yarn + pnpm support** | `scan-lockfile` parses `yarn.lock` and `pnpm-lock.yaml` alongside `package-lock.json` |
74
75
 
75
76
  ---
76
77
 
@@ -102,7 +103,7 @@ npx @lateos/npm-scan scan commander
102
103
 
103
104
  ```bash
104
105
  # Pull and run a single scan — no Node.js or npm required
105
- docker run --rm ghcr.io/lateos/npm-scan:cli scan lodash
106
+ docker run --rm lateos/npm-scan:cli scan lodash
106
107
 
107
108
  # Full pipeline with persistent storage and Compose
108
109
  docker compose --profile pipeline up -d
@@ -190,12 +191,16 @@ npm-scan scan --file path/to/malicious-package.tgz
190
191
  ### Scan a lockfile
191
192
 
192
193
  ```bash
193
- # Scan the current project's dependencies
194
+ # Scan the current project's dependencies (auto-detects npm/yarn/pnpm)
194
195
  npm-scan scan-lockfile
195
196
 
196
197
  # Scan a specific lockfile
197
198
  npm-scan scan-lockfile -f ./path/to/package-lock.json
198
199
 
200
+ # Scan yarn.lock or pnpm-lock.yaml
201
+ npm-scan scan-lockfile -f ./yarn.lock --yarn
202
+ npm-scan scan-lockfile -f ./pnpm-lock.yaml --pnpm
203
+
199
204
  # Fail CI/CD on high or critical findings (exit code 1)
200
205
  npm-scan scan-lockfile --fail-on high
201
206
 
@@ -211,7 +216,7 @@ npm-scan scan-lockfile --watch
211
216
  # Watch with faster debounce (500ms) — great for dev workflows
212
217
  npm-scan scan-lockfile --watch --debounce 500
213
218
 
214
- # Watch monorepo (all package-lock.json files in workspace)
219
+ # Watch monorepo (all lockfiles npm/yarn/pnpm — in workspace)
215
220
  npm-scan scan-lockfile --watch --monorepo
216
221
 
217
222
  # Output only risk score (0-10) for dashboards/thresholds
@@ -686,8 +691,12 @@ node --test test/detectors-corpus.test.js
686
691
  - `test/detectors-corpus.test.js` — 33 malicious + 50 clean tarball integration (offline)
687
692
  - `test/fetch.test.js` — tarball extraction, temp directory cleanup
688
693
  - `test/policy-edge-cases.test.js` — edge cases in suppress, override, load validation
694
+ - `test/policy.test.js` — policy YAML/JSON load, apply, suppress, severity override tests
689
695
  - `test/report-snapshots.test.js` — HTML/text/CRA/PDF format assertions
696
+ - `test/report.test.js` — SARIF, CSV, STIG, risk score format tests
697
+ - `test/lockfile.test.js` — npm/yarn/pnpm parser, auto-detect, ATK-007/011 lockfile tests
690
698
  - `test/cli.test.js` — commander integration tests (help, version, scan, report, error handling)
699
+ - `test/cli-lockfile.test.js` — scan-lockfile CLI options, yarn/pnpm/monorepo/watch tests
691
700
 
692
701
  ### Need help?
693
702
 
package/README.zh.md CHANGED
@@ -11,7 +11,7 @@
11
11
  [![Node](https://img.shields.io/badge/node-%3E%3D18-brightgreen?style=flat-square)](package.json)
12
12
  [![Tests](https://img.shields.io/badge/tests-222%20passing-brightgreen?style=flat-square)](https://github.com/lateos-ai/npm-scan)
13
13
  [![Coverage](https://img.shields.io/badge/coverage-85%25-yellowgreen?style=flat-square)](https://github.com/lateos-ai/npm-scan)
14
- [![Docker](https://img.shields.io/badge/docker-ghcr.io%2Flateos%2Fnpm--scan-2496ED?style=flat-square&logo=docker)](https://github.com/lateos-ai/npm-scan/pkgs/container/npm-scan)
14
+ [![Docker](https://img.shields.io/badge/docker-lateos%2Fnpm--scan-2496ED?style=flat-square&logo=docker)](https://hub.docker.com/r/lateos/npm-scan)
15
15
  [![Sigstore](https://img.shields.io/static/v1?label=Sigstore&message=Provenance&color=green&style=flat-square&logo=sigstore)](https://github.com/lateos-ai/npm-scan/actions/workflows/publish.yml)
16
16
 
17
17
  **适用于 npm 生态系统的现代供应链安全工具。**
@@ -100,7 +100,7 @@ npx @lateos/npm-scan scan commander
100
100
 
101
101
  ```bash
102
102
  # 拉取并运行单次扫描 — 无需 Node.js 或 npm
103
- docker run --rm ghcr.io/lateos/npm-scan:cli scan lodash
103
+ docker run --rm lateos/npm-scan:cli scan lodash
104
104
 
105
105
  # 使用持久化存储和 Compose 的完整流水线
106
106
  docker compose --profile pipeline up -d
@@ -78,6 +78,10 @@ export function validateLicense(key, feature = '*') {
78
78
 
79
79
  export function isFeatureEnabled(feature, licenseKey = process.env.NPM_SCAN_LICENSE_KEY) {
80
80
  try {
81
+ if (!licenseKey) {
82
+ const unlocked = feature === 'scan' || ALLOWED_UNLOCKED.includes(feature);
83
+ if (unlocked) return true;
84
+ }
81
85
  validateLicense(licenseKey, feature);
82
86
  return true;
83
87
  } catch {
@@ -1,47 +1,265 @@
1
1
  import { readFileSync } from 'fs';
2
2
  import { resolve, dirname } from 'path';
3
- import { fileURLToPath } from 'url';
3
+ import yaml from 'js-yaml';
4
4
 
5
- export function parseLockfile(filePath) {
5
+ export function parseLockfile(filePath, options = {}) {
6
+ const { autoDetect = false } = options;
6
7
  try {
7
8
  const content = readFileSync(filePath, 'utf8');
8
- const lockfile = JSON.parse(content);
9
- const packages = [];
9
+ const ext = filePath.split('.').pop().toLowerCase();
10
10
 
11
- if (lockfile.packages) {
12
- for (const [key, pkg] of Object.entries(lockfile.packages)) {
13
- if (key === '') continue;
14
- const name = pkg.name || key.replace(/^node_modules\//, '').replace(/^[^/]+\//, '');
11
+ if (ext === 'json' || ext === 'jsonc') {
12
+ return parseNpmLockfile(content, filePath);
13
+ }
14
+ if (ext === 'lock' && !autoDetect) {
15
+ return parseYarnLockfile(content, filePath);
16
+ }
17
+ if (ext === 'yaml' || ext === 'yml') {
18
+ return parsePnpmLockfile(content, filePath);
19
+ }
20
+
21
+ if (autoDetect) {
22
+ if (content.trimStart().startsWith('{')) {
23
+ return parseNpmLockfile(content, filePath);
24
+ }
25
+ if (content.includes('__metadata')) {
26
+ return parsePnpmLockfile(content, filePath);
27
+ }
28
+ if (content.includes('@npm:') || /^\s*"?[\w@/-]+['"]?\s*,\s*$/m.test(content)) {
29
+ return parseYarnLockfile(content, filePath);
30
+ }
31
+ }
32
+
33
+ return parseNpmLockfile(content, filePath);
34
+ } catch (e) {
35
+ throw new Error(`Failed to parse lockfile: ${e.message}`);
36
+ }
37
+ }
38
+
39
+ function parseNpmLockfile(content, filePath) {
40
+ const lockfile = JSON.parse(content);
41
+ const packages = [];
42
+
43
+ if (lockfile.packages) {
44
+ for (const [key, pkg] of Object.entries(lockfile.packages)) {
45
+ if (key === '') continue;
46
+ const name = pkg.name || key.replace(/^node_modules\//, '').replace(/^[^/]+\//, '');
47
+ packages.push({
48
+ name,
49
+ version: pkg.version || 'unknown',
50
+ resolved: pkg.resolved || '',
51
+ integrity: pkg.integrity || '',
52
+ path: key,
53
+ peerDeps: pkg.peerDependencies || {},
54
+ dev: pkg.dev || false,
55
+ optional: pkg.optional || false,
56
+ scripts: pkg.scripts || {},
57
+ dependencies: pkg.dependencies || {}
58
+ });
59
+ }
60
+ }
61
+
62
+ const rootDeps = lockfile.packages?.['node_modules/'] || {};
63
+ return {
64
+ version: lockfile.lockfileVersion,
65
+ packages,
66
+ root: {
67
+ name: rootDeps.name || 'unknown',
68
+ version: rootDeps.version || 'unknown',
69
+ dependencies: rootDeps.dependencies || {},
70
+ devDependencies: rootDeps.devDependencies || {},
71
+ peerDependencies: rootDeps.peerDependencies || {}
72
+ }
73
+ };
74
+ }
75
+
76
+ function parseYarnLockfile(content, filePath) {
77
+ const packages = [];
78
+ const lines = content.split('\n');
79
+ let i = 0;
80
+ const n = lines.length;
81
+
82
+ const MULTI_ENTRY_RE = /^"?([\w@./-]+)@(\^?[\w.+\-~]+)"?\s*,\s*"?([\w@./-]+)@(\^?[\w.+\-~]+)"?\s*:\s*$/;
83
+ const SINGLE_ENTRY_RE = /^"?([\w@./-]+)@(\^?[\w.+\-~]+)"?\s*:\s*$/;
84
+
85
+ while (i < n) {
86
+ let line = lines[i].trimEnd();
87
+
88
+ let specs = [];
89
+
90
+ const multiMatch = line.match(MULTI_ENTRY_RE);
91
+ const singleMatch = line.match(SINGLE_ENTRY_RE);
92
+
93
+ if (multiMatch) {
94
+ specs = [
95
+ { name: multiMatch[1], specVersion: multiMatch[2] },
96
+ { name: multiMatch[3], specVersion: multiMatch[4] }
97
+ ];
98
+ } else if (singleMatch) {
99
+ specs = [{ name: singleMatch[1], specVersion: singleMatch[2] }];
100
+ }
101
+
102
+ if (specs.length > 0) {
103
+ let version = '';
104
+ let resolved = '';
105
+ let integrity = '';
106
+ const dependencies = {};
107
+ const optionalDependencies = {};
108
+ const peerDependencies = {};
109
+ let dev = false;
110
+ let optional = false;
111
+
112
+ i++;
113
+ while (i < n) {
114
+ const bodyLine = lines[i];
115
+ const bodyTrim = bodyLine.trimEnd();
116
+
117
+ if (bodyTrim === '' || bodyTrim.startsWith('#')) {
118
+ i++;
119
+ continue;
120
+ }
121
+
122
+ if (bodyTrim.endsWith(':') && !bodyLine.startsWith(' ')) {
123
+ break;
124
+ }
125
+
126
+ if (bodyTrim.startsWith('version ')) {
127
+ const vMatch = bodyTrim.match(/^version ['"]([^'"]+)['"]/);
128
+ if (vMatch) version = vMatch[1];
129
+ } else if (bodyTrim.match(/^\s*resolved\s+(.+)/)) {
130
+ const rMatch = bodyTrim.match(/^\s*resolved\s+(.+)/);
131
+ if (rMatch) {
132
+ resolved = rMatch[1].trim().replace(/^['"]|['"]$/g, '');
133
+ if (resolved.startsWith('https://registry.yarnpkg.com/')) {
134
+ resolved = resolved.replace('https://registry.yarnpkg.com/', 'https://registry.npmjs.org/');
135
+ }
136
+ }
137
+ } else if (bodyTrim.startsWith('integrity ')) {
138
+ integrity = bodyTrim.replace('integrity ', '').trim();
139
+ } else if (bodyTrim.startsWith('dependencies')) {
140
+ const m = bodyTrim.match(/^dependencies\s+(.*)/);
141
+ if (m) parseDepList(m[1], dependencies);
142
+ } else if (bodyTrim.startsWith('optionalDependencies')) {
143
+ const m = bodyTrim.match(/^optionalDependencies\s+(.*)/);
144
+ if (m) parseDepList(m[1], optionalDependencies);
145
+ } else if (bodyTrim.startsWith('peerDependencies')) {
146
+ const m = bodyTrim.match(/^peerDependencies\s+(.*)/);
147
+ if (m) parseDepList(m[1], peerDependencies);
148
+ } else if (bodyTrim.match(/^\s*dev\s+(true|false)$/)) {
149
+ dev = bodyTrim.includes('true');
150
+ } else if (bodyTrim.match(/^\s*optional\s+(true|false)$/)) {
151
+ optional = bodyTrim.includes('true');
152
+ }
153
+
154
+ i++;
155
+ }
156
+
157
+ for (const { name, specVersion } of specs) {
15
158
  packages.push({
16
159
  name,
17
- version: pkg.version || 'unknown',
18
- resolved: pkg.resolved || '',
19
- integrity: pkg.integrity || '',
20
- path: key,
21
- peerDeps: pkg.peerDependencies || {},
22
- dev: pkg.dev || false,
23
- optional: pkg.optional || false,
24
- scripts: pkg.scripts || {},
25
- dependencies: pkg.dependencies || {}
160
+ version: version || specVersion,
161
+ resolved,
162
+ integrity,
163
+ path: `node_modules/${name}`,
164
+ peerDeps: peerDependencies,
165
+ dev,
166
+ optional,
167
+ scripts: {},
168
+ dependencies,
169
+ optionalDependencies
26
170
  });
27
171
  }
172
+ } else {
173
+ i++;
174
+ }
175
+ }
176
+
177
+ const rootDeps = {};
178
+ const rootDevDeps = {};
179
+
180
+ for (const pkg of packages) {
181
+ const topDeps = pkg.dev ? rootDevDeps : rootDeps;
182
+ for (const depName of Object.keys(pkg.dependencies)) {
183
+ topDeps[depName] = pkg.dependencies[depName];
28
184
  }
185
+ }
186
+
187
+ return {
188
+ version: 2,
189
+ packages,
190
+ root: {
191
+ name: 'root',
192
+ version: 'unknown',
193
+ dependencies: rootDeps,
194
+ devDependencies: rootDevDeps,
195
+ peerDependencies: {}
196
+ }
197
+ };
198
+ }
199
+
200
+ function parseDepList(str, dest) {
201
+ const cleaned = str.replace(/^[[\]]/g, '').trim();
202
+ if (!cleaned) return;
203
+ const re = /([\w@./-]+)\s+\^?([\w@./-]+)/g;
204
+ let m;
205
+ while ((m = re.exec(cleaned)) !== null) {
206
+ dest[m[1]] = m[2];
207
+ }
208
+ }
29
209
 
30
- const rootDeps = lockfile.packages?.['node_modules/'] || {};
31
- return {
32
- version: lockfile.lockfileVersion,
33
- packages,
34
- root: {
35
- name: rootDeps.name || 'unknown',
36
- version: rootDeps.version || 'unknown',
37
- dependencies: rootDeps.dependencies || {},
38
- devDependencies: rootDeps.devDependencies || {},
39
- peerDependencies: rootDeps.peerDependencies || {}
210
+ function parsePnpmLockfile(content, filePath) {
211
+ const lockfile = yaml.load(content);
212
+ const packages = [];
213
+
214
+ if (lockfile.packages) {
215
+ for (const [key, pkg] of Object.entries(lockfile.packages)) {
216
+ const nameMatch = key.match(/^\/(.+?)@([^@/]+)$/);
217
+ if (!nameMatch) continue;
218
+ const name = nameMatch[1];
219
+ const version = nameMatch[2];
220
+
221
+ const resolved = pkg.resolution?.url || '';
222
+ let integrity = '';
223
+ if (pkg.resolution?.integrity) {
224
+ integrity = pkg.resolution.integrity;
225
+ } else if (pkg.resolution?.sha512) {
226
+ integrity = `sha512-${pkg.resolution.sha512}`;
40
227
  }
41
- };
42
- } catch (e) {
43
- throw new Error(`Failed to parse lockfile: ${e.message}`);
228
+
229
+ packages.push({
230
+ name,
231
+ version,
232
+ resolved,
233
+ integrity,
234
+ path: `node_modules/${name}`,
235
+ peerDeps: pkg.peerDependencies || {},
236
+ dev: pkg.dev || false,
237
+ optional: pkg.optional || false,
238
+ scripts: pkg.hasBundledMedia ? { bundled: true } : {},
239
+ dependencies: pkg.dependencies || {},
240
+ optionalDependencies: pkg.optionalDependencies || {}
241
+ });
242
+ }
44
243
  }
244
+
245
+ const rootDeps = lockfile.importers?.['.'] || lockfile.root || {};
246
+ const rootDepsMap = rootDeps.dependencies || {};
247
+ const rootDevDepsMap = rootDeps.devDependencies || {};
248
+ const rootPeerDepsMap = rootDeps.peerDependencies || {};
249
+
250
+ const version = lockfile.version || (lockfile.lockfileVersion ?? 6);
251
+
252
+ return {
253
+ version,
254
+ packages,
255
+ root: {
256
+ name: 'root',
257
+ version: lockfile.lockfileVersion ? 'unknown' : 'unknown',
258
+ dependencies: rootDepsMap,
259
+ devDependencies: rootDevDepsMap,
260
+ peerDependencies: rootPeerDepsMap
261
+ }
262
+ };
45
263
  }
46
264
 
47
265
  export function checkMaliciousPatterns(pkg) {
@@ -96,7 +314,7 @@ export function analyzeDependencyGraph(lockfileData) {
96
314
  }
97
315
 
98
316
  if (pkg.dependencies && typeof pkg.dependencies === 'object' && Object.keys(pkg.dependencies).length > 5) {
99
- const transitiveCount = Object.keys(pkg.dependencies).filter(k => k.includes('scope')).length;
317
+ const transitiveCount = Object.keys(pkg.dependencies).filter(k => k.includes('/')).length;
100
318
  if (transitiveCount > 3) {
101
319
  findings.push({
102
320
  id: 'ATK-011',
@@ -107,6 +325,16 @@ export function analyzeDependencyGraph(lockfileData) {
107
325
  });
108
326
  }
109
327
  }
328
+
329
+ if (pkg.optionalDependencies && Object.keys(pkg.optionalDependencies).length > 10) {
330
+ findings.push({
331
+ id: 'ATK-011',
332
+ severity: 'low',
333
+ title: 'Transitive propagation (worm)',
334
+ description: `Package "${pkg.name}" has excessive optional dependencies (${Object.keys(pkg.optionalDependencies).length})`,
335
+ evidence: `optional dep chain: ${pkg.name} -> [${Object.keys(pkg.optionalDependencies).slice(0, 3).join(', ')}, ...]`
336
+ });
337
+ }
110
338
  }
111
339
 
112
340
  return findings;
package/cli/cli.js CHANGED
@@ -163,7 +163,7 @@ program
163
163
 
164
164
  program
165
165
  .command('scan-lockfile')
166
- .description('Scan package-lock.json')
166
+ .description('Scan package lockfile (npm/yarn/pnpm)')
167
167
  .option('-f, --file <path>', 'lockfile path', 'package-lock.json')
168
168
  .option('--fail-on <level>', 'Exit with code 1 if findings >= level (low|medium|high|critical)', 'none')
169
169
  .option('--csv [file]', 'Output CSV format to file or stdout')
@@ -171,43 +171,46 @@ program
171
171
  .option('--watch', 'Watch for changes and re-scan automatically')
172
172
  .option('--debounce <ms>', 'Debounce delay in ms before rescanning (default: 1000)', '1000')
173
173
  .option('--silent', 'Suppress stdout output (useful for piping)')
174
- .option('--monorepo', 'Scan all package-lock.json files in workspace')
174
+ .option('--monorepo', 'Scan all lockfiles in workspace (auto-detect type)')
175
+ .option('--yarn', 'Force yarn.lock format')
176
+ .option('--pnpm', 'Force pnpm-lock.yaml format')
175
177
  .action(async (options) => {
176
178
  const silent = options.silent;
177
179
  const debounce = parseInt(options.debounce, 10) || 1000;
178
180
  const isWatch = options.watch;
179
181
  const isMonorepo = options.monorepo;
180
182
 
181
- if (isWatch) {
182
- if (isMonorepo) {
183
- const lockfiles = await glob('**/package-lock.json', { ignore: 'node_modules/**' });
183
+ if (isWatch) {
184
+ if (isMonorepo) {
185
+ const lockfiles = await glob('**/{package-lock.json,yarn.lock,pnpm-lock.yaml}', { ignore: 'node_modules/**' });
184
186
 
185
- if (!silent) {
186
- console.log(`\x1b[32m✔\x1b[0m npm-scan watch mode (monorepo) — ${lockfiles.length} lockfiles`);
187
- console.log(` Debounce: ${debounce}ms | Press Ctrl+C to stop\n`);
188
- }
187
+ if (!silent) {
188
+ console.log(`\x1b[32m✔\x1b[0m npm-scan watch mode (monorepo) — ${lockfiles.length} lockfiles`);
189
+ console.log(` Debounce: ${debounce}ms | Press Ctrl+C to stop\n`);
190
+ }
189
191
 
190
- let timers = {};
191
- for (const lf of lockfiles) {
192
- if (!silent) console.log(` Watching: ${lf}`);
193
- const watcher = watch(lf, (eventType) => {
194
- if (eventType !== 'change') return;
195
- clearTimeout(timers[lf]);
196
- timers[lf] = setTimeout(() => {
197
- if (!silent) {
198
- console.log(`\n\x1b[90m[${new Date().toLocaleTimeString()}]\x1b[0m ${lf} changed — scanning...`);
199
- }
200
- try {
201
- execSync(`node cli/cli.js scan-lockfile -f "${lf}" --fail-on ${options.failOn || 'high'} --silent`, { stdio: silent ? 'ignore' : 'inherit' });
202
- } catch (e) {}
203
- }, debounce);
204
- });
205
- }
192
+ let timers = {};
193
+ for (const lf of lockfiles) {
194
+ if (!silent) console.log(` Watching: ${lf}`);
195
+ const watcher = watch(lf, (eventType) => {
196
+ if (eventType !== 'change') return;
197
+ clearTimeout(timers[lf]);
198
+ timers[lf] = setTimeout(() => {
199
+ if (!silent) {
200
+ console.log(`\n\x1b[90m[${new Date().toLocaleTimeString()}]\x1b[0m ${lf} changed — scanning...`);
201
+ }
202
+ const lockType = lf.includes('yarn') ? '--yarn' : lf.includes('pnpm') ? '--pnpm' : '';
203
+ try {
204
+ execSync(`node cli/cli.js scan-lockfile -f "${lf}" --fail-on ${options.failOn || 'high'} --silent ${lockType}`, { stdio: silent ? 'ignore' : 'inherit' });
205
+ } catch (e) {}
206
+ }, debounce);
207
+ });
208
+ }
206
209
 
207
- process.on('SIGINT', () => {
208
- if (!silent) console.log('\n\x1b[33m✖\x1b[0m Stopped.');
209
- process.exit(0);
210
- });
210
+ process.on('SIGINT', () => {
211
+ if (!silent) console.log('\n\x1b[33m✖\x1b[0m Stopped.');
212
+ process.exit(0);
213
+ });
211
214
  } else {
212
215
  const lockfile = options.file;
213
216
  let lastSize = 0;
@@ -242,7 +245,7 @@ program
242
245
 
243
246
  if (!silent) console.log(`\x1b[32m✔\x1b[0m Scanning lockfile: ${lockfile}`);
244
247
 
245
- const lockfileData = parseLockfile(lockfile);
248
+ const lockfileData = parseLockfile(lockfile, { autoDetect: !options.yarn && !options.pnpm });
246
249
  const results = generateLockfileReport(lockfileData);
247
250
 
248
251
  if (!silent) {
@@ -2,7 +2,7 @@
2
2
  # Deploy to your VPC: helm install -f values.byoc.yaml npm-scan ./
3
3
 
4
4
  image:
5
- repository: ghcr.io/lateos/npm-scan
5
+ repository: lateos/npm-scan
6
6
  tag: "1.0.0"
7
7
 
8
8
  premium:
@@ -2,7 +2,7 @@
2
2
  # Override per environment: helm install -f values-prod.yaml
3
3
 
4
4
  image:
5
- repository: ghcr.io/lateos/npm-scan
5
+ repository: lateos/npm-scan
6
6
  tag: latest
7
7
  pullPolicy: Always
8
8
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lateos/npm-scan",
3
- "version": "0.13.1",
3
+ "version": "0.15.0",
4
4
  "description": "Modern npm supply chain security scanner — detects obfuscated payloads, credential stealers, conditional triggers, sandbox evasion, and worm-like propagation. 11 attack types, SBOM, NIST/EU CRA compliance reporting.",
5
5
  "main": "backend/index.js",
6
6
  "bin": {
@@ -34,7 +34,9 @@
34
34
  "corpus": "node tests/corpus/run.js"
35
35
  },
36
36
  "lint-staged": {
37
- "**/package{,-lock}.json": "node cli/cli.js scan-lockfile --fail-on high"
37
+ "**/package{,-lock}.json": "node cli/cli.js scan-lockfile --fail-on high",
38
+ "**/yarn.lock": "node cli/cli.js scan-lockfile --fail-on high --yarn",
39
+ "**/pnpm-lock.yaml": "node cli/cli.js scan-lockfile --fail-on high --pnpm"
38
40
  },
39
41
  "publishConfig": {
40
42
  "access": "public"
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "test-project",
3
+ "version": "1.0.0",
4
+ "lockfileVersion": 3,
5
+ "packages": {
6
+ "": {
7
+ "name": "test-project",
8
+ "version": "1.0.0",
9
+ "dependencies": {
10
+ "lodash": "^4.17.21",
11
+ "axios": "^1.6.0"
12
+ },
13
+ "devDependencies": {
14
+ "@babel/core": "^7.23.0"
15
+ }
16
+ },
17
+ "node_modules/lodash": {
18
+ "name": "lodash",
19
+ "version": "4.17.21",
20
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
21
+ "integrity": "sha512-v2kDEeDAnj4p1hhL6Ogrgu4BSWwg8cD2fRIouDAiqwu+iNl1IvyMex9jG9j8OpNp1zntnv/headququbit",
22
+ "dependencies": {}
23
+ },
24
+ "node_modules/axios": {
25
+ "name": "axios",
26
+ "version": "1.6.8",
27
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.8.tgz",
28
+ "integrity": "sha512-j2xvyqwsdd456789abcdef",
29
+ "dependencies": {
30
+ "form-data": "4.0.0",
31
+ "proxy-from-env": "1.1.0"
32
+ }
33
+ },
34
+ "node_modules/axios/node_modules/form-data": {
35
+ "version": "4.0.0",
36
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
37
+ "integrity": "sha512-444567890123456"
38
+ },
39
+ "node_modules/@babel/core": {
40
+ "name": "@babel/core",
41
+ "version": "7.23.9",
42
+ "resolved": "https://registry.yarnpkg.com/@babel/core/-/core-7.23.9.tgz",
43
+ "integrity": "sha512-5q+M1iEJCOrGJs9NxzG3p3z7w2cJK/QuoRoI2pOJhtcNQjl9y7w6w4At5ZQHZdwqd+5N5G1lULu7I6pXVBw==",
44
+ "dev": true,
45
+ "dependencies": {
46
+ "@babel/generator": "^7.23.6",
47
+ "@babel/parser": "^7.23.9"
48
+ }
49
+ },
50
+ "node_modules/reakt": {
51
+ "name": "reakt",
52
+ "version": "18.2.0",
53
+ "resolved": "https://registry.yarnpkg.com/reakt/-/reakt-18.2.0.tgz",
54
+ "integrity": "sha-abcdabcd1234defghi",
55
+ "optional": true,
56
+ "dependencies": {}
57
+ },
58
+ "node_modules/express": {
59
+ "name": "express",
60
+ "version": "4.18.2",
61
+ "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz",
62
+ "integrity": "sha512-abcdabcd1234abcdefghi",
63
+ "dependencies": {
64
+ "accepts": "~1.3.8",
65
+ "body-parser": "1.20.2"
66
+ }
67
+ }
68
+ }
69
+ }
@@ -0,0 +1,118 @@
1
+ lockfileVersion: "6.0"
2
+
3
+ importers:
4
+ .:
5
+ dependencies:
6
+ lodash: "^4.17.21"
7
+ axios: "^1.6.0"
8
+ devDependencies:
9
+ "@babel/core": "^7.23.0"
10
+ optionalDependencies:
11
+ chalk: "^5.3.0"
12
+ peerDependencies:
13
+ react: ">=16"
14
+
15
+ packages:
16
+ "/lodash@4.17.21":
17
+ resolution:
18
+ url: "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"
19
+ sha512: X2xvyqwsdd456789abcdefghijk
20
+ dev: false
21
+ optional: false
22
+ dependencies: {}
23
+
24
+ "/axios@1.6.8":
25
+ resolution:
26
+ url: "https://registry.npmjs.org/axios/-/axios-1.6.8.tgz"
27
+ sha512: j2xvyqwsdd456789abcdef
28
+ dev: false
29
+ optional: false
30
+ dependencies:
31
+ form-data: "4.0.0"
32
+ proxy-from-env: "1.1.0"
33
+
34
+ "/reakt@18.2.0":
35
+ resolution:
36
+ url: "https://registry.yarnpkg.com/reakt/-/reakt-18.2.0.tgz"
37
+ sha512: abcdefghijk123456789
38
+ dev: false
39
+ optional: true
40
+ dependencies: []
41
+
42
+ "/@babel/core@7.23.9":
43
+ resolution:
44
+ url: "https://registry.yarnpkg.com/@babel/core/-/core-7.23.9.tgz"
45
+ sha512: k2yVyqwsdd456789abcdefghij
46
+ dev: true
47
+ optional: false
48
+ dependencies:
49
+ "@babel/generator": "7.23.6"
50
+ "@babel/parser": "7.23.9"
51
+ "@babel/traverse": "7.23.9"
52
+ "@babel/types": "7.23.9"
53
+ convert-source-map: "2.0.0"
54
+ debug: "4.1.0"
55
+ gensync: "1.0.0-beta.2"
56
+ json5: "2.2.3"
57
+ semver: "6.3.1"
58
+
59
+ "/@babel/generator@7.23.6":
60
+ resolution:
61
+ url: "https://registry.yarnpkg.com/@babel/generator/-/generator-7.23.6.tgz"
62
+ sha512: abcdefghijk12345678abcdef
63
+ dev: false
64
+ optional: false
65
+ dependencies:
66
+ "@babel/types": "7.23.6"
67
+ "@jridgewell/gen-mapping": "0.3.2"
68
+ "@jridgewell/trace-mapping": "0.3.17"
69
+ jsesc: "2.5.1"
70
+
71
+ "/expres@4.18.2":
72
+ resolution:
73
+ url: "https://registry.npmjs.org/expres-4.18.2.tgz"
74
+ sha512: abcdefghijk12345678
75
+ dev: false
76
+ optional: false
77
+ dependencies:
78
+ accepts: "1.3.8"
79
+ array-flatten: "1.1.1"
80
+ body-parser: "1.20.2"
81
+ content-disposition: "0.5.4"
82
+ content-type: "1.0.5"
83
+ cookie: "0.5.0"
84
+ cookie-signature: "1.0.6"
85
+ debug: "2.6.9"
86
+ depd: "2.0.0"
87
+ encodeurl: "1.0.2"
88
+ escape-html: "1.0.3"
89
+ etag: "1.8.1"
90
+ finalhandler: "1.2.0"
91
+ fresh: "0.5.2"
92
+ http-errors: "2.0.0"
93
+ merge-descriptors: "1.0.1"
94
+ methods: "1.1.2"
95
+ on-finished: "2.4.1"
96
+ parseurl: "1.3.3"
97
+ path-to-regexp: "0.1.7"
98
+ proxy-addr: "2.0.7"
99
+ qs: "6.11.0"
100
+ range-parser: "1.2.1"
101
+ safe-buffer: "5.2.1"
102
+ send: "0.18.0"
103
+ serve-static: "1.15.0"
104
+ setprototypeof: "1.2.0"
105
+ statuses: "2.0.1"
106
+ type-is: "1.6.18"
107
+ utils-merge: "1.0.1"
108
+ vary: "1.1.2"
109
+
110
+ "/my-scope-plugin@1.0.0":
111
+ resolution:
112
+ url: "https://registry.npmjs.org/my-scope-plugin/-/my-scope-plugin-1.0.0.tgz"
113
+ sha512: defghijk123456789abcdef
114
+ dev: false
115
+ optional: false
116
+ dependencies:
117
+ lodash: "4.17.21"
118
+ axios: "1.6.8"
@@ -0,0 +1,104 @@
1
+ lodash@^4.17.21:
2
+ version "4.17.21"
3
+ resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz"
4
+ integrity sha512-Vythumb
5
+ dependencies: {}
6
+ dev false
7
+ optional true
8
+
9
+ axios@^1.6.0:
10
+ version "1.6.8"
11
+ resolved "https://registry.yarnpkg.com/axios/-/axios-1.6.8.tgz"
12
+ integrity sha-j2xvyqwsdd456789abcdef
13
+ dependencies:
14
+ form-data "4.0.0"
15
+ proxy-from-env "1.1.0"
16
+ dev false
17
+ optional false
18
+
19
+ "@babel/core@^7.23.0", "@babel/core@^7.23.9":
20
+ version "7.23.9"
21
+ resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.23.9.tgz"
22
+ integrity sha512-5q+M1iEJCOrGJs9NxzG3p3z7w2cJK/QuoRoI2pOJhtcNQjl9y7w6w4At5ZQHZdwqd+5N5G1lULu7I6pXVBw==
23
+ dependencies:
24
+ "@babel/generator" "^7.23.6"
25
+ "@babel/parser" "^7.23.9"
26
+ "@babel/traverse" "^7.23.9"
27
+ "@babel/types" "^7.23.9"
28
+ convert-source-map "^2.0.0"
29
+ debug "^4.1.0"
30
+ gensync "^1.0.0-beta.2"
31
+ json5 "^2.2.3"
32
+ semver "^6.3.1"
33
+ rimraf "^3.0.2"
34
+ dev true
35
+ optional false
36
+
37
+ "@babel/generator@^7.23.6", "@babel/generator@^7.23.9":
38
+ version "7.23.6"
39
+ resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.23.6.tgz"
40
+ integrity sha512-56bfx9G1AJAFDl5QuK6t7MTCW3CBi7J8j+GxJJPvZ7L1f4P2FG8f9dBiH8Hg4U5Gcb6Bi4Y8DQ8x0j8b1QE8w==
41
+ dependencies:
42
+ "@babel/types" "^7.23.6"
43
+ "@jridgewell/gen-mapping" "^0.3.2"
44
+ "@jridgewell/trace-mapping" "^0.3.17"
45
+ jsesc "^2.5.1"
46
+ dev false
47
+ optional false
48
+
49
+ reakt@^18.2.0:
50
+ version "18.2.0"
51
+ resolved "https://registry.yarnpkg.com/reakt/-/reakt-18.2.0.tgz"
52
+ integrity sha512-abcdabcd1234defghi
53
+ dependencies: []
54
+ dev false
55
+ optional true
56
+
57
+ express@npm:expres@^4.18.2:
58
+ version "4.18.2"
59
+ resolved "https://registry.npmjs.org/expres-4.18.2.tgz"
60
+ integrity sha512-abcdabcd1234abcdefghi
61
+ dependencies:
62
+ accepts "~1.3.8"
63
+ array-flatten "1.1.1"
64
+ body-parser "1.20.2"
65
+ content-disposition "0.5.4"
66
+ content-type "~1.0.5"
67
+ cookie "0.5.0"
68
+ cookie-signature "1.0.6"
69
+ debug "2.6.9"
70
+ depd "2.0.0"
71
+ encodeurl "~1.0.2"
72
+ escape-html "~1.0.3"
73
+ etag "~1.8.1"
74
+ finalhandler "1.2.0"
75
+ fresh "0.5.2"
76
+ http-errors "2.0.0"
77
+ merge-descriptors "1.0.1"
78
+ methods "~1.1.2"
79
+ on-finished "2.4.1"
80
+ parseurl "~1.3.3"
81
+ path-to-regexp "0.1.7"
82
+ proxy-addr "~2.0.7"
83
+ qs "6.11.0"
84
+ range-parser "~1.2.1"
85
+ safe-buffer "5.2.1"
86
+ send "0.18.0"
87
+ serve-static "1.15.0"
88
+ setprototypeof "1.2.0"
89
+ statuses "2.0.1"
90
+ type-is "~1.6.18"
91
+ utils-merge "1.0.1"
92
+ vary "~1.1.2"
93
+ dev false
94
+ optional false
95
+
96
+ "my-scope-plugin@npm:my-scope-plugin@^1.0.0":
97
+ version "1.0.0"
98
+ resolved "https://registry.npmjs.org/my-scope-plugin-1.0.0.tgz"
99
+ integrity sha512-abcdefghijk123456789abcdef
100
+ dependencies:
101
+ lodash "^4.17.21"
102
+ axios "^1.6.0"
103
+ dev false
104
+ optional false