@lateos/npm-scan 0.14.0 → 0.15.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 CHANGED
@@ -3,8 +3,8 @@
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)
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
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
 
@@ -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
 
@@ -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
 
@@ -1,3 +1,45 @@
1
+ const DIST_BUILD_PATTERNS = [/\/dist\//, /\/build\//, /\/bundle/, /\/min\//, /\.min\.js$/, /\.bundled?\.js$/];
2
+ const TEST_FIXTURE_PATTERNS = [/\/test\//, /\/tests\//, /\/__tests__\//, /\/spec\//, /\.test\.js$/, /\.spec\.js$/, /fixtures?/];
3
+ const LIFECYCLE_HOOK_PATTERNS = [/postinstall/, /preinstall/, /['"]install['"]/, /['"]prepare['"]/];
4
+ const KNOWN_SAFE_DOMAINS = [
5
+ 'registry.npmjs.org', 'cdn.jsdelivr.net', 'unpkg.com', 'cdn.skypack.dev',
6
+ 'esm.sh', 'deno.land', 'raw.githubusercontent.com', 'github.com',
7
+ 'npmjs.com', 'nodejs.org', 'v8.dev', 'typescriptlang.org'
8
+ ];
9
+
10
+ function extractUrlDomain(code) {
11
+ const urlMatch = code.match(/https?:\/\/([^/'"\s]+)/);
12
+ return urlMatch ? urlMatch[1] : null;
13
+ }
14
+
15
+ function isDistOrBuild(filePath) {
16
+ return DIST_BUILD_PATTERNS.some(p => p.test(filePath));
17
+ }
18
+
19
+ function isTestOrFixture(filePath) {
20
+ return TEST_FIXTURE_PATTERNS.some(p => p.test(filePath));
21
+ }
22
+
23
+ function isLifecycleHook(code) {
24
+ return LIFECYCLE_HOOK_PATTERNS.some(p => p.test(code));
25
+ }
26
+
27
+ function isKnownSafeDomain(domain) {
28
+ if (!domain) return false;
29
+ return KNOWN_SAFE_DOMAINS.some(safe => domain === safe || domain.endsWith('.' + safe));
30
+ }
31
+
32
+ function createContext(filePath, code) {
33
+ return {
34
+ file_path: filePath,
35
+ is_dist_build: isDistOrBuild(filePath),
36
+ is_test_fixture: isTestOrFixture(filePath),
37
+ is_lifecycle_hook: isLifecycleHook(code),
38
+ url_domain: extractUrlDomain(code),
39
+ is_known_safe_domain: isKnownSafeDomain(extractUrlDomain(code)),
40
+ };
41
+ }
42
+
1
43
  export async function scan(pkgJson, files = []) {
2
44
  const findings = [];
3
45
  const pkgName = pkgJson?.name || '';
@@ -5,6 +47,7 @@ export async function scan(pkgJson, files = []) {
5
47
 
6
48
  for (const f of files) {
7
49
  const code = f.content;
50
+ const ctx = createContext(f.path, code);
8
51
 
9
52
  const hasEval = /eval\(|new Function\(|\bFunction\('/.test(code);
10
53
 
@@ -19,7 +62,8 @@ export async function scan(pkgJson, files = []) {
19
62
  severity: 'medium',
20
63
  title: 'Obfuscated payload',
21
64
  description: hexDecode ? 'Eval with hex-decoded payload' : 'Eval with base64-decoded payload',
22
- evidence: 'eval + decode pattern detected'
65
+ evidence: 'eval + decode pattern detected',
66
+ context: ctx,
23
67
  });
24
68
  return findings;
25
69
  }
@@ -32,7 +76,8 @@ export async function scan(pkgJson, files = []) {
32
76
  severity: 'high',
33
77
  title: 'Obfuscated payload',
34
78
  description: 'Double-encoded nested payload',
35
- evidence: 'nested encode/decode detected'
79
+ evidence: 'nested encode/decode detected',
80
+ context: { ...ctx, is_multi_layer: true },
36
81
  });
37
82
  return findings;
38
83
  }
@@ -48,7 +93,8 @@ export async function scan(pkgJson, files = []) {
48
93
  severity: 'medium',
49
94
  title: 'Obfuscated payload',
50
95
  description: 'Decoded string containing URL/fetch call',
51
- evidence: 'obfuscation with network call'
96
+ evidence: 'obfuscation with network call',
97
+ context: ctx,
52
98
  });
53
99
  return findings;
54
100
  }
@@ -60,7 +106,8 @@ export async function scan(pkgJson, files = []) {
60
106
  severity: 'medium',
61
107
  title: 'Obfuscated payload',
62
108
  description: 'Eval with String.fromCharCode obfuscation',
63
- evidence: 'charcode obfuscation detected'
109
+ evidence: 'charcode obfuscation detected',
110
+ context: ctx,
64
111
  });
65
112
  return findings;
66
113
  }
@@ -79,7 +126,8 @@ export async function scan(pkgJson, files = []) {
79
126
  severity: 'high',
80
127
  title: 'Obfuscated payload',
81
128
  description: 'Shell-code obfuscation pattern',
82
- evidence: p.source.substring(0, 60)
129
+ evidence: p.source.substring(0, 60),
130
+ context: ctx,
83
131
  });
84
132
  return findings;
85
133
  }
@@ -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 {
@@ -126,10 +126,10 @@ function parseYarnLockfile(content, filePath) {
126
126
  if (bodyTrim.startsWith('version ')) {
127
127
  const vMatch = bodyTrim.match(/^version ['"]([^'"]+)['"]/);
128
128
  if (vMatch) version = vMatch[1];
129
- } else if (bodyTrim.startsWith('resolved ')) {
130
- const rMatch = bodyTrim.match(/^resolved ['"]([^'"]+)['"]/);
129
+ } else if (bodyTrim.match(/^\s*resolved\s+(.+)/)) {
130
+ const rMatch = bodyTrim.match(/^\s*resolved\s+(.+)/);
131
131
  if (rMatch) {
132
- resolved = rMatch[1];
132
+ resolved = rMatch[1].trim().replace(/^['"]|['"]$/g, '');
133
133
  if (resolved.startsWith('https://registry.yarnpkg.com/')) {
134
134
  resolved = resolved.replace('https://registry.yarnpkg.com/', 'https://registry.npmjs.org/');
135
135
  }
package/backend/policy.js CHANGED
@@ -4,10 +4,77 @@ import { load as yamlLoad } from 'js-yaml';
4
4
  const SEVERITY_ORDER = ['none', 'low', 'medium', 'high', 'critical'];
5
5
  const VALID_SEVERITIES = new Set(SEVERITY_ORDER);
6
6
 
7
+ const KNOWN_REPUTABLE_PACKAGES = new Set([
8
+ 'react', 'react-dom', 'vue', 'angular', 'next', 'nuxt',
9
+ 'express', 'fastify', 'hono', 'koa', 'connect',
10
+ 'webpack', 'vite', 'rollup', 'esbuild', 'typescript', 'babel-core',
11
+ 'lodash', 'ramda', 'underscore',
12
+ 'axios', 'node-fetch', 'got', 'superagent',
13
+ 'sequelize', 'prisma', 'typeorm', 'mongoose',
14
+ 'jest', 'mocha', 'vitest', 'ava',
15
+ 'prettier', 'eslint', 'stylelint',
16
+ 'socket.io', 'ws',
17
+ 'rimraf', 'glob', 'minimatch', 'fs-extra',
18
+ ]);
19
+
7
20
  function severityIndex(s) {
8
21
  return SEVERITY_ORDER.indexOf(s);
9
22
  }
10
23
 
24
+ function matchesFilePath(filePath, pattern) {
25
+ if (!pattern) return false;
26
+ if (pattern === '*') return true;
27
+ const regexPattern = pattern
28
+ .replace(/\./g, '\\.')
29
+ .replace(/\*\*/g, '___DOUBLE_STAR___')
30
+ .replace(/\*/g, '[^/]*')
31
+ .replace(/___DOUBLE_STAR___/g, '.*');
32
+ return new RegExp(`^${regexPattern}$`).test(filePath);
33
+ }
34
+
35
+ function matchesContext(finding, rule) {
36
+ const ctx = finding.context;
37
+ if (!ctx) return false;
38
+
39
+ if (rule.context?.is_dist_build === true && !ctx.is_dist_build) return false;
40
+ if (rule.context?.is_dist_build === false && ctx.is_dist_build) return false;
41
+ if (rule.context?.is_test_fixture === true && !ctx.is_test_fixture) return false;
42
+ if (rule.context?.is_test_fixture === false && ctx.is_test_fixture) return false;
43
+ if (rule.context?.is_lifecycle_hook === true && !ctx.is_lifecycle_hook) return false;
44
+ if (rule.context?.is_lifecycle_hook === false && ctx.is_lifecycle_hook) return false;
45
+ if (rule.context?.is_known_safe_domain === true && !ctx.is_known_safe_domain) return false;
46
+ if (rule.context?.is_known_safe_domain === false && ctx.is_known_safe_domain) return false;
47
+
48
+ if (rule.context?.file_path && !matchesFilePath(ctx.file_path, rule.context.file_path)) return false;
49
+ if (rule.context?.url_domain) {
50
+ if (!ctx.url_domain) return false;
51
+ const domainPattern = rule.context.url_domain.replace(/\*/g, '.*');
52
+ if (!new RegExp(`^${domainPattern}$`).test(ctx.url_domain)) return false;
53
+ }
54
+
55
+ return true;
56
+ }
57
+
58
+ function getPackageReputationTier(pkgName) {
59
+ const name = pkgName?.replace(/^@/, '').replace(/\/.*/, '') || '';
60
+ if (KNOWN_REPUTABLE_PACKAGES.has(name)) return 'trusted';
61
+ return 'unknown';
62
+ }
63
+
64
+ function matchesSuppressRule(finding, pkgName, rule) {
65
+ if (rule.atk_id !== (finding.atk_id || finding.id)) return false;
66
+ if (rule.package && rule.package !== '*' && rule.package !== pkgName) return false;
67
+
68
+ if (rule.context && !matchesContext(finding, rule)) return false;
69
+
70
+ if (rule.reputation_tier) {
71
+ const tier = getPackageReputationTier(pkgName);
72
+ if (rule.reputation_tier !== tier && !(rule.reputation_tier === '*' || rule.reputation_tier === 'any')) return false;
73
+ }
74
+
75
+ return true;
76
+ }
77
+
11
78
  function loadPolicy(path) {
12
79
  const raw = readFileSync(path, 'utf8').trim();
13
80
  let policy;
@@ -63,6 +130,8 @@ function sanitizePolicy(policy) {
63
130
  atk_id: r.atk_id,
64
131
  package: r.package || '*',
65
132
  reason: r.reason || '',
133
+ context: r.context || null,
134
+ reputation_tier: r.reputation_tier || null,
66
135
  })),
67
136
  };
68
137
  }
@@ -73,19 +142,15 @@ function isAllowed(packageName, policy) {
73
142
  return policy.allow.packages.some(p => p === packageName || p === nameOnly);
74
143
  }
75
144
 
76
- function matchesSuppressRule(finding, pkgName, rule) {
77
- if (rule.atk_id !== (finding.atk_id || finding.id)) return false;
78
- if (rule.package === '*') return true;
79
- return rule.package === pkgName;
80
- }
81
-
82
145
  function applyPolicy(findings, packageName, policy) {
83
146
  let filtered = [...findings];
84
147
 
85
148
  if (policy.suppress.length) {
86
- filtered = filtered.filter(f =>
87
- !policy.suppress.some(r => matchesSuppressRule(f, packageName, r))
88
- );
149
+ filtered = filtered.filter(f => {
150
+ if (f.context?.is_lifecycle_hook) return true;
151
+ if (f.context?.is_multi_layer) return true;
152
+ return !policy.suppress.some(r => matchesSuppressRule(f, packageName, r));
153
+ });
89
154
  }
90
155
 
91
156
  filtered = filtered.map(f => {
@@ -108,4 +173,4 @@ function checkFailOn(findings, policy) {
108
173
  return findings.some(f => severityIndex(f.severity) >= threshold);
109
174
  }
110
175
 
111
- export { loadPolicy, applyPolicy, isAllowed };
176
+ export { loadPolicy, applyPolicy, isAllowed, getPackageReputationTier, matchesContext };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lateos/npm-scan",
3
- "version": "0.14.0",
3
+ "version": "0.15.1",
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,9 +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",
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"
37
+ "**/package{,-lock}.json": "sh -c 'node cli/cli.js scan-lockfile --fail-on high'",
38
+ "**/yarn.lock": "sh -c 'node cli/cli.js scan-lockfile --fail-on high --yarn'",
39
+ "**/pnpm-lock.yaml": "sh -c 'node cli/cli.js scan-lockfile --fail-on high --pnpm'"
40
40
  },
41
41
  "publishConfig": {
42
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
+ }
@@ -1,15 +1,15 @@
1
1
  lodash@^4.17.21:
2
2
  version "4.17.21"
3
- resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"
3
+ resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz"
4
4
  integrity sha512-Vythumb
5
5
  dependencies: {}
6
6
  dev false
7
- optional false
7
+ optional true
8
8
 
9
9
  axios@^1.6.0:
10
10
  version "1.6.8"
11
- resolved "https://registry.npmjs.org/axios/-/axios-1.6.8.tgz"
12
- integrity sha512-j2xvyqwsdd456789abcdef
11
+ resolved "https://registry.yarnpkg.com/axios/-/axios-1.6.8.tgz"
12
+ integrity sha-j2xvyqwsdd456789abcdef
13
13
  dependencies:
14
14
  form-data "4.0.0"
15
15
  proxy-from-env "1.1.0"
@@ -30,6 +30,7 @@ axios@^1.6.0:
30
30
  gensync "^1.0.0-beta.2"
31
31
  json5 "^2.2.3"
32
32
  semver "^6.3.1"
33
+ rimraf "^3.0.2"
33
34
  dev true
34
35
  optional false
35
36
 
@@ -45,7 +46,7 @@ axios@^1.6.0:
45
46
  dev false
46
47
  optional false
47
48
 
48
- reakt@^18.2.0, reakt@^18.2.0::version=18.2.0:
49
+ reakt@^18.2.0:
49
50
  version "18.2.0"
50
51
  resolved "https://registry.yarnpkg.com/reakt/-/reakt-18.2.0.tgz"
51
52
  integrity sha512-abcdabcd1234defghi
@@ -53,7 +54,7 @@ reakt@^18.2.0, reakt@^18.2.0::version=18.2.0:
53
54
  dev false
54
55
  optional true
55
56
 
56
- "express@npm:expres@^4.18.2", expres@^4.18.2::version=4.18.2:
57
+ express@npm:expres@^4.18.2:
57
58
  version "4.18.2"
58
59
  resolved "https://registry.npmjs.org/expres-4.18.2.tgz"
59
60
  integrity sha512-abcdabcd1234abcdefghi
@@ -92,7 +93,7 @@ reakt@^18.2.0, reakt@^18.2.0::version=18.2.0:
92
93
  dev false
93
94
  optional false
94
95
 
95
- "my-scope-plugin@npm:my-scope-plugin@^1.0.0", my-scope-plugin@^1.0.0::version=1.0.0:
96
+ "my-scope-plugin@npm:my-scope-plugin@^1.0.0":
96
97
  version "1.0.0"
97
98
  resolved "https://registry.npmjs.org/my-scope-plugin-1.0.0.tgz"
98
99
  integrity sha512-abcdefghijk123456789abcdef