@shomra/agent 0.3.28 → 0.3.30
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/package.json +1 -1
- package/src/agents/hook-command.mjs +1 -1
- package/src/artifacts/matchers.mjs +7 -0
- package/src/cli/flags.mjs +2 -2
- package/src/cli/help-sections.mjs +7 -0
- package/src/cli/help.mjs +1 -1
- package/src/commands/check.mjs +3 -11
- package/src/commands/gate.mjs +26 -4
- package/src/commands/git-hooks.mjs +2 -2
- package/src/commands/ledger.mjs +0 -1
- package/src/commands/mcp-add.mjs +2 -1
- package/src/commands/memory-scan.mjs +135 -47
- package/src/commands/pr.mjs +7 -10
- package/src/commands/provenance.mjs +8 -13
- package/src/commands/scan.mjs +7 -1
- package/src/commands/secrets.mjs +4 -5
- package/src/core/git-exec.mjs +79 -0
- package/src/core/yaml-lite.mjs +300 -0
- package/src/core/zip-lite.mjs +37 -0
- package/src/detect/local-redact.mjs +1 -3
- package/src/detect/sast/rules-config.mjs +1 -1
- package/src/detect/sast/rules-javascript.mjs +16 -3
- package/src/detect/sast/rules-python.mjs +8 -6
- package/src/detect/sast/scanner.mjs +1 -1
- package/src/detect/signals/agent-frameworks.mjs +231 -0
- package/src/detect/signals/agent-graph-surface.mjs +113 -0
- package/src/detect/signals/agentic-ci-surface.mjs +314 -0
- package/src/detect/signals/agentic-shim.mjs +82 -0
- package/src/detect/signals/artifacts.mjs +8 -36
- package/src/detect/signals/autonomy.mjs +9 -1
- package/src/detect/signals/chat-template.mjs +211 -0
- package/src/detect/signals/ci-workflow.mjs +169 -0
- package/src/detect/signals/credential-harvest.mjs +1 -1
- package/src/detect/signals/execution-hijack.mjs +4 -2
- package/src/detect/signals/gate.mjs +84 -11
- package/src/detect/signals/guardrail-shape.mjs +564 -0
- package/src/detect/signals/guardrail-surface.mjs +221 -0
- package/src/detect/signals/injection.mjs +8 -0
- package/src/detect/signals/inspect-shim.mjs +7 -0
- package/src/detect/signals/instruction-paths.mjs +60 -0
- package/src/detect/signals/manifests.mjs +302 -0
- package/src/detect/signals/masking.mjs +14 -1
- package/src/detect/signals/mcp-advisories.mjs +109 -0
- package/src/detect/signals/mcp-config.mjs +598 -0
- package/src/detect/signals/memory-directives.mjs +661 -0
- package/src/detect/signals/memory-locations.mjs +158 -0
- package/src/detect/signals/memory.mjs +56 -31
- package/src/detect/signals/model-config-rules.mjs +655 -0
- package/src/detect/signals/model-config.mjs +61 -0
- package/src/detect/signals/packages.mjs +2 -2
- package/src/detect/signals/prose-context.mjs +6 -9
- package/src/detect/signals/scan.mjs +4 -4
- package/src/detect/signals/secret-scanner.mjs +241 -0
- package/src/detect/signals/secrets.mjs +1 -48
- package/src/detect/signals/shell.mjs +10 -10
- package/src/gate/advisories.mjs +16 -0
- package/src/gate/batch.mjs +10 -0
- package/src/gate/environment.mjs +8 -53
- package/src/guard/artifact-paths.mjs +107 -0
- package/src/guard/classify.mjs +165 -7
- package/src/guard/command-resolve.mjs +35 -5
- package/src/guard/memory-write.mjs +218 -0
- package/src/guard/prompt-guard.mjs +0 -1
- package/src/guard/tool-guard.mjs +52 -77
- package/src/inventory/agent-posture.mjs +236 -57
- package/src/inventory/artifacts/classify.mjs +10 -1
- package/src/inventory/artifacts/discover.mjs +113 -3
- package/src/inventory/artifacts/extensions.mjs +70 -0
- package/src/inventory/artifacts/hook-scripts.mjs +128 -0
- package/src/inventory/artifacts/limits.mjs +1 -1
- package/src/inventory/artifacts/plugins.mjs +105 -0
- package/src/inventory/artifacts/roots.mjs +40 -0
- package/src/inventory/discovery/ai-dependencies.mjs +39 -12
- package/src/inventory/discovery/all.mjs +4 -0
- package/src/inventory/discovery/cloud-clis.mjs +472 -0
- package/src/inventory/discovery/coding-agents.mjs +19 -4
- package/src/inventory/discovery/mcp-clients.mjs +16 -10
- package/src/inventory/discovery/mcp-servers.mjs +125 -35
- package/src/inventory/discovery/mcp-stores.mjs +207 -0
- package/src/inventory/env-redirect.mjs +148 -0
- package/src/inventory/grant-extract.mjs +463 -0
- package/src/inventory/project-roots.mjs +108 -0
- package/src/inventory/vscode-state.mjs +153 -0
- package/src/mcp/server-tools.mjs +1 -1
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import {
|
|
2
|
+
guardrailFramework, guardrailModel, isCleartextRemote,
|
|
3
|
+
|
|
4
|
+
} from './guardrail-shape.mjs';
|
|
5
|
+
import { locate } from './agentic-shim.mjs';
|
|
6
|
+
import { textFindings } from './agentic-shim.mjs';
|
|
7
|
+
|
|
8
|
+
const FRAMEWORK_LABEL = {
|
|
9
|
+
nemo: 'NeMo Guardrails', colang: 'Colang', 'guardrails-ai': 'Guardrails AI', bedrock: 'Bedrock Guardrails',
|
|
10
|
+
'azure-rai': 'Azure OpenAI content filter', litellm: 'LiteLLM guardrail', portkey: 'Portkey guardrail',
|
|
11
|
+
'llm-guard': 'LLM Guard', kong: 'Kong AI plugin',
|
|
12
|
+
};
|
|
13
|
+
const MAX_CONTROL_FINDINGS = 12;
|
|
14
|
+
|
|
15
|
+
function controlFinding(a , fw , c ) {
|
|
16
|
+
const sev =
|
|
17
|
+
c.state === 'weak' ? (c.injection ? 'MEDIUM' : 'LOW') : c.injection ? 'HIGH' : 'MEDIUM';
|
|
18
|
+
const verb = c.state === 'disabled' ? 'is switched off' : c.state === 'observe-only' ? (c.validator ? 'returns the value the validator rejected' : 'records failures and blocks nothing') : 'is set too permissive to fire';
|
|
19
|
+
const what = c.injection ? 'This is the control a prompt-injection or jailbreak attempt has to get past' : 'The control is listed as configured';
|
|
20
|
+
return {
|
|
21
|
+
class: 'MISSING_CONTROL',
|
|
22
|
+
severity: sev,
|
|
23
|
+
title: `${fw} "${c.name}" ${verb}`,
|
|
24
|
+
detail:
|
|
25
|
+
`${c.why ? `${c.why}. ` : ''}${what}, and every inventory that counts configured guardrails counts this one - ` +
|
|
26
|
+
(c.state === 'observe-only'
|
|
27
|
+
? 'but a failed check hands the original input or output on unchanged, so the log says it fired and the user sees what it objected to.'
|
|
28
|
+
: c.state === 'disabled'
|
|
29
|
+
? 'but it inspects nothing.'
|
|
30
|
+
: 'but at this setting it only catches what is already unmistakable, which is not what a working attack looks like.'),
|
|
31
|
+
remediationText:
|
|
32
|
+
c.state === 'observe-only'
|
|
33
|
+
? 'Switch the action to one that changes the outcome (block / deny / exception / filter) for any control whose job is to stop something; keep log-only for controls you are deliberately measuring, and label them as such.'
|
|
34
|
+
: c.state === 'disabled'
|
|
35
|
+
? 'Enable the control, or remove it so the configuration states what is actually screened.'
|
|
36
|
+
: 'Restore the threshold or strength to the vendor default (or stricter) and measure the false-positive cost before loosening it again.',
|
|
37
|
+
remediationTier: c.injection ? 1 : 2,
|
|
38
|
+
evidence: { control: c.name, direction: c.direction, state: c.state, why: c.why, injection: c.injection, path: a.path, ...locate(c.anchor, a.content) },
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function coverageFindings(a , fw , m ) {
|
|
43
|
+
const out = [];
|
|
44
|
+
if (m.emptyRails.length) {
|
|
45
|
+
out.push({
|
|
46
|
+
class: 'MISSING_CONTROL',
|
|
47
|
+
severity: 'HIGH',
|
|
48
|
+
title: `Guardrail config "${a.name}" declares a rail that screens nothing`,
|
|
49
|
+
detail:
|
|
50
|
+
`The ${m.emptyRails.join(' and ')} rail${m.emptyRails.length === 1 ? ' is' : 's are'} declared with an empty \`flows\` list. A declared rail with no flows is not an absent control - it is a configured one that passes everything through, so the deployment reports guardrails as enabled while nothing inspects that side.` +
|
|
51
|
+
(m.declaredRails.length ? ` The ${m.declaredRails.join(', ')} rail${m.declaredRails.length === 1 ? '' : 's'} do carry flows, which is what makes the empty one read as an oversight rather than a decision.` : ''),
|
|
52
|
+
remediationText: 'Populate the rail with the flows it needs (a self-check, a jailbreak detector, a fact-check) or remove the key, so the configuration says what is actually screened.',
|
|
53
|
+
remediationTier: 1,
|
|
54
|
+
evidence: { emptyRails: m.emptyRails, declaredRails: m.declaredRails, path: a.path, ...locate('flows', a.content) },
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
if (m.streamedBeforeCheck) {
|
|
58
|
+
out.push({
|
|
59
|
+
class: 'MISSING_CONTROL',
|
|
60
|
+
severity: 'MEDIUM',
|
|
61
|
+
title: `${fw} "${a.name}" streams the answer before the output check runs`,
|
|
62
|
+
detail: `${m.streamedBeforeCheck.why}. The user receives each chunk first and the output control judges it afterwards, so a response it would have blocked - leaked data, a policy breach - has already been displayed when the verdict arrives. The control can end the stream; it cannot un-send it.`,
|
|
63
|
+
remediationText: 'Buffer output until the check passes (NeMo: `stream_first: false`; Azure: a blocking / default filter mode) for any deployment where the output rail is meant to prevent disclosure rather than record it.',
|
|
64
|
+
remediationTier: 2,
|
|
65
|
+
evidence: { why: m.streamedBeforeCheck.why, path: a.path, ...locate(m.streamedBeforeCheck.anchor, a.content) },
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
for (const o of m.optIn.slice(0, 6)) {
|
|
69
|
+
out.push({
|
|
70
|
+
class: 'MISSING_CONTROL',
|
|
71
|
+
severity: 'MEDIUM',
|
|
72
|
+
title: `${fw} "${o.name}" runs only when the caller asks for it`,
|
|
73
|
+
detail: '`default_on` is not true, so the guardrail applies only to requests (or keys / teams) that name it. A caller that simply omits it - including any client an attacker controls - gets the model with no screening, while the guardrail still appears in the proxy\'s configuration.',
|
|
74
|
+
remediationText: 'Set `default_on: true` for any guardrail meant to protect every request, and attach opt-in guardrails to keys or teams explicitly so the exemption is visible.',
|
|
75
|
+
remediationTier: 1,
|
|
76
|
+
evidence: { guardrail: o.name, path: a.path, ...locate(o.anchor, a.content) },
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
for (const f of m.failOpen.slice(0, 6)) {
|
|
80
|
+
out.push({
|
|
81
|
+
class: 'MISSING_CONTROL',
|
|
82
|
+
severity: 'MEDIUM',
|
|
83
|
+
title: `${fw} "${f.name}" fails open`,
|
|
84
|
+
detail: `${f.why}. When the guardrail service is slow, down or rate-limited, requests reach the model unscreened - and a caller who can make the check error (an oversized or malformed payload) chooses when that happens.`,
|
|
85
|
+
remediationText: 'Fail closed for controls that are meant to block; alert on guardrail errors instead of absorbing them.',
|
|
86
|
+
remediationTier: 2,
|
|
87
|
+
evidence: { guardrail: f.name, why: f.why, path: a.path, ...locate(f.anchor, a.content) },
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
for (const b of m.blindSpots.slice(0, 6)) {
|
|
91
|
+
const minor = /match_all_roles/.test(b.why);
|
|
92
|
+
out.push({
|
|
93
|
+
class: 'MISSING_CONTROL',
|
|
94
|
+
severity: minor ? 'LOW' : 'MEDIUM',
|
|
95
|
+
title: `${fw} "${b.name}" does not look at part of the conversation`,
|
|
96
|
+
detail: `${b.why}. An instruction placed where the control does not look passes it untouched and still reaches the model in full.`,
|
|
97
|
+
remediationText: 'Screen every message the model receives - all turns, all roles and tool results - or state the gap so another control covers it.',
|
|
98
|
+
remediationTier: 2,
|
|
99
|
+
evidence: { guardrail: b.name, why: b.why, path: a.path, ...locate(b.anchor, a.content) },
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function judgeFindings(a , fw , m ) {
|
|
106
|
+
const out = [];
|
|
107
|
+
for (const p of m.judgePrompts.slice(0, 20)) {
|
|
108
|
+
if (p.missingVars.length) {
|
|
109
|
+
out.push({
|
|
110
|
+
class: 'MISSING_CONTROL',
|
|
111
|
+
severity: 'HIGH',
|
|
112
|
+
title: `${fw} judge prompt "${p.task}" never receives the text it judges`,
|
|
113
|
+
detail: `The ${p.task} template has no {{ ${p.missingVars.join(' }}/{{ ')} }} placeholder, so the judge model is asked the same fixed question on every call and gives the same verdict whatever the user sent. The rail runs, costs a model call, and screens nothing.`,
|
|
114
|
+
remediationText: `Include {{ ${p.missingVars[0]} }} in the ${p.task} prompt, inside clear delimiters, and test the rail against a known-bad input.`,
|
|
115
|
+
remediationTier: 1,
|
|
116
|
+
evidence: { task: p.task, missingVars: p.missingVars, path: a.path, ...locate(p.task, a.content) },
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
for (const f of textFindings(a, p.content, 'PROMPT_INJECTION').filter((x) => x.class === 'PROMPT_INJECTION')) {
|
|
120
|
+
out.push({
|
|
121
|
+
...f,
|
|
122
|
+
severity: 'CRITICAL',
|
|
123
|
+
title: `Injected directive in the guardrail's own judge prompt (${p.task})`,
|
|
124
|
+
detail: `${f.detail} This text is the prompt the ${p.task} check sends to its judge model, so a directive here does not bypass one answer - it rewrites the instructions of the control that screens every answer, and the rail keeps reporting that it ran.`,
|
|
125
|
+
evidence: { ...(f.evidence ?? {}), task: p.task, surface: 'guardrail-judge-prompt', path: a.path },
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
for (const task of [...new Set(m.missingPrompts)].slice(0, 6)) {
|
|
130
|
+
out.push({
|
|
131
|
+
class: 'MISSING_CONTROL',
|
|
132
|
+
|
|
133
|
+
severity: 'LOW',
|
|
134
|
+
title: `${fw} rail needs a "${task}" prompt no file here defines`,
|
|
135
|
+
detail: `A flow that requires the ${task} prompt template is enabled, but no prompts entry in this configuration directory defines it. NeMo refuses to load such a config; an application that catches that error and carries on serves the model with no rails at all.`,
|
|
136
|
+
remediationText: `Add a prompts entry with task: ${task}, or remove the flow that needs it.`,
|
|
137
|
+
remediationTier: 2,
|
|
138
|
+
evidence: { task, path: a.path, ...locate(task.split(' ')[0].replace(/_/g, ' '), a.content) },
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function plumbingFindings(a , fw , m ) {
|
|
145
|
+
const out = [];
|
|
146
|
+
for (const e of m.endpoints.filter((x) => isCleartextRemote(x.url)).slice(0, 4)) {
|
|
147
|
+
out.push({
|
|
148
|
+
class: 'INSECURE_CONFIG',
|
|
149
|
+
severity: 'MEDIUM',
|
|
150
|
+
title: `${fw} "${a.name}" sends text to its checker over plain HTTP`,
|
|
151
|
+
detail: `${e.anchor} is ${e.url}. Every prompt the guardrail screens crosses the network in the clear, and whoever sits on that path can also answer "allowed" for the checker.`,
|
|
152
|
+
remediationText: 'Use HTTPS (or a local socket) for guardrail and action endpoints, and authenticate the endpoint the verdict comes from.',
|
|
153
|
+
remediationTier: 2,
|
|
154
|
+
evidence: { url: e.url, path: a.path, ...locate(e.anchor, a.content) },
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
for (const r of m.nonStoppingRefusals.slice(0, 6)) {
|
|
158
|
+
out.push({
|
|
159
|
+
class: 'MISSING_CONTROL',
|
|
160
|
+
severity: 'MEDIUM',
|
|
161
|
+
title: `Colang flow "${r.flow}" refuses but does not stop`,
|
|
162
|
+
detail: 'The refusal branch has no `stop` (or `abort`) after the bot message, so the flow ends and processing carries on: the user sees the refusal AND the model still generates the answer the check objected to.',
|
|
163
|
+
remediationText: 'Follow every refusal inside a guardrail flow with `stop` (Colang 1.0) or `abort` (Colang 2.x).',
|
|
164
|
+
remediationTier: 1,
|
|
165
|
+
evidence: { flow: r.flow, path: a.path, ...locate(r.anchor, a.content) },
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
if (m.unauthenticatedService) {
|
|
169
|
+
out.push({
|
|
170
|
+
class: 'WEAK_AUTH',
|
|
171
|
+
severity: 'MEDIUM',
|
|
172
|
+
title: `${fw} API "${a.name}" has no auth block`,
|
|
173
|
+
detail: 'Without `auth`, the scanner API answers anyone who can reach it: it can be used to probe which payloads pass, and flooded until the application calling it times out (and, if it fails open, stops screening).',
|
|
174
|
+
remediationText: 'Configure `auth` (http_bearer) and keep the service off public networks.',
|
|
175
|
+
remediationTier: 2,
|
|
176
|
+
evidence: { path: a.path, ...locate('app', a.content) },
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
const screensInput = m.controls.some((c) => c.direction === 'input' || c.direction === 'both' || c.direction === 'unknown');
|
|
180
|
+
|
|
181
|
+
const namesAreMeaningful = m.framework === 'nemo' || m.framework === 'bedrock' || m.framework === 'azure-rai' || m.framework === 'llm-guard' || m.framework === 'litellm';
|
|
182
|
+
if (namesAreMeaningful && screensInput && m.controls.length && !m.controls.some((c) => c.injection)) {
|
|
183
|
+
out.push({
|
|
184
|
+
class: 'MISSING_CONTROL',
|
|
185
|
+
severity: 'LOW',
|
|
186
|
+
title: `${fw} "${a.name}" screens input but nothing in it targets prompt injection`,
|
|
187
|
+
detail: `The configured controls (${m.controls.slice(0, 6).map((c) => c.name).join(', ')}) cover content categories, not instructions smuggled into the input. A jailbreak or injected instruction that is not itself offensive content passes all of them.`,
|
|
188
|
+
remediationText: 'Add a prompt-attack / jailbreak control on the input side, or record which other layer is responsible for it.',
|
|
189
|
+
remediationTier: 3,
|
|
190
|
+
evidence: { controls: m.controls.slice(0, 20).map((c) => c.name), path: a.path },
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function guardrailConfigFindings(a ) {
|
|
197
|
+
const text = a.content ?? '';
|
|
198
|
+
const fw = ((a.meta?.framework ) && FRAMEWORK_LABEL[a.meta .framework ] ? a.meta .framework : guardrailFramework(a.path, text)) ;
|
|
199
|
+
|
|
200
|
+
const out = textFindings(a, text, 'PROMPT_INJECTION').filter((f) => f.class === 'SECRET_EXPOSURE');
|
|
201
|
+
if (!fw) return out;
|
|
202
|
+
const m = guardrailModel(a.path, text, fw, Array.isArray(a.meta?.siblingPromptTasks) ? a.meta .siblingPromptTasks : []);
|
|
203
|
+
if (!m) return out;
|
|
204
|
+
const label = FRAMEWORK_LABEL[fw];
|
|
205
|
+
const bad = m.controls.filter((c) => c.state !== 'enforcing');
|
|
206
|
+
for (const c of bad.slice(0, MAX_CONTROL_FINDINGS)) out.push(controlFinding(a, label, c));
|
|
207
|
+
out.push(...coverageFindings(a, label, m), ...judgeFindings(a, label, m), ...plumbingFindings(a, label, m));
|
|
208
|
+
if (fw === 'nemo') out.push(...nemoInstructionFindings(a, text));
|
|
209
|
+
return out;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function nemoInstructionFindings(a , text ) {
|
|
213
|
+
const block = /^instructions\s*:[\s\S]*?(?=^\S)/m.exec(text + '\n~')?.[0];
|
|
214
|
+
if (!block) return [];
|
|
215
|
+
return textFindings(a, block, 'PROMPT_INJECTION').filter((f) => f.class === 'PROMPT_INJECTION').map((f) => ({
|
|
216
|
+
...f,
|
|
217
|
+
title: `Injected directive in the guarded model's instructions ("${a.name}")`,
|
|
218
|
+
detail: `${f.detail} NeMo sends \`instructions\` as the system prompt of the model the rails protect, so this text outranks what the rails were configured to allow.`,
|
|
219
|
+
}));
|
|
220
|
+
}
|
|
221
|
+
|
|
@@ -50,6 +50,14 @@ export function describesRatherThanInstructs(text, at) {
|
|
|
50
50
|
|
|
51
51
|
export const PRECEDING_NEGATION = /\b(never|not|do not|don'?t|cannot|can'?t|must not|mustn'?t|should not|shouldn'?t|avoid|refuse to|forbidden to|prohibited from|without)\s*$/i;
|
|
52
52
|
|
|
53
|
+
// Reaches a negation across a coordinated verb chain ("never modify or exfiltrate"); mirrors backend.
|
|
54
|
+
export const COORDINATED_NEGATION =
|
|
55
|
+
/\b(?:never|not|do not|don'?t|cannot|can'?t|must not|mustn'?t|should not|shouldn'?t|avoid|refuse to|forbidden to|prohibited from)\b[\w\t ,]*?(?:\bor\b|\band\b|\bnor\b|,)[\t ]*$/i;
|
|
56
|
+
|
|
57
|
+
export function precededByNegation(before) {
|
|
58
|
+
return PRECEDING_NEGATION.test(before.slice(-20)) || COORDINATED_NEGATION.test(before.slice(-60));
|
|
59
|
+
}
|
|
60
|
+
|
|
53
61
|
export const BUILD_ARTIFACT = /\b(node_modules|dist|build|out|coverage|target|cache|generated|tmp|temp|__pycache__|artifacts?|logs?|tests?|test|fixtures?|staging|scratch|migrations?)\b/i;
|
|
54
62
|
|
|
55
63
|
export const INVISIBLE_CHARS_RE = /[ᅟᅠ---ㅤᅠ-]|[\u{E0000}-\u{E007F}\u{E0100}-\u{E01EF}]/u;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { localScan } from './scan.mjs';
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
export function inspectText(text, { categories = ['injection'] } = {}) {
|
|
5
|
+
const { findings } = localScan(String(text ?? ''), { categories });
|
|
6
|
+
return { matches: findings.map((f) => ({ label: f.label, severity: f.severity, category: f.category, sample: f.sample ?? '' })) };
|
|
7
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
|
|
2
|
+
export const INSTRUCTION_BASENAMES = new Set([
|
|
3
|
+
'claude.md', 'agents.md', 'agent.md', 'gemini.md', 'llms.txt', 'llms-full.txt',
|
|
4
|
+
'.cursorrules', '.windsurfrules', '.clinerules', '.aiderrules', '.continuerules',
|
|
5
|
+
'.goosehints', 'copilot-instructions.md', 'conventions.md',
|
|
6
|
+
'qwen.md', 'codex.md', 'cline.md', 'kilocode.md', 'roo.md', 'windsurf.md',
|
|
7
|
+
'.kilocoderules', '.roorules', '.traerules', '.augmentrules', '.junie.md',
|
|
8
|
+
'ruler.md', 'opencode.md', 'crush.md',
|
|
9
|
+
'claude.local.md', 'agents.local.md', 'guidelines.md', '.rules', '.airules', '.agent.md', '.agents.md',
|
|
10
|
+
'kiro.md', 'devin.md', 'zed.md', 'antigravity.md', 'amazonq.md', 'q.md',
|
|
11
|
+
'.qoderrules', '.factoryrules', '.opencoderules', 'style.md',
|
|
12
|
+
'agents.override.md', '.augment-guidelines', 'global_rules.md', 'qwen.local.md', 'gemini.local.md',
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
const INSTRUCTION_PATH_RES = [
|
|
16
|
+
/(^|\/)\.github\/copilot-instructions\.md$/,
|
|
17
|
+
/(^|\/)\.github\/(instructions|prompts|chatmodes)\/.+\.(md|prompt\.md|chatmode\.md)$/,
|
|
18
|
+
/(^|\/)\.cursor\/rules\/.+\.(mdc|md)$/,
|
|
19
|
+
/(^|\/)\.(kilocode|roo|trae|junie|windsurf|augment)\/rules\/.+\.(md|mdc)$/,
|
|
20
|
+
/(^|\/)\.amazonq\/rules\/.+\.md$/,
|
|
21
|
+
/(^|\/)\.ruler\/.+\.md$/,
|
|
22
|
+
/(^|\/)\.(kilocode|roo|trae|junie|windsurf|augment|kiro|qoder|factory|opencode|crush|zed|goose|devin|antigravity|openclaw|clawhub|amp)\/(rules?|guidelines?|steering|memories)(\/.+)?\.(md|mdc|txt)$/,
|
|
23
|
+
/(^|\/)\.claude\/(output-styles|agents\/shared)\/.+\.md$/,
|
|
24
|
+
/(^|\/)\.(cursor|windsurf|codex|gemini|qwen)\/(memories|rules)\/.+\.(md|mdc)$/,
|
|
25
|
+
/(^|\/)\.ai-?rules\/.+\.(md|mdc|txt)$/,
|
|
26
|
+
/(^|\/)\.github\/agents\/.+\.md$/,
|
|
27
|
+
/(^|\/)\.claude\/claude\.md$/,
|
|
28
|
+
/(^|\/)\.claude\/rules\/.+\.md$/,
|
|
29
|
+
/(^|\/)\.(roo|kilocode)\/rules-[\w-]+\/.+\.(md|mdc|txt)$/,
|
|
30
|
+
/(^|\/)\.roorules(-[\w-]+)?$/,
|
|
31
|
+
/(^|\/)\.continue\/rules\/.+\.(md|ya?ml)$/,
|
|
32
|
+
/(^|\/)\.clinerules\/.+\.(md|txt)$/,
|
|
33
|
+
/(^|\/)cline\/rules\/.+\.(md|txt)$/,
|
|
34
|
+
/(^|\/)\.augment\/rules\/.+\.mdx$/,
|
|
35
|
+
/(^|\/)\.qwen\/team-memory\/.+\.(md|txt)$/,
|
|
36
|
+
/(^|\/)\.copilot\/instructions\/.+\.md$/,
|
|
37
|
+
/(^|\/)\.github\/agents\/.+\.agent\.md$/,
|
|
38
|
+
/(^|\/)\.junie\/playbook\.md$/,
|
|
39
|
+
/(^|\/)\.trae\/(?:rules\/)?(?:project|user)_rules\.md$/,
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
export function isInstructionPath(p) {
|
|
43
|
+
const lower = String(p ?? '').split(/[\\/]+/).join('/').toLowerCase();
|
|
44
|
+
const base = lower.slice(lower.lastIndexOf('/') + 1);
|
|
45
|
+
return INSTRUCTION_BASENAMES.has(base) || INSTRUCTION_PATH_RES.some((re) => re.test(lower));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const IMPORT_RE = /(?:^|[\s(<"'])@((?:~|\.{1,2})?\/?[\w.@-]+(?:\/[\w.@-]+)*\.(?:md|mdx|markdown|txt|mdc|rst))\b|#\[\[file:([^\]\n]{1,200})\]\]/g;
|
|
49
|
+
|
|
50
|
+
export function extractInstructionImports(text) {
|
|
51
|
+
if (!text) return [];
|
|
52
|
+
const out = new Set();
|
|
53
|
+
const prose = String(text).replace(/^\s*(```|~~~)[\s\S]*?^\s*\1/gm, '');
|
|
54
|
+
for (const m of prose.matchAll(IMPORT_RE)) {
|
|
55
|
+
const p = (m[1] ?? m[2] ?? '').trim();
|
|
56
|
+
if (p && p.length <= 200) out.add(p);
|
|
57
|
+
if (out.size >= 40) break;
|
|
58
|
+
}
|
|
59
|
+
return [...out];
|
|
60
|
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { assessUrl } from './egress.mjs';
|
|
2
|
+
import { lineOf } from './lines.mjs';
|
|
3
|
+
import { gradeMcpDocument, parseConfigDoc } from './mcp-config.mjs';
|
|
4
|
+
|
|
5
|
+
export const PLUGIN_MANIFEST_RE =
|
|
6
|
+
/(^|\/)(?:\.(?:claude|codex|cursor|copilot|github)-plugin|\.plugin|\.github\/plugin)\/(?:plugin|marketplace)\.json$|(^|\/)\.agents\/plugins\/marketplace\.json$|(^|\/)gemini-extension\.json$|(^|\/)openclaw\.plugin\.json$/i;
|
|
7
|
+
|
|
8
|
+
export const TOOL_MANIFEST_RE =
|
|
9
|
+
/(^|\/)(?:ai-plugin|apiplugin|declarativeagent)\.json$|(^|\/)\.well-known\/(?:ai-plugin\.json|openapi\.(?:json|ya?ml))$|(^|\/)\.roomodes$/i;
|
|
10
|
+
|
|
11
|
+
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
12
|
+
const str = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null);
|
|
13
|
+
const SHA_RE = /^[0-9a-f]{40}([0-9a-f]{24})?$/i;
|
|
14
|
+
const SEMVER_TAG_RE = /^v?\d+\.\d+\.\d+(?:[-+][\w.-]+)?$/;
|
|
15
|
+
const RAW_CODE_HOST_RE = /\b(?:raw\.githubusercontent\.com|gist\.github(?:usercontent)?\.com|gitlab\.com\/[^\s]*\/-\/raw\/|bitbucket\.org\/[^\s]*\/raw\/)/i;
|
|
16
|
+
const SHELL_COMMAND_RE = /(^|[\\/])(sh|bash|dash|zsh|ksh|cmd|cmd\.exe|powershell|powershell\.exe|pwsh)$/i;
|
|
17
|
+
const SHELL_FLAG_RE = /^(-c|-Command|-EncodedCommand|\/c|\/k)$/i;
|
|
18
|
+
const SECRETISH_KEY_RE = /(^|[_-])(api[_-]?key|apikey|token|secret|password|passwd|credential|private[_-]?key|access[_-]?key|bearer|auth)/i;
|
|
19
|
+
const MUTATING_METHODS = new Set(['post', 'put', 'patch', 'delete']);
|
|
20
|
+
const MUTATING_VERB_RE = /\b(create|update|delete|remove|send|post|submit|transfer|pay|refund|approve|cancel|write|modify|publish|share|grant|revoke|purge|drop|charge|execute|deploy)\w*/i;
|
|
21
|
+
|
|
22
|
+
export const RESERVED_MARKETPLACE_NAMES = new Set([
|
|
23
|
+
'claude-code-marketplace', 'claude-code-plugins', 'claude-plugins-official', 'claude-plugins-community',
|
|
24
|
+
'claude-community', 'anthropic-marketplace', 'anthropic-plugins', 'agent-skills', 'anthropic-agent-skills',
|
|
25
|
+
'knowledge-work-plugins', 'life-sciences', 'claude-for-legal', 'claude-for-financial-services',
|
|
26
|
+
'financial-services-plugins', 'first-party-plugins', 'claude-tag-plugins', 'healthcare',
|
|
27
|
+
]);
|
|
28
|
+
const IMPERSONATION_NAME_RE = /anthropic|(?:official|verified|first[-_. ]?party)[-_. ]*claude|claude[-_. ]*(?:official|verified|first[-_. ]?party)|claude[-_. ]*plugins[-_. ]*(?:official|v\d)/i;
|
|
29
|
+
|
|
30
|
+
function parse(content, path) {
|
|
31
|
+
const text = String(content ?? '').replace(/^/, '');
|
|
32
|
+
try {
|
|
33
|
+
const doc = parseConfigDoc(text, path || 'x.json');
|
|
34
|
+
if (doc) return doc;
|
|
35
|
+
} catch { }
|
|
36
|
+
try { return parseConfigDoc(text.replace(/,(\s*[}\]])/g, '$1'), path || 'x.json'); } catch { return null; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const CONFUSABLE = {
|
|
40
|
+
'а': 'a', 'е': 'e', 'о': 'o', 'р': 'p', 'с': 'c', 'у': 'y', 'х': 'x', 'і': 'i', 'ј': 'j', 'ѕ': 's',
|
|
41
|
+
'ԁ': 'd', 'һ': 'h', 'ӏ': 'l', 'ԛ': 'q', 'ԝ': 'w', 'ɡ': 'g', 'ο': 'o', 'α': 'a', 'ν': 'v', 'ι': 'i',
|
|
42
|
+
'κ': 'k', 'τ': 't', 'υ': 'u', 'ρ': 'p', 'ε': 'e',
|
|
43
|
+
};
|
|
44
|
+
const INVISIBLE_RE = /[͏ᅟᅠ឴឵----ㅤᅠ]/g;
|
|
45
|
+
|
|
46
|
+
export function nameSkeleton(name) {
|
|
47
|
+
return String(name ?? '').normalize('NFKC').replace(INVISIBLE_RE, '').toLowerCase().replace(/[^\x00-\x7f]/g, (ch) => CONFUSABLE[ch] ?? ch);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isDisguised(name) {
|
|
51
|
+
const n = String(name ?? '');
|
|
52
|
+
return /[͏ᅟᅠ឴឵----ㅤᅠ]/.test(n) || (/[A-Za-z]/.test(n) && /[Ͱ-ϿЀ-ԯ]/.test(n));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function normalizeSource(src) {
|
|
56
|
+
if (typeof src === 'string') {
|
|
57
|
+
const s = src.trim();
|
|
58
|
+
if (!s) return null;
|
|
59
|
+
if (/^https?:\/\//i.test(s)) {
|
|
60
|
+
const hash = s.indexOf('#');
|
|
61
|
+
const ref = hash > -1 ? s.slice(hash + 1) || null : null;
|
|
62
|
+
return { type: 'url', raw: s, url: hash > -1 ? s.slice(0, hash) : s, ref, sha: ref && SHA_RE.test(ref) ? ref : (/\b([0-9a-f]{40})\b/i.exec(s)?.[1] ?? null) };
|
|
63
|
+
}
|
|
64
|
+
if (/^git@|^(?:git\+)?(?:ssh|git):\/\//i.test(s)) return { type: 'git', raw: s, url: s, ref: null, sha: null };
|
|
65
|
+
return { type: 'relative', raw: s, path: s };
|
|
66
|
+
}
|
|
67
|
+
if (!isObj(src)) return null;
|
|
68
|
+
const kind = String(src.source ?? src.type ?? '').toLowerCase();
|
|
69
|
+
const ref = str(src.ref) ?? str(src.branch) ?? str(src.tag);
|
|
70
|
+
const sha = [src.sha, src.commit].map(str).find((x) => x && SHA_RE.test(x)) ?? null;
|
|
71
|
+
const url = str(src.url);
|
|
72
|
+
if (kind === 'github' || (!kind && str(src.repo))) return { type: 'github', raw: `github:${src.repo}`, url: src.repo ? `https://github.com/${src.repo}` : null, ref, sha };
|
|
73
|
+
if (kind === 'npm') return { type: 'npm', raw: `npm:${src.package}`, pkg: str(src.package), version: str(src.version) };
|
|
74
|
+
if (kind === 'local' || kind === 'path' || kind === 'directory' || kind === 'file') return { type: 'relative', raw: str(src.path) ?? '?', path: str(src.path) };
|
|
75
|
+
if (kind === 'archive') return { type: 'archive', raw: url ?? '?', url, digest: str(src.sha256) };
|
|
76
|
+
if (kind === 'command') return { type: 'command', raw: `command:${str(src.command) ?? '?'}`, command: str(src.command) };
|
|
77
|
+
return { type: url ? 'url' : 'unknown', raw: url ?? '?', url, ref, sha };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function pinning(s) {
|
|
81
|
+
if (s.type === 'relative' || s.type === 'command' || s.type === 'unknown') return null;
|
|
82
|
+
if (s.type === 'archive') return s.digest ? 'pinned' : 'floating';
|
|
83
|
+
if (s.type === 'npm') {
|
|
84
|
+
const v = s.version ?? '';
|
|
85
|
+
if (!v || /^(latest|\*|next)$/i.test(v)) return 'floating';
|
|
86
|
+
return /^[\^~><=*]|\|\||\.x\b/.test(v) ? 'range' : 'pinned';
|
|
87
|
+
}
|
|
88
|
+
if (s.sha) return 'pinned';
|
|
89
|
+
if (s.ref && SHA_RE.test(s.ref)) return 'pinned';
|
|
90
|
+
if (s.url && /\/(?:releases\/download|archive\/refs\/tags)\//i.test(s.url)) return 'pinned';
|
|
91
|
+
if (s.ref && SEMVER_TAG_RE.test(s.ref.replace(/^refs\/tags\//, ''))) return 'tag';
|
|
92
|
+
return s.ref ? 'branch' : 'floating';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function escapesRoot(p) {
|
|
96
|
+
const s = String(p).replace(/\\/g, '/').replace(/\$\{?(?:CLAUDE_PLUGIN_ROOT|PLUGIN_ROOT|extensionPath)\}?/g, '.');
|
|
97
|
+
if (/^(?:\/|~|[a-z]:\/|\$\{?HOME\}?|%\w+%)/i.test(s)) return true;
|
|
98
|
+
let depth = 0;
|
|
99
|
+
for (const seg of s.split('/')) {
|
|
100
|
+
if (seg === '..') { if (--depth < 0) return true; } else if (seg && seg !== '.') depth++;
|
|
101
|
+
}
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function runnerUnpinned(command, args) {
|
|
106
|
+
const bin = String(command ?? '').replace(/\\/g, '/').split('/').pop().toLowerCase().replace(/\.(cmd|exe)$/, '');
|
|
107
|
+
const eco = { npx: 'npm', bunx: 'npm', pnpx: 'npm', uvx: 'pip' }[bin];
|
|
108
|
+
if (!eco) return null;
|
|
109
|
+
const spec = (args ?? []).map(String).find((x) => !x.startsWith('-'));
|
|
110
|
+
if (!spec) return null;
|
|
111
|
+
const pinned = eco === 'npm' ? /^(@[^/]+\/)?[^@]+@\d+\.\d+\.\d+/.test(spec) : /==\d/.test(spec);
|
|
112
|
+
return pinned ? null : spec;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function localPlugin(content, { path } = {}) {
|
|
116
|
+
const out = [];
|
|
117
|
+
const push = (severity, title, remediationText, needle) => out.push({ severity, title, remediationText, ...(needle ? { line: lineOf(content, needle) } : {}) });
|
|
118
|
+
const doc = parse(content, path);
|
|
119
|
+
if (!isObj(doc)) return out;
|
|
120
|
+
|
|
121
|
+
if (Array.isArray(doc.plugins)) {
|
|
122
|
+
const name = nameSkeleton(String(doc.name ?? '').trim());
|
|
123
|
+
const disguised = [doc.name, ...doc.plugins.map((p) => p?.name)].find((n) => typeof n === 'string' && isDisguised(n));
|
|
124
|
+
if (disguised) push('HIGH', `Name "${nameSkeleton(disguised)}" is not what it looks like (invisible or look-alike characters)`, 'Reject the manifest until every name is plain ASCII.', disguised);
|
|
125
|
+
if (doc.plugins.length > 5000) push('MEDIUM', `Marketplace lists ${doc.plugins.length} plugins - only the first 5000 were checked`, 'Review the entries past the cap by hand.');
|
|
126
|
+
const claimsVendor = /@anthropic\.com$/i.test(String(doc.owner?.email ?? ''));
|
|
127
|
+
if (name && !claimsVendor && (RESERVED_MARKETPLACE_NAMES.has(name) || IMPERSONATION_NAME_RE.test(name))) {
|
|
128
|
+
push(RESERVED_MARKETPLACE_NAMES.has(name) ? 'HIGH' : 'MEDIUM', `Marketplace "${doc.name}" is named to look like the vendor's official catalogue`, 'Rename it to identify its real publisher.', String(doc.name));
|
|
129
|
+
}
|
|
130
|
+
for (const e of doc.plugins.slice(0, 5000)) {
|
|
131
|
+
if (!isObj(e)) continue;
|
|
132
|
+
const s = normalizeSource(e.source);
|
|
133
|
+
const label = String(e.name ?? '?');
|
|
134
|
+
if (s) {
|
|
135
|
+
if (s.type === 'relative' && s.path && escapesRoot(s.path)) push('MEDIUM', `Marketplace entry "${label}" installs from outside the marketplace (${s.path})`, 'Keep relative sources inside the marketplace repository.', s.path);
|
|
136
|
+
else if (s.type === 'command') push('MEDIUM', `Marketplace entry "${label}" is built by running a command on the installing machine`, 'Use a repository or archive source pinned by commit SHA / sha256.', 'command');
|
|
137
|
+
else {
|
|
138
|
+
const u = s.url ? assessUrl(s.url) : null;
|
|
139
|
+
if (s.url && /^[a-z][\w+.-]*:\/\/[^/\s@]+:[^/\s@]+@/i.test(s.url)) push('HIGH', `Marketplace entry "${label}" embeds a credential in its source URL`, 'Rotate the token and remove it from the URL.', label);
|
|
140
|
+
if (u?.metadataEndpoint) push('CRITICAL', `Marketplace entry "${label}" installs from the cloud metadata endpoint`, 'Remove the entry.', s.url);
|
|
141
|
+
else if (u?.suspiciousHost) push('HIGH', `Marketplace entry "${label}" installs from an untrusted host (${u.suspiciousHost})`, 'Install plugins only from a reviewed source.', s.url);
|
|
142
|
+
else if (u?.plaintext && !u.privateNetwork) push('MEDIUM', `Marketplace entry "${label}" installs over plaintext HTTP`, 'Use an https:// source.', s.url);
|
|
143
|
+
const pin = pinning(s);
|
|
144
|
+
if (s.url && RAW_CODE_HOST_RE.test(s.url) && pin !== 'pinned') push('HIGH', `Plugin "${label}" installs from an unpinned raw-file source`, 'Pin the source to a full commit SHA or a signed release.', s.url);
|
|
145
|
+
else if (pin === 'floating' || pin === 'branch' || pin === 'range') push('MEDIUM', `Plugin "${label}" installs from an unpinned source (${s.raw})`, 'Add a `sha` with the full commit of the reviewed revision (or an exact version / sha256).', label);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (isObj(e.mcpServers)) {
|
|
149
|
+
for (const f of gradeMcpDocument(JSON.stringify({ mcpServers: e.mcpServers }), 'marketplace.json').findings) push(f.severity, `${label}: ${f.title}`, f.remediationText, f.server);
|
|
150
|
+
}
|
|
151
|
+
if (isObj(e.lspServers)) out.push(...lspRows(e.lspServers, `Marketplace entry "${label}"`, content));
|
|
152
|
+
}
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
for (const key of ['commands', 'agents', 'skills', 'hooks', 'mcpServers', 'lspServers', 'monitors', 'outputStyles']) {
|
|
157
|
+
const v = doc[key];
|
|
158
|
+
const paths = typeof v === 'string' ? [v] : Array.isArray(v) ? v.filter((x) => typeof x === 'string') : [];
|
|
159
|
+
const bad = paths.filter(escapesRoot);
|
|
160
|
+
if (bad.length) push(['hooks', 'mcpServers', 'lspServers', 'monitors'].includes(key) ? 'HIGH' : 'MEDIUM', `Plugin loads ${key} from outside the plugin (${bad[0]})`, 'Reference components by paths inside the plugin.', bad[0]);
|
|
161
|
+
}
|
|
162
|
+
const uc = isObj(doc.userConfig) ? Object.entries(doc.userConfig).map(([k, v]) => ({ key: k, sensitive: v?.sensitive })) : [];
|
|
163
|
+
const gs = Array.isArray(doc.settings) ? doc.settings.filter(isObj).map((s) => ({ key: s.envVar ?? s.name, sensitive: s.sensitive })) : [];
|
|
164
|
+
const plain = [...uc, ...gs].filter((c) => SECRETISH_KEY_RE.test(String(c.key ?? '')) && c.sensitive !== true);
|
|
165
|
+
if (plain.length) push('MEDIUM', `Plugin collects a credential without marking it sensitive (${plain[0].key})`, 'Set `sensitive: true` on every field that carries a secret.', String(plain[0].key));
|
|
166
|
+
for (const [label, map] of [['MCP server', doc.mcpServers], ['language server', doc.lspServers]]) {
|
|
167
|
+
if (!isObj(map)) continue;
|
|
168
|
+
for (const [n, s] of Object.entries(map)) {
|
|
169
|
+
const args = Array.isArray(s?.args) ? s.args.map(String) : [];
|
|
170
|
+
const line = [s?.command, ...args].join(' ');
|
|
171
|
+
if (/\$\{user_config\.[\w.-]+\}/.test(line) && SHELL_COMMAND_RE.test(String(s?.command ?? '')) && args.some((x) => SHELL_FLAG_RE.test(x))) {
|
|
172
|
+
push('HIGH', `Plugin substitutes user configuration into a shell command line (${label} "${n}")`, 'Pass configuration as its own argv entry or an environment variable, never inside `sh -c`.', n);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (isObj(doc.lspServers)) out.push(...lspRows(doc.lspServers, 'Plugin', content));
|
|
177
|
+
if (str(doc.migratedTo)) {
|
|
178
|
+
const u = assessUrl(doc.migratedTo);
|
|
179
|
+
push(u?.suspiciousHost || u?.metadataEndpoint ? 'HIGH' : u?.plaintext ? 'MEDIUM' : 'LOW', `Extension moves its own install source to ${doc.migratedTo}`, 'Confirm the new source is the same publisher and pin it.', 'migratedTo');
|
|
180
|
+
}
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function lspRows(map, owner, content) {
|
|
185
|
+
const out = [];
|
|
186
|
+
for (const [n, s] of Object.entries(map).slice(0, 60)) {
|
|
187
|
+
const spec = runnerUnpinned(s?.command, s?.args);
|
|
188
|
+
if (spec) out.push({ severity: 'MEDIUM', title: `${owner} fetches language server "${n}" at an unpinned version (${spec})`, remediationText: 'Pin the package to an exact version or launch an installed binary.', line: lineOf(content, n) });
|
|
189
|
+
}
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function mutating(op, method) {
|
|
194
|
+
if (method && MUTATING_METHODS.has(method)) return true;
|
|
195
|
+
const dh = op?.capabilities?.security_info?.data_handling;
|
|
196
|
+
if (Array.isArray(dh) && dh.some((d) => /^(ResourceStateUpdate|DataExport)$/i.test(String(d)))) return true;
|
|
197
|
+
return !method && MUTATING_VERB_RE.test(`${op?.name ?? ''} ${op?.description ?? ''}`.replace(/_/g, ' '));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function localToolManifest(content, { path } = {}) {
|
|
201
|
+
const out = [];
|
|
202
|
+
const push = (severity, title, remediationText, needle) => out.push({ severity, title, remediationText, ...(needle ? { line: lineOf(content, needle) } : {}) });
|
|
203
|
+
const doc = parse(content, path);
|
|
204
|
+
if (!isObj(doc)) return out;
|
|
205
|
+
|
|
206
|
+
if (isObj(doc.paths)) {
|
|
207
|
+
const bypass = [];
|
|
208
|
+
for (const [p, item] of Object.entries(doc.paths)) {
|
|
209
|
+
if (!isObj(item)) continue;
|
|
210
|
+
for (const m of MUTATING_METHODS) {
|
|
211
|
+
const op = item[m];
|
|
212
|
+
const flag = op?.['x-openai-isConsequential'] ?? op?.['x-oai-isConsequential'];
|
|
213
|
+
if (isObj(op) && (flag === false || /^false$/i.test(String(flag ?? '').trim()))) bypass.push(op.operationId ?? `${m.toUpperCase()} ${p}`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (bypass.length) push(bypass.some((n) => /delete|remove|transfer|pay|refund|purge|drop/i.test(n)) ? 'HIGH' : 'MEDIUM', `Action lets the model change state without asking (${bypass.slice(0, 3).join(', ')})`, 'Remove `x-openai-isConsequential: false` from state-changing operations.', 'isConsequential');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (Array.isArray(doc.functions)) {
|
|
220
|
+
const noConfirm = doc.functions.filter((f) => isObj(f) && mutating(f) && (/^none$/i.test(String(f.capabilities?.confirmation?.type ?? '')) || f.capabilities?.confirmation?.isNonConsequential === true));
|
|
221
|
+
if (noConfirm.length) push('MEDIUM', `Plugin lets the model change state without asking (${noConfirm.slice(0, 3).map((f) => f.name).join(', ')})`, 'Use an AdaptiveCard confirmation on every function that changes state or exports data.', String(noConfirm[0].name));
|
|
222
|
+
const mut = doc.functions.filter((f) => isObj(f) && mutating(f));
|
|
223
|
+
for (const r of Array.isArray(doc.runtimes) ? doc.runtimes : []) {
|
|
224
|
+
if (/^none$/i.test(String(r?.auth?.type ?? '')) && mut.length) push('MEDIUM', `Plugin runs state-changing functions against an unauthenticated ${r.type ?? 'runtime'}`, 'Put the runtime behind OAuthPluginVault or ApiKeyPluginVault.', 'auth');
|
|
225
|
+
const u = assessUrl(r?.spec?.url);
|
|
226
|
+
if (u?.suspiciousHost || u?.metadataEndpoint) push(u.metadataEndpoint ? 'CRITICAL' : 'HIGH', `Plugin routes model calls to ${u.suspiciousHost ?? 'the cloud metadata endpoint'}`, 'Remove this runtime.', u.url);
|
|
227
|
+
else if (u?.plaintext && !u.privateNetwork) push('MEDIUM', 'Plugin loads a runtime over plaintext HTTP', 'Serve the runtime over https.', u.url);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (typeof doc.schema_version === 'string' && typeof doc.name_for_model === 'string') {
|
|
232
|
+
const api = str(doc.api?.url);
|
|
233
|
+
if (String(doc.auth?.type ?? '') === 'none' && api) push('MEDIUM', 'Tool manifest advertises an unauthenticated API', 'Require an authenticated scheme.', api);
|
|
234
|
+
for (const key of ['client_url', 'authorization_url']) {
|
|
235
|
+
const u = assessUrl(doc.auth?.[key]);
|
|
236
|
+
if (u?.suspiciousHost) push('HIGH', `Tool manifest sends users to sign in at ${u.suspiciousHost}`, 'Use the provider\'s real authorization server.', u.url);
|
|
237
|
+
else if (u?.plaintext && !u.privateNetwork) push('HIGH', 'Tool manifest runs its OAuth flow over plaintext HTTP', 'Use https for every OAuth endpoint.', u.url);
|
|
238
|
+
}
|
|
239
|
+
const u = assessUrl(api);
|
|
240
|
+
if (u?.suspiciousHost || u?.metadataEndpoint) push(u.metadataEndpoint ? 'CRITICAL' : 'HIGH', `Tool manifest routes model traffic to ${u.suspiciousHost ?? 'the cloud metadata endpoint'}`, 'Remove this manifest.', api);
|
|
241
|
+
else if (u?.plaintext && !u.privateNetwork) push('MEDIUM', 'Tool manifest points at a plaintext endpoint', 'Serve the API over https.', api);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (typeof doc.instructions === 'string' && Array.isArray(doc.capabilities) && typeof doc.model !== 'string') {
|
|
245
|
+
const caps = doc.capabilities.filter(isObj);
|
|
246
|
+
const unscoped = (n, keys) => caps.some((c) => c.name === n && !keys.some((k) => (Array.isArray(c[k]) ? c[k].length : c[k])));
|
|
247
|
+
const untrusted = unscoped('WebSearch', ['sites']) || unscoped('Email', ['shared_mailbox', 'group_mailboxes', 'folders']) || unscoped('TeamsMessages', ['urls']);
|
|
248
|
+
const priv = caps.some((c) => ['OneDriveAndSharePoint', 'Email', 'TeamsMessages', 'Meetings', 'People', 'Dataverse', 'GraphConnectors'].includes(c.name));
|
|
249
|
+
const sendsMail = caps.some((c) => c.name === 'EmailActions');
|
|
250
|
+
const egress = sendsMail || caps.some((c) => c.name === 'MeetingActions') || (Array.isArray(doc.actions) && doc.actions.length > 0);
|
|
251
|
+
const inboundOrWeb = caps.some((c) => c.name === 'Email') || unscoped('WebSearch', ['sites']);
|
|
252
|
+
if (untrusted && priv && egress) push(sendsMail && inboundOrWeb ? 'HIGH' : 'MEDIUM', 'Declarative agent assembles the lethal trifecta from its capabilities', 'Scope WebSearch/mail/Teams to named items and split outward actions into a separate agent.', 'capabilities');
|
|
253
|
+
}
|
|
254
|
+
return out;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const SENSITIVE_DEFAULT_RE = /(^|[\/])(\.ssh|\.aws|\.gnupg|\.kube|\.docker|\.config[\/]gcloud|\.azure|\.netrc|\.npmrc|\.git-credentials|Library[\/]Keychains)([\/]|$)/i;
|
|
258
|
+
const BROAD_DEFAULT_RE = /^(\/|~|~\/|[a-z]:[\/]?|\$\{?HOME\}?|%USERPROFILE%)$/i;
|
|
259
|
+
|
|
260
|
+
export function isDesktopExtensionDoc(doc) {
|
|
261
|
+
if (!isObj(doc)) return false;
|
|
262
|
+
if (typeof doc.dxt_version === 'string' || typeof doc.mcpb_version === 'string') return true;
|
|
263
|
+
return isObj(doc.server) && (isObj(doc.server.mcp_config) || (typeof doc.manifest_version === 'string' && typeof doc.server.entry_point === 'string'));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function localExtension(content, { path } = {}) {
|
|
267
|
+
const out = [];
|
|
268
|
+
const push = (severity, title, remediationText, needle) => out.push({ severity, title, remediationText, ...(needle ? { line: lineOf(content, needle) } : {}) });
|
|
269
|
+
const doc = parse(content, path || 'manifest.json');
|
|
270
|
+
if (!isDesktopExtensionDoc(doc)) return out;
|
|
271
|
+
const name = String(doc.display_name ?? doc.name ?? 'extension');
|
|
272
|
+
const cfg = isObj(doc.server?.mcp_config) ? doc.server.mcp_config : null;
|
|
273
|
+
const launches = cfg ? [['default', cfg], ...Object.entries(isObj(cfg.platform_overrides) ? cfg.platform_overrides : {}).map(([k, o]) => [k, { ...cfg, ...o }])] : [];
|
|
274
|
+
|
|
275
|
+
for (const [label, l] of launches) {
|
|
276
|
+
const servers = { [label === 'default' ? name : `${name} (${label})`]: { command: l.command, args: l.args, env: l.env } };
|
|
277
|
+
for (const f of gradeMcpDocument(JSON.stringify({ mcpServers: servers }), 'manifest.json').findings) push(f.severity, f.title, f.remediationText, f.server);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const fields = isObj(doc.user_config) ? Object.entries(doc.user_config).map(([key, v]) => ({ key, ...(isObj(v) ? v : {}) })) : [];
|
|
281
|
+
const argText = launches.flatMap(([, l]) => (Array.isArray(l.args) ? l.args.map(String) : [])).join(' ');
|
|
282
|
+
for (const [, l] of launches) {
|
|
283
|
+
const line = [l.command, ...(Array.isArray(l.args) ? l.args : [])].join(' ');
|
|
284
|
+
if (/\$\{user_config\.[\w.-]+\}/.test(line) && SHELL_COMMAND_RE.test(String(l.command ?? '')) && (l.args ?? []).some((x) => SHELL_FLAG_RE.test(String(x)))) {
|
|
285
|
+
push('HIGH', `Extension "${name}" substitutes user configuration into a shell command line`, 'Pass configuration as its own argv entry or an environment variable, never inside `sh -c`.', 'user_config');
|
|
286
|
+
break;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
const onArgv = fields.filter((f) => (f.sensitive === true || SECRETISH_KEY_RE.test(f.key)) && argText.includes(`\${user_config.${f.key}}`));
|
|
290
|
+
if (onArgv.length) push('MEDIUM', `Extension "${name}" passes a credential on the command line (${onArgv[0].key})`, 'Pass secrets through mcp_config.env, not argv.', onArgv[0].key);
|
|
291
|
+
const unmarked = fields.filter((f) => SECRETISH_KEY_RE.test(f.key) && f.sensitive !== true);
|
|
292
|
+
if (unmarked.length) push('MEDIUM', `Extension "${name}" collects a credential without marking it sensitive (${unmarked[0].key})`, 'Set `sensitive: true` on every field that carries a secret.', unmarked[0].key);
|
|
293
|
+
for (const f of fields) {
|
|
294
|
+
if ((f.type !== 'directory' && f.type !== 'file') || typeof f.default !== 'string') continue;
|
|
295
|
+
if (SENSITIVE_DEFAULT_RE.test(f.default)) push('HIGH', `Extension "${name}" defaults "${f.key}" to a credential store (${f.default})`, 'Leave the default empty or narrow it to a purpose-specific directory.', f.key);
|
|
296
|
+
else if (BROAD_DEFAULT_RE.test(f.default.trim())) push('MEDIUM', `Extension "${name}" defaults "${f.key}" to the whole disk / home directory`, 'Leave the default empty or narrow it to a purpose-specific directory.', f.key);
|
|
297
|
+
}
|
|
298
|
+
if (doc.tools_generated === true || doc.prompts_generated === true) push('LOW', `Extension "${name}" generates its tools/prompts at runtime - the declared list is not the served one`, 'Scan the live server and pin the extension version.', 'generated');
|
|
299
|
+
const entry = typeof doc.server?.entry_point === 'string' ? doc.server.entry_point.replace(/^\$\{__dirname\}\/?/, '') : null;
|
|
300
|
+
if (entry && escapesRoot(entry)) push('HIGH', `Extension "${name}" runs an entry point outside its own bundle (${entry})`, 'Point entry_point at a file inside the bundle.', 'entry_point');
|
|
301
|
+
return out;
|
|
302
|
+
}
|