@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.
- package/eslint.config.mjs +92 -7
- package/package.json +1 -1
- package/tools/git-hooks/pre-push.mjs +235 -0
- package/tools/git-hooks/verify-commit-message.mjs +37 -18
- package/tools/security/patterns.mjs +187 -0
- package/tools/security/risks.mjs +259 -0
- package/tools/security/scan.mjs +44 -245
- package/tools/security/scanner.mjs +233 -0
- package/tools/setup/install.mjs +13 -1
package/tools/security/scan.mjs
CHANGED
|
@@ -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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
// ───
|
|
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
|
-
* @
|
|
188
|
-
* @returns {FileFinding[]}
|
|
69
|
+
* @returns {import('./scanner.mjs').FileFinding[]}
|
|
189
70
|
*/
|
|
190
|
-
function scanStagedFiles(
|
|
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 (
|
|
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(`
|
|
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[]}
|
|
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
|
|
268
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|