@archpublicwebsite/eslint-config 1.0.13 → 1.0.16

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.
@@ -0,0 +1,465 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @archipelago/security-scan
4
+ *
5
+ * Pre-commit scanner inspired by socket.dev.
6
+ * Detects malicious code in staged files and supply-chain risks in
7
+ * newly added packages — blocking zero-day attacks before they reach
8
+ * CI or production.
9
+ *
10
+ * ┌─────────────────────────────────────────────────────┐
11
+ * │ What is checked │
12
+ * │ 1. Staged .js/.ts/.vue files — eval, obfuscation, │
13
+ * │ base64 payloads, net/dns/child_process in │
14
+ * │ build-config files, prototype pollution │
15
+ * │ 2. Newly added npm packages — install scripts │
16
+ * │ with curl/wget, base64-exec, credential theft │
17
+ * │ 3. Typosquatting — package names within edit- │
18
+ * │ distance 1 of popular npm packages │
19
+ * └─────────────────────────────────────────────────────┘
20
+ *
21
+ * Environment flags:
22
+ * SKIP_SECURITY_SCAN=1 Bypass all checks (logged loudly to stderr)
23
+ * SECURITY_AUDIT=1 Also run `pnpm audit` and surface known CVEs
24
+ */
25
+
26
+ import { existsSync, readFileSync } from 'node:fs'
27
+ import { extname, join } from 'node:path'
28
+ import {
29
+ CONFIG_FILE_PATTERNS,
30
+ FILE_PATTERNS,
31
+ INSTALL_SCRIPT_PATTERNS,
32
+ POPULAR_PACKAGES,
33
+ SCAN_EXTENSIONS,
34
+ } from './patterns.mjs'
35
+ import {
36
+ getRepoRoot,
37
+ getStagedNameStatus,
38
+ runSafe,
39
+ } from '../git-hooks/shared.mjs'
40
+
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
+ }
131
+
132
+ /**
133
+ * Read the staged (index) version of a file so we scan exactly what will be
134
+ * committed — not any unsaved working-tree changes.
135
+ */
136
+ function readStagedContent(filePath) {
137
+ return runSafe(`git show ":${filePath}"`)
138
+ }
139
+
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
+ // ─── Stage-file scanner ────────────────────────────────────────────────────────
179
+
180
+ /**
181
+ * @typedef {{ file: string, line: number, lineContent: string,
182
+ * patternId: string, severity: string, message: string }} FileFinding
183
+ */
184
+
185
+ /**
186
+ * Scan every staged JS/TS/Vue file against FILE_PATTERNS.
187
+ * @param {string} _repoRoot (unused here but kept for symmetry)
188
+ * @returns {FileFinding[]}
189
+ */
190
+ function scanStagedFiles(_repoRoot) {
191
+ const staged = getStagedNameStatus()
192
+ const toScan = staged.filter(({ status, path }) =>
193
+ ['A', 'M'].includes(status[0]) && SCAN_EXTENSIONS.has(extname(path)),
194
+ )
195
+
196
+ if (toScan.length === 0) return []
197
+
198
+ process.stdout.write(`\nScanning ${toScan.length} staged source file(s)...\n`)
199
+
200
+ const findings = []
201
+ let skipped = 0
202
+
203
+ for (const { path: filePath } of toScan) {
204
+ if (shouldSkipSelfReferentialFile(filePath)) {
205
+ skipped++
206
+ continue
207
+ }
208
+
209
+ const content = readStagedContent(filePath)
210
+ if (!content) continue
211
+
212
+ const lines = content.split('\n')
213
+ const isConfig = isConfigFile(filePath)
214
+
215
+ findings.push(
216
+ ...collectPatternFindings(lines, isConfig, filePath),
217
+ ...collectObfuscatedLineFindings(lines, filePath),
218
+ )
219
+ }
220
+
221
+ if (skipped > 0) {
222
+ process.stdout.write(`Skipping ${skipped} self-referential security definition/test file(s).\n`)
223
+ }
224
+
225
+ return findings
226
+ }
227
+
228
+ // ─── Supply-chain scanner ──────────────────────────────────────────────────────
229
+
230
+ /**
231
+ * Compare staged vs committed package.json files to find packages that were
232
+ * newly added in this commit (across all dependency fields).
233
+ * @returns {string[]} Array of package names
234
+ */
235
+ function getNewlyAddedPackages() {
236
+ const staged = getStagedNameStatus()
237
+ const pkgFiles = staged.filter(({ status, path }) =>
238
+ ['A', 'M'].includes(status[0])
239
+ && path.endsWith('package.json')
240
+ && !path.includes('node_modules'),
241
+ )
242
+
243
+ if (pkgFiles.length === 0) return []
244
+
245
+ const depFields = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']
246
+
247
+ function extractDepNames(jsonContent) {
248
+ if (!jsonContent) return new Set()
249
+ try {
250
+ const pkg = JSON.parse(jsonContent)
251
+ const all = new Set()
252
+ for (const field of depFields) {
253
+ if (pkg[field] && typeof pkg[field] === 'object') {
254
+ for (const name of Object.keys(pkg[field])) all.add(name)
255
+ }
256
+ }
257
+ return all
258
+ }
259
+ catch {
260
+ return new Set()
261
+ }
262
+ }
263
+
264
+ const added = new Set()
265
+
266
+ 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)
271
+ for (const name of newDeps) {
272
+ if (!oldDeps.has(name)) added.add(name)
273
+ }
274
+ }
275
+
276
+ return [...added]
277
+ }
278
+
279
+ /**
280
+ * @typedef {{ pkg: string, scriptName?: string, patternId: string,
281
+ * severity: string, message: string, lineContent: string }} SupplyFinding
282
+ */
283
+
284
+ /**
285
+ * Inspect a single package's install scripts in node_modules.
286
+ * @param {string} pkgName
287
+ * @param {string} repoRoot
288
+ * @returns {SupplyFinding[]}
289
+ */
290
+ function checkInstallScripts(pkgName, repoRoot) {
291
+ const pkgJsonPath = join(repoRoot, 'node_modules', pkgName, 'package.json')
292
+ if (!existsSync(pkgJsonPath)) return []
293
+
294
+ let pkg
295
+ try {
296
+ pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8'))
297
+ }
298
+ catch {
299
+ return []
300
+ }
301
+
302
+ const findings = []
303
+ const scriptFields = ['preinstall', 'install', 'postinstall']
304
+
305
+ for (const scriptName of scriptFields) {
306
+ const script = pkg.scripts?.[scriptName]
307
+ if (!script) continue
308
+
309
+ // Always surface that an install script exists (medium — for awareness)
310
+ findings.push({
311
+ pkg: pkgName,
312
+ scriptName,
313
+ patternId: 'install-script-present',
314
+ severity: 'medium',
315
+ message: `Package has a ${scriptName} script — verify it is legitimate`,
316
+ lineContent: script.slice(0, 120),
317
+ })
318
+
319
+ // Check for specific dangerous patterns (high/critical)
320
+ for (const pattern of INSTALL_SCRIPT_PATTERNS) {
321
+ if (pattern.regex.test(script)) {
322
+ findings.push({
323
+ pkg: pkgName,
324
+ scriptName,
325
+ patternId: pattern.id,
326
+ severity: pattern.severity,
327
+ message: pattern.message,
328
+ lineContent: script.slice(0, 120),
329
+ })
330
+ }
331
+ }
332
+ }
333
+
334
+ return findings
335
+ }
336
+
337
+ /**
338
+ * Run the full supply-chain scan: typosquatting + install-script analysis
339
+ * for every newly added package.
340
+ * @param {string} repoRoot
341
+ * @returns {SupplyFinding[]}
342
+ */
343
+ function scanNewDependencies(repoRoot) {
344
+ const newPkgs = getNewlyAddedPackages()
345
+ if (newPkgs.length === 0) return []
346
+
347
+ process.stdout.write(`\nSupply-chain scan: ${newPkgs.length} newly added package(s)...\n`)
348
+
349
+ const findings = []
350
+
351
+ for (const pkgName of newPkgs) {
352
+ // 1. Typosquatting
353
+ const similar = detectTyposquat(pkgName)
354
+ if (similar) {
355
+ findings.push({
356
+ pkg: pkgName,
357
+ patternId: 'typosquat',
358
+ severity: 'high',
359
+ message: `Possible typosquat of "${similar}" — verify the package name is correct`,
360
+ lineContent: `"${pkgName}" is 1 edit away from "${similar}"`,
361
+ })
362
+ }
363
+
364
+ // 2. Install scripts
365
+ const scriptFindings = checkInstallScripts(pkgName, repoRoot)
366
+ findings.push(...scriptFindings)
367
+ }
368
+
369
+ return findings
370
+ }
371
+
372
+ // ─── Optional: pnpm/npm audit ─────────────────────────────────────────────────
373
+
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
+ function runAudit(repoRoot) {
380
+ if (process.env.SECURITY_AUDIT !== '1') return
381
+
382
+ process.stdout.write(`\nRunning pnpm audit (SECURITY_AUDIT=1)...\n`)
383
+ 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}`)
406
+ }
407
+
408
+ // ─── Entry point ───────────────────────────────────────────────────────────────
409
+
410
+ function main() {
411
+ if (process.env.SKIP_SECURITY_SCAN === '1') {
412
+ process.stderr.write(
413
+ `\n${YLW}${BOLD}⚠ SKIP_SECURITY_SCAN=1 — all security checks bypassed.${R}\n`
414
+ + `${YLW} This bypass is intentional and has been logged.${R}\n\n`,
415
+ )
416
+ return
417
+ }
418
+
419
+ const repoRoot = getRepoRoot()
420
+
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)
424
+
425
+ const fileFindings = scanStagedFiles(repoRoot)
426
+ const supplyFindings = scanNewDependencies(repoRoot)
427
+
428
+ runAudit(repoRoot)
429
+
430
+ const allFindings = [...fileFindings, ...supplyFindings]
431
+
432
+ if (fileFindings.length > 0) {
433
+ console.log(`\n${BOLD}Source-code findings:${R}`)
434
+ fileFindings.forEach(printFileFinding)
435
+ }
436
+
437
+ if (supplyFindings.length > 0) {
438
+ console.log(`\n${BOLD}Supply-chain findings:${R}`)
439
+ supplyFindings.forEach(printSupplyFinding)
440
+ }
441
+
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)
463
+ }
464
+
465
+ main()
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Pattern-detection self-test
4
+ * Run: node packages/eslint-config/tools/security/test-patterns.mjs
5
+ *
6
+ * Validates that every attack vector in the wild (including the dropper from
7
+ * the supply-chain report) is caught by FILE_PATTERNS.
8
+ */
9
+ import { FILE_PATTERNS } from './patterns.mjs'
10
+
11
+ const RED = '\x1b[31m'
12
+ const GRN = '\x1b[32m'
13
+ const YLW = '\x1b[33m'
14
+ const BOLD = '\x1b[1m'
15
+ const R = '\x1b[0m'
16
+
17
+ // ─── Test cases (real-world attack lines) ─────────────────────────────────────
18
+
19
+ const testCases = [
20
+ // ── From the exact dropper submitted in the issue ─────────────────────────
21
+ {
22
+ label: 'Dropper — global require hijack',
23
+ expectedId: 'require-global-hijack',
24
+ line: "global[_$_1e42[0]]= require;",
25
+ },
26
+ {
27
+ label: 'Dropper — global bracket assignment via decoded array',
28
+ expectedId: 'global-bracket-assignment-index',
29
+ line: "global[_$_1e42[2]]= module",
30
+ },
31
+ {
32
+ label: 'Dropper — obfuscated _$_ variable names',
33
+ expectedId: 'obfuscated-variable-names',
34
+ line: "var _$_1e42=(function(l,e){var h=l.length;var g=[];",
35
+ },
36
+ {
37
+ label: 'Dropper — shuffle-cipher IIFE bootstrap',
38
+ expectedId: 'shuffle-cipher-iife',
39
+ line: `var _$_1e42=(function(l,e){var h=l.length;var g=[];for(var j=0;j<h;j++){g[j]=l.charAt(j)};for(var j=0;j<h;j++){var s=e*(j+489)+(e%19597);var w=e*(j+659)+(e%48014);var t=s%h;var p=w%h;var y=g[t];g[t]=g[p];g[p]=y;e=(s+w)%4573868};return g.join('')})("rmcej%otb%",2857687)`,
40
+ },
41
+ {
42
+ label: 'Dropper — array-based Function call chain (final execution)',
43
+ expectedId: 'array-fn-call-chain',
44
+ line: "var Tgw=jFD(LQI,pYd );Tgw(2509);",
45
+ },
46
+
47
+ // ── Classic attack patterns ───────────────────────────────────────────────
48
+ {
49
+ label: 'eval() call',
50
+ expectedId: 'no-eval',
51
+ line: "const x = eval(someVar)",
52
+ },
53
+ {
54
+ label: 'new Function() call',
55
+ expectedId: 'no-new-func',
56
+ line: "const fn = new Function('a','return a+1')",
57
+ },
58
+ {
59
+ label: 'Buffer base64 decode',
60
+ expectedId: 'base64-decode',
61
+ line: `const payload = Buffer.from('abc123', 'base64').toString()`,
62
+ },
63
+ {
64
+ label: 'String.fromCharCode obfuscation',
65
+ expectedId: 'charcode-obfuscation',
66
+ line: "const s = String.fromCharCode(104, 101, 108, 108, 111, 32, 119, 111, 114)",
67
+ },
68
+ {
69
+ label: 'Hex-escaped string payload',
70
+ expectedId: 'hex-string-obfuscation',
71
+ line: String.raw`const k='\x68\x65\x6c\x6c\x6f\x77\x6f\x72\x6c\x64\x68\x65\x6c\x6c\x6f\x77\x6f\x72\x6c\x64\x20\x68'`,
72
+ },
73
+ {
74
+ label: 'Trojan Source (unicode bidi control)',
75
+ expectedId: 'unicode-bidi-control',
76
+ line: 'const safe = true;\u202E // hidden direction override',
77
+ },
78
+ {
79
+ label: 'Zero-width hidden character',
80
+ expectedId: 'zero-width-hidden-char',
81
+ line: 'const key = "api\u200BToken"',
82
+ },
83
+ {
84
+ label: 'defineProperty hidden getter backdoor',
85
+ expectedId: 'define-property-backdoor-getter',
86
+ line: 'Object.defineProperty(globalThis, "loader", { get: function(){ return eval(secret) } })',
87
+ },
88
+ {
89
+ label: 'Prototype pollution',
90
+ expectedId: 'prototype-pollution',
91
+ line: "obj.__proto__ = { admin: true }",
92
+ },
93
+ {
94
+ label: 'child_process in config',
95
+ expectedId: 'child-process-in-config',
96
+ configOnly: true,
97
+ line: "const { execSync } = require('child_process')",
98
+ },
99
+ ]
100
+
101
+ // ─── Run tests ────────────────────────────────────────────────────────────────
102
+
103
+ let passed = 0
104
+ let failed = 0
105
+
106
+ for (const tc of testCases) {
107
+ // For configOnly patterns, the scanner applies them; we can test them directly
108
+ const matched = FILE_PATTERNS.filter(p => p.regex.test(tc.line))
109
+ const hit = matched.find(p => p.id === tc.expectedId)
110
+
111
+ if (hit) {
112
+ console.log(`${GRN}✔${R} ${tc.label}`)
113
+ console.log(` ${YLW}→ [${hit.severity.toUpperCase()}] ${hit.id}${R}`)
114
+ passed++
115
+ }
116
+ else {
117
+ console.log(`${RED}${BOLD}✖ ${tc.label}${R}`)
118
+ console.log(` Expected pattern id: ${tc.expectedId}`)
119
+ if (matched.length > 0)
120
+ console.log(` (other matches: ${matched.map(p => p.id).join(', ')})`)
121
+ else
122
+ console.log(` (no patterns matched this line)`)
123
+ failed++
124
+ }
125
+ }
126
+
127
+ console.log(`\n${'─'.repeat(50)}`)
128
+ const failedText = failed > 0 ? `${RED}${BOLD}${failed} FAILED${R}` : ''
129
+ console.log(`${passed} passed ${failedText}`)
130
+ if (failed > 0) process.exit(1)
@@ -1,9 +1,11 @@
1
1
  import { execSync } from 'node:child_process'
2
- import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
3
- import { join } from 'node:path'
2
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
3
+ import { dirname, join } from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
4
5
  import { ensureVscodeSettings } from './vscode.mjs'
5
6
 
6
7
  const TAG = '[@archpublicwebsite/eslint-config]'
8
+ const __dirname = dirname(fileURLToPath(import.meta.url))
7
9
 
8
10
  function log(msg) {
9
11
  console.log(`${TAG} ${msg}`)
@@ -193,6 +195,70 @@ function ensureHooksPath(projectRoot) {
193
195
  }
194
196
  }
195
197
 
198
+ // ─── security shell scripts ────────────────────────────────────────────────
199
+
200
+ function ensureSecurityScripts(projectRoot) {
201
+ const securityDir = join(__dirname, '../security')
202
+ const scripts = ['safe-reinstall.sh', 'scan-global.sh']
203
+
204
+ let created = false
205
+
206
+ for (const scriptName of scripts) {
207
+ const sourcePath = join(securityDir, scriptName)
208
+ const targetPath = join(projectRoot, scriptName)
209
+
210
+ if (!existsSync(sourcePath) || existsSync(targetPath)) {
211
+ continue
212
+ }
213
+
214
+ copyFileSync(sourcePath, targetPath)
215
+ chmodSync(targetPath, 0o755)
216
+ created = true
217
+ }
218
+
219
+ if (created) {
220
+ log('Created security scripts (safe-reinstall.sh, scan-global.sh)')
221
+ }
222
+ }
223
+
224
+ function ensurePackageScripts(projectRoot) {
225
+ const packageJsonPath = join(projectRoot, 'package.json')
226
+ if (!existsSync(packageJsonPath)) {
227
+ return
228
+ }
229
+
230
+ let pkg
231
+ try {
232
+ pkg = JSON.parse(readFileSync(packageJsonPath, 'utf8'))
233
+ }
234
+ catch {
235
+ return
236
+ }
237
+
238
+ const scripts = pkg.scripts && typeof pkg.scripts === 'object' ? pkg.scripts : {}
239
+ const desiredScripts = {
240
+ precommit: 'node node_modules/@archpublicwebsite/eslint-config/tools/git-hooks/pre-commit.mjs',
241
+ 'security:global-scan': 'bash ./node_modules/@archpublicwebsite/eslint-config/tools/security/scan-global.sh',
242
+ 'security:safe-check': 'bash ./node_modules/@archpublicwebsite/eslint-config/tools/security/safe-reinstall.sh --check-only',
243
+ }
244
+
245
+ let updated = false
246
+ for (const [name, value] of Object.entries(desiredScripts)) {
247
+ if (!scripts[name]) {
248
+ scripts[name] = value
249
+ updated = true
250
+ }
251
+ }
252
+
253
+ if (!updated) {
254
+ return
255
+ }
256
+
257
+ pkg.scripts = scripts
258
+ writeFileSync(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`, 'utf8')
259
+ log('Updated package.json scripts (precommit, security:global-scan, security:safe-check)')
260
+ }
261
+
196
262
  // ─── .vscode/extensions.json ────────────────────────────────────────────────
197
263
 
198
264
  function ensureVscodeExtensions(projectRoot) {
@@ -240,6 +306,8 @@ function main() {
240
306
  ensurePrettierConfig(projectRoot)
241
307
  ensureHooks(projectRoot)
242
308
  ensureHooksPath(projectRoot)
309
+ ensureSecurityScripts(projectRoot)
310
+ ensurePackageScripts(projectRoot)
243
311
  ensureVscodeSettings(projectRoot)
244
312
  ensureVscodeExtensions(projectRoot)
245
313