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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +11 -2
- package/CHANGELOG.md +47 -1
- package/README.md +96 -9
- package/assets/reviewed-by-ai-install-guard-light@1x.png +0 -0
- package/hooks/guard/badge.ts +35 -0
- package/hooks/guard/decisions.ts +100 -0
- package/hooks/guard/pane.tsx +253 -0
- package/hooks/guard/view.ts +87 -0
- package/hooks/mod-host.ts +1 -0
- package/hooks/register.tsx +171 -21
- package/hooks/scan/checks/code.ts +638 -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/mcp.ts +271 -0
- package/hooks/scan/checks/mod.ts +374 -0
- package/hooks/scan/checks/patterns.ts +107 -0
- package/hooks/scan/checks/programs.ts +150 -0
- package/hooks/scan/checks/prompts.ts +319 -0
- package/hooks/scan/checks/provenance.ts +190 -0
- package/hooks/scan/checks/quality.ts +56 -0
- package/hooks/scan/checks/secrets.ts +107 -0
- package/hooks/scan/checks/structure.ts +449 -0
- package/hooks/scan/checks/text.ts +362 -0
- package/hooks/scan/checks/types.ts +105 -0
- package/hooks/scan/checks/validator.ts +111 -0
- package/hooks/scan/github.ts +219 -0
- package/hooks/scan/host.ts +13 -0
- package/hooks/scan/lookups.ts +370 -0
- package/hooks/scan/remote.ts +406 -0
- package/hooks/scan/report.ts +62 -31
- package/hooks/scan/scan.ts +360 -0
- package/hooks/scan/verdict.ts +64 -0
- package/package.json +8 -3
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { isLive, type ScanContext } from './context'
|
|
2
|
+
import { SECRET_KEYS } from './patterns'
|
|
3
|
+
import { basename, findAll } from './text'
|
|
4
|
+
import { row, type CheckResult, type Evidence } from './types'
|
|
5
|
+
|
|
6
|
+
const GROUP = 'secrets' as const
|
|
7
|
+
|
|
8
|
+
const PLACEHOLDER = /^(?:|<.*>|\$\{.*\}|your[-_ ].*|xx+|\*+|changeme|change-me|todo|tbd|none|null|example|placeholder|dummy|test|secret|password|\.\.\.)$/i
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A binary file's text for the key patterns: UTF-16 decoded as such (a BOM,
|
|
12
|
+
* or NUL in every other byte), anything else with printable ASCII and line
|
|
13
|
+
* breaks kept and every other byte a space.
|
|
14
|
+
*/
|
|
15
|
+
function asciiOf(bytes: Uint8Array | undefined): string | undefined {
|
|
16
|
+
if (bytes === undefined) return undefined
|
|
17
|
+
// Decoded by hand: a mod's TextDecoder need not know UTF-16.
|
|
18
|
+
const utf16 = utf16Of(bytes)
|
|
19
|
+
if (utf16 !== undefined) {
|
|
20
|
+
let text = ''
|
|
21
|
+
const little = utf16 === 'utf-16le'
|
|
22
|
+
for (let i = 0; i + 1 < bytes.length; i += 2) {
|
|
23
|
+
const code = little ? (bytes[i] as number) | ((bytes[i + 1] as number) << 8) : ((bytes[i] as number) << 8) | (bytes[i + 1] as number)
|
|
24
|
+
text += String.fromCharCode(code)
|
|
25
|
+
}
|
|
26
|
+
return text
|
|
27
|
+
}
|
|
28
|
+
let out = ''
|
|
29
|
+
for (const byte of bytes) out += byte === 10 || (byte >= 32 && byte < 127) ? String.fromCharCode(byte) : ' '
|
|
30
|
+
return out
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function utf16Of(bytes: Uint8Array): 'utf-16le' | 'utf-16be' | undefined {
|
|
34
|
+
if (bytes[0] === 0xff && bytes[1] === 0xfe) return 'utf-16le'
|
|
35
|
+
if (bytes[0] === 0xfe && bytes[1] === 0xff) return 'utf-16be'
|
|
36
|
+
const sample = Math.min(bytes.length, 400) & ~1
|
|
37
|
+
if (sample < 8) return undefined
|
|
38
|
+
let evenNul = 0
|
|
39
|
+
let oddNul = 0
|
|
40
|
+
for (let i = 0; i < sample; i += 2) {
|
|
41
|
+
if (bytes[i] === 0) evenNul += 1
|
|
42
|
+
if (bytes[i + 1] === 0) oddNul += 1
|
|
43
|
+
}
|
|
44
|
+
const half = sample / 2
|
|
45
|
+
if (oddNul > half * 0.8 && evenNul < half * 0.2) return 'utf-16le'
|
|
46
|
+
if (evenNul > half * 0.8 && oddNul < half * 0.2) return 'utf-16be'
|
|
47
|
+
return undefined
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function secretChecks(ctx: ScanContext): CheckResult[] {
|
|
51
|
+
const keys: Evidence[] = []
|
|
52
|
+
for (const file of ctx.files) {
|
|
53
|
+
// Binary files are read too, as ASCII: a NUL byte must not hide a key.
|
|
54
|
+
const text = file.isText ? ctx.text(file.path) : asciiOf(ctx.tree.contents.get(file.path))
|
|
55
|
+
if (text === undefined) continue
|
|
56
|
+
const live = isLive(ctx.roleOf(file.path))
|
|
57
|
+
for (const { kind, pattern } of SECRET_KEYS) {
|
|
58
|
+
for (const match of findAll(text, pattern, 3)) {
|
|
59
|
+
keys.push({ file: file.path, line: match.line, excerpt: kind, status: live ? 'fail' : 'info' })
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const envFiles: Evidence[] = []
|
|
65
|
+
for (const file of ctx.files) {
|
|
66
|
+
const name = basename(file.path)
|
|
67
|
+
if (!/^\.env(?:\..+)?$/.test(name) || /\.(?:example|sample|template|dist|defaults?|schema)$/i.test(name)) continue
|
|
68
|
+
const text = ctx.text(file.path) ?? asciiOf(ctx.tree.contents.get(file.path)) ?? ''
|
|
69
|
+
const lines = text.split(/\r?\n/)
|
|
70
|
+
const filled = lines
|
|
71
|
+
.map((line, index) => ({ index, match: /^[ \t]*(?:export[ \t]+)?([A-Za-z_][A-Za-z0-9_]{0,200})[ \t]*=(.*)$/.exec(line) }))
|
|
72
|
+
.filter(item => item.match !== null && !PLACEHOLDER.test((item.match[2] as string).trim().replace(/^['"]|['"]$/g, '')))
|
|
73
|
+
if (filled.length > 0) {
|
|
74
|
+
const first = filled[0] as { index: number; match: RegExpExecArray }
|
|
75
|
+
envFiles.push({
|
|
76
|
+
file: file.path,
|
|
77
|
+
line: first.index + 1,
|
|
78
|
+
excerpt: `${filled.length} variable${filled.length === 1 ? '' : 's'} with values, first ${first.match[1]}`,
|
|
79
|
+
status: isLive(ctx.roleOf(file.path)) ? 'fail' : 'info',
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return [
|
|
85
|
+
row(
|
|
86
|
+
{
|
|
87
|
+
id: 'secrets.keys',
|
|
88
|
+
group: GROUP,
|
|
89
|
+
title: 'Committed keys and tokens',
|
|
90
|
+
advice: 'A real key in a public plugin is already leaked, and one that looks real may be bait. The value is never shown here.',
|
|
91
|
+
},
|
|
92
|
+
keys,
|
|
93
|
+
'fail',
|
|
94
|
+
'critical',
|
|
95
|
+
),
|
|
96
|
+
row(
|
|
97
|
+
{
|
|
98
|
+
id: 'secrets.env-files',
|
|
99
|
+
group: GROUP,
|
|
100
|
+
title: '.env files with values',
|
|
101
|
+
advice: 'A .env file with values ships someone\'s configuration, often their secrets.',
|
|
102
|
+
},
|
|
103
|
+
envFiles,
|
|
104
|
+
'fail',
|
|
105
|
+
),
|
|
106
|
+
]
|
|
107
|
+
}
|
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
import type { InventoryFile, InventoryLink } from '../inventory'
|
|
2
|
+
import { isLive, isRecord, isTestPath, parseJson, type ScanContext } from './context'
|
|
3
|
+
import { CLAUDE_SETTINGS } from './patterns'
|
|
4
|
+
import { ARCHIVE, ARCHIVE_EXTENSIONS, EXECUTABLE, IMAGE_EXTENSIONS, magicOf, NATIVE_EXTENSIONS, TEXT_EXTENSIONS } from './magic'
|
|
5
|
+
import { basename, dirname, extension, findFirst, joinPath } from './text'
|
|
6
|
+
import { row, type CheckResult, type Evidence, type Status } from './types'
|
|
7
|
+
|
|
8
|
+
export { magicOf }
|
|
9
|
+
import type { ValidatorReport } from './validator'
|
|
10
|
+
|
|
11
|
+
const GROUP = 'structure' as const
|
|
12
|
+
const LARGE_FILE = 1024 * 1024
|
|
13
|
+
const MINIFIED_LINE = 1000
|
|
14
|
+
|
|
15
|
+
export function structureChecks(ctx: ScanContext, validator: ValidatorReport | undefined): CheckResult[] {
|
|
16
|
+
return [
|
|
17
|
+
manifestCheck(ctx, validator),
|
|
18
|
+
componentsCheck(ctx),
|
|
19
|
+
filesCheck(ctx),
|
|
20
|
+
linksCheck(ctx),
|
|
21
|
+
imagesCheck(ctx),
|
|
22
|
+
settingsCheck(ctx),
|
|
23
|
+
filledInCheck(ctx),
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function manifestCheck(ctx: ScanContext, validator: ValidatorReport | undefined): CheckResult {
|
|
28
|
+
const base = {
|
|
29
|
+
id: 'structure.manifest',
|
|
30
|
+
group: GROUP,
|
|
31
|
+
title: 'Manifest and Claude Code validation',
|
|
32
|
+
advice: 'Claude Code refuses or misreads what fails validation; a candidate that does not validate cannot be judged as written.',
|
|
33
|
+
}
|
|
34
|
+
const evidence: Evidence[] = []
|
|
35
|
+
if (ctx.manifestError !== undefined) {
|
|
36
|
+
evidence.push({ file: '.claude-plugin/plugin.json', excerpt: `does not parse: ${ctx.manifestError}`, status: 'fail' })
|
|
37
|
+
}
|
|
38
|
+
for (const problem of ctx.hookConfigErrors) {
|
|
39
|
+
evidence.push({ file: problem.file, excerpt: `does not parse: ${problem.error}`, status: 'fail' })
|
|
40
|
+
}
|
|
41
|
+
if (validator?.ran === true) {
|
|
42
|
+
for (const error of validator.errors) evidence.push({ file: error.file, excerpt: error.message, status: 'fail' })
|
|
43
|
+
}
|
|
44
|
+
if (evidence.length > 0) {
|
|
45
|
+
return row(base, evidence, 'fail')
|
|
46
|
+
}
|
|
47
|
+
if (validator?.ran === false) {
|
|
48
|
+
return { ...base, status: 'info', severity: 'none', evidence: [{ file: '-', excerpt: `claude plugin validate did not run: ${validator.reason}` }] }
|
|
49
|
+
}
|
|
50
|
+
const warnings = validator?.ran === true ? validator.warnings.length : 0
|
|
51
|
+
return {
|
|
52
|
+
...base,
|
|
53
|
+
status: 'pass',
|
|
54
|
+
severity: 'none',
|
|
55
|
+
evidence: validator?.ran === true && warnings > 0
|
|
56
|
+
? [{ file: '-', excerpt: `claude plugin validate: ${warnings} warning${warnings === 1 ? '' : 's'}, no errors`, status: 'pass' }]
|
|
57
|
+
: [],
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function componentsCheck(ctx: ScanContext): CheckResult {
|
|
62
|
+
const count = (prefix: string, test: (path: string) => boolean = () => true): number =>
|
|
63
|
+
ctx.files.filter(file => file.path.startsWith(prefix) && test(file.path)).length
|
|
64
|
+
const parts: string[] = [`a ${ctx.kind}`]
|
|
65
|
+
const skills = new Set(ctx.prompts.filter(path => /(^|\/)SKILL\.md$/.test(path)).map(path => dirname(path)))
|
|
66
|
+
if (skills.size > 0) parts.push(`${skills.size} skill${skills.size === 1 ? '' : 's'}`)
|
|
67
|
+
const commands = ctx.prompts.filter(path => path.startsWith('commands/')).length
|
|
68
|
+
if (commands > 0) parts.push(`${commands} command${commands === 1 ? '' : 's'}`)
|
|
69
|
+
const agents = ctx.prompts.filter(path => path.startsWith('agents/')).length
|
|
70
|
+
if (agents > 0) parts.push(`${agents} agent${agents === 1 ? '' : 's'}`)
|
|
71
|
+
if (ctx.modules.length > 0) parts.push(`${ctx.modules.length} hooks module${ctx.modules.length === 1 ? '' : 's'} (a mod)`)
|
|
72
|
+
if (ctx.hookCommands.length > 0) parts.push(`${ctx.hookCommands.length} classic hook${ctx.hookCommands.length === 1 ? '' : 's'}`)
|
|
73
|
+
if (ctx.mcpServers.length > 0) parts.push(`${ctx.mcpServers.length} MCP server${ctx.mcpServers.length === 1 ? '' : 's'}`)
|
|
74
|
+
if (ctx.files.some(file => file.path === '.lsp.json')) parts.push('LSP servers')
|
|
75
|
+
const bins = count('bin/')
|
|
76
|
+
if (bins > 0) parts.push(`${bins} file${bins === 1 ? '' : 's'} in bin/ (added to your PATH)`)
|
|
77
|
+
if (ctx.files.some(file => file.path === 'settings.json')) parts.push('default settings')
|
|
78
|
+
return {
|
|
79
|
+
id: 'structure.components',
|
|
80
|
+
group: GROUP,
|
|
81
|
+
title: 'What it ships',
|
|
82
|
+
status: 'info',
|
|
83
|
+
severity: 'none',
|
|
84
|
+
evidence: [{ file: '-', excerpt: parts.join(', ') }],
|
|
85
|
+
advice: 'Each kind of component reaches your machine differently; the groups below check each one.',
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function filesCheck(ctx: ScanContext): CheckResult {
|
|
90
|
+
const evidence: Evidence[] = []
|
|
91
|
+
const strict = ctx.kind === 'skill' || ctx.modules.length > 0
|
|
92
|
+
for (const file of ctx.files) {
|
|
93
|
+
const bytes = ctx.tree.contents.get(file.path)
|
|
94
|
+
if (bytes === undefined) continue
|
|
95
|
+
// A versioned native library (libfoo.so.1, libbar.1.dylib) is a .so or .dylib.
|
|
96
|
+
const versioned = /\.(so|dylib)(?:\.\d+)+$|\.\d+\.(dylib)$/.exec(basename(file.path))
|
|
97
|
+
const ext = versioned !== null ? ((versioned[1] ?? versioned[2]) as string) : extension(file.path)
|
|
98
|
+
const magic = magicOf(bytes)
|
|
99
|
+
const live = isLive(ctx.roleOf(file.path))
|
|
100
|
+
const cap = (status: Status): Status => (live ? status : 'info')
|
|
101
|
+
const inSkillOrMod = strict || file.path.startsWith('skills/') || ctx.roleOf(file.path) === 'module'
|
|
102
|
+
|
|
103
|
+
if ((magic !== undefined && EXECUTABLE.has(magic)) || NATIVE_EXTENSIONS.has(ext) || basename(file.path).endsWith('.pyc')) {
|
|
104
|
+
const disguised = magic !== undefined && EXECUTABLE.has(magic) && !NATIVE_EXTENSIONS.has(ext) && ext !== '' && ext !== 'bin'
|
|
105
|
+
const what = magic === undefined ? `compiled file (.${ext})` : `${magic} binary`
|
|
106
|
+
evidence.push({
|
|
107
|
+
file: file.path,
|
|
108
|
+
excerpt: disguised ? `${what} named like a .${ext} file` : `${what}: cannot be reviewed as source`,
|
|
109
|
+
status: cap(disguised || inSkillOrMod ? 'fail' : 'warn'),
|
|
110
|
+
})
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
if ((magic !== undefined && ARCHIVE.has(magic)) || ARCHIVE_EXTENSIONS.has(ext)) {
|
|
114
|
+
const disguised = magic !== undefined && ARCHIVE.has(magic) && !ARCHIVE_EXTENSIONS.has(ext)
|
|
115
|
+
evidence.push({
|
|
116
|
+
file: file.path,
|
|
117
|
+
excerpt: disguised ? `${magic} archive named like a .${ext} file` : `${magic ?? ext} archive: its contents are not scanned`,
|
|
118
|
+
status: cap(disguised ? 'fail' : 'warn'),
|
|
119
|
+
})
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
122
|
+
const expectedImage = Object.hasOwn(IMAGE_EXTENSIONS, ext) ? IMAGE_EXTENSIONS[ext] : undefined
|
|
123
|
+
if (expectedImage !== undefined && magic !== expectedImage) {
|
|
124
|
+
evidence.push({ file: file.path, excerpt: `named .${ext} but is ${magic === undefined ? (file.isText ? 'text' : 'not an image') : magic}`, status: cap('fail') })
|
|
125
|
+
continue
|
|
126
|
+
}
|
|
127
|
+
if (!file.isText && TEXT_EXTENSIONS.has(ext)) {
|
|
128
|
+
evidence.push({ file: file.path, excerpt: `named .${ext} but holds NUL bytes, which hide its text from text tools`, status: cap('warn') })
|
|
129
|
+
}
|
|
130
|
+
if (file.size > LARGE_FILE) {
|
|
131
|
+
evidence.push({ file: file.path, excerpt: `${(file.size / LARGE_FILE).toFixed(1)} MB: large for a plugin file`, status: cap('warn') })
|
|
132
|
+
}
|
|
133
|
+
if (/^(js|mjs|cjs)$/.test(ext) && (file.path.endsWith('.min.js') || longestLine(ctx.text(file.path) ?? '') > MINIFIED_LINE)) {
|
|
134
|
+
evidence.push({ file: file.path, excerpt: 'minified or bundled JavaScript: hard to review', status: cap('warn') })
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
for (const entry of ctx.tree.inventory.entries) {
|
|
138
|
+
if (entry.kind === 'other') evidence.push({ file: entry.path, excerpt: 'not a regular file (a device, pipe or socket)', status: 'warn' })
|
|
139
|
+
}
|
|
140
|
+
return row(
|
|
141
|
+
{
|
|
142
|
+
id: 'structure.files',
|
|
143
|
+
group: GROUP,
|
|
144
|
+
title: 'Unexpected files',
|
|
145
|
+
advice: 'Binaries, archives and minified code cannot be reviewed here. Prefer plugins that ship readable source, and treat a file whose content does not match its name as hostile.',
|
|
146
|
+
},
|
|
147
|
+
evidence,
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function longestLine(text: string): number {
|
|
152
|
+
let longest = 0
|
|
153
|
+
let start = 0
|
|
154
|
+
for (let i = 0; i <= text.length; i += 1) {
|
|
155
|
+
if (i === text.length || text.charCodeAt(i) === 10) {
|
|
156
|
+
longest = Math.max(longest, i - start)
|
|
157
|
+
start = i + 1
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return longest
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function linksCheck(ctx: ScanContext): CheckResult {
|
|
164
|
+
const evidence: Evidence[] = []
|
|
165
|
+
for (const entry of ctx.tree.inventory.entries) {
|
|
166
|
+
if (entry.kind !== 'link') continue
|
|
167
|
+
evidence.push(describeLink(entry, ctx))
|
|
168
|
+
}
|
|
169
|
+
return row(
|
|
170
|
+
{
|
|
171
|
+
id: 'structure.links',
|
|
172
|
+
group: GROUP,
|
|
173
|
+
title: 'Symbolic links',
|
|
174
|
+
advice: 'A link that leaves the folder makes Claude Code read or run files outside the plugin, such as your keys. Do not install a plugin that ships one.',
|
|
175
|
+
},
|
|
176
|
+
evidence,
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function describeLink(link: InventoryLink, ctx: ScanContext): Evidence {
|
|
181
|
+
// A link in a test folder that nothing names is only reported.
|
|
182
|
+
const isAbsolute = link.target.startsWith('/') || /^[A-Za-z]:[\\/]/.test(link.target)
|
|
183
|
+
const climbsOut = !isAbsolute && joinPath(dirname(link.path), link.target) === undefined
|
|
184
|
+
const role = ctx.roleOf(link.path)
|
|
185
|
+
const live = role !== 'other' || !isTestPath(link.path)
|
|
186
|
+
if (isAbsolute && link.isInside) {
|
|
187
|
+
// It lands inside here, but an absolute path lands somewhere else on every other machine.
|
|
188
|
+
return { file: link.path, excerpt: `absolute link to ${link.target}: where it leads depends on the machine`, status: live ? 'warn' : 'info' }
|
|
189
|
+
}
|
|
190
|
+
if (isAbsolute || climbsOut || (link.landsAt !== undefined && !link.isInside)) {
|
|
191
|
+
return { file: link.path, excerpt: `links outside the folder to ${link.target}`, status: live ? 'fail' : 'info' }
|
|
192
|
+
}
|
|
193
|
+
if (link.landsAt === undefined) {
|
|
194
|
+
return { file: link.path, excerpt: `links to ${link.target}, which does not exist`, status: live ? 'warn' : 'info' }
|
|
195
|
+
}
|
|
196
|
+
return { file: link.path, excerpt: `links inside the folder to ${link.landsAt}`, status: 'info' }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function imagesCheck(ctx: ScanContext): CheckResult {
|
|
200
|
+
const evidence: Evidence[] = []
|
|
201
|
+
for (const file of ctx.files) {
|
|
202
|
+
const bytes = ctx.tree.contents.get(file.path)
|
|
203
|
+
if (bytes === undefined) continue
|
|
204
|
+
const trailing = trailingBytes(bytes)
|
|
205
|
+
if (trailing !== undefined && trailing.extra > 0) {
|
|
206
|
+
evidence.push({
|
|
207
|
+
file: file.path,
|
|
208
|
+
excerpt: `${trailing.extra} byte${trailing.extra === 1 ? '' : 's'} after the end of the ${trailing.format} image`,
|
|
209
|
+
status: isLive(ctx.roleOf(file.path)) ? 'fail' : 'info',
|
|
210
|
+
})
|
|
211
|
+
} else if (trailing?.malformed === true) {
|
|
212
|
+
evidence.push({ file: file.path, excerpt: `malformed ${trailing.format}: its end could not be found`, status: 'warn' })
|
|
213
|
+
}
|
|
214
|
+
if (extension(file.path) === 'svg') {
|
|
215
|
+
const found = findFirst(ctx.text(file.path) ?? '', /<script\b|\son[a-z]+\s*=|<foreignObject\b|javascript:/i)
|
|
216
|
+
if (found !== undefined) evidence.push({ file: file.path, line: found.line, excerpt: `SVG with active content: ${found.excerpt}`, status: 'warn' })
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return row(
|
|
220
|
+
{
|
|
221
|
+
id: 'structure.images',
|
|
222
|
+
group: GROUP,
|
|
223
|
+
title: 'Data hidden in images',
|
|
224
|
+
advice: 'Bytes after an image ends are invisible when the image is shown and are a common way to smuggle a payload. Do not install a plugin whose images carry them.',
|
|
225
|
+
},
|
|
226
|
+
evidence,
|
|
227
|
+
)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** For a PNG, JPEG or WebP: how many bytes follow the image's own end. */
|
|
231
|
+
export function trailingBytes(bytes: Uint8Array): { format: string; extra: number; malformed?: true } | undefined {
|
|
232
|
+
const magic = magicOf(bytes)
|
|
233
|
+
if (magic === 'png') {
|
|
234
|
+
let offset = 8
|
|
235
|
+
while (offset + 12 <= bytes.length) {
|
|
236
|
+
const length = (((bytes[offset] as number) << 24) | ((bytes[offset + 1] as number) << 16) | ((bytes[offset + 2] as number) << 8) | (bytes[offset + 3] as number)) >>> 0
|
|
237
|
+
const type = String.fromCharCode(...bytes.slice(offset + 4, offset + 8))
|
|
238
|
+
offset += 12 + length
|
|
239
|
+
if (type === 'IEND') return { format: 'PNG', extra: Math.max(0, bytes.length - offset) }
|
|
240
|
+
}
|
|
241
|
+
return { format: 'PNG', extra: 0, malformed: true }
|
|
242
|
+
}
|
|
243
|
+
if (magic === 'jpeg') {
|
|
244
|
+
const end = jpegEnd(bytes)
|
|
245
|
+
if (end === undefined) return { format: 'JPEG', extra: 0, malformed: true }
|
|
246
|
+
const rest = bytes.slice(end)
|
|
247
|
+
return { format: 'JPEG', extra: rest.every(byte => byte === 0) ? 0 : rest.length }
|
|
248
|
+
}
|
|
249
|
+
if (magic === 'webp') {
|
|
250
|
+
const size = ((bytes[4] as number) | ((bytes[5] as number) << 8) | ((bytes[6] as number) << 16) | ((bytes[7] as number) << 24)) >>> 0
|
|
251
|
+
const end = size + 8 + (size % 2)
|
|
252
|
+
// A size that claims more than the file holds is forged or broken.
|
|
253
|
+
if (end > bytes.length + (size % 2)) return { format: 'WebP', extra: 0, malformed: true }
|
|
254
|
+
return { format: 'WebP', extra: Math.max(0, bytes.length - end) }
|
|
255
|
+
}
|
|
256
|
+
if (magic === 'gif') {
|
|
257
|
+
const end = gifEnd(bytes)
|
|
258
|
+
if (end === undefined) return { format: 'GIF', extra: 0, malformed: true }
|
|
259
|
+
return { format: 'GIF', extra: bytes.length - end }
|
|
260
|
+
}
|
|
261
|
+
return undefined
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Where a GIF's trailer (0x3B) ends, walking its blocks; undefined when malformed. */
|
|
265
|
+
function gifEnd(bytes: Uint8Array): number | undefined {
|
|
266
|
+
if (bytes.length < 13) return undefined
|
|
267
|
+
let offset = 13
|
|
268
|
+
const flags = bytes[10] as number
|
|
269
|
+
if ((flags & 0x80) !== 0) offset += 3 * (1 << ((flags & 0x07) + 1))
|
|
270
|
+
const skipSubBlocks = (): boolean => {
|
|
271
|
+
while (offset < bytes.length) {
|
|
272
|
+
const size = bytes[offset] as number
|
|
273
|
+
offset += 1
|
|
274
|
+
if (size === 0) return true
|
|
275
|
+
offset += size
|
|
276
|
+
}
|
|
277
|
+
return false
|
|
278
|
+
}
|
|
279
|
+
while (offset < bytes.length) {
|
|
280
|
+
const block = bytes[offset] as number
|
|
281
|
+
if (block === 0x3b) return offset + 1
|
|
282
|
+
if (block === 0x21) {
|
|
283
|
+
offset += 2
|
|
284
|
+
if (!skipSubBlocks()) return undefined
|
|
285
|
+
} else if (block === 0x2c) {
|
|
286
|
+
if (offset + 10 > bytes.length) return undefined
|
|
287
|
+
const local = bytes[offset + 9] as number
|
|
288
|
+
offset += 10
|
|
289
|
+
if ((local & 0x80) !== 0) offset += 3 * (1 << ((local & 0x07) + 1))
|
|
290
|
+
offset += 1
|
|
291
|
+
if (!skipSubBlocks()) return undefined
|
|
292
|
+
} else {
|
|
293
|
+
return undefined
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return undefined
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function jpegEnd(bytes: Uint8Array): number | undefined {
|
|
300
|
+
let offset = 2
|
|
301
|
+
while (offset + 1 < bytes.length) {
|
|
302
|
+
if (bytes[offset] !== 0xff) return undefined
|
|
303
|
+
let marker = bytes[offset + 1] as number
|
|
304
|
+
while (marker === 0xff && offset + 2 < bytes.length) {
|
|
305
|
+
offset += 1
|
|
306
|
+
marker = bytes[offset + 1] as number
|
|
307
|
+
}
|
|
308
|
+
if (marker === 0xd9) return offset + 2
|
|
309
|
+
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
|
|
310
|
+
offset += 2
|
|
311
|
+
continue
|
|
312
|
+
}
|
|
313
|
+
const length = (((bytes[offset + 2] as number) << 8) | (bytes[offset + 3] as number)) >>> 0
|
|
314
|
+
offset += 2 + length
|
|
315
|
+
if (marker === 0xda) {
|
|
316
|
+
// Entropy-coded data runs to the next marker that is not a stuffed 0xFF00 or a restart.
|
|
317
|
+
while (offset + 1 < bytes.length) {
|
|
318
|
+
if (bytes[offset] === 0xff) {
|
|
319
|
+
const next = bytes[offset + 1] as number
|
|
320
|
+
if (next !== 0x00 && !(next >= 0xd0 && next <= 0xd7)) break
|
|
321
|
+
}
|
|
322
|
+
offset += 1
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return undefined
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function settingsCheck(ctx: ScanContext): CheckResult {
|
|
330
|
+
const evidence: Evidence[] = []
|
|
331
|
+
for (const file of ctx.files) {
|
|
332
|
+
if (!/^settings(\.local)?\.json$/.test(basename(file.path)) || !isLive(ctx.roleOf(file.path))) continue
|
|
333
|
+
const raw = ctx.text(file.path) ?? ''
|
|
334
|
+
const parsed = parseJson(raw)
|
|
335
|
+
if (!parsed.ok || !isRecord(parsed.value)) continue
|
|
336
|
+
const settings = parsed.value
|
|
337
|
+
const granted = isRecord(settings.permissions) ? settings.permissions : undefined
|
|
338
|
+
if (granted !== undefined && (Array.isArray(granted.allow) || typeof granted.defaultMode === 'string')) {
|
|
339
|
+
evidence.push({ file: file.path, excerpt: 'grants itself permissions (an allow list or a default mode)', status: 'fail' })
|
|
340
|
+
}
|
|
341
|
+
const hit = findFirst(raw, CLAUDE_SETTINGS)
|
|
342
|
+
if (hit !== undefined) evidence.push({ file: file.path, line: hit.line, excerpt: hit.excerpt, status: 'fail' })
|
|
343
|
+
for (const key of ['hooks', 'apiKeyHelper', 'statusLine', 'awsAuthRefresh', 'otelHeadersHelper']) {
|
|
344
|
+
if (key in settings) evidence.push({ file: file.path, excerpt: `sets ${key}, which runs commands`, status: 'warn' })
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return row(
|
|
348
|
+
{
|
|
349
|
+
id: 'structure.settings',
|
|
350
|
+
group: GROUP,
|
|
351
|
+
title: 'Bundled Claude Code settings',
|
|
352
|
+
advice: 'A plugin should never change your permissions. Settings that run commands deserve the same scrutiny as hooks.',
|
|
353
|
+
},
|
|
354
|
+
evidence,
|
|
355
|
+
)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export type { InventoryFile }
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Files whose real content is not in the folder: Git LFS pointers, whose file
|
|
362
|
+
* is downloaded at install, and paths a git filter rewrites on checkout. The
|
|
363
|
+
* scan reads what is stored, so the code that runs could be anything.
|
|
364
|
+
*/
|
|
365
|
+
function filledInCheck(ctx: ScanContext): CheckResult {
|
|
366
|
+
const evidence: Evidence[] = []
|
|
367
|
+
for (const file of ctx.files) {
|
|
368
|
+
const name = basename(file.path)
|
|
369
|
+
if (name === '.gitattributes') {
|
|
370
|
+
// A line covers the files its pattern names under its own folder:
|
|
371
|
+
// rewriting only tests or docs is noted, live code is a warning.
|
|
372
|
+
const folder = dirname(file.path)
|
|
373
|
+
const lines = (ctx.text(file.path) ?? '').split('\n')
|
|
374
|
+
for (let index = 0; index < lines.length && index < 1000; index += 1) {
|
|
375
|
+
const line = (lines[index] as string).trim()
|
|
376
|
+
if (line.startsWith('#') || line.startsWith('[attr]')) continue
|
|
377
|
+
const driver = /(?:^|\s)filter=([^\s]{1,100})/.exec(line)?.[1]
|
|
378
|
+
if (driver === undefined) continue
|
|
379
|
+
const pattern = line.split(/\s/)[0] as string
|
|
380
|
+
const coversLive = ctx.files.some(other => {
|
|
381
|
+
const rel = folder === '' ? other.path : other.path.startsWith(`${folder}/`) ? other.path.slice(folder.length + 1) : undefined
|
|
382
|
+
return rel !== undefined && attributeMatches(pattern, rel) && isLive(ctx.roleOf(other.path))
|
|
383
|
+
})
|
|
384
|
+
evidence.push({ file: file.path, line: index + 1, excerpt: `git rewrites these files with the "${driver}" filter on checkout: ${line.slice(0, 200)}`, status: coversLive ? 'warn' : 'info' })
|
|
385
|
+
}
|
|
386
|
+
continue
|
|
387
|
+
}
|
|
388
|
+
if (file.size > 1024) continue
|
|
389
|
+
const text = ctx.text(file.path)
|
|
390
|
+
if (text === undefined || !text.startsWith('version https://git-lfs.github.com/spec/')) continue
|
|
391
|
+
evidence.push({
|
|
392
|
+
file: file.path,
|
|
393
|
+
excerpt: 'a Git LFS pointer: the real file is downloaded when it is installed, and this scan could not read it',
|
|
394
|
+
// Images and docs often live in LFS; code that runs must not.
|
|
395
|
+
status: isLive(ctx.roleOf(file.path)) ? 'fail' : 'info',
|
|
396
|
+
})
|
|
397
|
+
if (evidence.length >= 20) break
|
|
398
|
+
}
|
|
399
|
+
return row(
|
|
400
|
+
{
|
|
401
|
+
id: 'structure.fetched',
|
|
402
|
+
group: GROUP,
|
|
403
|
+
title: 'Files filled in at install',
|
|
404
|
+
advice: 'The scan reads what the repository stores. A file that Git LFS downloads or a filter rewrites at install can hold different code, which was not checked.',
|
|
405
|
+
},
|
|
406
|
+
evidence,
|
|
407
|
+
)
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Whether a .gitattributes pattern names `path` (relative to the attributes
|
|
412
|
+
* file's folder): no slash matches the name at any depth, a slash anchors it,
|
|
413
|
+
* `*` and `?` stay within a folder and `**` crosses them. A pattern too
|
|
414
|
+
* elaborate to match cheaply counts as matching, which can only warn more.
|
|
415
|
+
*/
|
|
416
|
+
export function attributeMatches(pattern: string, path: string): boolean {
|
|
417
|
+
if (pattern.endsWith('/')) return false
|
|
418
|
+
if (pattern.length > 200 || path.length > 1000 || (pattern.match(/[*?[]/g) ?? []).length > 8) return true
|
|
419
|
+
const anchored = pattern.includes('/')
|
|
420
|
+
const body = pattern.startsWith('/') ? pattern.slice(1) : pattern
|
|
421
|
+
let source = ''
|
|
422
|
+
for (let index = 0; index < body.length; index += 1) {
|
|
423
|
+
const char = body[index] as string
|
|
424
|
+
if (char === '*' && body[index + 1] === '*') {
|
|
425
|
+
if (body[index + 2] === '/') {
|
|
426
|
+
source += '(?:.*/)?'
|
|
427
|
+
index += 2
|
|
428
|
+
} else {
|
|
429
|
+
source += '.*'
|
|
430
|
+
index += 1
|
|
431
|
+
}
|
|
432
|
+
} else if (char === '*') {
|
|
433
|
+
source += '[^/]*'
|
|
434
|
+
} else if (char === '?') {
|
|
435
|
+
source += '[^/]'
|
|
436
|
+
} else if (char === '[' && body.indexOf(']', index + 2) !== -1) {
|
|
437
|
+
const end = body.indexOf(']', index + 2)
|
|
438
|
+
source += `[${body.slice(index + 1, end).replace(/^!/, '^').replace(/[\\\]]/g, '\\$&')}]`
|
|
439
|
+
index = end
|
|
440
|
+
} else {
|
|
441
|
+
source += char.replace(/[.+^${}()|\\[\]]/g, '\\$&')
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
try {
|
|
445
|
+
return new RegExp(`^${source}$`).test(anchored ? path : basename(path))
|
|
446
|
+
} catch {
|
|
447
|
+
return true
|
|
448
|
+
}
|
|
449
|
+
}
|