ship-safe 4.2.0 → 4.3.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.
@@ -1,274 +1,356 @@
1
- /**
2
- * SupplyChainAudit Agent
3
- * =======================
4
- *
5
- * Comprehensive supply chain security analysis.
6
- * Goes beyond npm audit: checks for dependency confusion,
7
- * typosquatting, malicious install scripts, lockfile integrity,
8
- * EPSS scoring, and KEV flagging.
9
- */
10
-
11
- import fs from 'fs';
12
- import path from 'path';
13
- import { BaseAgent, createFinding } from './base-agent.js';
14
-
15
- // Common packages that are often typosquatted
16
- const POPULAR_PACKAGES = [
17
- 'lodash', 'express', 'react', 'axios', 'moment', 'request',
18
- 'chalk', 'commander', 'debug', 'uuid', 'dotenv', 'cors',
19
- 'body-parser', 'jsonwebtoken', 'bcrypt', 'mongoose', 'sequelize',
20
- 'webpack', 'babel', 'eslint', 'prettier', 'typescript',
21
- 'next', 'nuxt', 'svelte', 'vue', 'angular',
22
- ];
23
-
24
- // Well-known packages that happen to be close to other popular names
25
- // (not typosquats — verified legitimate packages)
26
- const KNOWN_SAFE = new Set([
27
- 'ora', 'got', 'ink', 'yup', 'joi', 'ava', 'tap', 'npm', 'nwb',
28
- 'pug', 'koa', 'hap', 'ejs', 'csv', 'ws', 'pg', 'ms',
29
- ]);
30
-
31
- // Known malicious package name patterns
32
- const SUSPICIOUS_NAME_PATTERNS = [
33
- /^@[^/]+\/[^/]+-[0-9]+$/, // @scope/package-123 (random suffix)
34
- /^[a-z]+-[a-z]+-[a-z]+-[a-z]+$/, // overly-generic multi-word names
35
- ];
36
-
37
- export class SupplyChainAudit extends BaseAgent {
38
- constructor() {
39
- super('SupplyChainAudit', 'Comprehensive supply chain security audit', 'supply-chain');
40
- }
41
-
42
- async analyze(context) {
43
- const { rootPath } = context;
44
- const findings = [];
45
-
46
- // ── 1. Check package.json ─────────────────────────────────────────────────
47
- const pkgPath = path.join(rootPath, 'package.json');
48
- if (fs.existsSync(pkgPath)) {
49
- try {
50
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
51
- const allDeps = {
52
- ...(pkg.dependencies || {}),
53
- ...(pkg.devDependencies || {}),
54
- ...(pkg.optionalDependencies || {}),
55
- };
56
-
57
- // ── Typosquatting detection ───────────────────────────────────────────
58
- for (const depName of Object.keys(allDeps)) {
59
- if (KNOWN_SAFE.has(depName)) continue;
60
- for (const popular of POPULAR_PACKAGES) {
61
- const distance = this.levenshtein(depName, popular);
62
- if (distance > 0 && distance <= 2 && depName !== popular) {
63
- findings.push(createFinding({
64
- file: pkgPath,
65
- line: 0,
66
- severity: 'high',
67
- category: 'supply-chain',
68
- rule: 'TYPOSQUAT_SUSPECT',
69
- title: `Possible Typosquat: "${depName}" (similar to "${popular}")`,
70
- description: `Package "${depName}" is ${distance} character(s) away from popular package "${popular}". This could be a typosquatting attempt.`,
71
- matched: depName,
72
- fix: `Verify this is the intended package. Did you mean "${popular}"?`,
73
- }));
74
- }
75
- }
76
- }
77
-
78
- // ── Deprecated/suspicious version pins ───────────────────────────────
79
- for (const [name, version] of Object.entries(allDeps)) {
80
- if (typeof version === 'string' && version.startsWith('git+')) {
81
- findings.push(createFinding({
82
- file: pkgPath,
83
- line: 0,
84
- severity: 'high',
85
- category: 'supply-chain',
86
- rule: 'GIT_DEPENDENCY',
87
- title: `Git Dependency: ${name}`,
88
- description: `"${name}" is installed from a git URL. Git dependencies bypass registry integrity checks.`,
89
- matched: `${name}: ${version}`,
90
- fix: 'Pin to a specific commit hash or use a published npm package version',
91
- }));
92
- }
93
-
94
- if (typeof version === 'string' && version.startsWith('http')) {
95
- findings.push(createFinding({
96
- file: pkgPath,
97
- line: 0,
98
- severity: 'critical',
99
- category: 'supply-chain',
100
- rule: 'URL_DEPENDENCY',
101
- title: `URL Dependency: ${name}`,
102
- description: `"${name}" is installed from a URL. This bypasses npm registry and integrity checks.`,
103
- matched: `${name}: ${version}`,
104
- fix: 'Publish the package to npm or use a private registry',
105
- }));
106
- }
107
-
108
- if (typeof version === 'string' && version === '*') {
109
- findings.push(createFinding({
110
- file: pkgPath,
111
- line: 0,
112
- severity: 'high',
113
- category: 'supply-chain',
114
- rule: 'WILDCARD_VERSION',
115
- title: `Wildcard Version: ${name}`,
116
- description: `"${name}" uses "*" version which accepts any version including malicious updates.`,
117
- matched: `${name}: *`,
118
- fix: 'Pin to a specific version or use a caret range: "^x.y.z"',
119
- }));
120
- }
121
- }
122
-
123
- // ── Install scripts ──────────────────────────────────────────────────
124
- if (pkg.scripts) {
125
- const dangerousScripts = ['preinstall', 'postinstall', 'preuninstall', 'postuninstall'];
126
- for (const script of dangerousScripts) {
127
- if (pkg.scripts[script]) {
128
- const cmd = pkg.scripts[script];
129
- const suspicious = /curl|wget|bash|sh\s|powershell|eval|base64|nc\s|ncat/i.test(cmd);
130
- if (suspicious) {
131
- findings.push(createFinding({
132
- file: pkgPath,
133
- line: 0,
134
- severity: 'critical',
135
- category: 'supply-chain',
136
- rule: 'SUSPICIOUS_INSTALL_SCRIPT',
137
- title: `Suspicious ${script} Script`,
138
- description: `The ${script} script contains potentially dangerous commands: ${cmd.slice(0, 100)}`,
139
- matched: cmd,
140
- fix: 'Review and remove suspicious install scripts',
141
- }));
142
- }
143
- }
144
- }
145
- }
146
-
147
- } catch { /* skip parse errors */ }
148
- }
149
-
150
- // ── 2. Check lockfile integrity ───────────────────────────────────────────
151
- const lockFiles = [
152
- { file: 'package-lock.json', manager: 'npm' },
153
- { file: 'yarn.lock', manager: 'yarn' },
154
- { file: 'pnpm-lock.yaml', manager: 'pnpm' },
155
- { file: 'bun.lockb', manager: 'bun' },
156
- ];
157
-
158
- const hasPackageJson = fs.existsSync(pkgPath);
159
- let hasLockfile = false;
160
-
161
- for (const { file, manager } of lockFiles) {
162
- if (fs.existsSync(path.join(rootPath, file))) {
163
- hasLockfile = true;
164
- }
165
- }
166
-
167
- if (hasPackageJson && !hasLockfile) {
168
- findings.push(createFinding({
169
- file: pkgPath,
170
- line: 0,
171
- severity: 'high',
172
- category: 'supply-chain',
173
- rule: 'MISSING_LOCKFILE',
174
- title: 'No Lock File Found',
175
- description: 'No package-lock.json, yarn.lock, or pnpm-lock.yaml found. Without a lockfile, installs are non-deterministic and vulnerable to dependency confusion.',
176
- matched: 'package.json without lockfile',
177
- fix: 'Run npm install, yarn install, or pnpm install to generate a lockfile, then commit it',
178
- }));
179
- }
180
-
181
- // ── 3. Check .npmrc for security settings ─────────────────────────────────
182
- const npmrcPath = path.join(rootPath, '.npmrc');
183
- if (fs.existsSync(npmrcPath)) {
184
- const content = this.readFile(npmrcPath) || '';
185
- if (content.includes('ignore-scripts=true')) {
186
- // Good — scripts are disabled
187
- }
188
- if (content.includes('registry=') && !content.includes('registry=https://registry.npmjs.org')) {
189
- findings.push(createFinding({
190
- file: npmrcPath,
191
- line: 0,
192
- severity: 'medium',
193
- category: 'supply-chain',
194
- rule: 'CUSTOM_REGISTRY',
195
- title: 'Custom NPM Registry Configured',
196
- description: 'A custom npm registry is configured. Verify it is trusted and uses HTTPS.',
197
- matched: content.match(/registry=.*/)?.[0] || '',
198
- confidence: 'medium',
199
- fix: 'Verify the registry URL is trusted and uses HTTPS',
200
- }));
201
- }
202
- }
203
-
204
- // ── 4. Check Python requirements ──────────────────────────────────────────
205
- const reqPath = path.join(rootPath, 'requirements.txt');
206
- if (fs.existsSync(reqPath)) {
207
- const content = this.readFile(reqPath) || '';
208
- const lines = content.split('\n');
209
- for (let i = 0; i < lines.length; i++) {
210
- const line = lines[i].trim();
211
- if (!line || line.startsWith('#')) continue;
212
-
213
- // Unpinned versions
214
- if (!line.includes('==') && !line.includes('>=') && !line.includes('~=') && !line.includes('@')) {
215
- findings.push(createFinding({
216
- file: reqPath,
217
- line: i + 1,
218
- severity: 'medium',
219
- category: 'supply-chain',
220
- rule: 'UNPINNED_PYTHON_DEP',
221
- title: `Unpinned Python Dependency: ${line}`,
222
- description: 'Python dependency without version pin. Pin to a specific version for reproducible builds.',
223
- matched: line,
224
- fix: `Pin version: ${line}==x.y.z`,
225
- }));
226
- }
227
-
228
- // Git/URL dependencies
229
- if (line.includes('git+') || line.startsWith('http')) {
230
- findings.push(createFinding({
231
- file: reqPath,
232
- line: i + 1,
233
- severity: 'high',
234
- category: 'supply-chain',
235
- rule: 'GIT_PYTHON_DEP',
236
- title: `Git/URL Python Dependency: ${line.slice(0, 60)}`,
237
- description: 'Installing from git/URL bypasses PyPI integrity checks.',
238
- matched: line,
239
- fix: 'Publish to PyPI or pin to a specific commit hash',
240
- }));
241
- }
242
- }
243
- }
244
-
245
- return findings;
246
- }
247
-
248
- /**
249
- * Simple Levenshtein distance for typosquatting detection.
250
- */
251
- levenshtein(a, b) {
252
- if (a.length === 0) return b.length;
253
- if (b.length === 0) return a.length;
254
-
255
- const matrix = [];
256
- for (let i = 0; i <= b.length; i++) matrix[i] = [i];
257
- for (let j = 0; j <= a.length; j++) matrix[0][j] = j;
258
-
259
- for (let i = 1; i <= b.length; i++) {
260
- for (let j = 1; j <= a.length; j++) {
261
- const cost = b[i - 1] === a[j - 1] ? 0 : 1;
262
- matrix[i][j] = Math.min(
263
- matrix[i - 1][j] + 1,
264
- matrix[i][j - 1] + 1,
265
- matrix[i - 1][j - 1] + cost
266
- );
267
- }
268
- }
269
-
270
- return matrix[b.length][a.length];
271
- }
272
- }
273
-
274
- export default SupplyChainAudit;
1
+ /**
2
+ * SupplyChainAudit Agent
3
+ * =======================
4
+ *
5
+ * Comprehensive supply chain security analysis.
6
+ * Goes beyond npm audit: checks for dependency confusion,
7
+ * typosquatting, malicious install scripts, lockfile integrity,
8
+ * EPSS scoring, and KEV flagging.
9
+ */
10
+
11
+ import fs from 'fs';
12
+ import path from 'path';
13
+ import { BaseAgent, createFinding } from './base-agent.js';
14
+
15
+ // Common packages that are often typosquatted
16
+ const POPULAR_PACKAGES = [
17
+ 'lodash', 'express', 'react', 'axios', 'moment', 'request',
18
+ 'chalk', 'commander', 'debug', 'uuid', 'dotenv', 'cors',
19
+ 'body-parser', 'jsonwebtoken', 'bcrypt', 'mongoose', 'sequelize',
20
+ 'webpack', 'babel', 'eslint', 'prettier', 'typescript',
21
+ 'next', 'nuxt', 'svelte', 'vue', 'angular',
22
+ ];
23
+
24
+ // Well-known packages that happen to be close to other popular names
25
+ // (not typosquats — verified legitimate packages)
26
+ const KNOWN_SAFE = new Set([
27
+ 'ora', 'got', 'ink', 'yup', 'joi', 'ava', 'tap', 'npm', 'nwb',
28
+ 'pug', 'koa', 'hap', 'ejs', 'csv', 'ws', 'pg', 'ms',
29
+ ]);
30
+
31
+ // Known malicious package name patterns
32
+ const SUSPICIOUS_NAME_PATTERNS = [
33
+ /^@[^/]+\/[^/]+-[0-9]+$/, // @scope/package-123 (random suffix)
34
+ /^[a-z]+-[a-z]+-[a-z]+-[a-z]+$/, // overly-generic multi-word names
35
+ ];
36
+
37
+ export class SupplyChainAudit extends BaseAgent {
38
+ constructor() {
39
+ super('SupplyChainAudit', 'Comprehensive supply chain security audit', 'supply-chain');
40
+ }
41
+
42
+ async analyze(context) {
43
+ const { rootPath } = context;
44
+ const findings = [];
45
+
46
+ // ── 1. Check package.json ─────────────────────────────────────────────────
47
+ const pkgPath = path.join(rootPath, 'package.json');
48
+ if (fs.existsSync(pkgPath)) {
49
+ try {
50
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
51
+ const allDeps = {
52
+ ...(pkg.dependencies || {}),
53
+ ...(pkg.devDependencies || {}),
54
+ ...(pkg.optionalDependencies || {}),
55
+ };
56
+
57
+ // ── Typosquatting detection ───────────────────────────────────────────
58
+ for (const depName of Object.keys(allDeps)) {
59
+ if (KNOWN_SAFE.has(depName)) continue;
60
+ for (const popular of POPULAR_PACKAGES) {
61
+ const distance = this.levenshtein(depName, popular);
62
+ if (distance > 0 && distance <= 2 && depName !== popular) {
63
+ findings.push(createFinding({
64
+ file: pkgPath,
65
+ line: 0,
66
+ severity: 'high',
67
+ category: 'supply-chain',
68
+ rule: 'TYPOSQUAT_SUSPECT',
69
+ title: `Possible Typosquat: "${depName}" (similar to "${popular}")`,
70
+ description: `Package "${depName}" is ${distance} character(s) away from popular package "${popular}". This could be a typosquatting attempt.`,
71
+ matched: depName,
72
+ fix: `Verify this is the intended package. Did you mean "${popular}"?`,
73
+ }));
74
+ }
75
+ }
76
+ }
77
+
78
+ // ── Deprecated/suspicious version pins ───────────────────────────────
79
+ for (const [name, version] of Object.entries(allDeps)) {
80
+ if (typeof version === 'string' && version.startsWith('git+')) {
81
+ findings.push(createFinding({
82
+ file: pkgPath,
83
+ line: 0,
84
+ severity: 'high',
85
+ category: 'supply-chain',
86
+ rule: 'GIT_DEPENDENCY',
87
+ title: `Git Dependency: ${name}`,
88
+ description: `"${name}" is installed from a git URL. Git dependencies bypass registry integrity checks.`,
89
+ matched: `${name}: ${version}`,
90
+ fix: 'Pin to a specific commit hash or use a published npm package version',
91
+ }));
92
+ }
93
+
94
+ if (typeof version === 'string' && version.startsWith('http')) {
95
+ findings.push(createFinding({
96
+ file: pkgPath,
97
+ line: 0,
98
+ severity: 'critical',
99
+ category: 'supply-chain',
100
+ rule: 'URL_DEPENDENCY',
101
+ title: `URL Dependency: ${name}`,
102
+ description: `"${name}" is installed from a URL. This bypasses npm registry and integrity checks.`,
103
+ matched: `${name}: ${version}`,
104
+ fix: 'Publish the package to npm or use a private registry',
105
+ }));
106
+ }
107
+
108
+ if (typeof version === 'string' && version === '*') {
109
+ findings.push(createFinding({
110
+ file: pkgPath,
111
+ line: 0,
112
+ severity: 'high',
113
+ category: 'supply-chain',
114
+ rule: 'WILDCARD_VERSION',
115
+ title: `Wildcard Version: ${name}`,
116
+ description: `"${name}" uses "*" version which accepts any version including malicious updates.`,
117
+ matched: `${name}: *`,
118
+ fix: 'Pin to a specific version or use a caret range: "^x.y.z"',
119
+ }));
120
+ }
121
+ }
122
+
123
+ // ── Install scripts ──────────────────────────────────────────────────
124
+ if (pkg.scripts) {
125
+ const dangerousScripts = ['preinstall', 'postinstall', 'preuninstall', 'postuninstall'];
126
+ for (const script of dangerousScripts) {
127
+ if (pkg.scripts[script]) {
128
+ const cmd = pkg.scripts[script];
129
+ const suspicious = /curl|wget|bash|sh\s|powershell|eval|base64|nc\s|ncat/i.test(cmd);
130
+ if (suspicious) {
131
+ findings.push(createFinding({
132
+ file: pkgPath,
133
+ line: 0,
134
+ severity: 'critical',
135
+ category: 'supply-chain',
136
+ rule: 'SUSPICIOUS_INSTALL_SCRIPT',
137
+ title: `Suspicious ${script} Script`,
138
+ description: `The ${script} script contains potentially dangerous commands: ${cmd.slice(0, 100)}`,
139
+ matched: cmd,
140
+ fix: 'Review and remove suspicious install scripts',
141
+ }));
142
+ }
143
+ }
144
+ }
145
+ }
146
+
147
+ } catch { /* skip parse errors */ }
148
+ }
149
+
150
+ // ── 2. Dependency confusion detection ─────────────────────────────────────
151
+ if (fs.existsSync(pkgPath)) {
152
+ try {
153
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
154
+ const allDeps = {
155
+ ...(pkg.dependencies || {}),
156
+ ...(pkg.devDependencies || {}),
157
+ };
158
+
159
+ // Check for scoped packages without registry pinning
160
+ const scopedPkgs = Object.keys(allDeps).filter(n => n.startsWith('@'));
161
+ if (scopedPkgs.length > 0) {
162
+ const npmrcPath = path.join(rootPath, '.npmrc');
163
+ const yarnrcPath = path.join(rootPath, '.yarnrc');
164
+ const yarnrcYmlPath = path.join(rootPath, '.yarnrc.yml');
165
+ const hasRegistryConfig = [npmrcPath, yarnrcPath, yarnrcYmlPath].some(p => {
166
+ if (!fs.existsSync(p)) return false;
167
+ const content = this.readFile(p) || '';
168
+ // Check if any scope is pinned to a registry
169
+ return /@[^:]+:registry/i.test(content) || /npmRegistryServer/i.test(content);
170
+ });
171
+
172
+ // Extract unique scopes
173
+ const scopes = [...new Set(scopedPkgs.map(n => n.split('/')[0]))];
174
+ // Check if this looks like an internal scope (not well-known public ones)
175
+ const publicScopes = new Set([
176
+ '@types', '@babel', '@eslint', '@jest', '@testing-library',
177
+ '@react-native', '@angular', '@vue', '@nuxt', '@next',
178
+ '@emotion', '@mui', '@radix-ui', '@tanstack', '@trpc',
179
+ '@prisma', '@supabase', '@aws-sdk', '@azure', '@google-cloud',
180
+ '@octokit', '@sentry', '@stripe', '@anthropic-ai', '@openai',
181
+ ]);
182
+ const internalScopes = scopes.filter(s => !publicScopes.has(s));
183
+
184
+ if (internalScopes.length > 0 && !hasRegistryConfig) {
185
+ findings.push(createFinding({
186
+ file: pkgPath,
187
+ line: 0,
188
+ severity: 'high',
189
+ category: 'supply-chain',
190
+ rule: 'DEPCONF_NO_SCOPE_REGISTRY',
191
+ title: `Scoped Packages Without Registry Pin: ${internalScopes.join(', ')}`,
192
+ description: `Scoped packages (${internalScopes.join(', ')}) found without a .npmrc pinning the scope to a private registry. An attacker could claim the scope on the public npm registry.`,
193
+ matched: internalScopes.join(', '),
194
+ confidence: 'medium',
195
+ fix: 'Add to .npmrc: @yourscope:registry=https://your-private-registry.com/',
196
+ }));
197
+ }
198
+ }
199
+
200
+ // Check for suspicious install scripts in dependencies
201
+ const nodeModulesPath = path.join(rootPath, 'node_modules');
202
+ if (fs.existsSync(nodeModulesPath)) {
203
+ for (const depName of Object.keys(allDeps).slice(0, 50)) {
204
+ const depPkgPath = path.join(nodeModulesPath, depName, 'package.json');
205
+ if (!fs.existsSync(depPkgPath)) continue;
206
+ try {
207
+ const depPkg = JSON.parse(fs.readFileSync(depPkgPath, 'utf-8'));
208
+ const scripts = depPkg.scripts || {};
209
+ for (const hook of ['preinstall', 'install', 'postinstall']) {
210
+ const cmd = scripts[hook];
211
+ if (!cmd) continue;
212
+ if (/(?:curl|wget|powershell|base64\s|eval\s|nc\s|ncat|\.sh\b)/i.test(cmd)) {
213
+ findings.push(createFinding({
214
+ file: depPkgPath,
215
+ line: 0,
216
+ severity: 'critical',
217
+ category: 'supply-chain',
218
+ rule: 'DEPCONF_SUSPICIOUS_INSTALL_SCRIPT',
219
+ title: `Suspicious ${hook} in ${depName}`,
220
+ description: `Dependency "${depName}" has a suspicious ${hook} script: ${cmd.slice(0, 120)}`,
221
+ matched: cmd.slice(0, 200),
222
+ fix: 'Review the script. If untrusted, remove the dependency or use npm with --ignore-scripts',
223
+ }));
224
+ }
225
+ }
226
+ } catch { /* skip */ }
227
+ }
228
+ }
229
+ } catch { /* skip */ }
230
+ }
231
+
232
+ // ── 3. Check lockfile integrity ── ───────────────────────────────────────────
233
+ const lockFiles = [
234
+ { file: 'package-lock.json', manager: 'npm' },
235
+ { file: 'yarn.lock', manager: 'yarn' },
236
+ { file: 'pnpm-lock.yaml', manager: 'pnpm' },
237
+ { file: 'bun.lockb', manager: 'bun' },
238
+ ];
239
+
240
+ const hasPackageJson = fs.existsSync(pkgPath);
241
+ let hasLockfile = false;
242
+
243
+ for (const { file, manager } of lockFiles) {
244
+ if (fs.existsSync(path.join(rootPath, file))) {
245
+ hasLockfile = true;
246
+ }
247
+ }
248
+
249
+ if (hasPackageJson && !hasLockfile) {
250
+ findings.push(createFinding({
251
+ file: pkgPath,
252
+ line: 0,
253
+ severity: 'high',
254
+ category: 'supply-chain',
255
+ rule: 'MISSING_LOCKFILE',
256
+ title: 'No Lock File Found',
257
+ description: 'No package-lock.json, yarn.lock, or pnpm-lock.yaml found. Without a lockfile, installs are non-deterministic and vulnerable to dependency confusion.',
258
+ matched: 'package.json without lockfile',
259
+ fix: 'Run npm install, yarn install, or pnpm install to generate a lockfile, then commit it',
260
+ }));
261
+ }
262
+
263
+ // ── 4. Check .npmrc for security settings ─────────────────────────────────
264
+ const npmrcPath = path.join(rootPath, '.npmrc');
265
+ if (fs.existsSync(npmrcPath)) {
266
+ const content = this.readFile(npmrcPath) || '';
267
+ if (content.includes('ignore-scripts=true')) {
268
+ // Good — scripts are disabled
269
+ }
270
+ if (content.includes('registry=') && !content.includes('registry=https://registry.npmjs.org')) {
271
+ findings.push(createFinding({
272
+ file: npmrcPath,
273
+ line: 0,
274
+ severity: 'medium',
275
+ category: 'supply-chain',
276
+ rule: 'CUSTOM_REGISTRY',
277
+ title: 'Custom NPM Registry Configured',
278
+ description: 'A custom npm registry is configured. Verify it is trusted and uses HTTPS.',
279
+ matched: content.match(/registry=.*/)?.[0] || '',
280
+ confidence: 'medium',
281
+ fix: 'Verify the registry URL is trusted and uses HTTPS',
282
+ }));
283
+ }
284
+ }
285
+
286
+ // ── 5. Check Python requirements ──────────────────────────────────────────
287
+ const reqPath = path.join(rootPath, 'requirements.txt');
288
+ if (fs.existsSync(reqPath)) {
289
+ const content = this.readFile(reqPath) || '';
290
+ const lines = content.split('\n');
291
+ for (let i = 0; i < lines.length; i++) {
292
+ const line = lines[i].trim();
293
+ if (!line || line.startsWith('#')) continue;
294
+
295
+ // Unpinned versions
296
+ if (!line.includes('==') && !line.includes('>=') && !line.includes('~=') && !line.includes('@')) {
297
+ findings.push(createFinding({
298
+ file: reqPath,
299
+ line: i + 1,
300
+ severity: 'medium',
301
+ category: 'supply-chain',
302
+ rule: 'UNPINNED_PYTHON_DEP',
303
+ title: `Unpinned Python Dependency: ${line}`,
304
+ description: 'Python dependency without version pin. Pin to a specific version for reproducible builds.',
305
+ matched: line,
306
+ fix: `Pin version: ${line}==x.y.z`,
307
+ }));
308
+ }
309
+
310
+ // Git/URL dependencies
311
+ if (line.includes('git+') || line.startsWith('http')) {
312
+ findings.push(createFinding({
313
+ file: reqPath,
314
+ line: i + 1,
315
+ severity: 'high',
316
+ category: 'supply-chain',
317
+ rule: 'GIT_PYTHON_DEP',
318
+ title: `Git/URL Python Dependency: ${line.slice(0, 60)}`,
319
+ description: 'Installing from git/URL bypasses PyPI integrity checks.',
320
+ matched: line,
321
+ fix: 'Publish to PyPI or pin to a specific commit hash',
322
+ }));
323
+ }
324
+ }
325
+ }
326
+
327
+ return findings;
328
+ }
329
+
330
+ /**
331
+ * Simple Levenshtein distance for typosquatting detection.
332
+ */
333
+ levenshtein(a, b) {
334
+ if (a.length === 0) return b.length;
335
+ if (b.length === 0) return a.length;
336
+
337
+ const matrix = [];
338
+ for (let i = 0; i <= b.length; i++) matrix[i] = [i];
339
+ for (let j = 0; j <= a.length; j++) matrix[0][j] = j;
340
+
341
+ for (let i = 1; i <= b.length; i++) {
342
+ for (let j = 1; j <= a.length; j++) {
343
+ const cost = b[i - 1] === a[j - 1] ? 0 : 1;
344
+ matrix[i][j] = Math.min(
345
+ matrix[i - 1][j] + 1,
346
+ matrix[i][j - 1] + 1,
347
+ matrix[i - 1][j - 1] + cost
348
+ );
349
+ }
350
+ }
351
+
352
+ return matrix[b.length][a.length];
353
+ }
354
+ }
355
+
356
+ export default SupplyChainAudit;