@archpublicwebsite/eslint-config 1.0.18 → 1.0.19

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.
@@ -11,7 +11,8 @@
11
11
  * │ What is checked │
12
12
  * │ 1. Staged .js/.ts/.vue files — eval, obfuscation, │
13
13
  * │ base64 payloads, net/dns/child_process in │
14
- * │ build-config files, prototype pollution
14
+ * │ build-config files, prototype pollution,
15
+ * │ XSS sinks, hardcoded secrets, SSRF, ReDoS │
15
16
  * │ 2. Newly added npm packages — install scripts │
16
17
  * │ with curl/wget, base64-exec, credential theft │
17
18
  * │ 3. Typosquatting — package names within edit- │
@@ -25,174 +26,53 @@
25
26
 
26
27
  import { existsSync, readFileSync } from 'node:fs'
27
28
  import { extname, join } from 'node:path'
29
+ import { INSTALL_SCRIPT_PATTERNS, SCAN_EXTENSIONS } from './patterns.mjs'
30
+ import { isBlocking, partitionFindings, sortFindings } from './risks.mjs'
28
31
  import {
29
- CONFIG_FILE_PATTERNS,
30
- FILE_PATTERNS,
31
- INSTALL_SCRIPT_PATTERNS,
32
- POPULAR_PACKAGES,
33
- SCAN_EXTENSIONS,
34
- } from './patterns.mjs'
32
+ BOLD,
33
+ DIM,
34
+ GRN,
35
+ R,
36
+ YLW,
37
+ collectObfuscatedLineFindings,
38
+ collectPatternFindings,
39
+ detectTyposquat,
40
+ isConfigFile,
41
+ printFileFinding,
42
+ printScanFooter,
43
+ printScanHeader,
44
+ printSupplyFinding,
45
+ shouldSkip,
46
+ } from './scanner.mjs'
35
47
  import {
36
48
  getRepoRoot,
37
49
  getStagedNameStatus,
38
50
  runSafe,
39
51
  } from '../git-hooks/shared.mjs'
40
52
 
41
- // ─── ANSI colours ──────────────────────────────────────────────────────────────
42
- const R = '\x1b[0m'
43
- const RED = '\x1b[31m'
44
- const YLW = '\x1b[33m'
45
- const CYN = '\x1b[36m'
46
- const GRN = '\x1b[32m'
47
- const BOLD = '\x1b[1m'
48
- const DIM = '\x1b[2m'
49
- const HR = `${DIM}${'─'.repeat(62)}${R}`
50
-
51
- function badge(severity) {
52
- switch (severity) {
53
- case 'critical': return `${RED}${BOLD}[CRITICAL]${R}`
54
- case 'high': return `${RED}[HIGH] ${R}`
55
- case 'medium': return `${YLW}[MEDIUM] ${R}`
56
- default: return `${CYN}[LOW] ${R}`
57
- }
58
- }
59
-
60
- function isBlocking(severity) {
61
- return severity === 'critical' || severity === 'high'
62
- }
63
-
64
- // ─── Helpers ───────────────────────────────────────────────────────────────────
65
-
66
- function isConfigFile(filePath) {
67
- return CONFIG_FILE_PATTERNS.some(p => p.test(filePath))
68
- }
69
-
70
- // Files that intentionally contain dangerous-pattern literals for lint/security
71
- // rule definitions and scanner fixture tests. Scanning them with FILE_PATTERNS
72
- // causes self-referential false positives and blocks commits.
73
- const SELF_REFERENTIAL_SCAN_EXCLUSIONS = [
74
- 'packages/eslint-config/eslint.config.mjs',
75
- 'packages/eslint-config/tools/security/patterns.mjs',
76
- 'packages/eslint-config/tools/security/test-patterns.mjs',
77
- ]
78
-
79
- function shouldSkipSelfReferentialFile(filePath) {
80
- const normalizedPath = filePath.replaceAll('\\', '/')
81
- return SELF_REFERENTIAL_SCAN_EXCLUSIONS.some(excluded => normalizedPath.endsWith(excluded))
82
- }
83
-
84
- function collectPatternFindings(lines, isConfig, filePath) {
85
- const findings = []
86
-
87
- for (const pattern of FILE_PATTERNS) {
88
- if (pattern.configOnly && !isConfig) continue
89
-
90
- for (let i = 0; i < lines.length; i++) {
91
- if (pattern.regex.test(lines[i])) {
92
- findings.push({
93
- file: filePath,
94
- line: i + 1,
95
- lineContent: lines[i].trim().slice(0, 120),
96
- patternId: pattern.id,
97
- severity: pattern.severity,
98
- message: pattern.message,
99
- })
100
- }
101
- }
102
- }
103
-
104
- return findings
105
- }
106
-
107
- function collectObfuscatedLineFindings(lines, filePath) {
108
- const findings = []
109
-
110
- for (let i = 0; i < lines.length; i++) {
111
- const line = lines[i]
112
- if (line.length <= 800) continue
113
-
114
- // Encoded payload lines tend to have unusually high symbol density.
115
- const nonWord = (line.match(/[^a-zA-Z0-9\s.,;:(){}[\]=<>+\-*/'"_]/g) || []).length
116
- const density = nonWord / line.length
117
- if (density <= 0.18) continue
118
-
119
- findings.push({
120
- file: filePath,
121
- line: i + 1,
122
- lineContent: `[${line.length} chars] ${line.trim().slice(0, 80)}…`,
123
- patternId: 'long-obfuscated-line',
124
- severity: 'critical',
125
- message: `Line is ${line.length} chars with high symbol density (${(density * 100).toFixed(0)}%) — likely an embedded payload (dropper pattern)`,
126
- })
127
- }
128
-
129
- return findings
130
- }
53
+ // ─── Read staged file content ──────────────────────────────────────────────────
131
54
 
132
55
  /**
133
56
  * Read the staged (index) version of a file so we scan exactly what will be
134
57
  * committed — not any unsaved working-tree changes.
58
+ * @param {string} filePath
59
+ * @returns {string}
135
60
  */
136
61
  function readStagedContent(filePath) {
137
62
  return runSafe(`git show ":${filePath}"`)
138
63
  }
139
64
 
140
- /**
141
- * Levenshtein distance between two strings.
142
- * Used to detect typosquatted package names.
143
- * @param {string} a
144
- * @param {string} b
145
- * @returns {number}
146
- */
147
- function levenshtein(a, b) {
148
- const dp = Array.from({ length: a.length + 1 }, (_, i) => [i])
149
- for (let j = 0; j <= b.length; j++) dp[0][j] = j
150
- for (let i = 1; i <= a.length; i++) {
151
- for (let j = 1; j <= b.length; j++) {
152
- dp[i][j] = a[i - 1] === b[j - 1]
153
- ? dp[i - 1][j - 1]
154
- : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1])
155
- }
156
- }
157
- return dp[a.length][b.length]
158
- }
159
-
160
- /**
161
- * Returns the popular package name that the given name is likely a typosquat
162
- * of (edit distance === 1), or null if no match.
163
- * @param {string} pkgName
164
- * @returns {string|null}
165
- */
166
- function detectTyposquat(pkgName) {
167
- // Strip npm scope for comparison
168
- const bare = pkgName.startsWith('@') ? (pkgName.split('/')[1] ?? pkgName) : pkgName
169
- if (bare.length < 3) return null
170
-
171
- for (const popular of POPULAR_PACKAGES) {
172
- if (bare === popular) return null // exact match is fine
173
- if (levenshtein(bare.toLowerCase(), popular.toLowerCase()) === 1) return popular
174
- }
175
- return null
176
- }
177
-
178
65
  // ─── Stage-file scanner ────────────────────────────────────────────────────────
179
66
 
180
- /**
181
- * @typedef {{ file: string, line: number, lineContent: string,
182
- * patternId: string, severity: string, message: string }} FileFinding
183
- */
184
-
185
67
  /**
186
68
  * Scan every staged JS/TS/Vue file against FILE_PATTERNS.
187
- * @param {string} _repoRoot (unused here but kept for symmetry)
188
- * @returns {FileFinding[]}
69
+ * @returns {import('./scanner.mjs').FileFinding[]}
189
70
  */
190
- function scanStagedFiles(_repoRoot) {
71
+ function scanStagedFiles() {
191
72
  const staged = getStagedNameStatus()
192
73
  const toScan = staged.filter(({ status, path }) =>
193
74
  ['A', 'M'].includes(status[0]) && SCAN_EXTENSIONS.has(extname(path)),
194
75
  )
195
-
196
76
  if (toScan.length === 0) return []
197
77
 
198
78
  process.stdout.write(`\nScanning ${toScan.length} staged source file(s)...\n`)
@@ -201,10 +81,7 @@ function scanStagedFiles(_repoRoot) {
201
81
  let skipped = 0
202
82
 
203
83
  for (const { path: filePath } of toScan) {
204
- if (shouldSkipSelfReferentialFile(filePath)) {
205
- skipped++
206
- continue
207
- }
84
+ if (shouldSkip(filePath)) { skipped++; continue }
208
85
 
209
86
  const content = readStagedContent(filePath)
210
87
  if (!content) continue
@@ -219,7 +96,7 @@ function scanStagedFiles(_repoRoot) {
219
96
  }
220
97
 
221
98
  if (skipped > 0) {
222
- process.stdout.write(`Skipping ${skipped} self-referential security definition/test file(s).\n`)
99
+ process.stdout.write(`Skipped ${skipped} self-referential security definition file(s).\n`)
223
100
  }
224
101
 
225
102
  return findings
@@ -230,7 +107,7 @@ function scanStagedFiles(_repoRoot) {
230
107
  /**
231
108
  * Compare staged vs committed package.json files to find packages that were
232
109
  * newly added in this commit (across all dependency fields).
233
- * @returns {string[]} Array of package names
110
+ * @returns {string[]}
234
111
  */
235
112
  function getNewlyAddedPackages() {
236
113
  const staged = getStagedNameStatus()
@@ -239,7 +116,6 @@ function getNewlyAddedPackages() {
239
116
  && path.endsWith('package.json')
240
117
  && !path.includes('node_modules'),
241
118
  )
242
-
243
119
  if (pkgFiles.length === 0) return []
244
120
 
245
121
  const depFields = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']
@@ -256,57 +132,38 @@ function getNewlyAddedPackages() {
256
132
  }
257
133
  return all
258
134
  }
259
- catch {
260
- return new Set()
261
- }
135
+ catch { return new Set() }
262
136
  }
263
137
 
264
138
  const added = new Set()
265
-
266
139
  for (const { path: pkgFile } of pkgFiles) {
267
- const newContent = readStagedContent(pkgFile)
268
- const oldContent = runSafe(`git show "HEAD:${pkgFile}"`)
269
- const newDeps = extractDepNames(newContent)
270
- const oldDeps = extractDepNames(oldContent)
140
+ const newDeps = extractDepNames(readStagedContent(pkgFile))
141
+ const oldDeps = extractDepNames(runSafe(`git show "HEAD:${pkgFile}"`))
271
142
  for (const name of newDeps) {
272
143
  if (!oldDeps.has(name)) added.add(name)
273
144
  }
274
145
  }
275
-
276
146
  return [...added]
277
147
  }
278
148
 
279
- /**
280
- * @typedef {{ pkg: string, scriptName?: string, patternId: string,
281
- * severity: string, message: string, lineContent: string }} SupplyFinding
282
- */
283
-
284
149
  /**
285
150
  * Inspect a single package's install scripts in node_modules.
286
151
  * @param {string} pkgName
287
152
  * @param {string} repoRoot
288
- * @returns {SupplyFinding[]}
289
153
  */
290
154
  function checkInstallScripts(pkgName, repoRoot) {
291
155
  const pkgJsonPath = join(repoRoot, 'node_modules', pkgName, 'package.json')
292
156
  if (!existsSync(pkgJsonPath)) return []
293
157
 
294
158
  let pkg
295
- try {
296
- pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8'))
297
- }
298
- catch {
299
- return []
300
- }
159
+ try { pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8')) }
160
+ catch { return [] }
301
161
 
302
162
  const findings = []
303
- const scriptFields = ['preinstall', 'install', 'postinstall']
304
-
305
- for (const scriptName of scriptFields) {
163
+ for (const scriptName of ['preinstall', 'install', 'postinstall']) {
306
164
  const script = pkg.scripts?.[scriptName]
307
165
  if (!script) continue
308
166
 
309
- // Always surface that an install script exists (medium — for awareness)
310
167
  findings.push({
311
168
  pkg: pkgName,
312
169
  scriptName,
@@ -316,7 +173,6 @@ function checkInstallScripts(pkgName, repoRoot) {
316
173
  lineContent: script.slice(0, 120),
317
174
  })
318
175
 
319
- // Check for specific dangerous patterns (high/critical)
320
176
  for (const pattern of INSTALL_SCRIPT_PATTERNS) {
321
177
  if (pattern.regex.test(script)) {
322
178
  findings.push({
@@ -330,15 +186,12 @@ function checkInstallScripts(pkgName, repoRoot) {
330
186
  }
331
187
  }
332
188
  }
333
-
334
189
  return findings
335
190
  }
336
191
 
337
192
  /**
338
- * Run the full supply-chain scan: typosquatting + install-script analysis
339
- * for every newly added package.
193
+ * Run the full supply-chain scan: typosquatting + install-script analysis.
340
194
  * @param {string} repoRoot
341
- * @returns {SupplyFinding[]}
342
195
  */
343
196
  function scanNewDependencies(repoRoot) {
344
197
  const newPkgs = getNewlyAddedPackages()
@@ -346,10 +199,8 @@ function scanNewDependencies(repoRoot) {
346
199
 
347
200
  process.stdout.write(`\nSupply-chain scan: ${newPkgs.length} newly added package(s)...\n`)
348
201
 
349
- const findings = []
350
-
351
- for (const pkgName of newPkgs) {
352
- // 1. Typosquatting
202
+ return newPkgs.flatMap((pkgName) => {
203
+ const findings = []
353
204
  const similar = detectTyposquat(pkgName)
354
205
  if (similar) {
355
206
  findings.push({
@@ -360,49 +211,18 @@ function scanNewDependencies(repoRoot) {
360
211
  lineContent: `"${pkgName}" is 1 edit away from "${similar}"`,
361
212
  })
362
213
  }
363
-
364
- // 2. Install scripts
365
- const scriptFindings = checkInstallScripts(pkgName, repoRoot)
366
- findings.push(...scriptFindings)
367
- }
368
-
369
- return findings
214
+ return [...findings, ...checkInstallScripts(pkgName, repoRoot)]
215
+ })
370
216
  }
371
217
 
372
218
  // ─── Optional: pnpm/npm audit ─────────────────────────────────────────────────
373
219
 
374
- /**
375
- * Run `pnpm audit --audit-level=high` when SECURITY_AUDIT=1 is set.
376
- * Non-fatal: output is printed but does not block the commit so it doesn't
377
- * slow down normal developer workflows.
378
- */
379
220
  function runAudit(repoRoot) {
380
221
  if (process.env.SECURITY_AUDIT !== '1') return
381
222
 
382
223
  process.stdout.write(`\nRunning pnpm audit (SECURITY_AUDIT=1)...\n`)
383
224
  const result = runSafe('pnpm audit --audit-level=high 2>&1')
384
- if (result) {
385
- process.stdout.write(`${DIM}${result}${R}\n`)
386
- }
387
- }
388
-
389
- // ─── Output helpers ────────────────────────────────────────────────────────────
390
-
391
- function printFileFinding(f) {
392
- console.log(`\n ${badge(f.severity)} ${DIM}${f.file}:${f.line}${R}`)
393
- console.log(` ${BOLD}${f.message}${R}`)
394
- console.log(` ${DIM}│${R} ${f.lineContent}`)
395
- }
396
-
397
- function printSupplyFinding(f) {
398
- if (f.patternId === 'install-script-present') {
399
- console.log(` ${DIM}⚑${R} ${BOLD}${f.pkg}${R} — ${f.scriptName}: ${DIM}${f.lineContent}${R}`)
400
- return
401
- }
402
- const scriptLabel = f.scriptName ? ` (${DIM}${f.scriptName}${R})` : ''
403
- console.log(`\n ${badge(f.severity)} ${BOLD}${f.pkg}${R}${scriptLabel}`)
404
- console.log(` ${f.message}`)
405
- console.log(` ${DIM}│${R} ${f.lineContent}`)
225
+ if (result) process.stdout.write(`${DIM}${result}${R}\n`)
406
226
  }
407
227
 
408
228
  // ─── Entry point ───────────────────────────────────────────────────────────────
@@ -418,16 +238,15 @@ function main() {
418
238
 
419
239
  const repoRoot = getRepoRoot()
420
240
 
421
- console.log(`\n${HR}`)
422
- console.log(` ${BOLD}@archipelago/security-scan${R} ${DIM}socket.dev-style pre-commit guard${R}`)
423
- console.log(HR)
241
+ printScanHeader('@archipelago/security-scan', 'socket.dev-style pre-commit guard')
424
242
 
425
- const fileFindings = scanStagedFiles(repoRoot)
243
+ const fileFindings = scanStagedFiles()
426
244
  const supplyFindings = scanNewDependencies(repoRoot)
427
245
 
428
246
  runAudit(repoRoot)
429
247
 
430
- const allFindings = [...fileFindings, ...supplyFindings]
248
+ const allFindings = sortFindings([...fileFindings, ...supplyFindings])
249
+ const { blocking, warnings } = partitionFindings(allFindings)
431
250
 
432
251
  if (fileFindings.length > 0) {
433
252
  console.log(`\n${BOLD}Source-code findings:${R}`)
@@ -439,27 +258,7 @@ function main() {
439
258
  supplyFindings.forEach(printSupplyFinding)
440
259
  }
441
260
 
442
- const blocking = allFindings.filter(f => isBlocking(f.severity))
443
- const warnings = allFindings.filter(f => !isBlocking(f.severity))
444
-
445
- console.log(`\n${HR}`)
446
-
447
- if (blocking.length === 0) {
448
- const warnNote = warnings.length > 0 ? ` ${DIM}(${warnings.length} warning(s) — review recommended)${R}` : ''
449
- console.log(` ${GRN}${BOLD}✔ Security scan passed${R}${warnNote}`)
450
- console.log(HR)
451
- console.log()
452
- return
453
- }
454
-
455
- console.log(
456
- ` ${RED}${BOLD}✖ COMMIT BLOCKED${R} — ${blocking.length} critical/high security issue(s) found`,
457
- )
458
- console.log(` ${DIM}To bypass (emergencies only): SKIP_SECURITY_SCAN=1 git commit …${R}`)
459
- console.log(HR)
460
- console.log()
461
-
462
- process.exit(1)
261
+ printScanFooter(blocking, warnings, 'COMMIT BLOCKED', 'SKIP_SECURITY_SCAN=1 git commit …')
463
262
  }
464
263
 
465
264
  main()
@@ -0,0 +1,233 @@
1
+ /**
2
+ * @archipelago/security — shared scanner utilities
3
+ *
4
+ * Single source of truth for all functions used by both the pre-commit scanner
5
+ * (tools/security/scan.mjs) and the pre-push scanner
6
+ * (tools/git-hooks/pre-push.mjs).
7
+ *
8
+ * Nothing here is an entry-point — every export is consumed by those two
9
+ * callers; do not add main() logic here.
10
+ */
11
+
12
+ import { CONFIG_FILE_PATTERNS, FILE_PATTERNS, POPULAR_PACKAGES } from './patterns.mjs'
13
+
14
+ // ─── ANSI colours ──────────────────────────────────────────────────────────────
15
+
16
+ export const R = '\x1b[0m'
17
+ export const RED = '\x1b[31m'
18
+ export const YLW = '\x1b[33m'
19
+ export const CYN = '\x1b[36m'
20
+ export const GRN = '\x1b[32m'
21
+ export const BOLD = '\x1b[1m'
22
+ export const DIM = '\x1b[2m'
23
+ export const HR = `\x1b[2m${'─'.repeat(62)}\x1b[0m`
24
+
25
+ // ─── Severity badge ────────────────────────────────────────────────────────────
26
+
27
+ /** @param {'critical'|'high'|'medium'|'low'} severity */
28
+ export function badge(severity) {
29
+ switch (severity) {
30
+ case 'critical': return `${RED}${BOLD}[CRITICAL]${R}`
31
+ case 'high': return `${RED}[HIGH] ${R}`
32
+ case 'medium': return `${YLW}[MEDIUM] ${R}`
33
+ default: return `${CYN}[LOW] ${R}`
34
+ }
35
+ }
36
+
37
+ // ─── Self-referential exclusions ───────────────────────────────────────────────
38
+
39
+ /**
40
+ * Files that intentionally contain dangerous-pattern literals for lint/security
41
+ * rule definitions and scanner fixture tests. Scanning them causes self-referential
42
+ * false positives and blocks commits.
43
+ */
44
+ export const SELF_REFERENTIAL_SCAN_EXCLUSIONS = [
45
+ 'packages/eslint-config/eslint.config.mjs',
46
+ 'packages/eslint-config/tools/git-hooks/pre-push.mjs',
47
+ 'packages/eslint-config/tools/security/patterns.mjs',
48
+ 'packages/eslint-config/tools/security/risks.mjs',
49
+ 'packages/eslint-config/tools/security/scan.mjs',
50
+ 'packages/eslint-config/tools/security/scanner.mjs',
51
+ 'packages/eslint-config/tools/setup/install.mjs',
52
+ 'packages/eslint-config/tools/security/test-patterns.mjs',
53
+ ]
54
+
55
+ /** @param {string} filePath */
56
+ export function shouldSkip(filePath) {
57
+ const normalized = filePath.replaceAll('\\', '/')
58
+ return SELF_REFERENTIAL_SCAN_EXCLUSIONS.some(excluded => normalized.endsWith(excluded))
59
+ }
60
+
61
+ // ─── Config-file detection ─────────────────────────────────────────────────────
62
+
63
+ /** @param {string} filePath */
64
+ export function isConfigFile(filePath) {
65
+ return CONFIG_FILE_PATTERNS.some(p => p.test(filePath))
66
+ }
67
+
68
+ // ─── Pattern-based finding collectors ─────────────────────────────────────────
69
+
70
+ /**
71
+ * @typedef {{ file: string, line: number, lineContent: string,
72
+ * patternId: string, severity: string, message: string,
73
+ * category?: string }} FileFinding
74
+ */
75
+
76
+ /**
77
+ * Scan `lines` against FILE_PATTERNS and return findings.
78
+ * @param {string[]} lines
79
+ * @param {boolean} isConfig Whether the file is a build-config file
80
+ * @param {string} filePath
81
+ * @returns {FileFinding[]}
82
+ */
83
+ export function collectPatternFindings(lines, isConfig, filePath) {
84
+ const findings = []
85
+ for (const pattern of FILE_PATTERNS) {
86
+ if (pattern.configOnly && !isConfig) continue
87
+ for (let i = 0; i < lines.length; i++) {
88
+ if (pattern.regex.test(lines[i])) {
89
+ findings.push({
90
+ file: filePath,
91
+ line: i + 1,
92
+ lineContent: lines[i].trim().slice(0, 120),
93
+ patternId: pattern.id,
94
+ severity: pattern.severity,
95
+ message: pattern.message,
96
+ category: pattern.category ?? 'general',
97
+ })
98
+ }
99
+ }
100
+ }
101
+ return findings
102
+ }
103
+
104
+ /**
105
+ * Flag lines that are suspiciously long AND have high symbol density —
106
+ * the hallmark of an embedded base64/shuffled-string payload (dropper pattern).
107
+ * @param {string[]} lines
108
+ * @param {string} filePath
109
+ * @returns {FileFinding[]}
110
+ */
111
+ export function collectObfuscatedLineFindings(lines, filePath) {
112
+ const findings = []
113
+ for (let i = 0; i < lines.length; i++) {
114
+ const line = lines[i]
115
+ if (line.length <= 800) continue
116
+ const nonWord = (line.match(/[^a-zA-Z0-9\s.,;:(){}[\]=<>+\-*/'"_]/g) || []).length
117
+ const density = nonWord / line.length
118
+ if (density <= 0.18) continue
119
+ findings.push({
120
+ file: filePath,
121
+ line: i + 1,
122
+ lineContent: `[${line.length} chars] ${line.trim().slice(0, 80)}…`,
123
+ patternId: 'long-obfuscated-line',
124
+ severity: 'critical',
125
+ message: `Line is ${line.length} chars with high symbol density (${(density * 100).toFixed(0)}%) — likely an embedded payload (dropper pattern)`,
126
+ category: 'obfuscation',
127
+ })
128
+ }
129
+ return findings
130
+ }
131
+
132
+ // ─── Typosquatting ─────────────────────────────────────────────────────────────
133
+
134
+ /**
135
+ * Levenshtein distance between two strings.
136
+ * @param {string} a
137
+ * @param {string} b
138
+ * @returns {number}
139
+ */
140
+ export function levenshtein(a, b) {
141
+ const dp = Array.from({ length: a.length + 1 }, (_, i) => [i])
142
+ for (let j = 0; j <= b.length; j++) dp[0][j] = j
143
+ for (let i = 1; i <= a.length; i++) {
144
+ for (let j = 1; j <= b.length; j++) {
145
+ dp[i][j] = a[i - 1] === b[j - 1]
146
+ ? dp[i - 1][j - 1]
147
+ : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1])
148
+ }
149
+ }
150
+ return dp[a.length][b.length]
151
+ }
152
+
153
+ /**
154
+ * Returns the popular package name that `pkgName` is likely a typosquat of
155
+ * (edit-distance === 1), or null if no match.
156
+ * @param {string} pkgName
157
+ * @returns {string|null}
158
+ */
159
+ export function detectTyposquat(pkgName) {
160
+ const bare = pkgName.startsWith('@') ? (pkgName.split('/')[1] ?? pkgName) : pkgName
161
+ if (bare.length < 3) return null
162
+ for (const popular of POPULAR_PACKAGES) {
163
+ if (bare === popular) return null
164
+ if (levenshtein(bare.toLowerCase(), popular.toLowerCase()) === 1) return popular
165
+ }
166
+ return null
167
+ }
168
+
169
+ // ─── Output helpers ────────────────────────────────────────────────────────────
170
+
171
+ /** @param {FileFinding} f */
172
+ export function printFileFinding(f) {
173
+ console.log(`\n ${badge(f.severity)} ${DIM}${f.file}:${f.line}${R}`)
174
+ console.log(` ${BOLD}${f.message}${R}`)
175
+ console.log(` ${DIM}│${R} ${f.lineContent}`)
176
+ if (f.category && f.category !== 'general') {
177
+ console.log(` ${DIM}category: ${f.category}${R}`)
178
+ }
179
+ }
180
+
181
+ /**
182
+ * @param {{ pkg?: string, file?: string, patternId: string,
183
+ * severity: string, message: string, lineContent: string,
184
+ * scriptName?: string }} f
185
+ */
186
+ export function printSupplyFinding(f) {
187
+ if (f.patternId === 'install-script-present') {
188
+ console.log(` ${DIM}⚑${R} ${BOLD}${f.pkg}${R} — ${f.scriptName}: ${DIM}${f.lineContent}${R}`)
189
+ return
190
+ }
191
+ const scriptLabel = f.scriptName ? ` (${DIM}${f.scriptName}${R})` : ''
192
+ console.log(`\n ${badge(f.severity)} ${BOLD}${f.pkg ?? f.file}${R}${scriptLabel}`)
193
+ console.log(` ${f.message}`)
194
+ console.log(` ${DIM}│${R} ${f.lineContent}`)
195
+ }
196
+
197
+ // ─── Scan result output ────────────────────────────────────────────────────────
198
+
199
+ /**
200
+ * Print the scan header banner.
201
+ * @param {string} title
202
+ * @param {string} subtitle
203
+ */
204
+ export function printScanHeader(title, subtitle) {
205
+ console.log(`\n${HR}`)
206
+ console.log(` ${BOLD}${title}${R} ${DIM}${subtitle}${R}`)
207
+ console.log(HR)
208
+ }
209
+
210
+ /**
211
+ * Print the summary footer and exit(1) if there are blocking findings.
212
+ * @param {import('../security/risks.mjs').FileFinding[]} blocking
213
+ * @param {import('../security/risks.mjs').FileFinding[]} warnings
214
+ * @param {string} blockMessage e.g. 'COMMIT BLOCKED' or 'PUSH BLOCKED'
215
+ * @param {string} bypassHint e.g. 'SKIP_SECURITY_SCAN=1 git commit …'
216
+ */
217
+ export function printScanFooter(blocking, warnings, blockMessage, bypassHint) {
218
+ console.log(`\n${HR}`)
219
+ if (blocking.length === 0) {
220
+ const warnNote = warnings.length > 0
221
+ ? ` ${DIM}(${warnings.length} warning(s) — review recommended)${R}`
222
+ : ''
223
+ console.log(` ${GRN}${BOLD}✔ Security scan passed${R}${warnNote}`)
224
+ console.log(HR)
225
+ console.log()
226
+ return
227
+ }
228
+ console.log(` ${RED}${BOLD}✖ ${blockMessage}${R} — ${blocking.length} critical/high security issue(s) found`)
229
+ console.log(` ${DIM}To bypass (emergencies only): ${bypassHint}${R}`)
230
+ console.log(HR)
231
+ console.log()
232
+ process.exit(1)
233
+ }