ai-install-guard 0.0.0 → 0.2.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,638 @@
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 { describePrograms, programsIn, type ProgramUse } from './programs'
25
+ import { row, worse, type CheckResult, type Evidence, type Status } from './types'
26
+
27
+ const GROUP = 'code' as const
28
+
29
+ const CONFIG_EXTENSIONS = new Set(['json', 'jsonc', 'yaml', 'yml', 'toml', 'ini', 'cfg', 'conf', 'env', 'plist', 'xml'])
30
+ 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$/
31
+ const LIVE_ROLES: ReadonlySet<FileRole> = new Set(['module', 'hook-script', 'server', 'prompt-script', 'bin', 'code'])
32
+ const JS_EXTENSIONS = new Set(['js', 'mjs', 'cjs', 'jsx', 'ts', 'tsx', 'mts', 'cts'])
33
+ const SHELL_EXTENSIONS = new Set(['sh', 'bash', 'zsh', 'fish', 'ksh'])
34
+ /** Names worth hiding by splitting them across strings. */
35
+ 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/
36
+
37
+ const LIFECYCLE_SCRIPTS = ['preinstall', 'install', 'postinstall', 'prepare', 'preprepare', 'postprepare', 'prepublish']
38
+
39
+ export function codeChecks(ctx: ScanContext): CheckResult[] {
40
+ return [hiddenCheck(ctx), execCheck(ctx), obfuscationCheck(ctx), credentialsCheck(ctx), persistenceCheck(ctx), installScriptsCheck(ctx), registriesCheck(ctx), domainsCheck(ctx)]
41
+ }
42
+
43
+ type Language = 'js' | 'python' | 'shell' | 'other'
44
+
45
+ /** `#!/bin/bash` → bash, `#!/usr/bin/env -S node --flag` → node. */
46
+ export function interpreterOf(text: string): string {
47
+ const line = /^#!\s*(\S+)((?:[ \t]+\S+)*)/.exec(text)
48
+ if (line === null) return ''
49
+ const program = basename(line[1] as string)
50
+ if (program !== 'env') return program
51
+ const args = (line[2] ?? '').trim().split(/\s+/).filter(arg => arg !== '' && !arg.startsWith('-') && !arg.includes('='))
52
+ return basename(args[0] ?? '')
53
+ }
54
+
55
+ export function languageOf(path: string, text: string): Language {
56
+ const ext = extension(path)
57
+ if (JS_EXTENSIONS.has(ext)) return 'js'
58
+ if (ext === 'py') return 'python'
59
+ if (SHELL_EXTENSIONS.has(ext)) return 'shell'
60
+ const shebang = interpreterOf(text)
61
+ if (/^(?:node|deno|bun)$/.test(shebang)) return 'js'
62
+ if (/^python/.test(shebang)) return 'python'
63
+ if (/^(?:ba|z|da|k|fi)?sh$/.test(shebang)) return 'shell'
64
+ return 'other'
65
+ }
66
+
67
+ type CodeFile = {
68
+ path: string
69
+ text: string
70
+ role: FileRole
71
+ language: Language
72
+ /** What the character at an index is: code, string, comment or pattern. */
73
+ kindAt: (index: number) => SpanKind
74
+ }
75
+
76
+ /** Text files the code checks read: code and config, never lockfiles. */
77
+ function codeFiles(ctx: ScanContext): CodeFile[] {
78
+ const out: CodeFile[] = []
79
+ for (const file of ctx.files) {
80
+ // Lockfiles and source maps are skipped, unless live code loads them.
81
+ if (!file.isText || (LOCKFILES.test(file.path) && !LIVE_ROLES.has(ctx.roleOf(file.path)))) continue
82
+ const text = ctx.text(file.path)
83
+ if (text === undefined) continue
84
+ // Whatever live code loads is code, whatever its name (a required ./helper.map).
85
+ const loaded = LIVE_ROLES.has(ctx.roleOf(file.path))
86
+ if (isCodeFile(file.path, text) || loaded || CONFIG_EXTENSIONS.has(extension(file.path)) || basename(file.path).startsWith('.')) {
87
+ // A hook or server that runs the file with an interpreter decides how it is read.
88
+ const language = ctx.interpreters.get(file.path) ?? languageOf(file.path, text)
89
+ const kindAt = isCodeFile(file.path, text) ? spanKinds(text, language) : () => 'string' as const
90
+ out.push({ path: file.path, text, role: ctx.roleOf(file.path), language, kindAt })
91
+ }
92
+ }
93
+ return out
94
+ }
95
+
96
+ /**
97
+ * A match inside a comment or a regular expression describes text rather
98
+ * than doing anything, so it is only reported.
99
+ */
100
+ function judged(file: CodeFile, match: Match, status: Status, excerpt: string): Evidence {
101
+ const kind = file.kindAt(match.index)
102
+ if (!isLive(file.role)) return { file: file.path, line: match.line, excerpt, status: 'info' }
103
+ if (kind === 'comment' || kind === 'regex') {
104
+ return { file: file.path, line: match.line, excerpt: `${excerpt} (in a ${kind === 'regex' ? 'pattern' : 'comment'})`, status: 'info' }
105
+ }
106
+ if (kind === 'trailing-comment') {
107
+ // A comment after code on the same line is a classic place to hide a
108
+ // command, so it can lower a failure to a warning, never further.
109
+ return { file: file.path, line: match.line, excerpt: `${excerpt} (in a comment after code)`, status: status === 'fail' ? 'warn' : status }
110
+ }
111
+ return { file: file.path, line: match.line, excerpt, status }
112
+ }
113
+
114
+ /** Matches of `pattern` in the file's code and strings, not its comments or patterns. */
115
+ /**
116
+ * Matches of `pattern` that may run: everything but whole-line comments and
117
+ * real patterns. A trailing comment counts here, since a tokenizer can be
118
+ * fooled into one, and gates such as "does it write files" must not be.
119
+ */
120
+ function active(file: CodeFile, pattern: RegExp, limit = 20): Match[] {
121
+ return findAll(file.text, pattern, limit, index => {
122
+ const kind = file.kindAt(index)
123
+ return kind !== 'comment' && kind !== 'regex'
124
+ })
125
+ }
126
+
127
+ /**
128
+ * Every match of `pattern`, judged: live matches first (up to `limit`),
129
+ * then at most two inert ones, so comments cannot crowd out real code.
130
+ */
131
+ function evidenceFor(file: CodeFile, pattern: RegExp, status: Status, describe: (match: Match) => string, limit = 5): Evidence[] {
132
+ const isInert = (index: number): boolean => {
133
+ const kind = file.kindAt(index)
134
+ return kind === 'comment' || kind === 'regex' || kind === 'trailing-comment'
135
+ }
136
+ const live = findAll(file.text, pattern, limit, index => !isInert(index))
137
+ const inert = findAll(file.text, pattern, 2, isInert)
138
+ return [...live, ...inert].map(match => judged(file, match, status, describe(match)))
139
+ }
140
+
141
+ /** `ANTHROPIC_API_KEY=sk-...` → `ANTHROPIC_API_KEY`: the name, never what follows it. */
142
+ export function secretName(match: Match): string {
143
+ return /[A-Z][A-Z0-9_]{2,}/.exec(match.text)?.[0] ?? match.text.trim().slice(0, 40)
144
+ }
145
+
146
+ function writesFiles(file: CodeFile): boolean {
147
+ if (file.language === 'shell') return active(file, WRITE_SHELL, 1).length > 0
148
+ return active(file, WRITE_CODE, 1).length > 0
149
+ }
150
+
151
+ function capFor(role: FileRole): (status: Status) => Status {
152
+ return status => (isLive(role) ? status : 'info')
153
+ }
154
+
155
+ const TAGS = /[\u{e0000}-\u{e007f}]+/gu
156
+ const VARIATION_SUPPLEMENT = /[\u{e0100}-\u{e01ef}]{2,}/gu
157
+ const BIDI = /[\u202a-\u202e\u2066-\u2069\u200e\u200f\u061c]/g
158
+ const ZERO_WIDTH = /[\u200b-\u200d\u2060-\u2064\u180e\u00ad\u034f\u115f\u1160\u3164\uffa0]|(?!^)\ufeff/g
159
+ const ANSI = /\u001b\[[0-9;?]{0,40}[A-Za-z]|\u001b\][^\u0007\u001b]{0,1000}\u0007/g
160
+
161
+ function hiddenCheck(ctx: ScanContext): CheckResult {
162
+ const evidence: Evidence[] = []
163
+ let foundLone = false
164
+ for (const file of ctx.files) {
165
+ const text = file.isText ? ctx.text(file.path) : undefined
166
+ if (text === undefined) continue
167
+ const role = ctx.roleOf(file.path)
168
+ const cap = (status: Status): Status => (role === 'test' ? 'info' : role === 'doc' && status === 'fail' ? 'warn' : status)
169
+ const isCode = isCodeFile(file.path, text)
170
+
171
+ for (const match of findAll(text, TAGS, 5)) {
172
+ evidence.push({ file: file.path, line: match.line, excerpt: `${[...match.text].length} invisible tag characters spelling "${decodeTags(match.text)}"`, status: cap('fail') })
173
+ }
174
+ for (const match of findAll(text, VARIATION_SUPPLEMENT, 3)) {
175
+ 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') })
176
+ }
177
+ // In code a control that is all of a string, with nothing after it, is
178
+ // only a warning, and only in a language the tokenizer knows. Failures
179
+ // are found first, so no number of such strings can crowd out a real one.
180
+ const language = isCode ? (ctx.interpreters.get(file.path) ?? languageOf(file.path, text)) : 'other'
181
+ let kinds: ((index: number) => SpanKind) | undefined
182
+ const kindAt = (index: number): SpanKind => (kinds ??= spanKinds(text, language))(index)
183
+ const lone = (index: number): boolean => language !== 'other' && isLoneInString(text, index, kindAt)
184
+ for (const match of findAll(text, BIDI, 5, index => !lone(index))) {
185
+ 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') })
186
+ }
187
+ if (language !== 'other') {
188
+ for (const match of findAll(text, BIDI, 3, lone)) {
189
+ const status = cap('warn')
190
+ if (status === 'warn') foundLone = true
191
+ const excerpt = `bidirectional control ${codePoints(match.text)} is all of a string: it cannot reorder code there, but the string looks empty; write it as ${escapeFor(match, file.path, text, language)}`
192
+ evidence.push({ file: file.path, line: match.line, excerpt, status })
193
+ }
194
+ }
195
+ const zero = findAll(text, ZERO_WIDTH, 50)
196
+ if (zero.length > 0 && (isCode || isMarkdown(file.path))) {
197
+ const first = zero[0] as (typeof zero)[number]
198
+ 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') })
199
+ }
200
+ const ansi = findFirst(text, ANSI)
201
+ if (ansi !== undefined) {
202
+ evidence.push({ file: file.path, line: ansi.line, excerpt: 'terminal escape sequence: can hide or repaint text in a terminal', status: cap('warn') })
203
+ }
204
+ }
205
+ return row(
206
+ {
207
+ id: 'code.hidden-characters',
208
+ group: GROUP,
209
+ title: 'Hidden characters',
210
+ 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.${
211
+ foundLone ? " A plugin that needs a direction mark, as one for right-to-left text does, should write it as an escape such as '\\u200f', never the raw character, which makes its string look empty." : ''
212
+ }`,
213
+ },
214
+ evidence,
215
+ )
216
+ }
217
+
218
+ /**
219
+ * Whether the bidirectional control at `index` is all of a one-line string,
220
+ * as in `const RLM = '\u200f'`, with nothing after it on its line but blanks,
221
+ * `;`, `,` or a comment. What it can reorder runs to the end of its line, and
222
+ * there that is no code; what comes before it does not move.
223
+ */
224
+ function isLoneInString(text: string, index: number, kindAt: (index: number) => SpanKind): boolean {
225
+ const quote = text[index - 1]
226
+ if ((quote !== "'" && quote !== '"') || text[index + 1] !== quote) return false
227
+ if (kindAt(index - 1) !== 'string' || kindAt(index) !== 'string' || kindAt(index + 1) !== 'string') return false
228
+ // The quote must open the string: what comes before it is no string, and no
229
+ // `}`, which may close a template's `${...}` and leave the quote as its text.
230
+ if (index >= 2 && (kindAt(index - 2) === 'string' || text[index - 2] === '}')) return false
231
+ // Only LF ends the line here. U+2028 and U+2029 end a JavaScript line, but
232
+ // editors often show them inline and U+2028 ends no bidi paragraph, so code
233
+ // after one can still be reordered: that fails.
234
+ for (let i = index + 2; i < text.length && text[i] !== '\n'; i += 1) {
235
+ const char = text[i]
236
+ if (char === ' ' || char === '\t' || char === ';' || char === ',') continue
237
+ // Only a CR that ends the line: a lone one ends a line too, and code may follow it.
238
+ if (char === '\r' && (i + 1 === text.length || text[i + 1] === '\n')) continue
239
+ const kind = kindAt(i)
240
+ if (kind !== 'comment' && kind !== 'trailing-comment') return false
241
+ }
242
+ return true
243
+ }
244
+
245
+ /** `'\u200f'` for a lone control, as its language writes the escape. */
246
+ function escapeFor(match: Match, path: string, text: string, language: Language): string {
247
+ const hex = (match.text.codePointAt(0) as number).toString(16).padStart(4, '0')
248
+ if (language !== 'shell') return `${text[match.index - 1]}\\u${hex}${text[match.index - 1]}`
249
+ // fish reads the escape bare. Other shells differ (sh has no such escape,
250
+ // and bash before 4.2 not in $'...'), but every printf takes octal bytes.
251
+ if ((interpreterOf(text) || extension(path)) === 'fish') return `\\u${hex}`
252
+ const octal = [...new TextEncoder().encode(match.text)].map(byte => `\\${byte.toString(8)}`).join('')
253
+ return `"$(printf '${octal}')"`
254
+ }
255
+
256
+ /** Tag characters U+E0020..U+E007E mirror printable ASCII. */
257
+ export function decodeTags(text: string): string {
258
+ let out = ''
259
+ for (const char of text) {
260
+ const code = (char.codePointAt(0) as number) - 0xe0000
261
+ out += code >= 0x20 && code <= 0x7e ? String.fromCharCode(code) : ''
262
+ }
263
+ return safeLine(out.length > 120 ? `${out.slice(0, 120)}…` : out)
264
+ }
265
+
266
+ function codePoints(text: string): string {
267
+ return [...text].map(char => `U+${(char.codePointAt(0) as number).toString(16).toUpperCase().padStart(4, '0')}`).join(' ')
268
+ }
269
+
270
+ function execCheck(ctx: ScanContext): CheckResult {
271
+ const evidence: Evidence[] = []
272
+ const uses: ProgramUse[] = []
273
+ let buildsCode = 0
274
+ let downloads = 0
275
+ for (const file of codeFiles(ctx)) {
276
+ // Code files, and anything live code runs or loads whatever its name.
277
+ if (!isCodeFile(file.path, file.text) && !LIVE_ROLES.has(file.role)) continue
278
+ const inModule = file.role === 'module'
279
+ const python = [{ pattern: PYTHON_PROCESS, what: 'runs programs' }, { pattern: PYTHON_CODE_FROM_TEXT, what: 'runs code built from text' }]
280
+ const shell = [{ pattern: SHELL_EVAL, what: 'evaluates text as shell code' }]
281
+ const js = [{ pattern: PROCESS_CODE, what: 'runs programs' }, { pattern: CODE_FROM_TEXT, what: 'runs code built from text' }]
282
+ // A file of no known language is checked as all of them.
283
+ const patterns: { pattern: RegExp; what: string }[] =
284
+ file.language === 'python' ? python : file.language === 'shell' ? shell : file.language === 'js' ? js : [...js, ...python, ...shell]
285
+ for (const { pattern, what } of patterns) {
286
+ evidence.push(...evidenceFor(file, pattern, inModule ? 'fail' : 'warn', match => `${what}: ${match.excerpt}`, 3))
287
+ }
288
+ evidence.push(...evidenceFor(file, DOWNLOAD_AND_RUN, 'fail', match => `downloads or decodes code and runs it: ${match.excerpt}`, 3))
289
+ // The summary counts live code only: tests and docs are only reported.
290
+ if (isLive(file.role)) {
291
+ const keep = (index: number) => !['comment', 'regex', 'trailing-comment'].includes(file.kindAt(index))
292
+ uses.push(...programsIn(file.path, file.text, file.language, keep))
293
+ // The same language rules as the evidence, each place counted once.
294
+ const fromText = file.language === 'js' ? [CODE_FROM_TEXT] : file.language === 'python' ? [PYTHON_CODE_FROM_TEXT] : file.language === 'shell' ? [SHELL_EVAL] : [CODE_FROM_TEXT, PYTHON_CODE_FROM_TEXT, SHELL_EVAL]
295
+ buildsCode += new Set(fromText.flatMap(pattern => findAll(file.text, pattern, 50, keep).map(match => match.line))).size
296
+ downloads += findAll(file.text, DOWNLOAD_AND_RUN, 50, keep).length
297
+ }
298
+ }
299
+ return row(
300
+ {
301
+ id: 'code.execution',
302
+ group: GROUP,
303
+ title: 'Running programs and building code at run time',
304
+ advice:
305
+ 'Check that each program fits what it says it does. Shells, compilers and programs chosen while it runs can do anything, so look at those first. Inside a mod any of this is a red flag: a mod reaches your machine only through Claude Code.',
306
+ summary: summaryOrFallback(describePrograms(uses, { buildsCode, downloads }), evidence),
307
+ },
308
+ evidence,
309
+ )
310
+ }
311
+
312
+ /** A row that found something but could not name it still says so in words. */
313
+ function summaryOrFallback(summary: string[], evidence: readonly Evidence[]): string[] {
314
+ if (summary.length > 0 || !evidence.some(item => item.status === 'warn' || item.status === 'fail')) return summary
315
+ return ['Starts other programs; which ones could not be read from the code']
316
+ }
317
+
318
+ function obfuscationCheck(ctx: ScanContext): CheckResult {
319
+ const evidence: Evidence[] = []
320
+ for (const file of codeFiles(ctx)) {
321
+ const { path, text } = file
322
+ const runs = [PROCESS_CODE, PYTHON_PROCESS, CODE_FROM_TEXT, SHELL_EVAL, PYTHON_CODE_FROM_TEXT].some(pattern => active(file, pattern, 1).length > 0)
323
+ const decodes = active(file, DECODE, 1).length > 0
324
+ const level: Status = runs && decodes ? 'fail' : 'warn'
325
+ const found: { match: Match; what: string }[] = []
326
+ const split: { line: number; what: string; status: Status }[] = []
327
+ const liveFirst = (pattern: RegExp): Match[] => {
328
+ const isInert = (index: number): boolean => file.kindAt(index) === 'comment' || file.kindAt(index) === 'regex'
329
+ return [...findAll(text, pattern, 2, index => !isInert(index)), ...findAll(text, pattern, 1, isInert)]
330
+ }
331
+ 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` })
332
+ for (const match of liveFirst(/\b(?:[0-9a-fA-F]{2}){100,}\b/g)) found.push({ match, what: `${match.text.length}-character hex blob` })
333
+ // A payload wrapped over short lines: four or more lines of nothing but base64.
334
+ 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' })
335
+ 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}` })
336
+ 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' })
337
+ // Adjacent literals joined, whatever their quotes and across line breaks:
338
+ // "child" + '_process', or 'child'⏎+ '_process'.
339
+ const JOIN = /(['"`])\s*\+\s*(['"`])/g
340
+ const liveJoins = findAll(text, JOIN, Number.MAX_SAFE_INTEGER, index => {
341
+ const plus = text.indexOf('+', index)
342
+ return !['comment', 'trailing-comment', 'regex'].includes(file.kindAt(plus))
343
+ })
344
+ // Only the live joins are joined: one in a comment or pattern stays as written.
345
+ let joined = text
346
+ if (liveJoins.length > 0) {
347
+ let rebuilt = ''
348
+ let from = 0
349
+ for (const join of liveJoins) {
350
+ rebuilt += text.slice(from, join.index)
351
+ from = join.index + join.text.length
352
+ }
353
+ joined = rebuilt + text.slice(from)
354
+ }
355
+ if (joined !== text) {
356
+ // A dangerous name that only appears once adjacent strings are joined
357
+ // was split to hide it: compare how often each name appears before and
358
+ // after (joins in comments and patterns do not count), and report it at
359
+ // the first live join.
360
+ const count = (source: string): Map<string, number> => {
361
+ const counts = new Map<string, number>()
362
+ for (const match of findAll(source, SPLIT_NAMES, Number.MAX_SAFE_INTEGER)) counts.set(match.text, (counts.get(match.text) ?? 0) + 1)
363
+ return counts
364
+ }
365
+ const beforeCounts = count(text)
366
+ const firstJoin = liveJoins[0] as Match
367
+ for (const [name, after] of count(joined)) {
368
+ if (after > (beforeCounts.get(name) ?? 0)) {
369
+ const match = { ...firstJoin, text: name }
370
+ // A split name is a warning; once joined, if the code runs programs or
371
+ // builds code (require("child" + '_process').exec), it fails.
372
+ const runsJoined = [PROCESS_CODE, PYTHON_PROCESS, CODE_FROM_TEXT, SHELL_EVAL, PYTHON_CODE_FROM_TEXT].some(pattern => pattern.test(joined))
373
+ split.push({ line: match.line, what: `a name split across strings to hide it: ${match.text}`, status: runsJoined ? 'fail' : level })
374
+ break
375
+ }
376
+ }
377
+ }
378
+ const padding = whitespacePadding(text)
379
+ if (padding !== undefined) found.push({ match: padding, what: 'long run of whitespace pushing text out of view' })
380
+ if (!isMarkdown(path)) {
381
+ const dense = denseLine(text)
382
+ if (dense !== undefined) found.push({ match: dense, what: 'a long line of random-looking text' })
383
+ }
384
+ for (const item of found) evidence.push(judged(file, item.match, level, item.what))
385
+ for (const item of split) evidence.push({ file: path, line: item.line, excerpt: item.what, status: isLive(file.role) ? item.status : 'info' })
386
+ }
387
+ for (const path of ctx.prompts) {
388
+ const text = ctx.text(path) ?? ''
389
+ const padding = whitespacePadding(text)
390
+ if (padding !== undefined) {
391
+ evidence.push({ file: path, line: padding.line, excerpt: 'long run of whitespace pushing text out of view', status: capFor(ctx.roleOf(path))('warn') })
392
+ }
393
+ }
394
+ return row(
395
+ {
396
+ id: 'code.obfuscation',
397
+ group: GROUP,
398
+ title: 'Obfuscated or encoded content',
399
+ 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.',
400
+ },
401
+ evidence,
402
+ )
403
+ }
404
+
405
+ /**
406
+ * The first run of 300+ spaces or tabs, or 80+ blank lines, that is followed
407
+ * by more text: padding that pushes content out of view. One linear pass, so
408
+ * a file of nothing but spaces cannot stall the scan.
409
+ */
410
+ export function whitespacePadding(text: string): Match | undefined {
411
+ let line = 1
412
+ let spaces = 0
413
+ let blankLines = 0
414
+ let runStart = -1
415
+ let runLine = 1
416
+ let lineHasText = false
417
+ for (let i = 0; i < text.length; i += 1) {
418
+ const code = text.charCodeAt(i)
419
+ if (code === 32 || code === 9) {
420
+ if (spaces === 0 && blankLines === 0) {
421
+ runStart = i
422
+ runLine = line
423
+ }
424
+ spaces += 1
425
+ } else if (code === 10) {
426
+ if (!lineHasText) {
427
+ if (blankLines === 0 && spaces === 0) {
428
+ runStart = i
429
+ runLine = line
430
+ }
431
+ blankLines += 1
432
+ } else {
433
+ blankLines = 1
434
+ runStart = i
435
+ runLine = line + 1
436
+ }
437
+ spaces = 0
438
+ lineHasText = false
439
+ line += 1
440
+ } else if (code !== 13) {
441
+ if (spaces >= 300 || blankLines >= 80) {
442
+ return { index: runStart, line: runLine, text: '', excerpt: '' }
443
+ }
444
+ spaces = 0
445
+ blankLines = 0
446
+ lineHasText = true
447
+ }
448
+ }
449
+ return undefined
450
+ }
451
+
452
+ /**
453
+ * The first line of 200+ characters that looks like random tokens: nearly all
454
+ * letters, digits and `+/=_-`, at more than 5.6 bits a character.
455
+ */
456
+ function denseLine(text: string): Match | undefined {
457
+ let start = 0
458
+ const lines = text.split('\n')
459
+ for (let i = 0; i < lines.length; i += 1) {
460
+ const line = lines[i] as string
461
+ const lineStart = start
462
+ start += line.length + 1
463
+ const trimmed = line.trim()
464
+ if (trimmed.length < 200) continue
465
+ const tokenChars = trimmed.replace(/[^A-Za-z0-9+/=_-]/g, '').length
466
+ if (tokenChars / trimmed.length < 0.9) continue
467
+ const counts = new Map<string, number>()
468
+ for (const char of trimmed) counts.set(char, (counts.get(char) ?? 0) + 1)
469
+ let entropy = 0
470
+ for (const count of counts.values()) {
471
+ const p = count / trimmed.length
472
+ entropy -= p * Math.log2(p)
473
+ }
474
+ if (entropy > 5.6) return { index: lineStart + line.indexOf(trimmed), line: i + 1, text: trimmed, excerpt: '' }
475
+ }
476
+ return undefined
477
+ }
478
+
479
+ function credentialsCheck(ctx: ScanContext): CheckResult {
480
+ const evidence: Evidence[] = []
481
+ const secrets = new Set<string>()
482
+ const places = new Set<string>()
483
+ const files = new Set<string>()
484
+ const sending = new Set<string>()
485
+ for (const file of codeFiles(ctx)) {
486
+ // Hook scripts and MCP configuration are judged where their target is
487
+ // known: the hooks check, and mcp.credentials with the server's host.
488
+ if (file.role === 'hook-script' || file.role === 'mcp-config') continue
489
+ const sends = [NETWORK_CODE, NETWORK_COMMAND, COLLECTION_HOST].some(pattern => active(file, pattern, 1).length > 0)
490
+ const lead = sends ? 'with network code in the same file: ' : ''
491
+ const paths = evidenceFor(file, CREDENTIAL_PATH, sends ? 'fail' : 'warn', match => `${lead}${match.excerpt}`, 3)
492
+ const names = evidenceFor(file, SECRET_ENV, sends ? 'fail' : 'warn', match => `${lead}reads ${secretName(match)}`, 3)
493
+ evidence.push(...paths, ...names)
494
+ if (!isLive(file.role)) continue
495
+ for (const match of active(file, CREDENTIAL_PATH, 20)) places.add(match.text.trim().replace(/\/$/, ''))
496
+ // A dump of the whole environment (JSON.stringify(process.env), printenv) is every secret at once.
497
+ for (const match of active(file, SECRET_ENV, 20)) secrets.add(/process\.env|os\.environ|printenv|\benv\b/.test(match.text) ? 'all your environment variables' : secretName(match))
498
+ if (paths.length + names.length > 0) {
499
+ files.add(file.path)
500
+ if (sends) sending.add(file.path)
501
+ }
502
+ }
503
+ const summary: string[] = []
504
+ const things = [...secrets, ...places]
505
+ if (things.length > 0) {
506
+ summary.push(`Reads ${things.slice(0, 5).join(', ')}${things.length > 5 ? ` and ${things.length - 5} more` : ''}${sending.size > 0 ? ', in code that can also reach the network' : ''}`)
507
+ if (secrets.size > 0) summary.push(`Secrets from your environment: ${[...secrets].join(', ')}`)
508
+ if (places.size > 0) summary.push(`Credential files and stores: ${[...places].join(', ')}`)
509
+ if (sending.size > 0) summary.push(`Worth a look: ${[...sending].slice(0, 4).join(', ')} can also send data over the network`)
510
+ summary.push(`In ${files.size} file${files.size === 1 ? '' : 's'}: ${[...files].slice(0, 4).join(', ')}${files.size > 4 ? ` and ${files.size - 4} more` : ''}`)
511
+ }
512
+ return row(
513
+ {
514
+ id: 'code.credentials',
515
+ group: GROUP,
516
+ title: 'Credentials and secrets read by code',
517
+ 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.',
518
+ summary,
519
+ },
520
+ evidence,
521
+ )
522
+ }
523
+
524
+ function persistenceCheck(ctx: ScanContext): CheckResult {
525
+ const evidence: Evidence[] = []
526
+ for (const file of codeFiles(ctx)) {
527
+ if (file.role === 'hook-script' || basename(file.path) === 'settings.json') continue
528
+ const writes = writesFiles(file)
529
+ evidence.push(...evidenceFor(file, PERSISTENCE_TARGET, writes ? 'fail' : 'warn', match => `${writes ? 'writes to' : 'names'} a startup location: ${match.excerpt}`, 3))
530
+ evidence.push(...evidenceFor(file, CLAUDE_SETTINGS, writes ? 'fail' : 'warn', match => `Claude Code permission setting: ${match.excerpt}`, 3))
531
+ if (writes) {
532
+ evidence.push(...evidenceFor(file, CLAUDE_LOAD_DIR, 'fail', match => `writes where Claude Code loads plugins, skills or memory: ${match.excerpt}`, 3))
533
+ }
534
+ }
535
+ return row(
536
+ {
537
+ id: 'code.persistence',
538
+ group: GROUP,
539
+ title: 'Persistence and permission changes',
540
+ advice: 'Writing to shell startup files, schedulers, git hooks or Claude Code settings outlives the plugin and can grant it more than you approved.',
541
+ },
542
+ evidence,
543
+ )
544
+ }
545
+
546
+ function installScriptsCheck(ctx: ScanContext): CheckResult {
547
+ const evidence: Evidence[] = []
548
+ for (const file of ctx.files) {
549
+ if (basename(file.path) !== 'package.json') continue
550
+ const raw = ctx.text(file.path) ?? ''
551
+ const parsed = parseJson(raw)
552
+ if (!parsed.ok || !isRecord(parsed.value) || !isRecord(parsed.value.scripts)) continue
553
+ const cap = capFor(ctx.roleOf(file.path))
554
+ for (const name of LIFECYCLE_SCRIPTS) {
555
+ const script = parsed.value.scripts[name]
556
+ if (typeof script !== 'string') continue
557
+ 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)
558
+ const line = findFirst(raw, new RegExp(`"${name}"\\s*:`))?.line
559
+ evidence.push({ file: file.path, line, excerpt: `${name}: ${script}`, status: cap(remote ? 'fail' : 'warn') })
560
+ }
561
+ }
562
+ return row(
563
+ {
564
+ id: 'code.install-scripts',
565
+ group: GROUP,
566
+ title: 'Package install scripts',
567
+ advice: 'npm runs these on install with your permissions. One that fetches or runs remote code is a classic supply-chain attack.',
568
+ },
569
+ evidence,
570
+ )
571
+ }
572
+
573
+ function registriesCheck(ctx: ScanContext): CheckResult {
574
+ const evidence: Evidence[] = []
575
+ for (const file of ctx.files) {
576
+ const name = basename(file.path)
577
+ const text = ctx.text(file.path)
578
+ if (text === undefined) continue
579
+ let pattern: RegExp | undefined
580
+ if (/^\.npmrc$/.test(name)) pattern = /^[ \t]*(?:@[\w-]+:)?registry[ \t]*=[ \t]*(?!https:\/\/registry\.npmjs\.org\/?[ \t]*$).+$/m
581
+ else if (name === '.yarnrc.yml' || name === '.yarnrc') pattern = /npmRegistryServer|^[ \t]*registry[ \t]+/m
582
+ else if (name === 'pip.conf' || /^requirements.*\.txt$/.test(name)) pattern = /--(?:extra-)?index-url\b|^[ \t]*-i[ \t]+https?:|^[ \t]*index-url[ \t]*=/m
583
+ else if (name === 'pyproject.toml' || name === 'uv.toml' || name === 'bunfig.toml') pattern = /\bindex-url\b|\[\[tool\.uv\.index\]\]|^[ \t]*registry[ \t]*=/m
584
+ if (pattern === undefined) continue
585
+ const match = findFirst(text, pattern)
586
+ if (match !== undefined) evidence.push({ file: file.path, line: match.line, excerpt: match.excerpt, status: capFor(ctx.roleOf(file.path))('warn') })
587
+ }
588
+ return row(
589
+ {
590
+ id: 'code.registries',
591
+ group: GROUP,
592
+ title: 'Package registry redirects',
593
+ advice: 'A custom registry decides what code an install fetches. Check that it belongs to the publisher.',
594
+ },
595
+ evidence,
596
+ )
597
+ }
598
+
599
+ function domainsCheck(ctx: ScanContext): CheckResult {
600
+ const hosts = new Map<string, { file: string; line: number }>()
601
+ const evidence: Evidence[] = []
602
+ const configFiles = new Set(ctx.mcpServers.map(server => server.file))
603
+ for (const file of codeFiles(ctx)) {
604
+ if (!isLive(file.role) && !configFiles.has(file.path)) continue
605
+ for (const match of active(file, /\b(?:https?|wss?|ftp):\/\/[^\s'"`<>)]{1,500}/g, 200)) {
606
+ // The host is what follows any user-info: in https://a.com@b.site/ it is b.site.
607
+ const authority = /\/\/([^/?#]{0,300})/.exec(match.text)?.[1] ?? ''
608
+ const hasUserInfo = authority.includes('@')
609
+ const host = (/(\[[0-9a-fA-F:]+\]|[A-Za-z0-9.-]+)/.exec(authority.slice(authority.lastIndexOf('@') + 1))?.[1] ?? '').toLowerCase()
610
+ // A one-word host (https://x) is a placeholder, not a place on the internet.
611
+ if (host === '' || (!host.includes('.') && !host.startsWith('['))) continue
612
+ if (hasUserInfo) evidence.push(judged(file, match, 'warn', `an address whose real host hides after "@": ${host}`))
613
+ if (/^(?:localhost|127\.0\.0\.1|\[::1\]|0\.0\.0\.0|example\.(?:com|org|net))$/.test(host)) continue
614
+ if (!hosts.has(host)) hosts.set(host, { file: file.path, line: match.line })
615
+ if (/^\d{1,3}(?:\.\d{1,3}){3}$|^\[/.test(host)) {
616
+ evidence.push(judged(file, match, 'warn', `talks to a raw IP address: ${host}`))
617
+ }
618
+ const lowered = match.text.toLowerCase()
619
+ if (COLLECTION_HOST.test(`${host}${lowered.slice(lowered.indexOf(host) + host.length)}`)) {
620
+ evidence.push(judged(file, match, 'warn', `talks to a host often used to collect data: ${host}`))
621
+ }
622
+ }
623
+ }
624
+ const status = evidence.reduce<Status>((acc, item) => worse(acc, item.status ?? 'info'), 'info')
625
+ const list = [...hosts.keys()].sort()
626
+ return {
627
+ id: 'code.domains',
628
+ group: GROUP,
629
+ title: 'Network addresses in code and config',
630
+ status,
631
+ severity: status === 'warn' ? 'medium' : 'none',
632
+ evidence: [
633
+ ...evidence,
634
+ ...(list.length > 0 ? [{ file: '-', excerpt: `${list.length} host${list.length === 1 ? '' : 's'}: ${list.slice(0, 30).join(', ')}${list.length > 30 ? ', …' : ''}`, status: 'info' as const }] : []),
635
+ ],
636
+ advice: 'Every address the code can talk to. Check each against what the plugin says it does.',
637
+ }
638
+ }