perimetercli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +203 -0
- package/bin/perimeter.js +5 -0
- package/package.json +55 -0
- package/src/audit.js +164 -0
- package/src/catalog.js +335 -0
- package/src/cli.js +469 -0
- package/src/discover.js +262 -0
- package/src/guard.js +159 -0
- package/src/index.js +9 -0
- package/src/report.js +333 -0
- package/src/rules.js +357 -0
- package/src/serve.js +64 -0
- package/src/server.js +194 -0
- package/src/sessions.js +161 -0
- package/src/tokens.js +99 -0
- package/src/util.js +137 -0
- package/src/version.js +1 -0
package/src/report.js
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import { escapeHtml } from "./util.js";
|
|
2
|
+
import { money } from "./tokens.js";
|
|
3
|
+
import { SEVERITY_RANK } from "./rules.js";
|
|
4
|
+
|
|
5
|
+
export function severityLabel(severity) {
|
|
6
|
+
return String(severity || "info").toUpperCase();
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function formatText(audit) {
|
|
10
|
+
const L = [];
|
|
11
|
+
L.push("Perimeter — agent toolchain audit");
|
|
12
|
+
L.push("=".repeat(38));
|
|
13
|
+
L.push(
|
|
14
|
+
`Scanned ${audit.servers.length} server${audit.servers.length === 1 ? "" : "s"} · ${
|
|
15
|
+
audit.contextFiles.length
|
|
16
|
+
} context file${audit.contextFiles.length === 1 ? "" : "s"}`
|
|
17
|
+
);
|
|
18
|
+
L.push(`Verdict: ${sevColor(audit.effectiveVerdict)}`);
|
|
19
|
+
const c = audit.counts;
|
|
20
|
+
L.push(
|
|
21
|
+
` ${c.critical} critical · ${c.high} high · ${c.medium} medium · ${c.low} low · ${c.info} info`
|
|
22
|
+
);
|
|
23
|
+
L.push("");
|
|
24
|
+
|
|
25
|
+
if (audit.servers.length) {
|
|
26
|
+
L.push(`── Servers (${audit.servers.length}) ──`);
|
|
27
|
+
for (const s of audit.servers) {
|
|
28
|
+
L.push(
|
|
29
|
+
` ${s.displayName.padEnd(18)} ${sevColor(s.verdict, true).padEnd(9)} ${(
|
|
30
|
+
s.capabilities.length
|
|
31
|
+
)} caps · ${s.estimatedTokens.toLocaleString()} tok · ${s.source}`
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
L.push("");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const findings = audit.effectiveFindings.length
|
|
38
|
+
? audit.effectiveFindings
|
|
39
|
+
: audit.findings;
|
|
40
|
+
if (findings.length) {
|
|
41
|
+
L.push(`── Findings (${findings.length}) ──`);
|
|
42
|
+
for (const f of findings) {
|
|
43
|
+
L.push(
|
|
44
|
+
` [${sevColor(f.severity)}] ${f.id} · ${f.title}`
|
|
45
|
+
);
|
|
46
|
+
L.push(` ${f.server}${f.packageName ? ` (${f.packageName})` : ""} — ${f.detail}`);
|
|
47
|
+
L.push(` Fix: ${f.remediation}`);
|
|
48
|
+
}
|
|
49
|
+
L.push("");
|
|
50
|
+
} else {
|
|
51
|
+
L.push("No findings — this toolchain is clean. ✓");
|
|
52
|
+
L.push("");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const cost = audit.costs;
|
|
56
|
+
L.push(`── Cost ──`);
|
|
57
|
+
L.push(
|
|
58
|
+
` model ${cost.model}${cost.estimated ? " (estimated)" : ""} · ~${cost.totalTokens.toLocaleString()} tokens/load · ${money(
|
|
59
|
+
cost.perLoadCost
|
|
60
|
+
)}/load`
|
|
61
|
+
);
|
|
62
|
+
if (cost.budget) {
|
|
63
|
+
L.push(
|
|
64
|
+
` budget ${money(cost.budget)}/day → ${cost.loadsPerDayWithinBudget ?? 0} loads/day`
|
|
65
|
+
);
|
|
66
|
+
if (cost.overBudget) L.push(` ⚠ over budget`);
|
|
67
|
+
}
|
|
68
|
+
return L.join("\n");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function formatSessionText(report) {
|
|
72
|
+
const L = [];
|
|
73
|
+
L.push("Perimeter — agent runtime observability");
|
|
74
|
+
L.push("=".repeat(38));
|
|
75
|
+
L.push(
|
|
76
|
+
`${report.files} session file${report.files === 1 ? "" : "s"} · ${report.messages.toLocaleString()} messages · ${report.totalTokens.toLocaleString()} tokens${
|
|
77
|
+
report.usageExact ? " (exact)" : " (estimated)"
|
|
78
|
+
}`
|
|
79
|
+
);
|
|
80
|
+
L.push("");
|
|
81
|
+
L.push(`── Tool usage (${report.tools.length}) ──`);
|
|
82
|
+
if (report.tools.length === 0) {
|
|
83
|
+
L.push(" No tool calls recorded.");
|
|
84
|
+
} else {
|
|
85
|
+
for (const t of report.tools) {
|
|
86
|
+
L.push(
|
|
87
|
+
` ${t.name.padEnd(18)} ${String(t.count).padStart(4)} calls ${sevColor(t.severity, true).padEnd(9)} ${t.capability}`
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
L.push("");
|
|
92
|
+
if (report.riskyTools.length) {
|
|
93
|
+
L.push(`── Risky tool calls (${report.riskyTools.length}) ──`);
|
|
94
|
+
for (const t of report.riskyTools) {
|
|
95
|
+
L.push(` [${sevColor(t.severity)}] ${t.name} called ${t.count}×`);
|
|
96
|
+
}
|
|
97
|
+
L.push("");
|
|
98
|
+
}
|
|
99
|
+
const cost = report.cost;
|
|
100
|
+
L.push(`── Cost ──`);
|
|
101
|
+
L.push(
|
|
102
|
+
` model ${cost.model}${cost.estimated ? " (estimated)" : ""} · ${money(cost.perLoad)}`
|
|
103
|
+
);
|
|
104
|
+
return L.join("\n");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function formatSessionJson(report) {
|
|
108
|
+
return JSON.stringify(report, null, 2);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function formatMarkdown(audit) {
|
|
112
|
+
const lines = [];
|
|
113
|
+
lines.push("# Perimeter — Agent Toolchain Audit", "");
|
|
114
|
+
lines.push(`**Verdict:** ${severityLabel(audit.effectiveVerdict)}`);
|
|
115
|
+
lines.push("");
|
|
116
|
+
lines.push(
|
|
117
|
+
`Scanned **${audit.servers.length}** servers and **${audit.contextFiles.length}** context files on ${audit.generatedAt.slice(0, 10)}.`
|
|
118
|
+
);
|
|
119
|
+
lines.push("");
|
|
120
|
+
const c = audit.counts;
|
|
121
|
+
lines.push(
|
|
122
|
+
`**Findings:** ${c.critical} critical · ${c.high} high · ${c.medium} medium · ${c.low} low · ${c.info} info`
|
|
123
|
+
);
|
|
124
|
+
lines.push("");
|
|
125
|
+
|
|
126
|
+
lines.push("## Servers", "");
|
|
127
|
+
lines.push("| Server | Transport | Capabilities | Tokens | Verdict |");
|
|
128
|
+
lines.push("| --- | --- | --- | --- | --- |");
|
|
129
|
+
for (const s of audit.servers) {
|
|
130
|
+
lines.push(
|
|
131
|
+
`| ${s.displayName} | ${s.transport} | ${s.capabilities.join(", ") || "—"} | ${s.estimatedTokens.toLocaleString()} | **${severityLabel(s.verdict)}** |`
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
lines.push("");
|
|
135
|
+
|
|
136
|
+
const findings = audit.effectiveFindings.length
|
|
137
|
+
? audit.effectiveFindings
|
|
138
|
+
: audit.findings;
|
|
139
|
+
if (findings.length) {
|
|
140
|
+
lines.push("## Findings", "");
|
|
141
|
+
for (const f of findings) {
|
|
142
|
+
lines.push(`### [${severityLabel(f.severity)}] ${f.id} — ${f.title}`, "");
|
|
143
|
+
lines.push(`**Server:** ${f.server}${f.packageName ? ` (\`${f.packageName}\`)` : ""}`);
|
|
144
|
+
lines.push("");
|
|
145
|
+
lines.push(f.detail, "");
|
|
146
|
+
lines.push(`**Remediation:** ${f.remediation}`, "");
|
|
147
|
+
lines.push(`> OWASP Agentic: ${f.owasp}`, "");
|
|
148
|
+
}
|
|
149
|
+
} else {
|
|
150
|
+
lines.push("## Findings", "", "No findings — this toolchain is clean. ✓", "");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const cost = audit.costs;
|
|
154
|
+
lines.push("## Cost", "");
|
|
155
|
+
lines.push(
|
|
156
|
+
`- Model: \`${cost.model}\`${cost.estimated ? " (estimated pricing)" : ""}`
|
|
157
|
+
);
|
|
158
|
+
lines.push(`- ~${cost.totalTokens.toLocaleString()} tokens per load`);
|
|
159
|
+
lines.push(`- ${money(cost.perLoadCost)} per load`);
|
|
160
|
+
if (cost.budget) {
|
|
161
|
+
lines.push(`- Budget: ${money(cost.budget)}/day → ~${cost.loadsPerDayWithinBudget ?? 0} loads/day`);
|
|
162
|
+
}
|
|
163
|
+
lines.push("");
|
|
164
|
+
lines.push("---", "");
|
|
165
|
+
lines.push(`_Generated by [Perimeter](https://perimetercli.dev)._`);
|
|
166
|
+
return lines.join("\n");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function formatJson(audit) {
|
|
170
|
+
return JSON.stringify(
|
|
171
|
+
{
|
|
172
|
+
generatedAt: audit.generatedAt,
|
|
173
|
+
cwd: audit.cwd,
|
|
174
|
+
verdict: audit.effectiveVerdict,
|
|
175
|
+
counts: audit.counts,
|
|
176
|
+
config: audit.config,
|
|
177
|
+
costs: audit.costs,
|
|
178
|
+
servers: audit.servers,
|
|
179
|
+
findings: audit.findings,
|
|
180
|
+
contextFiles: audit.contextFiles,
|
|
181
|
+
},
|
|
182
|
+
(key, value) => (value === undefined ? undefined : value),
|
|
183
|
+
2
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function formatSarif(audit) {
|
|
188
|
+
const results = audit.findings.map((f, i) => ({
|
|
189
|
+
ruleId: f.id,
|
|
190
|
+
level: sarifLevel(f.severity),
|
|
191
|
+
message: { text: `${f.title} — ${f.detail}` },
|
|
192
|
+
locations: [
|
|
193
|
+
{
|
|
194
|
+
physicalLocation: {
|
|
195
|
+
artifactLocation: { uri: f.location || f.serverId || "config" },
|
|
196
|
+
region: { startLine: 1, startColumn: 1 },
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
],
|
|
200
|
+
properties: {
|
|
201
|
+
severity: f.severity,
|
|
202
|
+
server: f.server,
|
|
203
|
+
owasp: f.owasp,
|
|
204
|
+
remediation: f.remediation,
|
|
205
|
+
},
|
|
206
|
+
}));
|
|
207
|
+
return JSON.stringify(
|
|
208
|
+
{
|
|
209
|
+
version: "2.1.0",
|
|
210
|
+
$schema:
|
|
211
|
+
"https://json.schemastore.org/sarif-2.1.0.json",
|
|
212
|
+
runs: [
|
|
213
|
+
{
|
|
214
|
+
tool: {
|
|
215
|
+
driver: {
|
|
216
|
+
name: "perimeter",
|
|
217
|
+
version: "0.1.0",
|
|
218
|
+
informationUri: "https://perimetercli.dev",
|
|
219
|
+
rules: audit.findings.map((f, i) => ({
|
|
220
|
+
id: f.id,
|
|
221
|
+
name: f.title,
|
|
222
|
+
shortDescription: { text: f.title },
|
|
223
|
+
help: { text: f.remediation },
|
|
224
|
+
properties: { severity: f.severity, owasp: f.owasp },
|
|
225
|
+
})),
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
results,
|
|
229
|
+
},
|
|
230
|
+
],
|
|
231
|
+
},
|
|
232
|
+
null,
|
|
233
|
+
2
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function sarifLevel(severity) {
|
|
238
|
+
if (severity === "critical" || severity === "high") return "error";
|
|
239
|
+
if (severity === "medium") return "warning";
|
|
240
|
+
if (severity === "low") return "note";
|
|
241
|
+
return "none";
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function formatHtml(audit) {
|
|
245
|
+
const c = audit.counts;
|
|
246
|
+
const findingsHtml = audit.findings.length
|
|
247
|
+
? audit.findings
|
|
248
|
+
.map((f) => {
|
|
249
|
+
return `<div class="finding ${f.severity}">
|
|
250
|
+
<div class="head"><span class="sev">${severityLabel(f.severity)}</span><code>${escapeHtml(
|
|
251
|
+
f.id
|
|
252
|
+
)}</code><h3>${escapeHtml(f.title)}</h3></div>
|
|
253
|
+
<p>${escapeHtml(f.detail)}</p>
|
|
254
|
+
<div class="meta"><span>${escapeHtml(f.server)}</span>${
|
|
255
|
+
f.packageName ? `<code>${escapeHtml(f.packageName)}</code>` : ""
|
|
256
|
+
}</div>
|
|
257
|
+
<div class="fix"><strong>Fix:</strong> ${escapeHtml(f.remediation)}</div>
|
|
258
|
+
<div class="owasp">OWASP Agentic: ${escapeHtml(f.owasp)}</div>
|
|
259
|
+
</div>`;
|
|
260
|
+
})
|
|
261
|
+
.join("\n")
|
|
262
|
+
: `<p class="clean">No findings — this toolchain is clean. ✓</p>`;
|
|
263
|
+
|
|
264
|
+
const serversHtml = audit.servers
|
|
265
|
+
.map((s) => {
|
|
266
|
+
return `<tr>
|
|
267
|
+
<td>${escapeHtml(s.displayName)}</td>
|
|
268
|
+
<td>${escapeHtml(s.transport)}</td>
|
|
269
|
+
<td><code>${escapeHtml(s.capabilities.join(", ") || "—")}</code></td>
|
|
270
|
+
<td>${s.estimatedTokens.toLocaleString()}</td>
|
|
271
|
+
<td><span class="badge ${s.verdict}">${severityLabel(s.verdict)}</span></td>
|
|
272
|
+
</tr>`;
|
|
273
|
+
})
|
|
274
|
+
.join("\n");
|
|
275
|
+
|
|
276
|
+
return `<!doctype html>
|
|
277
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
278
|
+
<title>Perimeter audit — ${escapeHtml(audit.cwd)}</title>
|
|
279
|
+
<style>
|
|
280
|
+
:root{--bg:#0b0f17;--panel:#111827;--border:#1f2937;--text:#e5e7eb;--muted:#94a3b8;--accent:#6366f1}
|
|
281
|
+
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font-family:ui-sans-serif,system-ui,sans-serif;line-height:1.6}
|
|
282
|
+
.wrap{max-width:980px;margin:0 auto;padding:32px 24px}
|
|
283
|
+
h1{letter-spacing:-.02em}.muted{color:var(--muted)}
|
|
284
|
+
.verdict{display:inline-block;padding:6px 14px;border-radius:999px;font-weight:700}
|
|
285
|
+
.critical{background:#7f1d1d;color:#fecaca}.high{background:#7c2d12;color:#fed7aa}.medium{background:#713f12;color:#fde68a}.low{background:#1e3a8a;color:#bfdbfe}.info{background:#1f2937;color:#cbd5e1}
|
|
286
|
+
.badge{padding:2px 10px;border-radius:999px;font-size:12px;font-weight:600}
|
|
287
|
+
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:10px;margin:18px 0}
|
|
288
|
+
.card{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:14px}
|
|
289
|
+
.card .n{font-size:26px;font-weight:800}.card .l{color:var(--muted);font-size:13px}
|
|
290
|
+
table{border-collapse:collapse;width:100%;font-size:14px;margin:18px 0}
|
|
291
|
+
th,td{padding:8px 12px;text-align:left;border-bottom:1px solid var(--border)}
|
|
292
|
+
th{color:var(--muted)}code{background:#0f172a;padding:1px 6px;border-radius:5px;font-size:12.5px}
|
|
293
|
+
.finding{background:var(--panel);border:1px solid var(--border);border-left:4px solid var(--accent);border-radius:12px;padding:16px;margin:12px 0}
|
|
294
|
+
.finding.critical{border-left-color:#ef4444}.finding.high{border-left-color:#f97316}.finding.medium{border-left-color:#facc15}.finding.low{border-left-color:#3b82f6}
|
|
295
|
+
.finding .head{display:flex;align-items:center;gap:10px}
|
|
296
|
+
.finding .sev{font-size:11px;font-weight:700;color:var(--muted)}
|
|
297
|
+
.finding h3{margin:0}.finding p{margin:.5rem 0;color:#cbd5e1}
|
|
298
|
+
.fix{font-size:13px;margin-top:8px}.owasp{font-size:12px;color:var(--muted);margin-top:6px}
|
|
299
|
+
.clean{color:var(--muted)}
|
|
300
|
+
footer{margin-top:28px;color:var(--muted);font-size:13px;text-align:center}
|
|
301
|
+
</style></head>
|
|
302
|
+
<body><div class="wrap">
|
|
303
|
+
<h1>Perimeter · Agent toolchain audit</h1>
|
|
304
|
+
<p class="muted">${escapeHtml(audit.cwd)} · ${audit.generatedAt.slice(0, 19).replace("T", " ")}</p>
|
|
305
|
+
<p>Verdict: <span class="verdict ${audit.effectiveVerdict}">${severityLabel(audit.effectiveVerdict)}</span></p>
|
|
306
|
+
<div class="cards">
|
|
307
|
+
<div class="card"><div class="n">${audit.servers.length}</div><div class="l">Servers</div></div>
|
|
308
|
+
<div class="card"><div class="n">${audit.counts.critical}</div><div class="l">Critical</div></div>
|
|
309
|
+
<div class="card"><div class="n">${audit.counts.high}</div><div class="l">High</div></div>
|
|
310
|
+
<div class="card"><div class="n">${audit.costs.totalTokens.toLocaleString()}</div><div class="l">Tokens/load</div></div>
|
|
311
|
+
<div class="card"><div class="n">${money(audit.costs.perLoadCost)}</div><div class="l">Cost/load</div></div>
|
|
312
|
+
</div>
|
|
313
|
+
<h2>Servers</h2>
|
|
314
|
+
<table><thead><tr><th>Server</th><th>Transport</th><th>Capabilities</th><th>Tokens</th><th>Verdict</th></tr></thead>
|
|
315
|
+
<tbody>${serversHtml}</tbody></table>
|
|
316
|
+
<h2>Findings</h2>
|
|
317
|
+
${findingsHtml}
|
|
318
|
+
<footer>Generated by <a href="https://perimetercli.dev">Perimeter</a></footer>
|
|
319
|
+
</div></body></html>`;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function sevColor(severity, short = false) {
|
|
323
|
+
const map = {
|
|
324
|
+
critical: "\x1b[31m",
|
|
325
|
+
high: "\x1b[33m",
|
|
326
|
+
medium: "\x1b[33m",
|
|
327
|
+
low: "\x1b[36m",
|
|
328
|
+
info: "\x1b[90m",
|
|
329
|
+
};
|
|
330
|
+
const reset = "\x1b[0m";
|
|
331
|
+
const label = severityLabel(severity);
|
|
332
|
+
return `${map[severity] || ""}${label}${reset}`;
|
|
333
|
+
}
|
package/src/rules.js
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import { looksTyposquatted } from "./catalog.js";
|
|
2
|
+
import { tokensForTools } from "./tokens.js";
|
|
3
|
+
|
|
4
|
+
export const SEVERITIES = ["critical", "high", "medium", "low", "info"];
|
|
5
|
+
export const SEVERITY_RANK = { critical: 5, high: 4, medium: 3, low: 2, info: 1 };
|
|
6
|
+
|
|
7
|
+
const INJECT_PATTERNS = [
|
|
8
|
+
/ignore\s+(?:previous|above|all|any)\s+instructions?/i,
|
|
9
|
+
/disregard\s+(?:the|previous|prior|above)/i,
|
|
10
|
+
/\bnow\s+you\s+(?:are|will be|should be)\b/i,
|
|
11
|
+
/\bdo\s+not\s+tell\s+(?:the\s+)?(?:user|anyone)\b/i,
|
|
12
|
+
/\bexfiltrat/i,
|
|
13
|
+
/\bemail\s+(?:the\s+)?(?:contents|files|data|everything)\b/i,
|
|
14
|
+
/\bsend\s+(?:everything|all|the\s+contents)\s+to\b/i,
|
|
15
|
+
/\b(?:copy|send)\s+~?\/(?:\.ssh|home|etc)|id_rsa|\.aws\/credentials\b/i,
|
|
16
|
+
/BEGIN\s+(?:SYSTEM|USER|OVERRIDE)/i,
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const CAPABILITY_PATTERNS = {
|
|
20
|
+
shell: /(^|[^a-z])(exec|exec_js|bash|shell|terminal|run_command|run_cmd|command_line|zsh|sh\b)/i,
|
|
21
|
+
"code-exec": /(eval|evaluate_js|python|node|run_code|code_exec|interpret|js_eval|powershell)/i,
|
|
22
|
+
"filesystem-write": /(write_file|writefile|edit_file|editfile|create_file|make_directory|append_file|save_file|delete_file|rm\b)/i,
|
|
23
|
+
"filesystem-read": /(read_file|readfile|list_directory|list_dir|get_file|search_files|glob)/i,
|
|
24
|
+
"network-out": /(http_(post|put|patch)|fetch|post_message|send_message|publish|webhook|upload|send_to|curl|mailto|email_)/i,
|
|
25
|
+
"network-in": /(search_|query|web_search|http_get|fetch_url|get_page|browser_)/i,
|
|
26
|
+
payment: /(payment|checkout|purchase|transfer|withdraw|balance|charge|billing|pay_|make_payment|refund)/i,
|
|
27
|
+
credentials: /(~\/\.ssh|id_rsa|getenv|read_env|env_var|credential|secret|api[_ ]?key|access[_-]?token|password|\.aws\/credentials)/i,
|
|
28
|
+
"db-write": /(sql|execute|insert|update|delete|run_query|execute_query|sqlexecute|write_table|create_table)/i,
|
|
29
|
+
"secrets-write": /(set_secret|put_secret|store_secret|create_secret|write_(token|key)|vault_)/i,
|
|
30
|
+
"cloud-write": /(aws_|ec2|s3_|create_instance|terraform|cloudformation|gcp_|azure_|kubectl|deploy)/i,
|
|
31
|
+
browser: /(browser|puppeteer|playwright|navigate|click|screenshot)/i,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Determine the capabilities of a server from its catalog profile and from
|
|
36
|
+
* heuristics over the command, args, env keys and any tool descriptions.
|
|
37
|
+
*/
|
|
38
|
+
export function capabilitiesFor(server) {
|
|
39
|
+
const caps = new Set(server.catalog?.capabilities || []);
|
|
40
|
+
// A hook is arbitrary code executed by the agent at one or more lifecycle
|
|
41
|
+
// points — treat it as code execution unconditionally.
|
|
42
|
+
if (server.kind === "hook" || server.transport === "hook") caps.add("code-exec");
|
|
43
|
+
const haystack = [
|
|
44
|
+
server.command,
|
|
45
|
+
...(server.args || []),
|
|
46
|
+
...Object.keys(server.env || {}),
|
|
47
|
+
...(server.tools || []).map((t) => `${t.name} ${t.description}`),
|
|
48
|
+
]
|
|
49
|
+
.filter(Boolean)
|
|
50
|
+
.join(" ");
|
|
51
|
+
for (const [cap, re] of Object.entries(CAPABILITY_PATTERNS)) {
|
|
52
|
+
if (re.test(haystack)) caps.add(cap);
|
|
53
|
+
}
|
|
54
|
+
return caps;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function maxRank(items) {
|
|
58
|
+
return items.reduce((max, f) => Math.max(max, SEVERITY_RANK[f.severity]), 0);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Run the rule set against a server. `ctx` = { config, baseline, catalog, cost }.
|
|
63
|
+
*/
|
|
64
|
+
export function runRules(server, ctx = {}) {
|
|
65
|
+
const findings = [];
|
|
66
|
+
const caps = capabilitiesFor(server);
|
|
67
|
+
const config = ctx.config || {};
|
|
68
|
+
const baseline = ctx.baseline || null;
|
|
69
|
+
const maxTokens = config.maxTokensPerServer ?? 5000;
|
|
70
|
+
const add = (f) => findings.push({
|
|
71
|
+
server: server.displayName,
|
|
72
|
+
serverId: server.id,
|
|
73
|
+
location: server.source,
|
|
74
|
+
packageName: server.packageName || null,
|
|
75
|
+
owasp: f.owasp || "AGENT-06",
|
|
76
|
+
...f,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const toolText = (server.tools || [])
|
|
80
|
+
.map((t) => `${t.name || ""} ${t.description || ""}`)
|
|
81
|
+
.join("\n");
|
|
82
|
+
|
|
83
|
+
// Tool poisoning / prompt injection in tool metadata.
|
|
84
|
+
if (INJECT_PATTERNS.some((re) => re.test(toolText))) {
|
|
85
|
+
add({
|
|
86
|
+
id: "INJECT-001",
|
|
87
|
+
severity: "critical",
|
|
88
|
+
title: "Tool poisoning: likely injection in tool description",
|
|
89
|
+
detail: "A tool description contains wording used by prompt-injection and tool-poisoning attacks. An agent may follow instructions hidden in tool metadata.",
|
|
90
|
+
remediation: "Remove embedded instructions from tool metadata; use a verifier or proxy that sandboxes tool output.",
|
|
91
|
+
owasp: "AGENT-01",
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Dangerous capability combinations.
|
|
96
|
+
if (caps.has("shell") || caps.has("code-exec")) {
|
|
97
|
+
add({
|
|
98
|
+
id: "RISK-SHELL",
|
|
99
|
+
severity: caps.has("network-out") ? "critical" : "high",
|
|
100
|
+
title: "Arbitrary command execution",
|
|
101
|
+
detail: "The server can execute shell/code. This is the highest-impact capability an agent can hold.",
|
|
102
|
+
remediation: "Run in a sandbox/container with no network egress; use an allowlist of permitted commands.",
|
|
103
|
+
owasp: "AGENT-05",
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
if (caps.has("payment")) {
|
|
107
|
+
add({
|
|
108
|
+
id: "RISK-PAYMENT",
|
|
109
|
+
severity: "high",
|
|
110
|
+
title: "Payment / financial capability",
|
|
111
|
+
detail: "The server can initiate transactions or alter balances. Unauthorised or prompt-injected calls can move money.",
|
|
112
|
+
remediation: "Require a human approval gate; scope keys to a read-only or sandboxed account.",
|
|
113
|
+
owasp: "AGENT-05",
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
if (caps.has("credentials")) {
|
|
117
|
+
add({
|
|
118
|
+
id: "RISK-CREDS",
|
|
119
|
+
severity: "high",
|
|
120
|
+
title: "Credential / secret access",
|
|
121
|
+
detail: "The server can read credentials (SSH keys, env vars, API tokens) or references them in its env.",
|
|
122
|
+
remediation: "Inject short-lived, scoped credentials only; never pass durable secrets in config.",
|
|
123
|
+
owasp: "AGENT-04",
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
if (
|
|
127
|
+
caps.has("network-out") &&
|
|
128
|
+
(caps.has("filesystem-read") || caps.has("filesystem-write") || caps.has("credentials"))
|
|
129
|
+
) {
|
|
130
|
+
add({
|
|
131
|
+
id: "RISK-EXFIL",
|
|
132
|
+
severity: "critical",
|
|
133
|
+
title: "Data access plus outbound network (exfiltration risk)",
|
|
134
|
+
detail: "A server that can read/write data or secrets and reach an external endpoint is a prototypical data-exfiltration path.",
|
|
135
|
+
remediation: "Remove egress or file-write; split capabilities across least-privilege tools.",
|
|
136
|
+
owasp: "AGENT-02",
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
if (caps.has("filesystem-write")) {
|
|
140
|
+
add({
|
|
141
|
+
id: "RISK-FSWRITE",
|
|
142
|
+
severity: "medium",
|
|
143
|
+
title: "Filesystem write access",
|
|
144
|
+
detail: "The server can create or modify files. Ensure writes are confined to the project directory.",
|
|
145
|
+
remediation: "Pin the working directory; deny paths outside an allowlist.",
|
|
146
|
+
owasp: "AGENT-04",
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
if (caps.has("cloud-write")) {
|
|
150
|
+
add({
|
|
151
|
+
id: "RISK-CLOUD",
|
|
152
|
+
severity: "high",
|
|
153
|
+
title: "Cloud infrastructure write",
|
|
154
|
+
detail: "The server can mutate cloud resources. Broad cloud credentials are a high-impact risk.",
|
|
155
|
+
remediation: "Use a read-only or project-scoped role; require an approval step.",
|
|
156
|
+
owasp: "AGENT-05",
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
if (caps.has("db-write")) {
|
|
160
|
+
add({
|
|
161
|
+
id: "RISK-DBWRITE",
|
|
162
|
+
severity: "medium",
|
|
163
|
+
title: "Database write capability",
|
|
164
|
+
detail: "The server can execute writes against a database. Unsupervised writes can corrupt or exfiltrate data held in tables.",
|
|
165
|
+
remediation: "Scope to a read-only role or a dedicated schema; add an approval gate for mutations.",
|
|
166
|
+
owasp: "AGENT-05",
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Supply-chain concerns.
|
|
171
|
+
const pkg = server.packageName || "";
|
|
172
|
+
if (pkg && looksTyposquatted(pkg)) {
|
|
173
|
+
add({
|
|
174
|
+
id: "SUP-TYPOSQUAT",
|
|
175
|
+
severity: "critical",
|
|
176
|
+
title: "Possible typosquatted package name",
|
|
177
|
+
detail: `"${pkg}" is one edit away from a well-known package — a common supply-chain attack.`,
|
|
178
|
+
remediation: "Verify the publisher; pin an exact version from the canonical registry entry.",
|
|
179
|
+
owasp: "AGENT-06",
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
if (!server.catalog && server.transport !== "hook") {
|
|
183
|
+
add({
|
|
184
|
+
id: "SUP-UNVERIFIED",
|
|
185
|
+
severity: caps.size ? "medium" : "info",
|
|
186
|
+
title: "Unverified server capabilities",
|
|
187
|
+
detail: "No offline profile recognised for this package. Capabilities are inferred heuristically.",
|
|
188
|
+
remediation: "Confirm the package origin and enumerate its tools before granting it to an agent.",
|
|
189
|
+
owasp: "AGENT-06",
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
if (isUnpinnedStdio(server)) {
|
|
193
|
+
add({
|
|
194
|
+
id: "SUP-UNPINNED",
|
|
195
|
+
severity: "low",
|
|
196
|
+
title: "Unpinned package version",
|
|
197
|
+
detail: "The server is fetched at runtime (npx/uvx) without a pinned version or lockfile.",
|
|
198
|
+
remediation: "Pin an exact version or vendor the package into the lockfile.",
|
|
199
|
+
owasp: "AGENT-06",
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
const secretEnv = Object.keys(server.env || {}).filter((k) =>
|
|
203
|
+
/token|secret|key|password|credential|auth/i.test(k)
|
|
204
|
+
);
|
|
205
|
+
if (secretEnv.length) {
|
|
206
|
+
add({
|
|
207
|
+
id: "SUP-CREDS-ENV",
|
|
208
|
+
severity: "medium",
|
|
209
|
+
title: "Secrets present in server config env",
|
|
210
|
+
detail: `Credential-like env vars are configured: ${secretEnv.join(", ")}. They may be visible in version control.`,
|
|
211
|
+
remediation: "Reference secrets from the runtime secret store; never commit them.",
|
|
212
|
+
owasp: "AGENT-04",
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Context / token budget.
|
|
217
|
+
const tokens = estimateServerTokens(server);
|
|
218
|
+
server.estimatedTokens = tokens;
|
|
219
|
+
if (tokens > maxTokens) {
|
|
220
|
+
add({
|
|
221
|
+
id: "TOK-BLOAT",
|
|
222
|
+
severity: "low",
|
|
223
|
+
title: "Large context footprint",
|
|
224
|
+
detail: `Estimated ${tokens.toLocaleString()} tokens to load this server, above the ${maxTokens.toLocaleString()} threshold.`,
|
|
225
|
+
remediation: "Trim tool schemas, or lazy-load tools with a compressor such as mcp-compressor.",
|
|
226
|
+
owasp: "AGENT-03",
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Drift detection against a stored baseline.
|
|
231
|
+
if (baseline) {
|
|
232
|
+
const sig = server.signature;
|
|
233
|
+
const prior = baseline.servers && baseline.servers[server.id];
|
|
234
|
+
if (prior && prior.signature !== sig) {
|
|
235
|
+
add({
|
|
236
|
+
id: "DRIFT-CHANGED",
|
|
237
|
+
severity: "high",
|
|
238
|
+
title: "Server configuration changed since baseline",
|
|
239
|
+
detail: "A tool command, argument or description changed. This can indicate a rug-pull or silent supply-chain swap.",
|
|
240
|
+
remediation: "Review the diff and re-approve; consider pinning content hashes.",
|
|
241
|
+
owasp: "AGENT-06",
|
|
242
|
+
});
|
|
243
|
+
} else if (!prior) {
|
|
244
|
+
add({
|
|
245
|
+
id: "DRIFT-NEW",
|
|
246
|
+
severity: "medium",
|
|
247
|
+
title: "Server not in baseline",
|
|
248
|
+
detail: "A new server was added to the configuration.",
|
|
249
|
+
remediation: "Approve the new server or remove it.",
|
|
250
|
+
owasp: "AGENT-06",
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
findings.sort((a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity]);
|
|
256
|
+
return { capabilities: [...caps], findings, verdict: verdictOf(findings) };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const SECRET_PATTERNS = [
|
|
260
|
+
/\bgh[pousr]_[A-Za-z0-9]{16,}\b/,
|
|
261
|
+
/\bsk-live-[A-Za-z0-9]{16,}\b/,
|
|
262
|
+
/\bsk-[A-Za-z0-9]{16,}\b/,
|
|
263
|
+
/\bsk_live_[A-Za-z0-9]{16,}\b/,
|
|
264
|
+
/\bsk_[A-Za-z0-9]{16,}\b/,
|
|
265
|
+
/\bAKIA[0-9A-Z]{16}\b/,
|
|
266
|
+
/-----BEGIN (?:RSA|OPENSSH|EC|DSA|PGP) (?:PRIVATE )?KEY-----/,
|
|
267
|
+
/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/,
|
|
268
|
+
/(?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*['"][^'"]{8,}['"]/i,
|
|
269
|
+
];
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Analyse an agent instruction file or a skill for embedded instructions and
|
|
273
|
+
* committed secrets. These files are read by the agent every session.
|
|
274
|
+
*/
|
|
275
|
+
export function runContextRules(ctx) {
|
|
276
|
+
const findings = [];
|
|
277
|
+
const text = String(ctx.content || "");
|
|
278
|
+
if (INJECT_PATTERNS.some((re) => re.test(text))) {
|
|
279
|
+
findings.push({
|
|
280
|
+
id: "INJECT-002",
|
|
281
|
+
severity: "high",
|
|
282
|
+
title: "Suspicious instructions in agent context file",
|
|
283
|
+
detail: `Embedded instruction phrasing detected in ${ctx.label}. An agent reads this text every session, so it can redirect behaviour.`,
|
|
284
|
+
remediation: "Review the file and remove embedded instruction phrasing; consider pinning or signing it.",
|
|
285
|
+
server: ctx.label,
|
|
286
|
+
serverId: ctx.label,
|
|
287
|
+
location: ctx.label,
|
|
288
|
+
packageName: null,
|
|
289
|
+
owasp: "AGENT-01",
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
const secret = SECRET_PATTERNS.find((re) => re.test(text));
|
|
293
|
+
if (secret) {
|
|
294
|
+
findings.push({
|
|
295
|
+
id: "SECRET-LEAK",
|
|
296
|
+
severity: "critical",
|
|
297
|
+
title: "Possible secret in context file",
|
|
298
|
+
detail: `A credential-like value was detected in ${ctx.label}.`,
|
|
299
|
+
remediation: "Rotate the secret and remove it from version control and agent context.",
|
|
300
|
+
server: ctx.label,
|
|
301
|
+
serverId: ctx.label,
|
|
302
|
+
location: ctx.label,
|
|
303
|
+
packageName: null,
|
|
304
|
+
owasp: "AGENT-04",
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
return findings;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function estimateServerTokens(server) {
|
|
311
|
+
if (server.tools && server.tools.length) {
|
|
312
|
+
return tokensForTools(server.tools, 400);
|
|
313
|
+
}
|
|
314
|
+
if (server.catalog && Number.isFinite(server.catalog.tokens)) {
|
|
315
|
+
return server.catalog.tokens;
|
|
316
|
+
}
|
|
317
|
+
// Fall back to the server name/description so we at least tokenise something.
|
|
318
|
+
return tokensForTools(
|
|
319
|
+
[
|
|
320
|
+
{
|
|
321
|
+
name: server.displayName,
|
|
322
|
+
description: server.catalog?.note || server.command || "",
|
|
323
|
+
},
|
|
324
|
+
],
|
|
325
|
+
400
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function isUnpinnedStdio(server) {
|
|
330
|
+
const args = server.args || [];
|
|
331
|
+
if (!/npx|uvx|pipx|deno|bunx/i.test(server.command || "")) return false;
|
|
332
|
+
return !args.some((a) => /@\d+\.\d+\.\d+|=v?\d+\.\d+\.\d+/i.test(a));
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export function verdictOf(findings) {
|
|
336
|
+
let rank = 0;
|
|
337
|
+
let verdict = "info";
|
|
338
|
+
for (const f of findings) {
|
|
339
|
+
if (SEVERITY_RANK[f.severity] > rank) {
|
|
340
|
+
rank = SEVERITY_RANK[f.severity];
|
|
341
|
+
verdict = f.severity;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return verdict;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Aggregate a set of server results into a single report verdict. */
|
|
348
|
+
export function overallVerdict(serverResults) {
|
|
349
|
+
const all = serverResults.flatMap((r) => r.findings || []);
|
|
350
|
+
const bySeverity = {};
|
|
351
|
+
for (const s of SEVERITIES) bySeverity[s] = all.filter((f) => f.severity === s).length;
|
|
352
|
+
return {
|
|
353
|
+
verdict: verdictOf(all),
|
|
354
|
+
counts: bySeverity,
|
|
355
|
+
total: all.length,
|
|
356
|
+
};
|
|
357
|
+
}
|