@lateos/npm-scan 0.15.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.
@@ -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
  }
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.15.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"