@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.
- package/README.md +20 -1
- package/eslint.config.mjs +63 -14
- package/package.json +7 -1
- package/tools/git-hooks/pre-commit.mjs +30 -0
- package/tools/security/patterns.mjs +352 -0
- package/tools/security/safe-reinstall.sh +179 -0
- package/tools/security/scan-global.sh +155 -0
- package/tools/security/scan.mjs +465 -0
- package/tools/security/test-patterns.mjs +130 -0
- package/tools/setup/install.mjs +70 -2
|
@@ -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)
|
package/tools/setup/install.mjs
CHANGED
|
@@ -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
|
|