@lateos/npm-scan 1.2.0 → 1.2.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.zh.md CHANGED
@@ -9,8 +9,8 @@
9
9
  [![npm version](https://img.shields.io/npm/v/@lateos/npm-scan?style=flat-square)](https://www.npmjs.com/package/@lateos/npm-scan)
10
10
  [![License](https://img.shields.io/badge/license-Apache%202.0%20%2B%20Commons%20Clause-blue?style=flat-square)](LICENSING.md)
11
11
  [![Node](https://img.shields.io/badge/node-%3E%3D18-brightgreen?style=flat-square)](package.json)
12
- [![Tests](https://img.shields.io/badge/tests-459%20passing-brightgreen?style=flat-square)](https://github.com/lateos-ai/npm-scan)
13
- [![Coverage](https://img.shields.io/badge/coverage-85%25-yellowgreen?style=flat-square)](https://github.com/lateos-ai/npm-scan)
12
+ [![Tests](https://img.shields.io/badge/tests-696%20passing-brightgreen?style=flat-square)](https://github.com/lateos-ai/npm-scan)
13
+ [![Coverage](https://img.shields.io/badge/coverage-90%25-brightgreen?style=flat-square)](https://github.com/lateos-ai/npm-scan)
14
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
 
@@ -485,102 +485,6 @@ npm-scan report --html > report.html
485
485
 
486
486
  ### Docker
487
487
 
488
- 请参见上方的 [Docker 快速入门部分](#-在任何地方通过-docker-运行-lateosnpm-scan--零安装),了解拉取命令、Compose 流水线和多架构镜像。
489
-
490
- 在每个 PR 上扫描您项目的 `package-lock.json`——在它们进入生产环境之前检测域名抢注、混淆载荷、凭证窃取器和蠕虫传播:
491
-
492
- ```yaml
493
- # .github/workflows/scan.yml
494
- name: npm-scan
495
- on:
496
- pull_request:
497
- paths:
498
- - 'package-lock.json'
499
- - '**/package.json'
500
- jobs:
501
- scan:
502
- runs-on: ubuntu-latest
503
- steps:
504
- - uses: actions/checkout@v4
505
- - uses: actions/setup-node@v4
506
- with:
507
- node-version: 20
508
- - name: Scan lockfile
509
- uses: lateos/npm-scan@v1
510
- with:
511
- scan-type: lockfile
512
- fail-on: high
513
- ```
514
-
515
- #### Action 输入
516
-
517
- | 输入 | 默认值 | 描述 |
518
- |-------|---------|-------------|
519
- | `scan-type` | `lockfile` | `lockfile` 扫描 `package-lock.json` 或 `package` 扫描特定 npm 包 |
520
- | `package` | — | 包名(`scan-type=package` 时需要) |
521
- | `fail-on` | `high` | 在此严重性阈值处使工作流失败:`none`、`low`、`medium`、`high`、`critical` |
522
- | `policy-file` | — | YAML/JSON 策略文件路径,用于白名单、严重性覆盖和抑制 |
523
- | `license-key` | — | 用于 SIEM 导出和 PDF 报告的高级版许可证密钥 |
524
- | `siem-format` | — | SIEM 输出:`cef`、`ecs`、`sentinel`、`qradar`(高级版) |
525
- | `sbom-format` | — | SBOM 输出:`json`、`xml`、`spdx` |
526
-
527
- #### Action 输出
528
-
529
- | 输出 | 描述 |
530
- |--------|-------------|
531
- | `findings-count` | 检测到的发现项数量 |
532
- | `scan-id` | 扫描 ID,用于后续报告引用 |
533
-
534
- #### 示例:使用策略 + SBOM 扫描特定包
535
-
536
- ```yaml
537
- - uses: lateos/npm-scan@v1
538
- with:
539
- scan-type: package
540
- package: lodash
541
- policy-file: .npm-scan.yml
542
- sbom-format: spdx
543
- fail-on: critical
544
- ```
545
-
546
- #### 示例:使用 SIEM 导出扫描(高级版)
547
-
548
- ```yaml
549
- - uses: lateos/npm-scan@v1
550
- with:
551
- scan-type: lockfile
552
- siem-format: cef
553
- license-key: ${{ secrets.NPM_SCAN_LICENSE_KEY }}
554
- ```
555
-
556
- ### CI/CD 流水线
557
-
558
- 直接集成到您现有的流水线中,无需复合操作:
559
-
560
- ```bash
561
- # 扫描锁定文件,在高严重性时使构建失败
562
- npm-scan scan-lockfile --policy .npm-scan.yml || exit 1
563
-
564
- # 扫描特定包,仅在严重时失败
565
- npm-scan scan lodash --policy .npm-scan.yml || exit 1
566
-
567
- # 生成 SBOM 作为构建产物
568
- npm-scan scan express --sbom spdx > express-sbom.spdx.json
569
-
570
- # 在 CI 中生成 HTML 合规报告
571
- npm-scan report --html > report.html
572
-
573
- # 上传报告作为产物
574
- # uses: actions/upload-artifact@v4
575
- # with:
576
- # name: npm-scan-report
577
- # path: report.html
578
- ```
579
-
580
- ### Docker
581
-
582
- 请参见上方的 [Docker 快速入门部分](#-在任何地方通过-docker-运行-lateosnpm-scan--零安装),了解拉取命令、Compose 流水线和多架构镜像。
583
-
584
488
  ---
585
489
 
586
490
  ## 🗺️ 路线图与企业功能
@@ -706,4 +610,5 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
706
610
 
707
611
  ```bash
708
612
  npx @lateos/npm-scan scan lodash
613
+ ```
709
614
  ```
@@ -108,4 +108,60 @@ export default {
108
108
  cross_package_burst_weight: 50,
109
109
  notes: 'D13: Version velocity anomaly and maintainer compromise detection',
110
110
  },
111
+ 'D14-BUILD-CONFIG-ABUSE': {
112
+ flag_threshold: 70,
113
+ warn_threshold: 50,
114
+ pattern_weights: {
115
+ shell_exec: 50,
116
+ process_spawn: 45,
117
+ env_access: 35,
118
+ fs_access: 40,
119
+ http_request: 50,
120
+ path_traversal: 55,
121
+ hardcoded_key: 60,
122
+ getenv_call: 35,
123
+ curl_call: 45,
124
+ execve_call: 55,
125
+ credential_scan: 50,
126
+ socket_call: 40,
127
+ environ_access: 35,
128
+ },
129
+ legitimate_native_addons: [
130
+ 'node-sass',
131
+ 'sqlite3',
132
+ 'bcrypt',
133
+ '@mapbox/node-pre-gyp',
134
+ 'better-sqlite3',
135
+ 'sharp',
136
+ 'canvas',
137
+ 'node-gyp',
138
+ 'prebuild-install',
139
+ ],
140
+ pattern_confidence: {
141
+ hardcoded_key: 0.95,
142
+ execve_call: 0.9,
143
+ socket_call: 0.75,
144
+ env_access: 0.6,
145
+ getenv_call: 0.7,
146
+ curl_call: 0.85,
147
+ },
148
+ max_binary_size_bytes: 10 * 1024 * 1024,
149
+ binary_size_weight: 30,
150
+ binary_age_mismatch_days: 30,
151
+ undeclared_gyp_weight: 45,
152
+ known_reputable_packages: [
153
+ 'electron',
154
+ 'puppeteer',
155
+ 'playwright',
156
+ 'sharp',
157
+ 'esbuild',
158
+ 'node-gyp',
159
+ '@mapbox/node-pre-gyp',
160
+ 'node-pre-gyp',
161
+ 'webpack',
162
+ 'vite',
163
+ 'rollup',
164
+ ],
165
+ notes: 'D14: Build Configuration Abuse (Phantom Gyp / Miasma variant)',
166
+ },
111
167
  };
@@ -33,6 +33,7 @@ import { scan as tier1SelfPropagationScan } from './tier1-self-propagation.js';
33
33
  import { scan as tier1EncryptedC2Scan } from './tier1-encrypted-c2.js';
34
34
  import { scan as tier1TransitiveDepsScan } from './tier1-transitive-deps.js';
35
35
  import { scan as tier1MaintainerCompromiseScan } from './tier1-maintainer-compromise.js';
36
+ import { scan as tier1BuildConfigAbuseScan } from './tier1-build-config-abuse.js';
36
37
 
37
38
  function timeout(ms) {
38
39
  return new Promise((_, reject) =>
@@ -231,5 +232,15 @@ export async function runAll(pkgJson, files = [], registryMeta = null, allFiles
231
232
  allFiles || files
232
233
  ))
233
234
  );
235
+ findings.push(
236
+ ...(await runTier1(
237
+ 'tier1-build-config-abuse',
238
+ tier1BuildConfigAbuseScan,
239
+ pkgJson,
240
+ files,
241
+ registryMeta,
242
+ allFiles || files
243
+ ))
244
+ );
234
245
  return findings.sort((a, b) => b.severity.localeCompare(a.severity));
235
246
  }
@@ -0,0 +1,264 @@
1
+ import thresholds from './config/thresholds.js';
2
+
3
+ const cfg = thresholds['D14-BUILD-CONFIG-ABUSE'];
4
+ const PATTERN_WEIGHTS = cfg.pattern_weights;
5
+ const PATTERN_CONFIDENCE = cfg.pattern_confidence;
6
+ const LEGITIMATE_ADDONS = new Set(cfg.legitimate_native_addons);
7
+
8
+ function fileByName(files, name) {
9
+ if (!files) return null;
10
+ const target = name.replace(/\\/g, '/').toLowerCase();
11
+ return (
12
+ files.find((f) => {
13
+ const fp = (f.path || f.name || '').replace(/\\/g, '/').toLowerCase();
14
+ return fp === target || fp.endsWith('/' + target);
15
+ }) || null
16
+ );
17
+ }
18
+
19
+ function filesByExt(files, exts) {
20
+ if (!files) return [];
21
+ const lower = exts.map((e) => e.toLowerCase());
22
+ return files.filter((f) => {
23
+ const fp = (f.path || f.name || '').toLowerCase();
24
+ return lower.some((e) => fp.endsWith(e));
25
+ });
26
+ }
27
+
28
+ function extractLines(content, matchIndex) {
29
+ if (!content) return 1;
30
+ const before = content.slice(0, matchIndex);
31
+ return (before.match(/\n/g) || []).length + 1;
32
+ }
33
+
34
+ export const name = 'tier1-build-config-abuse';
35
+
36
+ export async function scan(pkgJson, jsFiles, registryMeta, allFiles) {
37
+ const pkgName = pkgJson?.name;
38
+ if (
39
+ pkgName &&
40
+ cfg.known_reputable_packages?.some((r) => pkgName === r || pkgName.startsWith(r + '/'))
41
+ ) {
42
+ return [];
43
+ }
44
+
45
+ const files = allFiles || jsFiles || [];
46
+ if (files.length === 0) return [];
47
+
48
+ const findings = [];
49
+ let aggregatedRisk = 0;
50
+
51
+ const hasGypFile = !!fileByName(files, 'binding.gyp');
52
+ const isLegitimateAddon = pkgName && LEGITIMATE_ADDONS.has(pkgName);
53
+
54
+ // Step 1: binding.gyp presence on non-legitimate addon
55
+ const hasGypDeclared =
56
+ pkgJson &&
57
+ (pkgJson.gypfile === true ||
58
+ !!pkgJson.binary ||
59
+ (pkgJson.scripts &&
60
+ typeof pkgJson.scripts.install === 'string' &&
61
+ pkgJson.scripts.install.includes('node-gyp')) ||
62
+ (pkgJson.scripts &&
63
+ typeof pkgJson.scripts.install === 'string' &&
64
+ pkgJson.scripts.install.includes('node-pre-gyp')));
65
+
66
+ if (hasGypFile && !isLegitimateAddon && !hasGypDeclared) {
67
+ findings.push({
68
+ detector: 'tier1-build-config-abuse',
69
+ id: 'D14-BUILD-CONFIG-ABUSE',
70
+ severity: 'medium',
71
+ confidence: 'MEDIUM',
72
+ confidenceScore: 40,
73
+ message: `Unexpected binding.gyp in non-native-addon package${pkgName ? ': ' + pkgName : ''}`,
74
+ evidence: ['binding.gyp present but package is not a known native addon'],
75
+ locations: [{ file: 'binding.gyp', line: 1 }],
76
+ });
77
+ aggregatedRisk += 20;
78
+ }
79
+
80
+ // Step 2: Parse binding.gyp for suspicious patterns
81
+ if (hasGypFile) {
82
+ const gypFile = fileByName(files, 'binding.gyp');
83
+ const gypContent = gypFile?.content || '';
84
+
85
+ if (gypContent) {
86
+ const gypPatterns = {
87
+ shell_exec: /<!?\(.*\)/g,
88
+ process_spawn: /\b(spawn|exec|execSync|spawnSync|fork)\s*\(/g,
89
+ env_access: /process\.env\./g,
90
+ fs_access: /\bfs\.(read|write|readFile|writeFile|readdir|exists|stat|mkdir|rm|unlink)/g,
91
+ http_request: /\b(http|https|curl|wget|fetch)\b/gi,
92
+ path_traversal: /\.\.\//g,
93
+ };
94
+
95
+ for (const [patternName, regex] of Object.entries(gypPatterns)) {
96
+ regex.lastIndex = 0;
97
+ let match;
98
+ while ((match = regex.exec(gypContent)) !== null) {
99
+ const line = extractLines(gypContent, match.index);
100
+ findings.push({
101
+ detector: 'tier1-build-config-abuse',
102
+ id: 'D14-BUILD-CONFIG-ABUSE',
103
+ severity: PATTERN_WEIGHTS[patternName] >= 50 ? 'high' : 'medium',
104
+ confidence: 'HIGH',
105
+ confidenceScore: PATTERN_WEIGHTS[patternName],
106
+ message: `binding.gyp contains ${patternName.replace(/_/g, ' ')} pattern`,
107
+ evidence: [`pattern: ${patternName}`, `match: ${match[0].slice(0, 120)}`],
108
+ locations: [{ file: 'binding.gyp', line }],
109
+ });
110
+ aggregatedRisk += PATTERN_WEIGHTS[patternName] || 30;
111
+ }
112
+ }
113
+ }
114
+ }
115
+
116
+ // Step 3: Analyze C/C++ Source Code
117
+ const cppFiles = filesByExt(files, ['.cc', '.cpp', '.c', '.cxx', '.h', '.hpp']);
118
+ for (const cppFile of cppFiles) {
119
+ const content = cppFile.content || '';
120
+ if (!content) continue;
121
+
122
+ const cPatterns = {
123
+ hardcoded_key:
124
+ /(?:AWS|GITHUB|SLACK|STRIPE|TOKEN|SECRET|API_KEY|PASSWORD)\s*[:=]\s*['"][A-Za-z0-9_-]{20,}['"]|['"](?:sk_live_|sk_test_|ghp_|gho_|ghs_|ghu_)[A-Za-z0-9_-]{20,}['"]/g,
125
+ getenv_call: /\b(getenv|secure_getenv|getenv_s)\s*\(/g,
126
+ curl_call: /\b(curl_easy_perform|curl_slist_append|CURLOPT_URL|curl_easy_setopt)\s*\(/g,
127
+ execve_call: /\b(execve|execvp|execl|execlp|system|popen|pclose)\s*\(/g,
128
+ credential_scan: /(~\/\.aws|~\/\.ssh|\.env|credentials\.json|\/etc\/passwd)/g,
129
+ socket_call: /\b(socket\s*\(|listen\s*\(|accept\s*\(|connect\s*\(AF_)/g,
130
+ environ_access: /\b(environ|__environ)\s*\[/g,
131
+ };
132
+
133
+ for (const [patternName, regex] of Object.entries(cPatterns)) {
134
+ regex.lastIndex = 0;
135
+ let match;
136
+ while ((match = regex.exec(content)) !== null) {
137
+ const line = extractLines(content, match.index);
138
+ const confidence = PATTERN_CONFIDENCE[patternName] || 0.7;
139
+ const risk = PATTERN_WEIGHTS[patternName] || 30;
140
+
141
+ findings.push({
142
+ detector: 'tier1-build-config-abuse',
143
+ id: 'D14-BUILD-CONFIG-ABUSE',
144
+ severity: risk >= 50 ? 'high' : 'medium',
145
+ confidence: confidence >= 0.85 ? 'HIGH' : confidence >= 0.7 ? 'MEDIUM' : 'LOW',
146
+ confidenceScore: risk,
147
+ message: `C/C++ code contains ${patternName.replace(/_/g, ' ')} pattern`,
148
+ evidence: [
149
+ `pattern: ${patternName}`,
150
+ `match: ${match[0].slice(0, 120)}`,
151
+ `confidence: ${confidence}`,
152
+ ],
153
+ locations: [{ file: cppFile.path || cppFile.name || 'unknown.cc', line }],
154
+ });
155
+ aggregatedRisk += risk;
156
+ }
157
+ }
158
+ }
159
+
160
+ // Step 4: Check .node file legitimacy
161
+ const nodeFiles = filesByExt(files, ['.node']);
162
+ for (const nodeFile of nodeFiles) {
163
+ const content = nodeFile.content || '';
164
+ const fileSize = content.length;
165
+
166
+ if (fileSize > cfg.max_binary_size_bytes) {
167
+ findings.push({
168
+ detector: 'tier1-build-config-abuse',
169
+ id: 'D14-BUILD-CONFIG-ABUSE',
170
+ severity: 'medium',
171
+ confidence: 'MEDIUM',
172
+ confidenceScore: cfg.binary_size_weight,
173
+ message: 'Large prebuilt .node binary',
174
+ evidence: [
175
+ `file: ${nodeFile.path || nodeFile.name}`,
176
+ `size: ${(fileSize / (1024 * 1024)).toFixed(1)} MB`,
177
+ `max allowed: ${(cfg.max_binary_size_bytes / (1024 * 1024)).toFixed(1)} MB`,
178
+ ],
179
+ locations: [{ file: nodeFile.path || nodeFile.name || 'unknown.node' }],
180
+ });
181
+ aggregatedRisk += cfg.binary_size_weight;
182
+ }
183
+ }
184
+
185
+ // Step 5: Cross-reference — undeclared binding.gyp
186
+ if (hasGypFile && pkgJson && !hasGypDeclared) {
187
+ findings.push({
188
+ detector: 'tier1-build-config-abuse',
189
+ id: 'D14-BUILD-CONFIG-ABUSE',
190
+ severity: 'high',
191
+ confidence: 'HIGH',
192
+ confidenceScore: cfg.undeclared_gyp_weight,
193
+ message: 'Undeclared binding.gyp — package.json does not advertise native build',
194
+ evidence: ['binding.gyp exists but no gypfile/binary/install-script in package.json'],
195
+ locations: [{ file: 'binding.gyp', line: 1 }],
196
+ });
197
+ aggregatedRisk += cfg.undeclared_gyp_weight;
198
+ }
199
+
200
+ if (findings.length === 0) return [];
201
+
202
+ const overallScore = Math.min(100, Math.max(0, aggregatedRisk));
203
+ let severity;
204
+ if (overallScore >= cfg.flag_threshold) {
205
+ severity = 'critical';
206
+ } else if (overallScore >= cfg.warn_threshold) {
207
+ severity = 'high';
208
+ } else if (overallScore >= 30) {
209
+ severity = 'medium';
210
+ } else {
211
+ severity = 'low';
212
+ }
213
+
214
+ function confidenceLabel(sc) {
215
+ if (sc >= 80) return 'HIGH';
216
+ if (sc >= 50) return 'MEDIUM';
217
+ return 'LOW';
218
+ }
219
+
220
+ const hasShellExec = findings.some((f) => f.evidence?.some((e) => e.includes('shell_exec')));
221
+ const hasCreds = findings.some((f) => f.evidence?.some((e) => e.includes('hardcoded_key')));
222
+ const hasNetwork = findings.some((f) =>
223
+ f.evidence?.some((e) => e.includes('curl_call') || e.includes('http_request'))
224
+ );
225
+ const hasExec = findings.some((f) => f.evidence?.some((e) => e.includes('execve_call')));
226
+
227
+ let recommendation = 'PASS';
228
+ if (hasShellExec || hasCreds || hasExec) {
229
+ recommendation = 'BLOCK - Native addon build contains malicious patterns';
230
+ } else if (hasNetwork) {
231
+ recommendation = 'WARN - Native addon build makes network calls';
232
+ } else if (aggregatedRisk > cfg.warn_threshold) {
233
+ recommendation = 'REVIEW - Suspicious build configuration detected';
234
+ }
235
+
236
+ return [
237
+ {
238
+ detector: 'tier1-build-config-abuse',
239
+ id: 'D14-BUILD-CONFIG-ABUSE',
240
+ severity,
241
+ confidence: confidenceLabel(overallScore),
242
+ confidenceScore: overallScore,
243
+ message: `Build Configuration Abuse detected (aggregated risk: ${aggregatedRisk})`,
244
+ evidence: [
245
+ `total_findings: ${findings.length}`,
246
+ `aggregated_risk: ${aggregatedRisk}`,
247
+ ...findings.map((f) => {
248
+ const loc = f.locations?.[0];
249
+ return `${f.message}${loc ? ' @ ' + (loc.file || '') + (loc.line ? ':' + loc.line : '') : ''}`;
250
+ }),
251
+ ],
252
+ locations: findings.flatMap((f) => f.locations || []),
253
+ recommendation,
254
+ detail: findings.map((f) => ({
255
+ type:
256
+ f.evidence?.find((e) => e.startsWith('pattern:'))?.replace('pattern: ', '') || 'unknown',
257
+ pattern: f.evidence?.find((e) => e.startsWith('pattern:'))?.replace('pattern: ', ''),
258
+ confidence: f.confidenceScore,
259
+ risk: f.confidenceScore,
260
+ location: f.locations?.[0] || null,
261
+ })),
262
+ },
263
+ ];
264
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lateos/npm-scan",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "description": "Production-grade npm supply chain vulnerability scanner. Detects 100% of 3 real May 2026 supply chain campaigns (dependency confusion, obfuscation, impersonation) with 0% false positive rate on top 1,000 npm packages.",
5
5
  "main": "backend/index.js",
6
6
  "bin": {