ai-install-guard 0.0.0 → 0.1.0

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.
@@ -0,0 +1,536 @@
1
+ import { safeLine } from '../safe-text'
2
+ import { isCodeFile, isLive, isMarkdown, isRecord, parseJson, type FileRole, type ScanContext } from './context'
3
+ import {
4
+ CLAUDE_LOAD_DIR,
5
+ CLAUDE_SETTINGS,
6
+ CODE_FROM_TEXT,
7
+ COLLECTION_HOST,
8
+ CREDENTIAL_PATH,
9
+ DECODE,
10
+ DOWNLOAD_AND_RUN,
11
+ NETWORK_CODE,
12
+ NETWORK_COMMAND,
13
+ PERSISTENCE_TARGET,
14
+ PROCESS_CODE,
15
+ PYTHON_CODE_FROM_TEXT,
16
+ PYTHON_PROCESS,
17
+ SECRET_ENV,
18
+ SHELL_EVAL,
19
+ UNPINNED_RUNNER,
20
+ WRITE_CODE,
21
+ WRITE_SHELL,
22
+ } from './patterns'
23
+ import { basename, extension, findAll, findFirst, spanKinds, type Match, type SpanKind } from './text'
24
+ import { row, worse, type CheckResult, type Evidence, type Status } from './types'
25
+
26
+ const GROUP = 'code' as const
27
+
28
+ const CONFIG_EXTENSIONS = new Set(['json', 'jsonc', 'yaml', 'yml', 'toml', 'ini', 'cfg', 'conf', 'env', 'plist', 'xml'])
29
+ const LOCKFILES = /(?:^|\/)(?:package-lock\.json|npm-shrinkwrap\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb?|Cargo\.lock|go\.sum|poetry\.lock|uv\.lock|Pipfile\.lock|composer\.lock|Gemfile\.lock)$|\.map$/
30
+ const LIVE_ROLES: ReadonlySet<FileRole> = new Set(['module', 'hook-script', 'server', 'prompt-script', 'bin', 'code'])
31
+ const JS_EXTENSIONS = new Set(['js', 'mjs', 'cjs', 'jsx', 'ts', 'tsx', 'mts', 'cts'])
32
+ const SHELL_EXTENSIONS = new Set(['sh', 'bash', 'zsh', 'fish', 'ksh'])
33
+ /** Names worth hiding by splitting them across strings. */
34
+ const SPLIT_NAMES = /\b(?:curl|wget|netcat|ncat|socat|child_process|execSync|spawnSync|execFile|eval|Function|subprocess|system|popen|powershell|bash|zsh|ssh|id_rsa|id_ed25519|credentials|Keychains|ANTHROPIC_API_KEY|GITHUB_TOKEN)\b|\.ssh\b|\.aws\b/
35
+
36
+ const LIFECYCLE_SCRIPTS = ['preinstall', 'install', 'postinstall', 'prepare', 'preprepare', 'postprepare', 'prepublish']
37
+
38
+ export function codeChecks(ctx: ScanContext): CheckResult[] {
39
+ return [hiddenCheck(ctx), execCheck(ctx), obfuscationCheck(ctx), credentialsCheck(ctx), persistenceCheck(ctx), installScriptsCheck(ctx), registriesCheck(ctx), domainsCheck(ctx)]
40
+ }
41
+
42
+ type Language = 'js' | 'python' | 'shell' | 'other'
43
+
44
+ /** `#!/bin/bash` → bash, `#!/usr/bin/env -S node --flag` → node. */
45
+ export function interpreterOf(text: string): string {
46
+ const line = /^#!\s*(\S+)((?:[ \t]+\S+)*)/.exec(text)
47
+ if (line === null) return ''
48
+ const program = basename(line[1] as string)
49
+ if (program !== 'env') return program
50
+ const args = (line[2] ?? '').trim().split(/\s+/).filter(arg => arg !== '' && !arg.startsWith('-') && !arg.includes('='))
51
+ return basename(args[0] ?? '')
52
+ }
53
+
54
+ export function languageOf(path: string, text: string): Language {
55
+ const ext = extension(path)
56
+ if (JS_EXTENSIONS.has(ext)) return 'js'
57
+ if (ext === 'py') return 'python'
58
+ if (SHELL_EXTENSIONS.has(ext)) return 'shell'
59
+ const shebang = interpreterOf(text)
60
+ if (/^(?:node|deno|bun)$/.test(shebang)) return 'js'
61
+ if (/^python/.test(shebang)) return 'python'
62
+ if (/^(?:ba|z|da|k|fi)?sh$/.test(shebang)) return 'shell'
63
+ return 'other'
64
+ }
65
+
66
+ type CodeFile = {
67
+ path: string
68
+ text: string
69
+ role: FileRole
70
+ language: Language
71
+ /** What the character at an index is: code, string, comment or pattern. */
72
+ kindAt: (index: number) => SpanKind
73
+ }
74
+
75
+ /** Text files the code checks read: code and config, never lockfiles. */
76
+ function codeFiles(ctx: ScanContext): CodeFile[] {
77
+ const out: CodeFile[] = []
78
+ for (const file of ctx.files) {
79
+ // Lockfiles and source maps are skipped, unless live code loads them.
80
+ if (!file.isText || (LOCKFILES.test(file.path) && !LIVE_ROLES.has(ctx.roleOf(file.path)))) continue
81
+ const text = ctx.text(file.path)
82
+ if (text === undefined) continue
83
+ // Whatever live code loads is code, whatever its name (a required ./helper.map).
84
+ const loaded = LIVE_ROLES.has(ctx.roleOf(file.path))
85
+ if (isCodeFile(file.path, text) || loaded || CONFIG_EXTENSIONS.has(extension(file.path)) || basename(file.path).startsWith('.')) {
86
+ // A hook or server that runs the file with an interpreter decides how it is read.
87
+ const language = ctx.interpreters.get(file.path) ?? languageOf(file.path, text)
88
+ const kindAt = isCodeFile(file.path, text) ? spanKinds(text, language) : () => 'string' as const
89
+ out.push({ path: file.path, text, role: ctx.roleOf(file.path), language, kindAt })
90
+ }
91
+ }
92
+ return out
93
+ }
94
+
95
+ /**
96
+ * A match inside a comment or a regular expression describes text rather
97
+ * than doing anything, so it is only reported.
98
+ */
99
+ function judged(file: CodeFile, match: Match, status: Status, excerpt: string): Evidence {
100
+ const kind = file.kindAt(match.index)
101
+ if (!isLive(file.role)) return { file: file.path, line: match.line, excerpt, status: 'info' }
102
+ if (kind === 'comment' || kind === 'regex') {
103
+ return { file: file.path, line: match.line, excerpt: `${excerpt} (in a ${kind === 'regex' ? 'pattern' : 'comment'})`, status: 'info' }
104
+ }
105
+ if (kind === 'trailing-comment') {
106
+ // A comment after code on the same line is a classic place to hide a
107
+ // command, so it can lower a failure to a warning, never further.
108
+ return { file: file.path, line: match.line, excerpt: `${excerpt} (in a comment after code)`, status: status === 'fail' ? 'warn' : status }
109
+ }
110
+ return { file: file.path, line: match.line, excerpt, status }
111
+ }
112
+
113
+ /** Matches of `pattern` in the file's code and strings, not its comments or patterns. */
114
+ /**
115
+ * Matches of `pattern` that may run: everything but whole-line comments and
116
+ * real patterns. A trailing comment counts here, since a tokenizer can be
117
+ * fooled into one, and gates such as "does it write files" must not be.
118
+ */
119
+ function active(file: CodeFile, pattern: RegExp, limit = 20): Match[] {
120
+ return findAll(file.text, pattern, limit, index => {
121
+ const kind = file.kindAt(index)
122
+ return kind !== 'comment' && kind !== 'regex'
123
+ })
124
+ }
125
+
126
+ /**
127
+ * Every match of `pattern`, judged: live matches first (up to `limit`),
128
+ * then at most two inert ones, so comments cannot crowd out real code.
129
+ */
130
+ function evidenceFor(file: CodeFile, pattern: RegExp, status: Status, describe: (match: Match) => string, limit = 5): Evidence[] {
131
+ const isInert = (index: number): boolean => {
132
+ const kind = file.kindAt(index)
133
+ return kind === 'comment' || kind === 'regex' || kind === 'trailing-comment'
134
+ }
135
+ const live = findAll(file.text, pattern, limit, index => !isInert(index))
136
+ const inert = findAll(file.text, pattern, 2, isInert)
137
+ return [...live, ...inert].map(match => judged(file, match, status, describe(match)))
138
+ }
139
+
140
+ /** `ANTHROPIC_API_KEY=sk-...` → `ANTHROPIC_API_KEY`: the name, never what follows it. */
141
+ export function secretName(match: Match): string {
142
+ return /[A-Z][A-Z0-9_]{2,}/.exec(match.text)?.[0] ?? match.text.trim().slice(0, 40)
143
+ }
144
+
145
+ function writesFiles(file: CodeFile): boolean {
146
+ if (file.language === 'shell') return active(file, WRITE_SHELL, 1).length > 0
147
+ return active(file, WRITE_CODE, 1).length > 0
148
+ }
149
+
150
+ function capFor(role: FileRole): (status: Status) => Status {
151
+ return status => (isLive(role) ? status : 'info')
152
+ }
153
+
154
+ const TAGS = /[\u{e0000}-\u{e007f}]+/gu
155
+ const VARIATION_SUPPLEMENT = /[\u{e0100}-\u{e01ef}]{2,}/gu
156
+ const BIDI = /[\u202a-\u202e\u2066-\u2069\u200e\u200f\u061c]/g
157
+ const ZERO_WIDTH = /[\u200b-\u200d\u2060-\u2064\u180e\u00ad\u034f\u115f\u1160\u3164\uffa0]|(?!^)\ufeff/g
158
+ const ANSI = /\u001b\[[0-9;?]{0,40}[A-Za-z]|\u001b\][^\u0007\u001b]{0,1000}\u0007/g
159
+
160
+ function hiddenCheck(ctx: ScanContext): CheckResult {
161
+ const evidence: Evidence[] = []
162
+ for (const file of ctx.files) {
163
+ const text = file.isText ? ctx.text(file.path) : undefined
164
+ if (text === undefined) continue
165
+ const role = ctx.roleOf(file.path)
166
+ const cap = (status: Status): Status => (role === 'test' ? 'info' : role === 'doc' && status === 'fail' ? 'warn' : status)
167
+ const isCode = isCodeFile(file.path, text)
168
+
169
+ for (const match of findAll(text, TAGS, 5)) {
170
+ evidence.push({ file: file.path, line: match.line, excerpt: `${[...match.text].length} invisible tag characters spelling "${decodeTags(match.text)}"`, status: cap('fail') })
171
+ }
172
+ for (const match of findAll(text, VARIATION_SUPPLEMENT, 3)) {
173
+ evidence.push({ file: file.path, line: match.line, excerpt: `${[...match.text].length} invisible variation selectors in a row (a way to smuggle data)`, status: cap('fail') })
174
+ }
175
+ const bidi = findAll(text, BIDI, 5)
176
+ for (const match of bidi) {
177
+ evidence.push({ file: file.path, line: match.line, excerpt: `bidirectional control ${codePoints(match.text)}: text may read differently than it runs`, status: cap(isCode ? 'fail' : 'warn') })
178
+ }
179
+ const zero = findAll(text, ZERO_WIDTH, 50)
180
+ if (zero.length > 0 && (isCode || isMarkdown(file.path))) {
181
+ const first = zero[0] as (typeof zero)[number]
182
+ evidence.push({ file: file.path, line: first.line, excerpt: `${zero.length}${zero.length === 50 ? '+' : ''} zero-width or invisible characters, first ${codePoints(first.text)}`, status: cap('warn') })
183
+ }
184
+ const ansi = findFirst(text, ANSI)
185
+ if (ansi !== undefined) {
186
+ evidence.push({ file: file.path, line: ansi.line, excerpt: 'terminal escape sequence: can hide or repaint text in a terminal', status: cap('warn') })
187
+ }
188
+ }
189
+ return row(
190
+ {
191
+ id: 'code.hidden-characters',
192
+ group: GROUP,
193
+ title: 'Hidden characters',
194
+ advice: 'Invisible tag characters carry text the model reads and you do not see. Bidirectional controls make code read differently than it runs. Neither belongs in a plugin.',
195
+ },
196
+ evidence,
197
+ )
198
+ }
199
+
200
+ /** Tag characters U+E0020..U+E007E mirror printable ASCII. */
201
+ export function decodeTags(text: string): string {
202
+ let out = ''
203
+ for (const char of text) {
204
+ const code = (char.codePointAt(0) as number) - 0xe0000
205
+ out += code >= 0x20 && code <= 0x7e ? String.fromCharCode(code) : ''
206
+ }
207
+ return safeLine(out.length > 120 ? `${out.slice(0, 120)}…` : out)
208
+ }
209
+
210
+ function codePoints(text: string): string {
211
+ return [...text].map(char => `U+${(char.codePointAt(0) as number).toString(16).toUpperCase().padStart(4, '0')}`).join(' ')
212
+ }
213
+
214
+ function execCheck(ctx: ScanContext): CheckResult {
215
+ const evidence: Evidence[] = []
216
+ for (const file of codeFiles(ctx)) {
217
+ // Code files, and anything live code runs or loads whatever its name.
218
+ if (!isCodeFile(file.path, file.text) && !LIVE_ROLES.has(file.role)) continue
219
+ const inModule = file.role === 'module'
220
+ const python = [{ pattern: PYTHON_PROCESS, what: 'runs programs' }, { pattern: PYTHON_CODE_FROM_TEXT, what: 'runs code built from text' }]
221
+ const shell = [{ pattern: SHELL_EVAL, what: 'evaluates text as shell code' }]
222
+ const js = [{ pattern: PROCESS_CODE, what: 'runs programs' }, { pattern: CODE_FROM_TEXT, what: 'runs code built from text' }]
223
+ // A file of no known language is checked as all of them.
224
+ const patterns: { pattern: RegExp; what: string }[] =
225
+ file.language === 'python' ? python : file.language === 'shell' ? shell : file.language === 'js' ? js : [...js, ...python, ...shell]
226
+ for (const { pattern, what } of patterns) {
227
+ evidence.push(...evidenceFor(file, pattern, inModule ? 'fail' : 'warn', match => `${what}: ${match.excerpt}`, 3))
228
+ }
229
+ evidence.push(...evidenceFor(file, DOWNLOAD_AND_RUN, 'fail', match => `downloads or decodes code and runs it: ${match.excerpt}`, 3))
230
+ }
231
+ return row(
232
+ {
233
+ id: 'code.execution',
234
+ group: GROUP,
235
+ title: 'Running programs and building code at run time',
236
+ advice: 'Inside a mod this is a red flag: mods reach the machine only through $. In a script, check that each command it runs is one its purpose needs.',
237
+ },
238
+ evidence,
239
+ )
240
+ }
241
+
242
+ function obfuscationCheck(ctx: ScanContext): CheckResult {
243
+ const evidence: Evidence[] = []
244
+ for (const file of codeFiles(ctx)) {
245
+ const { path, text } = file
246
+ const runs = [PROCESS_CODE, PYTHON_PROCESS, CODE_FROM_TEXT, SHELL_EVAL, PYTHON_CODE_FROM_TEXT].some(pattern => active(file, pattern, 1).length > 0)
247
+ const decodes = active(file, DECODE, 1).length > 0
248
+ const level: Status = runs && decodes ? 'fail' : 'warn'
249
+ const found: { match: Match; what: string }[] = []
250
+ const split: { line: number; what: string; status: Status }[] = []
251
+ const liveFirst = (pattern: RegExp): Match[] => {
252
+ const isInert = (index: number): boolean => file.kindAt(index) === 'comment' || file.kindAt(index) === 'regex'
253
+ return [...findAll(text, pattern, 2, index => !isInert(index)), ...findAll(text, pattern, 1, isInert)]
254
+ }
255
+ for (const match of liveFirst(/(?<!data:[\w/+.-]{1,40};base64,)(?<![A-Za-z0-9+/])[A-Za-z0-9+/]{200,}={0,2}/g)) found.push({ match, what: `${match.text.length}-character base64 blob` })
256
+ for (const match of liveFirst(/\b(?:[0-9a-fA-F]{2}){100,}\b/g)) found.push({ match, what: `${match.text.length}-character hex blob` })
257
+ // A payload wrapped over short lines: four or more lines of nothing but base64.
258
+ for (const match of liveFirst(/(?:^[ \t]{0,20}['"`]?[A-Za-z0-9+/]{40,120}={0,2}['"`]?(?:[ \t]{0,20}[,+\\])?[ \t]{0,20}\r?\n){4,}/gm)) found.push({ match, what: 'base64 wrapped over several lines' })
259
+ for (const match of liveFirst(/String\.fromCharCode\s*\(\s*(?:\d+\s*,\s*){8,}|\bchr\s*\(\s*\d+\s*\)\s*\+\s*chr\s*\(/g)) found.push({ match, what: `text built from character codes: ${match.excerpt}` })
260
+ for (const match of liveFirst(/(?:\\x[0-9a-fA-F]{2}){16,}|(?:\\u00[0-9a-fA-F]{2}){12,}/g)) found.push({ match, what: 'long run of escaped bytes' })
261
+ // Adjacent literals joined, whatever their quotes and across line breaks:
262
+ // "child" + '_process', or 'child'⏎+ '_process'.
263
+ const JOIN = /(['"`])\s*\+\s*(['"`])/g
264
+ const liveJoins = findAll(text, JOIN, Number.MAX_SAFE_INTEGER, index => {
265
+ const plus = text.indexOf('+', index)
266
+ return !['comment', 'trailing-comment', 'regex'].includes(file.kindAt(plus))
267
+ })
268
+ // Only the live joins are joined: one in a comment or pattern stays as written.
269
+ let joined = text
270
+ if (liveJoins.length > 0) {
271
+ let rebuilt = ''
272
+ let from = 0
273
+ for (const join of liveJoins) {
274
+ rebuilt += text.slice(from, join.index)
275
+ from = join.index + join.text.length
276
+ }
277
+ joined = rebuilt + text.slice(from)
278
+ }
279
+ if (joined !== text) {
280
+ // A dangerous name that only appears once adjacent strings are joined
281
+ // was split to hide it: compare how often each name appears before and
282
+ // after (joins in comments and patterns do not count), and report it at
283
+ // the first live join.
284
+ const count = (source: string): Map<string, number> => {
285
+ const counts = new Map<string, number>()
286
+ for (const match of findAll(source, SPLIT_NAMES, Number.MAX_SAFE_INTEGER)) counts.set(match.text, (counts.get(match.text) ?? 0) + 1)
287
+ return counts
288
+ }
289
+ const beforeCounts = count(text)
290
+ const firstJoin = liveJoins[0] as Match
291
+ for (const [name, after] of count(joined)) {
292
+ if (after > (beforeCounts.get(name) ?? 0)) {
293
+ const match = { ...firstJoin, text: name }
294
+ // A split name is a warning; once joined, if the code runs programs or
295
+ // builds code (require("child" + '_process').exec), it fails.
296
+ const runsJoined = [PROCESS_CODE, PYTHON_PROCESS, CODE_FROM_TEXT, SHELL_EVAL, PYTHON_CODE_FROM_TEXT].some(pattern => pattern.test(joined))
297
+ split.push({ line: match.line, what: `a name split across strings to hide it: ${match.text}`, status: runsJoined ? 'fail' : level })
298
+ break
299
+ }
300
+ }
301
+ }
302
+ const padding = whitespacePadding(text)
303
+ if (padding !== undefined) found.push({ match: padding, what: 'long run of whitespace pushing text out of view' })
304
+ if (!isMarkdown(path)) {
305
+ const dense = denseLine(text)
306
+ if (dense !== undefined) found.push({ match: dense, what: 'a long line of random-looking text' })
307
+ }
308
+ for (const item of found) evidence.push(judged(file, item.match, level, item.what))
309
+ for (const item of split) evidence.push({ file: path, line: item.line, excerpt: item.what, status: isLive(file.role) ? item.status : 'info' })
310
+ }
311
+ for (const path of ctx.prompts) {
312
+ const text = ctx.text(path) ?? ''
313
+ const padding = whitespacePadding(text)
314
+ if (padding !== undefined) {
315
+ evidence.push({ file: path, line: padding.line, excerpt: 'long run of whitespace pushing text out of view', status: capFor(ctx.roleOf(path))('warn') })
316
+ }
317
+ }
318
+ return row(
319
+ {
320
+ id: 'code.obfuscation',
321
+ group: GROUP,
322
+ title: 'Obfuscated or encoded content',
323
+ advice: 'Encoded blobs and split-up names hide what code does. Together with code that decodes and runs text, assume it is hiding a payload.',
324
+ },
325
+ evidence,
326
+ )
327
+ }
328
+
329
+ /**
330
+ * The first run of 300+ spaces or tabs, or 80+ blank lines, that is followed
331
+ * by more text: padding that pushes content out of view. One linear pass, so
332
+ * a file of nothing but spaces cannot stall the scan.
333
+ */
334
+ export function whitespacePadding(text: string): Match | undefined {
335
+ let line = 1
336
+ let spaces = 0
337
+ let blankLines = 0
338
+ let runStart = -1
339
+ let runLine = 1
340
+ let lineHasText = false
341
+ for (let i = 0; i < text.length; i += 1) {
342
+ const code = text.charCodeAt(i)
343
+ if (code === 32 || code === 9) {
344
+ if (spaces === 0 && blankLines === 0) {
345
+ runStart = i
346
+ runLine = line
347
+ }
348
+ spaces += 1
349
+ } else if (code === 10) {
350
+ if (!lineHasText) {
351
+ if (blankLines === 0 && spaces === 0) {
352
+ runStart = i
353
+ runLine = line
354
+ }
355
+ blankLines += 1
356
+ } else {
357
+ blankLines = 1
358
+ runStart = i
359
+ runLine = line + 1
360
+ }
361
+ spaces = 0
362
+ lineHasText = false
363
+ line += 1
364
+ } else if (code !== 13) {
365
+ if (spaces >= 300 || blankLines >= 80) {
366
+ return { index: runStart, line: runLine, text: '', excerpt: '' }
367
+ }
368
+ spaces = 0
369
+ blankLines = 0
370
+ lineHasText = true
371
+ }
372
+ }
373
+ return undefined
374
+ }
375
+
376
+ /**
377
+ * The first line of 200+ characters that looks like random tokens: nearly all
378
+ * letters, digits and `+/=_-`, at more than 5.6 bits a character.
379
+ */
380
+ function denseLine(text: string): Match | undefined {
381
+ let start = 0
382
+ const lines = text.split('\n')
383
+ for (let i = 0; i < lines.length; i += 1) {
384
+ const line = lines[i] as string
385
+ const lineStart = start
386
+ start += line.length + 1
387
+ const trimmed = line.trim()
388
+ if (trimmed.length < 200) continue
389
+ const tokenChars = trimmed.replace(/[^A-Za-z0-9+/=_-]/g, '').length
390
+ if (tokenChars / trimmed.length < 0.9) continue
391
+ const counts = new Map<string, number>()
392
+ for (const char of trimmed) counts.set(char, (counts.get(char) ?? 0) + 1)
393
+ let entropy = 0
394
+ for (const count of counts.values()) {
395
+ const p = count / trimmed.length
396
+ entropy -= p * Math.log2(p)
397
+ }
398
+ if (entropy > 5.6) return { index: lineStart + line.indexOf(trimmed), line: i + 1, text: trimmed, excerpt: '' }
399
+ }
400
+ return undefined
401
+ }
402
+
403
+ function credentialsCheck(ctx: ScanContext): CheckResult {
404
+ const evidence: Evidence[] = []
405
+ for (const file of codeFiles(ctx)) {
406
+ if (file.role === 'hook-script') continue
407
+ const sends = [NETWORK_CODE, NETWORK_COMMAND, COLLECTION_HOST].some(pattern => active(file, pattern, 1).length > 0)
408
+ const lead = sends ? 'with network code in the same file: ' : ''
409
+ evidence.push(...evidenceFor(file, CREDENTIAL_PATH, sends ? 'fail' : 'warn', match => `${lead}${match.excerpt}`, 3))
410
+ evidence.push(...evidenceFor(file, SECRET_ENV, sends ? 'fail' : 'warn', match => `${lead}reads ${secretName(match)}`, 3))
411
+ }
412
+ return row(
413
+ {
414
+ id: 'code.credentials',
415
+ group: GROUP,
416
+ title: 'Credentials and secrets read by code',
417
+ advice: 'Code that reads your keys, tokens or browser data and can reach the network can steal them. Do not install it unless that is plainly its job.',
418
+ },
419
+ evidence,
420
+ )
421
+ }
422
+
423
+ function persistenceCheck(ctx: ScanContext): CheckResult {
424
+ const evidence: Evidence[] = []
425
+ for (const file of codeFiles(ctx)) {
426
+ if (file.role === 'hook-script' || basename(file.path) === 'settings.json') continue
427
+ const writes = writesFiles(file)
428
+ evidence.push(...evidenceFor(file, PERSISTENCE_TARGET, writes ? 'fail' : 'warn', match => `${writes ? 'writes to' : 'names'} a startup location: ${match.excerpt}`, 3))
429
+ evidence.push(...evidenceFor(file, CLAUDE_SETTINGS, writes ? 'fail' : 'warn', match => `Claude Code permission setting: ${match.excerpt}`, 3))
430
+ if (writes) {
431
+ evidence.push(...evidenceFor(file, CLAUDE_LOAD_DIR, 'fail', match => `writes where Claude Code loads plugins, skills or memory: ${match.excerpt}`, 3))
432
+ }
433
+ }
434
+ return row(
435
+ {
436
+ id: 'code.persistence',
437
+ group: GROUP,
438
+ title: 'Persistence and permission changes',
439
+ advice: 'Writing to shell startup files, schedulers, git hooks or Claude Code settings outlives the plugin and can grant it more than you approved.',
440
+ },
441
+ evidence,
442
+ )
443
+ }
444
+
445
+ function installScriptsCheck(ctx: ScanContext): CheckResult {
446
+ const evidence: Evidence[] = []
447
+ for (const file of ctx.files) {
448
+ if (basename(file.path) !== 'package.json') continue
449
+ const raw = ctx.text(file.path) ?? ''
450
+ const parsed = parseJson(raw)
451
+ if (!parsed.ok || !isRecord(parsed.value) || !isRecord(parsed.value.scripts)) continue
452
+ const cap = capFor(ctx.roleOf(file.path))
453
+ for (const name of LIFECYCLE_SCRIPTS) {
454
+ const script = parsed.value.scripts[name]
455
+ if (typeof script !== 'string') continue
456
+ const remote = NETWORK_COMMAND.test(script) || DOWNLOAD_AND_RUN.test(script) || UNPINNED_RUNNER.test(script) || /\bnode\s+-e\b|\|\s*(?:ba|z)?sh\b|https?:\/\//.test(script)
457
+ const line = findFirst(raw, new RegExp(`"${name}"\\s*:`))?.line
458
+ evidence.push({ file: file.path, line, excerpt: `${name}: ${script}`, status: cap(remote ? 'fail' : 'warn') })
459
+ }
460
+ }
461
+ return row(
462
+ {
463
+ id: 'code.install-scripts',
464
+ group: GROUP,
465
+ title: 'Package install scripts',
466
+ advice: 'npm runs these on install with your permissions. One that fetches or runs remote code is a classic supply-chain attack.',
467
+ },
468
+ evidence,
469
+ )
470
+ }
471
+
472
+ function registriesCheck(ctx: ScanContext): CheckResult {
473
+ const evidence: Evidence[] = []
474
+ for (const file of ctx.files) {
475
+ const name = basename(file.path)
476
+ const text = ctx.text(file.path)
477
+ if (text === undefined) continue
478
+ let pattern: RegExp | undefined
479
+ if (/^\.npmrc$/.test(name)) pattern = /^[ \t]*(?:@[\w-]+:)?registry[ \t]*=[ \t]*(?!https:\/\/registry\.npmjs\.org\/?[ \t]*$).+$/m
480
+ else if (name === '.yarnrc.yml' || name === '.yarnrc') pattern = /npmRegistryServer|^[ \t]*registry[ \t]+/m
481
+ else if (name === 'pip.conf' || /^requirements.*\.txt$/.test(name)) pattern = /--(?:extra-)?index-url\b|^[ \t]*-i[ \t]+https?:|^[ \t]*index-url[ \t]*=/m
482
+ else if (name === 'pyproject.toml' || name === 'uv.toml' || name === 'bunfig.toml') pattern = /\bindex-url\b|\[\[tool\.uv\.index\]\]|^[ \t]*registry[ \t]*=/m
483
+ if (pattern === undefined) continue
484
+ const match = findFirst(text, pattern)
485
+ if (match !== undefined) evidence.push({ file: file.path, line: match.line, excerpt: match.excerpt, status: capFor(ctx.roleOf(file.path))('warn') })
486
+ }
487
+ return row(
488
+ {
489
+ id: 'code.registries',
490
+ group: GROUP,
491
+ title: 'Package registry redirects',
492
+ advice: 'A custom registry decides what code an install fetches. Check that it belongs to the publisher.',
493
+ },
494
+ evidence,
495
+ )
496
+ }
497
+
498
+ function domainsCheck(ctx: ScanContext): CheckResult {
499
+ const hosts = new Map<string, { file: string; line: number }>()
500
+ const evidence: Evidence[] = []
501
+ const configFiles = new Set(ctx.mcpServers.map(server => server.file))
502
+ for (const file of codeFiles(ctx)) {
503
+ if (!isLive(file.role) && !configFiles.has(file.path)) continue
504
+ for (const match of active(file, /\b(?:https?|wss?|ftp):\/\/[^\s'"`<>)]{1,500}/g, 200)) {
505
+ // The host is what follows any user-info: in https://a.com@b.site/ it is b.site.
506
+ const authority = /\/\/([^/?#]{0,300})/.exec(match.text)?.[1] ?? ''
507
+ const hasUserInfo = authority.includes('@')
508
+ const host = (/(\[[0-9a-fA-F:]+\]|[A-Za-z0-9.-]+)/.exec(authority.slice(authority.lastIndexOf('@') + 1))?.[1] ?? '').toLowerCase()
509
+ if (host === '') continue
510
+ if (hasUserInfo) evidence.push(judged(file, match, 'warn', `an address whose real host hides after "@": ${host}`))
511
+ if (/^(?:localhost|127\.0\.0\.1|\[::1\]|0\.0\.0\.0|example\.(?:com|org|net))$/.test(host)) continue
512
+ if (!hosts.has(host)) hosts.set(host, { file: file.path, line: match.line })
513
+ if (/^\d{1,3}(?:\.\d{1,3}){3}$|^\[/.test(host)) {
514
+ evidence.push(judged(file, match, 'warn', `talks to a raw IP address: ${host}`))
515
+ }
516
+ const lowered = match.text.toLowerCase()
517
+ if (COLLECTION_HOST.test(`${host}${lowered.slice(lowered.indexOf(host) + host.length)}`)) {
518
+ evidence.push(judged(file, match, 'warn', `talks to a host often used to collect data: ${host}`))
519
+ }
520
+ }
521
+ }
522
+ const status = evidence.reduce<Status>((acc, item) => worse(acc, item.status ?? 'info'), 'info')
523
+ const list = [...hosts.keys()].sort()
524
+ return {
525
+ id: 'code.domains',
526
+ group: GROUP,
527
+ title: 'Network addresses in code and config',
528
+ status,
529
+ severity: status === 'warn' ? 'medium' : 'none',
530
+ evidence: [
531
+ ...evidence,
532
+ ...(list.length > 0 ? [{ file: '-', excerpt: `${list.length} host${list.length === 1 ? '' : 's'}: ${list.slice(0, 30).join(', ')}${list.length > 30 ? ', …' : ''}`, status: 'info' as const }] : []),
533
+ ],
534
+ advice: 'Every address the code can talk to. Check each against what the plugin says it does.',
535
+ }
536
+ }