@socketsecurity/mcp 0.0.17 → 0.0.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/README.md +221 -184
- package/dist/index.cjs +58128 -0
- package/dist/index.d.ts +4 -0
- package/hooks/socket-gate/README.md +117 -0
- package/hooks/socket-gate/index.mts +289 -0
- package/hooks/socket-gate/socket-gate.cjs +1047 -0
- package/package.json +90 -65
- package/index.d.ts +0 -3
- package/index.d.ts.map +0 -1
- package/index.js +0 -460
- package/mock-client/debug-client.d.ts +0 -3
- package/mock-client/debug-client.d.ts.map +0 -1
- package/mock-client/debug-client.js +0 -134
- package/mock-client/http-client.d.ts +0 -3
- package/mock-client/http-client.d.ts.map +0 -1
- package/mock-client/http-client.js +0 -123
- package/mock-client/stdio-client.d.ts +0 -3
- package/mock-client/stdio-client.d.ts.map +0 -1
- package/mock-client/stdio-client.js +0 -52
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
export { buildSocketHeaders, getApiKeyInteractively, getForwardedHeaderValue, getRequestBaseUrl, getRequestHeaderValue, parseJsonObject, writeJson, writeOAuthError, } from './lib/http-helpers.ts';
|
|
3
|
+
export { createConfiguredServer } from './lib/server.ts';
|
|
4
|
+
export { authenticateRequest, buildProtectedResourceMetadata, getProtectedResourceMetadataUrl, loadOAuthMetadata, splitScopes, verifyAccessToken, } from './lib/oauth.ts';
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# socket-gate
|
|
2
|
+
|
|
3
|
+
A Claude Code `PreToolUse` hook you can copy into your own setup to block
|
|
4
|
+
high-risk package installs before they run. When Claude Code is about to
|
|
5
|
+
execute a `Bash` install command, the hook queries the public Socket MCP server
|
|
6
|
+
at `https://mcp.socket.dev/` and denies the install when the package's supply
|
|
7
|
+
chain score is below `20` (known malware, typosquats, high-risk supply chain
|
|
8
|
+
signals).
|
|
9
|
+
|
|
10
|
+
No API key, no CLI, no registration. Copy the directory and wire it up.
|
|
11
|
+
|
|
12
|
+
This directory holds the hook source (`index.mts`) plus the bundled
|
|
13
|
+
`socket-gate.cjs` that a build produces. You copy the whole directory into
|
|
14
|
+
`~/.claude/hooks/` and point your settings at the `.cjs` (see
|
|
15
|
+
[Copy it in](#copy-it-in)).
|
|
16
|
+
|
|
17
|
+
## Why
|
|
18
|
+
|
|
19
|
+
A package install is the moment a supply chain attack lands. Catching a
|
|
20
|
+
typosquat or known-malware package at the `PreToolUse` boundary, before the
|
|
21
|
+
install command runs, stops it from reaching disk or firing a postinstall
|
|
22
|
+
script. The hook is an advisory guardrail: it fails open, so a Socket outage
|
|
23
|
+
never blocks legitimate work.
|
|
24
|
+
|
|
25
|
+
## What it catches
|
|
26
|
+
|
|
27
|
+
Install commands across six ecosystems, parsed out of the `Bash` tool's command
|
|
28
|
+
string:
|
|
29
|
+
|
|
30
|
+
| Ecosystem | Commands |
|
|
31
|
+
| --------- | ----------------------------------------------------------------------------------------- |
|
|
32
|
+
| npm | `npm install`, `npm i`, `npm add`, `yarn add`, `pnpm add`, `bun add` |
|
|
33
|
+
| PyPI | `pip install`, `pip3 install`, `uv add`, `uv pip install`, `poetry add`, `pipenv install` |
|
|
34
|
+
| Cargo | `cargo add`, `cargo install` |
|
|
35
|
+
| RubyGems | `gem install`, `bundle add` |
|
|
36
|
+
| Go | `go get`, `go install` |
|
|
37
|
+
| NuGet | `dotnet add package`, `nuget install` |
|
|
38
|
+
|
|
39
|
+
For a matched package the hook calls the Socket MCP `depscore` tool, parses the
|
|
40
|
+
`supplyChain` score, and denies when `supplyChain < 20`. Everything else
|
|
41
|
+
allows.
|
|
42
|
+
|
|
43
|
+
## Copy it in
|
|
44
|
+
|
|
45
|
+
Copy the whole `socket-gate` directory into your hooks folder so the bundle and
|
|
46
|
+
its docs travel together, and so you own your copy to edit. The bundled
|
|
47
|
+
`socket-gate.cjs` is self-contained: rolldown inlines its one dependency
|
|
48
|
+
(`@socketsecurity/lib-stable`), so it runs without a `package.json` or
|
|
49
|
+
`node_modules` beside it.
|
|
50
|
+
|
|
51
|
+
From an installed `@socketsecurity/mcp` package:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
mkdir -p ~/.claude/hooks
|
|
55
|
+
cp -R node_modules/@socketsecurity/mcp/hooks/socket-gate ~/.claude/hooks/
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
From a checkout of this repo, run `pnpm run build` first to produce
|
|
59
|
+
`socket-gate.cjs`, then copy:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
pnpm run build
|
|
63
|
+
cp -R hooks/socket-gate ~/.claude/hooks/
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Wire it up
|
|
67
|
+
|
|
68
|
+
Add to `~/.claude/settings.json`:
|
|
69
|
+
|
|
70
|
+
```json
|
|
71
|
+
{
|
|
72
|
+
"hooks": {
|
|
73
|
+
"PreToolUse": [
|
|
74
|
+
{
|
|
75
|
+
"matcher": "Bash",
|
|
76
|
+
"hooks": [
|
|
77
|
+
{
|
|
78
|
+
"type": "command",
|
|
79
|
+
"command": "node ~/.claude/hooks/socket-gate/socket-gate.cjs"
|
|
80
|
+
}
|
|
81
|
+
]
|
|
82
|
+
}
|
|
83
|
+
]
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## How it works
|
|
89
|
+
|
|
90
|
+
`supplyChain < 20` denies, anything else allows — e.g. `express` / `lodash` /
|
|
91
|
+
`react` (75–97) allow, `browserlist` (typosquat of `browserslist`, 15) and
|
|
92
|
+
confirmed malware (0) block. Network, timeout, and parse errors all fail open
|
|
93
|
+
(decision `allow`), with the cause written to stderr; stdout stays the
|
|
94
|
+
allow/deny IPC channel Claude Code parses.
|
|
95
|
+
|
|
96
|
+
## Limitations
|
|
97
|
+
|
|
98
|
+
A best-effort guardrail, not a complete defense. Known gaps:
|
|
99
|
+
|
|
100
|
+
- **Manifest edits + lockfile installs.** Editing a manifest (`package.json`,
|
|
101
|
+
`requirements.txt`, `Cargo.toml`, `Gemfile`, `go.mod`, `*.csproj`) then
|
|
102
|
+
running a bare install (`npm install`, `pip install -r requirements.txt`,
|
|
103
|
+
`cargo build`, `bundle install`, `go mod tidy`, `dotnet restore`) leaves no
|
|
104
|
+
package name on the command line to check.
|
|
105
|
+
- **Package-manager invocations only.** Direct downloads (`curl | sh`,
|
|
106
|
+
`wget`), postinstall scripts of already-accepted packages, and transitive
|
|
107
|
+
dependencies are not re-checked.
|
|
108
|
+
- **Indirect Claude paths.** Sub-agents, MCP tools that shell out, and
|
|
109
|
+
non-`Bash` tool calls are not covered unless the `matcher` is broadened.
|
|
110
|
+
|
|
111
|
+
## Test
|
|
112
|
+
|
|
113
|
+
The unit tests run against the `.mts` source (not the bundle):
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
pnpm test
|
|
117
|
+
```
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Socket-gate — Claude Code PreToolUse hook (source for the bundled
|
|
3
|
+
* socket-gate.cjs).
|
|
4
|
+
*
|
|
5
|
+
* Intercepts package install commands across npm, PyPI, Cargo, RubyGems, Go,
|
|
6
|
+
* and NuGet, and checks the target package against the public Socket MCP
|
|
7
|
+
* server. Blocks installs when the supply chain score is below 20 (known
|
|
8
|
+
* malware, typosquats, high-risk supply chain signals).
|
|
9
|
+
*
|
|
10
|
+
* No API key, no CLI, no registration beyond copying the bundled directory.
|
|
11
|
+
*
|
|
12
|
+
* Setup (see README.md):
|
|
13
|
+
*
|
|
14
|
+
* 1. Copy the whole hooks/socket-gate/ directory to ~/.claude/hooks/ (from a
|
|
15
|
+
* published @socketsecurity/mcp install, or build it here with `pnpm run
|
|
16
|
+
* build`).
|
|
17
|
+
* 2. Point a PreToolUse Bash hook at ~/.claude/hooks/socket-gate/socket-gate.cjs
|
|
18
|
+
* in ~/.claude/settings.json.
|
|
19
|
+
*
|
|
20
|
+
* This source imports @socketsecurity/lib-stable; rolldown inlines it into the
|
|
21
|
+
* bundled socket-gate.cjs so the copied-out hook stays self-contained (a Claude
|
|
22
|
+
* Code hook has no package.json and no node_modules — it can only run what is
|
|
23
|
+
* physically present in the file).
|
|
24
|
+
*
|
|
25
|
+
* Fails open on network, parse, and timeout errors so a Socket outage does not
|
|
26
|
+
* block legitimate work.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { readFileSync } from 'node:fs'
|
|
30
|
+
import { argv } from 'node:process'
|
|
31
|
+
import { fileURLToPath } from 'node:url'
|
|
32
|
+
|
|
33
|
+
import { errorMessage } from '@socketsecurity/lib-stable/errors'
|
|
34
|
+
|
|
35
|
+
const MCP_URL = 'https://mcp.socket.dev/'
|
|
36
|
+
const SUPPLY_CHAIN_THRESHOLD = 20
|
|
37
|
+
const REQUEST_TIMEOUT_MS = 10_000
|
|
38
|
+
|
|
39
|
+
type Ecosystem = 'npm' | 'pypi' | 'cargo' | 'gem' | 'golang' | 'nuget'
|
|
40
|
+
|
|
41
|
+
interface HookInput {
|
|
42
|
+
session_id: string
|
|
43
|
+
tool_name: string
|
|
44
|
+
tool_input: Record<string, unknown> | string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const INSTALL_PATTERNS: Array<{ ecosystem: Ecosystem; pattern: RegExp }> = [
|
|
48
|
+
{ ecosystem: 'npm', pattern: /\bnpm\s+(?:add|i|install)\s+([^\s-][^\s]*)/i },
|
|
49
|
+
{ ecosystem: 'npm', pattern: /\byarn\s+add\s+([^\s-][^\s]*)/i },
|
|
50
|
+
{ ecosystem: 'npm', pattern: /\bpnpm\s+add\s+([^\s-][^\s]*)/i },
|
|
51
|
+
{ ecosystem: 'npm', pattern: /\bbun\s+add\s+([^\s-][^\s]*)/i },
|
|
52
|
+
{
|
|
53
|
+
ecosystem: 'pypi',
|
|
54
|
+
pattern: /(?:\bpython3?\s+-m\s+)?\bpip3?\s+install\s+([^\s-][^\s]*)/i,
|
|
55
|
+
},
|
|
56
|
+
{ ecosystem: 'pypi', pattern: /\buv\s+add\s+([^\s-][^\s]*)/i },
|
|
57
|
+
{ ecosystem: 'pypi', pattern: /\buv\s+pip\s+install\s+([^\s-][^\s]*)/i },
|
|
58
|
+
{ ecosystem: 'pypi', pattern: /\bpoetry\s+add\s+([^\s-][^\s]*)/i },
|
|
59
|
+
{ ecosystem: 'pypi', pattern: /\bpipenv\s+install\s+([^\s-][^\s]*)/i },
|
|
60
|
+
{
|
|
61
|
+
ecosystem: 'cargo',
|
|
62
|
+
pattern: /\bcargo\s+(?:add|install)\s+([^\s-][^\s]*)/i,
|
|
63
|
+
},
|
|
64
|
+
{ ecosystem: 'gem', pattern: /\bgem\s+install\s+([^\s-][^\s]*)/i },
|
|
65
|
+
{ ecosystem: 'gem', pattern: /\bbundle\s+add\s+([^\s-][^\s]*)/i },
|
|
66
|
+
{ ecosystem: 'golang', pattern: /\bgo\s+(?:get|install)\s+([^\s-][^\s]*)/i },
|
|
67
|
+
{ ecosystem: 'nuget', pattern: /\bdotnet\s+add\s+package\s+([^\s-][^\s]*)/i },
|
|
68
|
+
{ ecosystem: 'nuget', pattern: /\bnuget\s+install\s+([^\s-][^\s]*)/i },
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
export async function checkPackage(
|
|
72
|
+
ecosystem: Ecosystem,
|
|
73
|
+
packageName: string,
|
|
74
|
+
fetchImpl: typeof fetch = fetch,
|
|
75
|
+
): Promise<{ decision: 'allow' | 'deny'; reason: string }> {
|
|
76
|
+
const signal = AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
77
|
+
const commonHeaders = {
|
|
78
|
+
'Content-Type': 'application/json',
|
|
79
|
+
Accept: 'application/json, text/event-stream',
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const initRes = await fetchImpl(MCP_URL, {
|
|
83
|
+
method: 'POST',
|
|
84
|
+
headers: commonHeaders,
|
|
85
|
+
body: JSON.stringify({
|
|
86
|
+
jsonrpc: '2.0',
|
|
87
|
+
id: 1,
|
|
88
|
+
method: 'initialize',
|
|
89
|
+
params: {
|
|
90
|
+
protocolVersion: '2024-11-05',
|
|
91
|
+
capabilities: {},
|
|
92
|
+
clientInfo: { name: 'socket-gate', version: '1.0' },
|
|
93
|
+
},
|
|
94
|
+
}),
|
|
95
|
+
signal,
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
if (!initRes.ok) {
|
|
99
|
+
throw new Error(`Socket MCP initialize returned ${initRes.status}`)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const sessionId = initRes.headers.get('mcp-session-id')
|
|
103
|
+
if (!sessionId) {
|
|
104
|
+
throw new Error('Socket MCP did not return a session id')
|
|
105
|
+
}
|
|
106
|
+
await initRes.text()
|
|
107
|
+
|
|
108
|
+
const callRes = await fetchImpl(MCP_URL, {
|
|
109
|
+
method: 'POST',
|
|
110
|
+
headers: { ...commonHeaders, 'Mcp-Session-Id': sessionId },
|
|
111
|
+
body: JSON.stringify({
|
|
112
|
+
jsonrpc: '2.0',
|
|
113
|
+
id: 2,
|
|
114
|
+
method: 'tools/call',
|
|
115
|
+
params: {
|
|
116
|
+
name: 'depscore',
|
|
117
|
+
arguments: {
|
|
118
|
+
packages: [{ ecosystem, depname: packageName }],
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
}),
|
|
122
|
+
signal,
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
if (!callRes.ok) {
|
|
126
|
+
throw new Error(`Socket MCP depscore returned ${callRes.status}`)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const payload = (await callRes.json()) as {
|
|
130
|
+
result?:
|
|
131
|
+
| {
|
|
132
|
+
content?: Array<{ type: string; text: string }> | undefined
|
|
133
|
+
isError?: boolean | undefined
|
|
134
|
+
}
|
|
135
|
+
| undefined
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (payload.result?.isError) {
|
|
139
|
+
throw new Error(
|
|
140
|
+
'Socket MCP reported a tool error (package likely not found)',
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const text = payload.result?.content?.[0]?.text ?? ''
|
|
145
|
+
const score = parseSupplyChainScore(text)
|
|
146
|
+
|
|
147
|
+
if (score === undefined) {
|
|
148
|
+
throw new Error('Could not parse supplyChain score from MCP response')
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (score < SUPPLY_CHAIN_THRESHOLD) {
|
|
152
|
+
return {
|
|
153
|
+
decision: 'deny',
|
|
154
|
+
reason: `Socket blocked "${packageName}" (${ecosystem}): supply chain score is ${score} (threshold ${SUPPLY_CHAIN_THRESHOLD}).\n\nReview: https://socket.dev/${ecosystem}/package/${packageName}`,
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return { decision: 'allow', reason: '' }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function extractPackage(
|
|
162
|
+
command: string,
|
|
163
|
+
): { ecosystem: Ecosystem; name: string } | undefined {
|
|
164
|
+
for (const { ecosystem, pattern } of INSTALL_PATTERNS) {
|
|
165
|
+
const match = command.match(pattern)
|
|
166
|
+
if (match?.[1]) {
|
|
167
|
+
const name = stripVersion(match[1], ecosystem)
|
|
168
|
+
if (!name) {
|
|
169
|
+
continue
|
|
170
|
+
}
|
|
171
|
+
return { ecosystem, name }
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return undefined
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// stdout is the Claude Code hook IPC channel — the harness parses this exact
|
|
178
|
+
// JSON as the permission decision, so these must be raw writes (a logger would
|
|
179
|
+
// add formatting/timestamps and break the protocol).
|
|
180
|
+
export function outputAllow(): void {
|
|
181
|
+
const payload = JSON.stringify({
|
|
182
|
+
hookSpecificOutput: {
|
|
183
|
+
hookEventName: 'PreToolUse',
|
|
184
|
+
permissionDecision: 'allow',
|
|
185
|
+
},
|
|
186
|
+
})
|
|
187
|
+
process.stdout.write(payload) // socket-hook: allow console
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function outputDeny(reason: string): void {
|
|
191
|
+
const payload = JSON.stringify({
|
|
192
|
+
hookSpecificOutput: {
|
|
193
|
+
hookEventName: 'PreToolUse',
|
|
194
|
+
permissionDecision: 'deny',
|
|
195
|
+
permissionDecisionReason: reason,
|
|
196
|
+
},
|
|
197
|
+
})
|
|
198
|
+
process.stdout.write(payload) // socket-hook: allow console
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function parseSupplyChainScore(text: string): number | undefined {
|
|
202
|
+
const match = text.match(/supplyChain:\s*(\d+(?:\.\d+)?)/i)
|
|
203
|
+
return match ? Number(match[1]) : undefined
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function stripVersion(pkg: string, ecosystem: Ecosystem): string {
|
|
207
|
+
switch (ecosystem) {
|
|
208
|
+
case 'npm':
|
|
209
|
+
return pkg.replace(/@[\d^~].*/u, '').replace(/@latest$/u, '')
|
|
210
|
+
case 'pypi':
|
|
211
|
+
return pkg.split(/[=<>!~[]/)[0] ?? pkg
|
|
212
|
+
case 'cargo':
|
|
213
|
+
case 'golang':
|
|
214
|
+
case 'nuget':
|
|
215
|
+
return pkg.replace(/@.*$/u, '')
|
|
216
|
+
default:
|
|
217
|
+
return pkg
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function main(): Promise<void> {
|
|
222
|
+
let raw: string
|
|
223
|
+
try {
|
|
224
|
+
raw = readFileSync(0, 'utf-8')
|
|
225
|
+
} catch {
|
|
226
|
+
outputAllow()
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (!raw.trim()) {
|
|
231
|
+
outputAllow()
|
|
232
|
+
return
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
let input: HookInput
|
|
236
|
+
try {
|
|
237
|
+
input = JSON.parse(raw)
|
|
238
|
+
} catch {
|
|
239
|
+
outputAllow()
|
|
240
|
+
return
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (input.tool_name !== 'Bash') {
|
|
244
|
+
outputAllow()
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const command =
|
|
249
|
+
typeof input.tool_input === 'string'
|
|
250
|
+
? input.tool_input
|
|
251
|
+
: (input.tool_input?.['command'] as string) || ''
|
|
252
|
+
|
|
253
|
+
if (!command) {
|
|
254
|
+
outputAllow()
|
|
255
|
+
return
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const target = extractPackage(command)
|
|
259
|
+
if (!target) {
|
|
260
|
+
outputAllow()
|
|
261
|
+
return
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
try {
|
|
265
|
+
const result = await checkPackage(target.ecosystem, target.name)
|
|
266
|
+
if (result.decision === 'deny') {
|
|
267
|
+
outputDeny(result.reason)
|
|
268
|
+
} else {
|
|
269
|
+
outputAllow()
|
|
270
|
+
}
|
|
271
|
+
} catch (e) {
|
|
272
|
+
// Deliberate fail-OPEN: a Socket outage / network error / parse failure
|
|
273
|
+
// must not block legitimate installs (this hook is an advisory guardrail,
|
|
274
|
+
// not a hard gate — see the file header). Surface the error on stderr so
|
|
275
|
+
// the failure is observable; stdout stays the allow/deny IPC channel.
|
|
276
|
+
const errLine = `socket-gate: check failed for ${target.ecosystem}/${target.name}, failing open: ${errorMessage(e)}\n`
|
|
277
|
+
process.stderr.write(errLine) // socket-hook: allow console
|
|
278
|
+
outputAllow()
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const isMainModule =
|
|
283
|
+
argv[1] !== undefined && fileURLToPath(import.meta.url) === argv[1]
|
|
284
|
+
|
|
285
|
+
if (isMainModule) {
|
|
286
|
+
main().catch(() => {
|
|
287
|
+
outputAllow()
|
|
288
|
+
})
|
|
289
|
+
}
|