@archpublicwebsite/eslint-config 1.0.15 → 1.0.18
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 +19 -1
- package/eslint.config.mjs +60 -4
- package/package.json +7 -1
- package/tools/git-hooks/pre-commit.mjs +30 -0
- package/tools/security/patterns.mjs +311 -0
- package/tools/security/safe-reinstall.sh +179 -0
- package/tools/security/scan-global.sh +155 -0
- package/tools/security/scan.mjs +465 -0
- package/tools/security/test-patterns.mjs +114 -0
- package/tools/setup/install.mjs +70 -2
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# =============================================================================
|
|
3
|
+
# scan-global.sh
|
|
4
|
+
# Scan global node_modules and caches for PolinRider IOC markers.
|
|
5
|
+
# Run once per machine — no need to be inside any repo.
|
|
6
|
+
#
|
|
7
|
+
# Locations scanned:
|
|
8
|
+
# • npm global node_modules (npm root -g)
|
|
9
|
+
# • All nvm node versions (~/.nvm/versions/node/*/lib/node_modules)
|
|
10
|
+
# • pnpm global node_modules (pnpm root -g)
|
|
11
|
+
# • pnpm store (pnpm store path)
|
|
12
|
+
# • npx cache (~/.npm/_npx)
|
|
13
|
+
#
|
|
14
|
+
# Usage:
|
|
15
|
+
# bash scan-global.sh
|
|
16
|
+
# =============================================================================
|
|
17
|
+
|
|
18
|
+
set -euo pipefail
|
|
19
|
+
|
|
20
|
+
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'
|
|
21
|
+
BLUE='\033[0;34m'; GRAY='\033[0;90m'; NC='\033[0m'
|
|
22
|
+
|
|
23
|
+
step() { echo -e "\n${BLUE}[→]${NC} $1"; }
|
|
24
|
+
ok() { echo -e "${GREEN}[✓]${NC} $1"; }
|
|
25
|
+
warn() { echo -e "${YELLOW}[!]${NC} $1"; }
|
|
26
|
+
skip() { echo -e "${GRAY}[–]${NC} $1"; }
|
|
27
|
+
hr() { echo -e "${BLUE}────────────────────────────────────────────────────${NC}"; }
|
|
28
|
+
|
|
29
|
+
# IOC patterns: payload markers + C2 hosts from the incident report
|
|
30
|
+
IOC_PATTERN="9-0224-2|9-857-1|trongrid\.io|aptoslabs\.com|bsc-dataseed\.binance|publicnode\.com"
|
|
31
|
+
|
|
32
|
+
SCAN_DIRS=() # entries formatted as "label|path"
|
|
33
|
+
TOTAL_HITS=0
|
|
34
|
+
|
|
35
|
+
hr
|
|
36
|
+
echo -e "${BLUE} Global IOC Scan · Post-PolinRider (Jun 2026)${NC}"
|
|
37
|
+
echo -e " Host: $(hostname)"
|
|
38
|
+
hr
|
|
39
|
+
|
|
40
|
+
# ── Collect scan targets ──────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
# 1. npm global node_modules
|
|
43
|
+
step "Locating npm global node_modules..."
|
|
44
|
+
NPM_GLOBAL=$(npm root -g 2>/dev/null || echo "")
|
|
45
|
+
if [[ -n "$NPM_GLOBAL" && -d "$NPM_GLOBAL" ]]; then
|
|
46
|
+
SCAN_DIRS+=("npm global|$NPM_GLOBAL")
|
|
47
|
+
ok "$NPM_GLOBAL"
|
|
48
|
+
else
|
|
49
|
+
skip "npm global not found"
|
|
50
|
+
fi
|
|
51
|
+
|
|
52
|
+
# 2. All nvm-managed node versions
|
|
53
|
+
step "Locating nvm node_modules..."
|
|
54
|
+
NVM_BASE="${NVM_DIR:-$HOME/.nvm}/versions/node"
|
|
55
|
+
if [[ -d "$NVM_BASE" ]]; then
|
|
56
|
+
while IFS= read -r ver; do
|
|
57
|
+
nm="$ver/lib/node_modules"
|
|
58
|
+
if [[ -d "$nm" ]]; then
|
|
59
|
+
SCAN_DIRS+=("nvm $(basename "$ver")|$nm")
|
|
60
|
+
ok "$nm"
|
|
61
|
+
fi
|
|
62
|
+
done < <(find "$NVM_BASE" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sort)
|
|
63
|
+
else
|
|
64
|
+
skip "nvm not found (checked $NVM_BASE)"
|
|
65
|
+
fi
|
|
66
|
+
|
|
67
|
+
# 3. pnpm global node_modules
|
|
68
|
+
step "Locating pnpm global node_modules..."
|
|
69
|
+
if command -v pnpm >/dev/null 2>&1; then
|
|
70
|
+
PNPM_GLOBAL=$(pnpm root -g 2>/dev/null || echo "")
|
|
71
|
+
if [[ -n "$PNPM_GLOBAL" && -d "$PNPM_GLOBAL" ]]; then
|
|
72
|
+
SCAN_DIRS+=("pnpm global|$PNPM_GLOBAL")
|
|
73
|
+
ok "$PNPM_GLOBAL"
|
|
74
|
+
else
|
|
75
|
+
skip "pnpm global node_modules not found"
|
|
76
|
+
fi
|
|
77
|
+
else
|
|
78
|
+
skip "pnpm not installed"
|
|
79
|
+
fi
|
|
80
|
+
|
|
81
|
+
# 4. pnpm store (content-addressable cache)
|
|
82
|
+
step "Locating pnpm store..."
|
|
83
|
+
if command -v pnpm >/dev/null 2>&1; then
|
|
84
|
+
PNPM_STORE=$(pnpm store path 2>/dev/null || echo "")
|
|
85
|
+
if [[ -n "$PNPM_STORE" && -d "$PNPM_STORE" ]]; then
|
|
86
|
+
SCAN_DIRS+=("pnpm store|$PNPM_STORE")
|
|
87
|
+
warn "pnpm store can be large — this may take a moment"
|
|
88
|
+
warn "$PNPM_STORE"
|
|
89
|
+
else
|
|
90
|
+
skip "pnpm store not found"
|
|
91
|
+
fi
|
|
92
|
+
else
|
|
93
|
+
skip "pnpm not installed"
|
|
94
|
+
fi
|
|
95
|
+
|
|
96
|
+
# 5. npx cache
|
|
97
|
+
step "Locating npx cache..."
|
|
98
|
+
NPM_CACHE=$(npm config get cache 2>/dev/null || echo "$HOME/.npm")
|
|
99
|
+
NPX_CACHE="$NPM_CACHE/_npx"
|
|
100
|
+
if [[ -d "$NPX_CACHE" ]]; then
|
|
101
|
+
SCAN_DIRS+=("npx cache|$NPX_CACHE")
|
|
102
|
+
ok "$NPX_CACHE"
|
|
103
|
+
else
|
|
104
|
+
skip "npx cache not found at $NPX_CACHE"
|
|
105
|
+
fi
|
|
106
|
+
|
|
107
|
+
# ── Run scan ──────────────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
echo ""
|
|
110
|
+
hr
|
|
111
|
+
echo -e "${BLUE} Scanning ${#SCAN_DIRS[@]} location(s)...${NC}"
|
|
112
|
+
hr
|
|
113
|
+
|
|
114
|
+
for ENTRY in "${SCAN_DIRS[@]}"; do
|
|
115
|
+
LABEL="${ENTRY%%|*}"
|
|
116
|
+
DIR="${ENTRY##*|}"
|
|
117
|
+
|
|
118
|
+
echo -e "\n${BLUE}[→]${NC} ${LABEL}"
|
|
119
|
+
echo -e " ${GRAY}${DIR}${NC}"
|
|
120
|
+
|
|
121
|
+
HITS=$(grep -rl \
|
|
122
|
+
--include="*.js" --include="*.mjs" --include="*.cjs" \
|
|
123
|
+
-E "$IOC_PATTERN" "$DIR" 2>/dev/null || true)
|
|
124
|
+
|
|
125
|
+
if [[ -z "$HITS" ]]; then
|
|
126
|
+
ok "Clean"
|
|
127
|
+
else
|
|
128
|
+
HIT_COUNT=$(echo "$HITS" | grep -c "." || true)
|
|
129
|
+
echo -e "${RED}[✗]${NC} IOC markers found in ${HIT_COUNT} file(s):"
|
|
130
|
+
while IFS= read -r f; do
|
|
131
|
+
echo -e " ${RED}${f}${NC}"
|
|
132
|
+
PREVIEW=$(grep -m1 -oE ".{0,30}(${IOC_PATTERN}).{0,30}" "$f" 2>/dev/null || true)
|
|
133
|
+
[[ -n "$PREVIEW" ]] && echo -e " ${GRAY}→ ${PREVIEW}${NC}"
|
|
134
|
+
done <<< "$HITS"
|
|
135
|
+
TOTAL_HITS=$((TOTAL_HITS + HIT_COUNT))
|
|
136
|
+
fi
|
|
137
|
+
done
|
|
138
|
+
|
|
139
|
+
# ── Summary ───────────────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
echo ""
|
|
142
|
+
hr
|
|
143
|
+
if [[ "$TOTAL_HITS" -eq 0 ]]; then
|
|
144
|
+
echo -e "${GREEN} ✓ All ${#SCAN_DIRS[@]} location(s) clean — machine is clear${NC}"
|
|
145
|
+
else
|
|
146
|
+
echo -e "${RED} ✗ IOC markers found in ${TOTAL_HITS} file(s) across global locations${NC}"
|
|
147
|
+
echo ""
|
|
148
|
+
echo -e "${YELLOW} Recommended next steps:${NC}"
|
|
149
|
+
echo -e "${YELLOW} 1. npm cache clean --force${NC}"
|
|
150
|
+
echo -e "${YELLOW} 2. pnpm store prune (if pnpm is installed)${NC}"
|
|
151
|
+
echo -e "${YELLOW} 3. Identify and remove the flagged packages globally${NC}"
|
|
152
|
+
echo -e "${YELLOW} 4. See: Security-Remediation-Runbook-2026-06.md${NC}"
|
|
153
|
+
fi
|
|
154
|
+
hr
|
|
155
|
+
echo ""
|
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @archipelago/security-scan
|
|
4
|
+
*
|
|
5
|
+
* Pre-commit scanner inspired by socket.dev.
|
|
6
|
+
* Detects malicious code in staged files and supply-chain risks in
|
|
7
|
+
* newly added packages — blocking zero-day attacks before they reach
|
|
8
|
+
* CI or production.
|
|
9
|
+
*
|
|
10
|
+
* ┌─────────────────────────────────────────────────────┐
|
|
11
|
+
* │ What is checked │
|
|
12
|
+
* │ 1. Staged .js/.ts/.vue files — eval, obfuscation, │
|
|
13
|
+
* │ base64 payloads, net/dns/child_process in │
|
|
14
|
+
* │ build-config files, prototype pollution │
|
|
15
|
+
* │ 2. Newly added npm packages — install scripts │
|
|
16
|
+
* │ with curl/wget, base64-exec, credential theft │
|
|
17
|
+
* │ 3. Typosquatting — package names within edit- │
|
|
18
|
+
* │ distance 1 of popular npm packages │
|
|
19
|
+
* └─────────────────────────────────────────────────────┘
|
|
20
|
+
*
|
|
21
|
+
* Environment flags:
|
|
22
|
+
* SKIP_SECURITY_SCAN=1 Bypass all checks (logged loudly to stderr)
|
|
23
|
+
* SECURITY_AUDIT=1 Also run `pnpm audit` and surface known CVEs
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
27
|
+
import { extname, join } from 'node:path'
|
|
28
|
+
import {
|
|
29
|
+
CONFIG_FILE_PATTERNS,
|
|
30
|
+
FILE_PATTERNS,
|
|
31
|
+
INSTALL_SCRIPT_PATTERNS,
|
|
32
|
+
POPULAR_PACKAGES,
|
|
33
|
+
SCAN_EXTENSIONS,
|
|
34
|
+
} from './patterns.mjs'
|
|
35
|
+
import {
|
|
36
|
+
getRepoRoot,
|
|
37
|
+
getStagedNameStatus,
|
|
38
|
+
runSafe,
|
|
39
|
+
} from '../git-hooks/shared.mjs'
|
|
40
|
+
|
|
41
|
+
// ─── ANSI colours ──────────────────────────────────────────────────────────────
|
|
42
|
+
const R = '\x1b[0m'
|
|
43
|
+
const RED = '\x1b[31m'
|
|
44
|
+
const YLW = '\x1b[33m'
|
|
45
|
+
const CYN = '\x1b[36m'
|
|
46
|
+
const GRN = '\x1b[32m'
|
|
47
|
+
const BOLD = '\x1b[1m'
|
|
48
|
+
const DIM = '\x1b[2m'
|
|
49
|
+
const HR = `${DIM}${'─'.repeat(62)}${R}`
|
|
50
|
+
|
|
51
|
+
function badge(severity) {
|
|
52
|
+
switch (severity) {
|
|
53
|
+
case 'critical': return `${RED}${BOLD}[CRITICAL]${R}`
|
|
54
|
+
case 'high': return `${RED}[HIGH] ${R}`
|
|
55
|
+
case 'medium': return `${YLW}[MEDIUM] ${R}`
|
|
56
|
+
default: return `${CYN}[LOW] ${R}`
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isBlocking(severity) {
|
|
61
|
+
return severity === 'critical' || severity === 'high'
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ─── Helpers ───────────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
function isConfigFile(filePath) {
|
|
67
|
+
return CONFIG_FILE_PATTERNS.some(p => p.test(filePath))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Files that intentionally contain dangerous-pattern literals for lint/security
|
|
71
|
+
// rule definitions and scanner fixture tests. Scanning them with FILE_PATTERNS
|
|
72
|
+
// causes self-referential false positives and blocks commits.
|
|
73
|
+
const SELF_REFERENTIAL_SCAN_EXCLUSIONS = [
|
|
74
|
+
'packages/eslint-config/eslint.config.mjs',
|
|
75
|
+
'packages/eslint-config/tools/security/patterns.mjs',
|
|
76
|
+
'packages/eslint-config/tools/security/test-patterns.mjs',
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
function shouldSkipSelfReferentialFile(filePath) {
|
|
80
|
+
const normalizedPath = filePath.replaceAll('\\', '/')
|
|
81
|
+
return SELF_REFERENTIAL_SCAN_EXCLUSIONS.some(excluded => normalizedPath.endsWith(excluded))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function collectPatternFindings(lines, isConfig, filePath) {
|
|
85
|
+
const findings = []
|
|
86
|
+
|
|
87
|
+
for (const pattern of FILE_PATTERNS) {
|
|
88
|
+
if (pattern.configOnly && !isConfig) continue
|
|
89
|
+
|
|
90
|
+
for (let i = 0; i < lines.length; i++) {
|
|
91
|
+
if (pattern.regex.test(lines[i])) {
|
|
92
|
+
findings.push({
|
|
93
|
+
file: filePath,
|
|
94
|
+
line: i + 1,
|
|
95
|
+
lineContent: lines[i].trim().slice(0, 120),
|
|
96
|
+
patternId: pattern.id,
|
|
97
|
+
severity: pattern.severity,
|
|
98
|
+
message: pattern.message,
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return findings
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function collectObfuscatedLineFindings(lines, filePath) {
|
|
108
|
+
const findings = []
|
|
109
|
+
|
|
110
|
+
for (let i = 0; i < lines.length; i++) {
|
|
111
|
+
const line = lines[i]
|
|
112
|
+
if (line.length <= 800) continue
|
|
113
|
+
|
|
114
|
+
// Encoded payload lines tend to have unusually high symbol density.
|
|
115
|
+
const nonWord = (line.match(/[^a-zA-Z0-9\s.,;:(){}[\]=<>+\-*/'"_]/g) || []).length
|
|
116
|
+
const density = nonWord / line.length
|
|
117
|
+
if (density <= 0.18) continue
|
|
118
|
+
|
|
119
|
+
findings.push({
|
|
120
|
+
file: filePath,
|
|
121
|
+
line: i + 1,
|
|
122
|
+
lineContent: `[${line.length} chars] ${line.trim().slice(0, 80)}…`,
|
|
123
|
+
patternId: 'long-obfuscated-line',
|
|
124
|
+
severity: 'critical',
|
|
125
|
+
message: `Line is ${line.length} chars with high symbol density (${(density * 100).toFixed(0)}%) — likely an embedded payload (dropper pattern)`,
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return findings
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Read the staged (index) version of a file so we scan exactly what will be
|
|
134
|
+
* committed — not any unsaved working-tree changes.
|
|
135
|
+
*/
|
|
136
|
+
function readStagedContent(filePath) {
|
|
137
|
+
return runSafe(`git show ":${filePath}"`)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Levenshtein distance between two strings.
|
|
142
|
+
* Used to detect typosquatted package names.
|
|
143
|
+
* @param {string} a
|
|
144
|
+
* @param {string} b
|
|
145
|
+
* @returns {number}
|
|
146
|
+
*/
|
|
147
|
+
function levenshtein(a, b) {
|
|
148
|
+
const dp = Array.from({ length: a.length + 1 }, (_, i) => [i])
|
|
149
|
+
for (let j = 0; j <= b.length; j++) dp[0][j] = j
|
|
150
|
+
for (let i = 1; i <= a.length; i++) {
|
|
151
|
+
for (let j = 1; j <= b.length; j++) {
|
|
152
|
+
dp[i][j] = a[i - 1] === b[j - 1]
|
|
153
|
+
? dp[i - 1][j - 1]
|
|
154
|
+
: 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1])
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return dp[a.length][b.length]
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Returns the popular package name that the given name is likely a typosquat
|
|
162
|
+
* of (edit distance === 1), or null if no match.
|
|
163
|
+
* @param {string} pkgName
|
|
164
|
+
* @returns {string|null}
|
|
165
|
+
*/
|
|
166
|
+
function detectTyposquat(pkgName) {
|
|
167
|
+
// Strip npm scope for comparison
|
|
168
|
+
const bare = pkgName.startsWith('@') ? (pkgName.split('/')[1] ?? pkgName) : pkgName
|
|
169
|
+
if (bare.length < 3) return null
|
|
170
|
+
|
|
171
|
+
for (const popular of POPULAR_PACKAGES) {
|
|
172
|
+
if (bare === popular) return null // exact match is fine
|
|
173
|
+
if (levenshtein(bare.toLowerCase(), popular.toLowerCase()) === 1) return popular
|
|
174
|
+
}
|
|
175
|
+
return null
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ─── Stage-file scanner ────────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* @typedef {{ file: string, line: number, lineContent: string,
|
|
182
|
+
* patternId: string, severity: string, message: string }} FileFinding
|
|
183
|
+
*/
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Scan every staged JS/TS/Vue file against FILE_PATTERNS.
|
|
187
|
+
* @param {string} _repoRoot (unused here but kept for symmetry)
|
|
188
|
+
* @returns {FileFinding[]}
|
|
189
|
+
*/
|
|
190
|
+
function scanStagedFiles(_repoRoot) {
|
|
191
|
+
const staged = getStagedNameStatus()
|
|
192
|
+
const toScan = staged.filter(({ status, path }) =>
|
|
193
|
+
['A', 'M'].includes(status[0]) && SCAN_EXTENSIONS.has(extname(path)),
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
if (toScan.length === 0) return []
|
|
197
|
+
|
|
198
|
+
process.stdout.write(`\nScanning ${toScan.length} staged source file(s)...\n`)
|
|
199
|
+
|
|
200
|
+
const findings = []
|
|
201
|
+
let skipped = 0
|
|
202
|
+
|
|
203
|
+
for (const { path: filePath } of toScan) {
|
|
204
|
+
if (shouldSkipSelfReferentialFile(filePath)) {
|
|
205
|
+
skipped++
|
|
206
|
+
continue
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const content = readStagedContent(filePath)
|
|
210
|
+
if (!content) continue
|
|
211
|
+
|
|
212
|
+
const lines = content.split('\n')
|
|
213
|
+
const isConfig = isConfigFile(filePath)
|
|
214
|
+
|
|
215
|
+
findings.push(
|
|
216
|
+
...collectPatternFindings(lines, isConfig, filePath),
|
|
217
|
+
...collectObfuscatedLineFindings(lines, filePath),
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (skipped > 0) {
|
|
222
|
+
process.stdout.write(`Skipping ${skipped} self-referential security definition/test file(s).\n`)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return findings
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ─── Supply-chain scanner ──────────────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Compare staged vs committed package.json files to find packages that were
|
|
232
|
+
* newly added in this commit (across all dependency fields).
|
|
233
|
+
* @returns {string[]} Array of package names
|
|
234
|
+
*/
|
|
235
|
+
function getNewlyAddedPackages() {
|
|
236
|
+
const staged = getStagedNameStatus()
|
|
237
|
+
const pkgFiles = staged.filter(({ status, path }) =>
|
|
238
|
+
['A', 'M'].includes(status[0])
|
|
239
|
+
&& path.endsWith('package.json')
|
|
240
|
+
&& !path.includes('node_modules'),
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
if (pkgFiles.length === 0) return []
|
|
244
|
+
|
|
245
|
+
const depFields = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']
|
|
246
|
+
|
|
247
|
+
function extractDepNames(jsonContent) {
|
|
248
|
+
if (!jsonContent) return new Set()
|
|
249
|
+
try {
|
|
250
|
+
const pkg = JSON.parse(jsonContent)
|
|
251
|
+
const all = new Set()
|
|
252
|
+
for (const field of depFields) {
|
|
253
|
+
if (pkg[field] && typeof pkg[field] === 'object') {
|
|
254
|
+
for (const name of Object.keys(pkg[field])) all.add(name)
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return all
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
return new Set()
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const added = new Set()
|
|
265
|
+
|
|
266
|
+
for (const { path: pkgFile } of pkgFiles) {
|
|
267
|
+
const newContent = readStagedContent(pkgFile)
|
|
268
|
+
const oldContent = runSafe(`git show "HEAD:${pkgFile}"`)
|
|
269
|
+
const newDeps = extractDepNames(newContent)
|
|
270
|
+
const oldDeps = extractDepNames(oldContent)
|
|
271
|
+
for (const name of newDeps) {
|
|
272
|
+
if (!oldDeps.has(name)) added.add(name)
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return [...added]
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* @typedef {{ pkg: string, scriptName?: string, patternId: string,
|
|
281
|
+
* severity: string, message: string, lineContent: string }} SupplyFinding
|
|
282
|
+
*/
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Inspect a single package's install scripts in node_modules.
|
|
286
|
+
* @param {string} pkgName
|
|
287
|
+
* @param {string} repoRoot
|
|
288
|
+
* @returns {SupplyFinding[]}
|
|
289
|
+
*/
|
|
290
|
+
function checkInstallScripts(pkgName, repoRoot) {
|
|
291
|
+
const pkgJsonPath = join(repoRoot, 'node_modules', pkgName, 'package.json')
|
|
292
|
+
if (!existsSync(pkgJsonPath)) return []
|
|
293
|
+
|
|
294
|
+
let pkg
|
|
295
|
+
try {
|
|
296
|
+
pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8'))
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
return []
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const findings = []
|
|
303
|
+
const scriptFields = ['preinstall', 'install', 'postinstall']
|
|
304
|
+
|
|
305
|
+
for (const scriptName of scriptFields) {
|
|
306
|
+
const script = pkg.scripts?.[scriptName]
|
|
307
|
+
if (!script) continue
|
|
308
|
+
|
|
309
|
+
// Always surface that an install script exists (medium — for awareness)
|
|
310
|
+
findings.push({
|
|
311
|
+
pkg: pkgName,
|
|
312
|
+
scriptName,
|
|
313
|
+
patternId: 'install-script-present',
|
|
314
|
+
severity: 'medium',
|
|
315
|
+
message: `Package has a ${scriptName} script — verify it is legitimate`,
|
|
316
|
+
lineContent: script.slice(0, 120),
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
// Check for specific dangerous patterns (high/critical)
|
|
320
|
+
for (const pattern of INSTALL_SCRIPT_PATTERNS) {
|
|
321
|
+
if (pattern.regex.test(script)) {
|
|
322
|
+
findings.push({
|
|
323
|
+
pkg: pkgName,
|
|
324
|
+
scriptName,
|
|
325
|
+
patternId: pattern.id,
|
|
326
|
+
severity: pattern.severity,
|
|
327
|
+
message: pattern.message,
|
|
328
|
+
lineContent: script.slice(0, 120),
|
|
329
|
+
})
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return findings
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Run the full supply-chain scan: typosquatting + install-script analysis
|
|
339
|
+
* for every newly added package.
|
|
340
|
+
* @param {string} repoRoot
|
|
341
|
+
* @returns {SupplyFinding[]}
|
|
342
|
+
*/
|
|
343
|
+
function scanNewDependencies(repoRoot) {
|
|
344
|
+
const newPkgs = getNewlyAddedPackages()
|
|
345
|
+
if (newPkgs.length === 0) return []
|
|
346
|
+
|
|
347
|
+
process.stdout.write(`\nSupply-chain scan: ${newPkgs.length} newly added package(s)...\n`)
|
|
348
|
+
|
|
349
|
+
const findings = []
|
|
350
|
+
|
|
351
|
+
for (const pkgName of newPkgs) {
|
|
352
|
+
// 1. Typosquatting
|
|
353
|
+
const similar = detectTyposquat(pkgName)
|
|
354
|
+
if (similar) {
|
|
355
|
+
findings.push({
|
|
356
|
+
pkg: pkgName,
|
|
357
|
+
patternId: 'typosquat',
|
|
358
|
+
severity: 'high',
|
|
359
|
+
message: `Possible typosquat of "${similar}" — verify the package name is correct`,
|
|
360
|
+
lineContent: `"${pkgName}" is 1 edit away from "${similar}"`,
|
|
361
|
+
})
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// 2. Install scripts
|
|
365
|
+
const scriptFindings = checkInstallScripts(pkgName, repoRoot)
|
|
366
|
+
findings.push(...scriptFindings)
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return findings
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ─── Optional: pnpm/npm audit ─────────────────────────────────────────────────
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Run `pnpm audit --audit-level=high` when SECURITY_AUDIT=1 is set.
|
|
376
|
+
* Non-fatal: output is printed but does not block the commit so it doesn't
|
|
377
|
+
* slow down normal developer workflows.
|
|
378
|
+
*/
|
|
379
|
+
function runAudit(repoRoot) {
|
|
380
|
+
if (process.env.SECURITY_AUDIT !== '1') return
|
|
381
|
+
|
|
382
|
+
process.stdout.write(`\nRunning pnpm audit (SECURITY_AUDIT=1)...\n`)
|
|
383
|
+
const result = runSafe('pnpm audit --audit-level=high 2>&1')
|
|
384
|
+
if (result) {
|
|
385
|
+
process.stdout.write(`${DIM}${result}${R}\n`)
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ─── Output helpers ────────────────────────────────────────────────────────────
|
|
390
|
+
|
|
391
|
+
function printFileFinding(f) {
|
|
392
|
+
console.log(`\n ${badge(f.severity)} ${DIM}${f.file}:${f.line}${R}`)
|
|
393
|
+
console.log(` ${BOLD}${f.message}${R}`)
|
|
394
|
+
console.log(` ${DIM}│${R} ${f.lineContent}`)
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function printSupplyFinding(f) {
|
|
398
|
+
if (f.patternId === 'install-script-present') {
|
|
399
|
+
console.log(` ${DIM}⚑${R} ${BOLD}${f.pkg}${R} — ${f.scriptName}: ${DIM}${f.lineContent}${R}`)
|
|
400
|
+
return
|
|
401
|
+
}
|
|
402
|
+
const scriptLabel = f.scriptName ? ` (${DIM}${f.scriptName}${R})` : ''
|
|
403
|
+
console.log(`\n ${badge(f.severity)} ${BOLD}${f.pkg}${R}${scriptLabel}`)
|
|
404
|
+
console.log(` ${f.message}`)
|
|
405
|
+
console.log(` ${DIM}│${R} ${f.lineContent}`)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// ─── Entry point ───────────────────────────────────────────────────────────────
|
|
409
|
+
|
|
410
|
+
function main() {
|
|
411
|
+
if (process.env.SKIP_SECURITY_SCAN === '1') {
|
|
412
|
+
process.stderr.write(
|
|
413
|
+
`\n${YLW}${BOLD}⚠ SKIP_SECURITY_SCAN=1 — all security checks bypassed.${R}\n`
|
|
414
|
+
+ `${YLW} This bypass is intentional and has been logged.${R}\n\n`,
|
|
415
|
+
)
|
|
416
|
+
return
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const repoRoot = getRepoRoot()
|
|
420
|
+
|
|
421
|
+
console.log(`\n${HR}`)
|
|
422
|
+
console.log(` ${BOLD}@archipelago/security-scan${R} ${DIM}socket.dev-style pre-commit guard${R}`)
|
|
423
|
+
console.log(HR)
|
|
424
|
+
|
|
425
|
+
const fileFindings = scanStagedFiles(repoRoot)
|
|
426
|
+
const supplyFindings = scanNewDependencies(repoRoot)
|
|
427
|
+
|
|
428
|
+
runAudit(repoRoot)
|
|
429
|
+
|
|
430
|
+
const allFindings = [...fileFindings, ...supplyFindings]
|
|
431
|
+
|
|
432
|
+
if (fileFindings.length > 0) {
|
|
433
|
+
console.log(`\n${BOLD}Source-code findings:${R}`)
|
|
434
|
+
fileFindings.forEach(printFileFinding)
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (supplyFindings.length > 0) {
|
|
438
|
+
console.log(`\n${BOLD}Supply-chain findings:${R}`)
|
|
439
|
+
supplyFindings.forEach(printSupplyFinding)
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const blocking = allFindings.filter(f => isBlocking(f.severity))
|
|
443
|
+
const warnings = allFindings.filter(f => !isBlocking(f.severity))
|
|
444
|
+
|
|
445
|
+
console.log(`\n${HR}`)
|
|
446
|
+
|
|
447
|
+
if (blocking.length === 0) {
|
|
448
|
+
const warnNote = warnings.length > 0 ? ` ${DIM}(${warnings.length} warning(s) — review recommended)${R}` : ''
|
|
449
|
+
console.log(` ${GRN}${BOLD}✔ Security scan passed${R}${warnNote}`)
|
|
450
|
+
console.log(HR)
|
|
451
|
+
console.log()
|
|
452
|
+
return
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
console.log(
|
|
456
|
+
` ${RED}${BOLD}✖ COMMIT BLOCKED${R} — ${blocking.length} critical/high security issue(s) found`,
|
|
457
|
+
)
|
|
458
|
+
console.log(` ${DIM}To bypass (emergencies only): SKIP_SECURITY_SCAN=1 git commit …${R}`)
|
|
459
|
+
console.log(HR)
|
|
460
|
+
console.log()
|
|
461
|
+
|
|
462
|
+
process.exit(1)
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
main()
|