@archpublicwebsite/eslint-config 1.0.18 → 1.0.20

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 CHANGED
@@ -37,23 +37,108 @@ export function createArchipelagoConfig(...overrides) {
37
37
  ...tailwind.configs['flat/recommended'],
38
38
 
39
39
  // ── Security — OWASP / supply-chain hardening ─────────────────────────────
40
- // These rules catch patterns that appear in real-world npm supply-chain
41
- // attacks (eval payloads, implied eval, script-URL injection, etc.).
42
- // They apply to ALL project files.
40
+ // Covers: A01 Broken Access Control, A02 Cryptographic Failures,
41
+ // A03 Injection (XSS, eval, prototype pollution),
42
+ // A08 Software and Data Integrity Failures (supply-chain).
43
+ // Risk taxonomy: packages/eslint-config/tools/security/risks.mjs
44
+ // Pre-commit pattern scanner: tools/security/scan.mjs
45
+ // These rules apply to ALL project files.
43
46
  {
44
47
  name: 'archipelago/security',
45
48
  rules: {
46
- // Eval family — arbitrary code execution
49
+ // ── A03: Injection — arbitrary code execution ─────────────────────────
50
+ // eval() / new Function() / setTimeout("string") allow an attacker to
51
+ // execute arbitrary code. Primary dropper entry-points in supply-chain
52
+ // attacks (event-stream, polyfill.io, etc.).
47
53
  'no-eval': 'error',
48
54
  'no-new-func': 'error',
49
55
  'no-implied-eval': 'error',
50
- // javascript: URI as href/src — XSS vector
56
+
57
+ // ── A03: Injection — XSS via javascript: URI ──────────────────────────
58
+ // javascript: in href/src executes in the page's origin context — XSS.
51
59
  'no-script-url': 'error',
52
- // Dangerous globals that accept strings as code
60
+
61
+ // ── A03: Injection — prototype pollution ──────────────────────────────
62
+ // __proto__ / Object.prototype assignment poisons all objects and can
63
+ // escalate to RCE in some server-side Node.js frameworks.
64
+ 'no-prototype-builtins': 'error',
65
+
66
+ // ── A03: Injection — DOM XSS sinks ───────────────────────────────────
67
+ // innerHTML / outerHTML / insertAdjacentHTML / document.write let
68
+ // unescaped strings execute as HTML/script in the browser.
69
+ // Use textContent, innerText, or a trusted sanitiser (DOMPurify) instead.
70
+ 'no-restricted-syntax': [
71
+ 'error',
72
+ // innerHTML / outerHTML assignment
73
+ {
74
+ selector: 'AssignmentExpression[left.property.name=/^(inner|outer)HTML$/]',
75
+ message:
76
+ '[security/xss] innerHTML/outerHTML is a DOM XSS sink — use textContent or sanitise with DOMPurify',
77
+ },
78
+ // insertAdjacentHTML()
79
+ {
80
+ selector: 'CallExpression[callee.property.name="insertAdjacentHTML"]',
81
+ message:
82
+ '[security/xss] insertAdjacentHTML() is a DOM XSS sink — use insertAdjacentText() or sanitise first',
83
+ },
84
+ // document.write / document.writeln
85
+ {
86
+ selector:
87
+ 'CallExpression[callee.object.name="document"][callee.property.name=/^write(ln)?$/]',
88
+ message:
89
+ '[security/xss] document.write() is deprecated and a direct XSS sink — remove it',
90
+ },
91
+ // Object.__proto__ assignment (belt-and-suspenders with no-prototype-builtins)
92
+ {
93
+ selector: 'AssignmentExpression[left.property.name="__proto__"]',
94
+ message:
95
+ '[security/prototype-pollution] __proto__ assignment pollutes all objects — use Object.create(null) or Object.assign()',
96
+ },
97
+ ],
98
+
99
+ // ── A08: Supply-chain — dangerous globals ─────────────────────────────
53
100
  'no-restricted-globals': [
54
101
  'error',
55
- { name: 'eval', message: 'eval() is a security risk — use a safe alternative' },
102
+ {
103
+ name: 'eval',
104
+ message: '[security/rce] eval() is a security risk — use a safe alternative',
105
+ },
106
+ {
107
+ name: 'isFinite',
108
+ message: 'Use Number.isFinite() instead of the global isFinite()',
109
+ },
110
+ {
111
+ name: 'isNaN',
112
+ message: 'Use Number.isNaN() instead of the global isNaN()',
113
+ },
56
114
  ],
115
+
116
+ // ── A02: Cryptographic failures — weak algorithms ─────────────────────
117
+ // MD5 and SHA-1 are cryptographically broken.
118
+ 'no-restricted-imports': [
119
+ 'error',
120
+ {
121
+ name: 'md5',
122
+ message: '[security/crypto] MD5 is cryptographically broken — use SHA-256 or better',
123
+ },
124
+ {
125
+ name: 'sha1',
126
+ message: '[security/crypto] SHA-1 is cryptographically broken — use SHA-256 or better',
127
+ },
128
+ ],
129
+ },
130
+ },
131
+
132
+ // ── Security — Vue-specific XSS hardening ─────────────────────────────────
133
+ // v-html renders raw HTML without escaping. If the bound value contains
134
+ // user-controlled content it is a direct XSS sink.
135
+ // Warn (not error): v-html is legitimate for trusted CMS content, but every
136
+ // usage must be explicitly reviewed.
137
+ {
138
+ name: 'archipelago/security-vue',
139
+ files: ['**/*.vue'],
140
+ rules: {
141
+ 'vue/no-v-html': 'warn',
57
142
  },
58
143
  },
59
144
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@archpublicwebsite/eslint-config",
3
- "version": "1.0.18",
3
+ "version": "1.0.20",
4
4
  "author": "Archipelago Hotels",
5
5
  "description": "Reusable ESLint flat config and git-hook toolkit for Archipelago projects",
6
6
  "type": "module",
@@ -0,0 +1,235 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @archipelago/pre-push
4
+ *
5
+ * Full-branch security gate — runs before code leaves the local machine.
6
+ * Stricter than the pre-commit scanner: operates over ALL committed JS/TS/Vue
7
+ * files in the push range and checks all package.json deps for known CVEs
8
+ * and typosquatting.
9
+ *
10
+ * ┌────────────────────────────────────────────────────────┐
11
+ * │ What is checked │
12
+ * │ 1. All .js/.ts/.vue files in the push range against │
13
+ * │ all FILE_PATTERNS (eval, XSS, secrets, SSRF, …) │
14
+ * │ 2. pnpm audit --audit-level=high (CVE scan) │
15
+ * │ 3. Typosquatting check on all package.json deps │
16
+ * └────────────────────────────────────────────────────────┘
17
+ *
18
+ * Environment flags:
19
+ * SKIP_SECURITY_SCAN=1 Bypass all checks (logged loudly to stderr)
20
+ * SKIP_PUSH_AUDIT=1 Skip the pnpm audit step only
21
+ *
22
+ * Git push stdin lines: <local-ref> <local-sha1> <remote-ref> <remote-sha1>
23
+ */
24
+
25
+ import { existsSync, readFileSync } from 'node:fs'
26
+ import { extname, join } from 'node:path'
27
+ import { SCAN_EXTENSIONS } from '../security/patterns.mjs'
28
+ import { partitionFindings, sortFindings } from '../security/risks.mjs'
29
+ import {
30
+ BOLD,
31
+ DIM,
32
+ GRN,
33
+ R,
34
+ YLW,
35
+ collectObfuscatedLineFindings,
36
+ collectPatternFindings,
37
+ detectTyposquat,
38
+ isConfigFile,
39
+ printFileFinding,
40
+ printScanFooter,
41
+ printScanHeader,
42
+ printSupplyFinding,
43
+ shouldSkip,
44
+ } from '../security/scanner.mjs'
45
+ import { getRepoRoot, runSafe } from './shared.mjs'
46
+
47
+ // ─── Parse git push stdin refs ─────────────────────────────────────────────────
48
+
49
+ const ZERO_SHA = '0000000000000000000000000000000000000000'
50
+
51
+ /**
52
+ * Parse git push stdin lines and return push ranges.
53
+ * @returns {{ localSha: string, remoteSha: string }[]}
54
+ */
55
+ function parsePushRefs() {
56
+ let stdin = ''
57
+ try { stdin = readFileSync('/dev/stdin', 'utf8') }
58
+ catch { return [] }
59
+
60
+ return stdin.trim().split('\n').filter(Boolean).map((line) => {
61
+ const parts = line.trim().split(/\s+/)
62
+ return { localSha: parts[1] ?? '', remoteSha: parts[3] ?? ZERO_SHA }
63
+ }).filter(({ localSha }) => localSha && localSha !== ZERO_SHA)
64
+ }
65
+
66
+ // ─── Collect files in push range ───────────────────────────────────────────────
67
+
68
+ /**
69
+ * @param {{ localSha: string, remoteSha: string }[]} refs
70
+ * @returns {string[]}
71
+ */
72
+ function collectPushFiles(refs) {
73
+ const seen = new Set()
74
+ for (const { localSha, remoteSha } of refs) {
75
+ const range = remoteSha === ZERO_SHA ? localSha : `${remoteSha}..${localSha}`
76
+ const output = runSafe(`git diff-tree --no-commit-id --name-only -r ${range}`)
77
+ if (!output) continue
78
+ for (const f of output.split('\n').filter(Boolean)) {
79
+ if (SCAN_EXTENSIONS.has(extname(f))) seen.add(f)
80
+ }
81
+ }
82
+ return [...seen]
83
+ }
84
+
85
+ // ─── Scan committed files ──────────────────────────────────────────────────────
86
+
87
+ /**
88
+ * @param {string[]} files
89
+ * @param {string} repoRoot
90
+ * @returns {import('../security/scanner.mjs').FileFinding[]}
91
+ */
92
+ function scanFiles(files, repoRoot) {
93
+ if (files.length === 0) return []
94
+
95
+ process.stdout.write(`\nScanning ${files.length} file(s) in push range...\n`)
96
+
97
+ const findings = []
98
+ let skipped = 0
99
+
100
+ for (const filePath of files) {
101
+ if (shouldSkip(filePath)) { skipped++; continue }
102
+
103
+ const content = runSafe(`git show "HEAD:${filePath}"`)
104
+ || (() => {
105
+ const abs = join(repoRoot, filePath)
106
+ return existsSync(abs) ? readFileSync(abs, 'utf8') : ''
107
+ })()
108
+
109
+ if (!content) continue
110
+
111
+ const lines = content.split('\n')
112
+ findings.push(
113
+ ...collectPatternFindings(lines, isConfigFile(filePath), filePath),
114
+ ...collectObfuscatedLineFindings(lines, filePath),
115
+ )
116
+ }
117
+
118
+ if (skipped > 0) {
119
+ process.stdout.write(`Skipped ${skipped} self-referential file(s).\n`)
120
+ }
121
+
122
+ return findings
123
+ }
124
+
125
+ // ─── Dependency checks ─────────────────────────────────────────────────────────
126
+
127
+ /**
128
+ * Scan all package.json deps in the repo root for typosquatted names.
129
+ * @param {string} repoRoot
130
+ */
131
+ function scanDependencies(repoRoot) {
132
+ const pkgJsonPath = join(repoRoot, 'package.json')
133
+ if (!existsSync(pkgJsonPath)) return []
134
+
135
+ let pkg
136
+ try { pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8')) }
137
+ catch { return [] }
138
+
139
+ const depFields = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']
140
+ return depFields.flatMap((field) => {
141
+ const deps = pkg[field]
142
+ if (!deps || typeof deps !== 'object') return []
143
+ return Object.keys(deps).flatMap((name) => {
144
+ const similar = detectTyposquat(name)
145
+ return similar
146
+ ? [{
147
+ pkg: name,
148
+ patternId: 'typosquat',
149
+ severity: 'high',
150
+ message: `Possible typosquat of "${similar}" in ${field} — verify the package name`,
151
+ lineContent: `"${name}" is 1 edit away from "${similar}"`,
152
+ category: 'supply-chain',
153
+ }]
154
+ : []
155
+ })
156
+ })
157
+ }
158
+
159
+ /**
160
+ * Run pnpm audit and surface a single finding if vulnerabilities are detected.
161
+ * @returns {object[]}
162
+ */
163
+ function runAudit() {
164
+ if (process.env.SKIP_PUSH_AUDIT === '1') {
165
+ process.stdout.write(`${DIM}Skipping pnpm audit (SKIP_PUSH_AUDIT=1)${R}\n`)
166
+ return []
167
+ }
168
+
169
+ process.stdout.write(`\nRunning pnpm audit --audit-level=high...\n`)
170
+ const result = runSafe('pnpm audit --audit-level=high 2>&1')
171
+ if (!result) return []
172
+
173
+ const lines = result.split('\n').filter(Boolean)
174
+ const hasVulns = /vulnerabilit(?:y|ies)|moderate|high|critical/i.test(result)
175
+
176
+ if (hasVulns) {
177
+ console.log(`\n${BOLD}pnpm audit output:${R}`)
178
+ console.log(`${DIM}${result}${R}`)
179
+ return [{
180
+ pkg: 'pnpm-audit',
181
+ patternId: 'known-cve',
182
+ severity: 'high',
183
+ message: `pnpm audit detected known CVEs — run 'pnpm audit --fix' or pin safe versions`,
184
+ lineContent: lines.find(l => /vulnerabilit/i.test(l)) ?? result.slice(0, 120),
185
+ category: 'supply-chain',
186
+ }]
187
+ }
188
+
189
+ process.stdout.write(`${GRN}pnpm audit: no known vulnerabilities found.${R}\n`)
190
+ return []
191
+ }
192
+
193
+ // ─── Entry point ───────────────────────────────────────────────────────────────
194
+
195
+ function main() {
196
+ if (process.env.SKIP_SECURITY_SCAN === '1') {
197
+ process.stderr.write(
198
+ `\n${YLW}${BOLD}⚠ SKIP_SECURITY_SCAN=1 — pre-push security checks bypassed.${R}\n`
199
+ + `${YLW} This bypass is intentional and has been logged.${R}\n\n`,
200
+ )
201
+ return
202
+ }
203
+
204
+ const repoRoot = getRepoRoot()
205
+ const refs = parsePushRefs()
206
+
207
+ printScanHeader('@archipelago/pre-push', 'full-branch security gate')
208
+
209
+ const files = refs.length > 0
210
+ ? collectPushFiles(refs)
211
+ : (runSafe('git diff-tree --no-commit-id --name-only -r HEAD') || '')
212
+ .split('\n')
213
+ .filter(f => SCAN_EXTENSIONS.has(extname(f)))
214
+
215
+ const fileFindings = scanFiles(files, repoRoot)
216
+ const depsFindings = scanDependencies(repoRoot)
217
+ const auditFindings = runAudit()
218
+
219
+ const allFindings = sortFindings([...fileFindings, ...depsFindings, ...auditFindings])
220
+ const { blocking, warnings } = partitionFindings(allFindings)
221
+
222
+ if (fileFindings.length > 0) {
223
+ console.log(`\n${BOLD}Source-file findings:${R}`)
224
+ fileFindings.forEach(printFileFinding)
225
+ }
226
+
227
+ if (depsFindings.length > 0 || auditFindings.length > 0) {
228
+ console.log(`\n${BOLD}Dependency findings:${R}`)
229
+ ;[...depsFindings, ...auditFindings].forEach(printSupplyFinding)
230
+ }
231
+
232
+ printScanFooter(blocking, warnings, 'PUSH BLOCKED', 'SKIP_SECURITY_SCAN=1 git push …')
233
+ }
234
+
235
+ main()
@@ -1,9 +1,15 @@
1
1
  import { readFileSync } from 'node:fs'
2
2
 
3
+ const COMMIT_RE = /^(revert: )?(feat|fix|docs|refactor|perf|test|build|ci|chore|style|types|workflow|release|deps)(\([a-z0-9-]+\))?: \S.*$/
4
+ const RELEASE_RE = /^v\d/
5
+
6
+ const EXAMPLES = [
7
+ "feat: add 'comments' option",
8
+ 'fix: handle events on blur (close #28)',
9
+ ]
10
+
3
11
  export function isValidCommitMessage(message) {
4
- const releaseRE = /^v\d/
5
- const commitRE = /^(revert: )?(feat|fix|docs|refactor|perf|test|build|ci|chore|style|types|workflow|release|deps)(\([a-z0-9-]+\))?: \S.*$/
6
- return releaseRE.test(message) || commitRE.test(message)
12
+ return RELEASE_RE.test(message) || COMMIT_RE.test(message)
7
13
  }
8
14
 
9
15
  function getCommitSubject(rawMessage) {
@@ -13,36 +19,49 @@ function getCommitSubject(rawMessage) {
13
19
  .find(line => line && !line.startsWith('#')) || ''
14
20
  }
15
21
 
16
- function isSkippableCommitSubject(subject) {
17
- return !subject || subject.startsWith('Merge ') || subject.startsWith('fixup! ') || subject.startsWith('squash! ')
22
+ function isSkippableSubject(subject) {
23
+ return !subject
24
+ || subject.startsWith('Merge ')
25
+ || subject.startsWith('fixup! ')
26
+ || subject.startsWith('squash! ')
18
27
  }
19
28
 
29
+ /**
30
+ * Verify a commit message file against the conventional-commit format.
31
+ *
32
+ * Behaviour is controlled by the environment:
33
+ * - Default (no env var): warn to stderr but never block the commit.
34
+ * - ARCHI_STRICT_COMMIT_MSG=1: enforce the format and call process.exit(1)
35
+ * on an invalid message so CI or git hooks can block the commit.
36
+ * - STRICT_COMMIT_MSG=1: alias for ARCHI_STRICT_COMMIT_MSG.
37
+ *
38
+ * @param {string} messageFilePath Path to the COMMIT_EDITMSG file.
39
+ */
20
40
  export function verifyCommitMessageFile(messageFilePath) {
21
41
  const rawMessage = readFileSync(messageFilePath, 'utf-8')
22
42
  const subject = getCommitSubject(rawMessage)
23
43
 
24
- if (isSkippableCommitSubject(subject))
25
- return
44
+ if (isSkippableSubject(subject)) return
45
+ if (isValidCommitMessage(subject)) return
26
46
 
27
- if (isValidCommitMessage(subject))
28
- return
47
+ const isStrict = process.env.ARCHI_STRICT_COMMIT_MSG === '1'
48
+ || process.env.STRICT_COMMIT_MSG === '1'
29
49
 
30
- const examples = [
31
- "feat: add 'comments' option",
32
- 'fix: handle events on blur (close #28)',
33
- ]
50
+ const prefix = isStrict ? '\nERROR' : '\nSUGGESTION'
51
+ const suffix = isStrict ? 'See README.md for details.\n' : 'See README.md (Auto commit message flow) for details.\n'
34
52
 
35
- console.warn('\nSUGGESTION: conventional commit format is recommended.\n')
53
+ console.warn(`${prefix}: commit message does not follow conventional-commit format.\n`)
36
54
  console.warn('Required format: <type>(optional-scope): <description>')
37
55
  console.warn('Suggested subject length: around 100 characters\n')
38
56
  console.warn('Examples:')
39
- examples.forEach(example => console.warn(` - ${example}`))
40
- console.warn('\nSee README.md (Auto commit message flow) for details.\n')
57
+ EXAMPLES.forEach(example => console.warn(` - ${example}`))
58
+ console.warn(`\n${suffix}`)
59
+
60
+ if (isStrict) process.exit(1)
41
61
  }
42
62
 
43
63
  if (import.meta.url === `file://${process.argv[1]}`) {
44
64
  const messageFilePath = process.argv[2]
45
- if (!messageFilePath)
46
- process.exit(0)
65
+ if (!messageFilePath) process.exit(0)
47
66
  verifyCommitMessageFile(messageFilePath)
48
67
  }