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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +2 -1
- package/CHANGELOG.md +9 -1
- package/README.md +31 -5
- package/hooks/guard/decisions.ts +91 -0
- package/hooks/guard/pane.tsx +206 -0
- package/hooks/guard/view.ts +69 -0
- package/hooks/register.tsx +111 -20
- package/hooks/scan/checks/code.ts +536 -0
- package/hooks/scan/checks/context.ts +655 -0
- package/hooks/scan/checks/hooks.ts +156 -0
- package/hooks/scan/checks/magic.ts +40 -0
- package/hooks/scan/checks/mod.ts +290 -0
- package/hooks/scan/checks/patterns.ts +106 -0
- package/hooks/scan/checks/prompts.ts +316 -0
- package/hooks/scan/checks/quality.ts +56 -0
- package/hooks/scan/checks/secrets.ts +107 -0
- package/hooks/scan/checks/structure.ts +357 -0
- package/hooks/scan/checks/text.ts +362 -0
- package/hooks/scan/checks/types.ts +89 -0
- package/hooks/scan/checks/validator.ts +97 -0
- package/hooks/scan/report.ts +53 -31
- package/hooks/scan/scan.ts +274 -0
- package/hooks/scan/verdict.ts +58 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|