@polygraphso/litmus 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +190 -0
- package/README.md +114 -0
- package/dist/chunk-2K6T4FZX.js +1458 -0
- package/dist/chunk-6QM4RK25.js +180 -0
- package/dist/chunk-MQC54LFV.js +218 -0
- package/dist/chunk-SAZKXB35.js +120 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +119 -0
- package/dist/docker/egress-sniff.Dockerfile +13 -0
- package/dist/docker/sink-entrypoint.sh +9 -0
- package/dist/docker/sinkhole.mjs +90 -0
- package/dist/index.d.ts +594 -0
- package/dist/index.js +130 -0
- package/dist/mcp.d.ts +16 -0
- package/dist/mcp.js +230 -0
- package/dist/src-XIEFSTXC.js +29 -0
- package/package.json +75 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import {
|
|
2
|
+
canonicalStringify
|
|
3
|
+
} from "./chunk-SAZKXB35.js";
|
|
4
|
+
|
|
5
|
+
// ../cli/src/litmus.ts
|
|
6
|
+
import { existsSync } from "fs";
|
|
7
|
+
import { createRequire } from "module";
|
|
8
|
+
import * as path from "path";
|
|
9
|
+
|
|
10
|
+
// ../cli/src/format.ts
|
|
11
|
+
function formatBundle(b) {
|
|
12
|
+
const status = (code) => b.categories.find((c) => c.code === code)?.status ?? "?";
|
|
13
|
+
const lines = [];
|
|
14
|
+
lines.push(`\u2192 ${b.methodologyVersion} \xB7 ${b.serverRef}`);
|
|
15
|
+
if (b.resolvedVersion) lines.push(`\u2192 version ${b.resolvedVersion}`);
|
|
16
|
+
lines.push(`\u2192 C-01 ${status("C-01")} \xB7 C-02 ${status("C-02")} \xB7 C-03 ${status("C-03")}`);
|
|
17
|
+
const c01 = b.categories.find((c) => c.code === "C-01");
|
|
18
|
+
if (c01?.status === "fail") {
|
|
19
|
+
const highs = c01.probes.flatMap((p) => p.findings).filter((f) => f.severity === "high");
|
|
20
|
+
for (const f of highs.slice(0, 3)) {
|
|
21
|
+
lines.push(` \u26A0 ${f.tool ?? "?"}: ${f.kind} \u2014 ${truncate(f.match, 64)}`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
lines.push(`\u2192 fingerprint ${shortFp(b.toolDefsFingerprint)}`);
|
|
25
|
+
lines.push(`\u2192 grade: ${b.grade}`);
|
|
26
|
+
lines.push(` ${b.gradeRationale}`);
|
|
27
|
+
return lines.join("\n") + "\n";
|
|
28
|
+
}
|
|
29
|
+
function shortFp(fp) {
|
|
30
|
+
return fp.length > 14 ? `${fp.slice(0, 6)}\u2026${fp.slice(-4)}` : fp;
|
|
31
|
+
}
|
|
32
|
+
function truncate(s, n) {
|
|
33
|
+
return s.length > n ? `${s.slice(0, n)}\u2026` : s;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ../cli/src/api.ts
|
|
37
|
+
var DEFAULT_BASE = "https://polygraph.so";
|
|
38
|
+
function apiBaseUrl() {
|
|
39
|
+
const override = process.env.POLYGRAPH_API_URL;
|
|
40
|
+
if (!override || override.length === 0) return DEFAULT_BASE;
|
|
41
|
+
const trimmed = override.replace(/\/+$/, "");
|
|
42
|
+
let u;
|
|
43
|
+
try {
|
|
44
|
+
u = new URL(trimmed);
|
|
45
|
+
} catch {
|
|
46
|
+
throw new Error(`POLYGRAPH_API_URL is not a valid URL: ${override}`);
|
|
47
|
+
}
|
|
48
|
+
const isLoopback = u.hostname === "localhost" || u.hostname === "127.0.0.1" || u.hostname === "::1";
|
|
49
|
+
if (u.protocol !== "https:" && !(u.protocol === "http:" && isLoopback)) {
|
|
50
|
+
throw new Error(`POLYGRAPH_API_URL must use https (http allowed only for localhost): ${override}`);
|
|
51
|
+
}
|
|
52
|
+
return trimmed;
|
|
53
|
+
}
|
|
54
|
+
function pinUrl() {
|
|
55
|
+
return `${apiBaseUrl()}/api/pin`;
|
|
56
|
+
}
|
|
57
|
+
function attestationsUrl() {
|
|
58
|
+
return `${apiBaseUrl()}/api/attestations`;
|
|
59
|
+
}
|
|
60
|
+
function mintUrl(params) {
|
|
61
|
+
const u = new URL(`${apiBaseUrl()}/mint`);
|
|
62
|
+
u.searchParams.set("cid", params.cid);
|
|
63
|
+
u.searchParams.set("ref", params.ref);
|
|
64
|
+
u.searchParams.set("fp", params.fp);
|
|
65
|
+
if (params.ver) u.searchParams.set("ver", params.ver);
|
|
66
|
+
return u.toString();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ../cli/src/litmus.ts
|
|
70
|
+
async function runLitmusCli(args) {
|
|
71
|
+
const json = args.includes("--json");
|
|
72
|
+
const { headers, allowStateChanging, positionals } = parseAuthFlags(args);
|
|
73
|
+
const target = positionals[0];
|
|
74
|
+
if (!target) {
|
|
75
|
+
process.stderr.write(
|
|
76
|
+
'usage: polygraphso litmus [--json] [--bearer <token>] [--header "Key: Value"] [--allow-state-changing] <registry-ref | https-url | path-to-mcp>\n'
|
|
77
|
+
);
|
|
78
|
+
return 2;
|
|
79
|
+
}
|
|
80
|
+
const { runLitmus } = await import("./src-XIEFSTXC.js");
|
|
81
|
+
const input = resolveTarget(target);
|
|
82
|
+
try {
|
|
83
|
+
const bundle = await runLitmus(input, { headers, allowStateChanging });
|
|
84
|
+
process.stdout.write(json ? canonicalStringify(bundle) + "\n" : formatBundle(bundle));
|
|
85
|
+
await maybePin(bundle, json);
|
|
86
|
+
return bundle.grade === "D" || bundle.grade === "F" ? 1 : 0;
|
|
87
|
+
} catch (err) {
|
|
88
|
+
process.stderr.write(`\u2192 litmus failed: ${err instanceof Error ? err.message : String(err)}
|
|
89
|
+
`);
|
|
90
|
+
return 1;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function parseAuthFlags(args, env = process.env) {
|
|
94
|
+
const headers = {};
|
|
95
|
+
const headerArgs = [];
|
|
96
|
+
let allowStateChanging = false;
|
|
97
|
+
let bearer = env.LITMUS_BEARER || void 0;
|
|
98
|
+
const positionals = [];
|
|
99
|
+
for (let i = 0; i < args.length; i++) {
|
|
100
|
+
const a = args[i];
|
|
101
|
+
if (a === "--json") continue;
|
|
102
|
+
if (a === "--allow-state-changing") {
|
|
103
|
+
allowStateChanging = true;
|
|
104
|
+
} else if (a === "--bearer") {
|
|
105
|
+
bearer = args[++i] ?? bearer;
|
|
106
|
+
} else if (a.startsWith("--bearer=")) {
|
|
107
|
+
bearer = a.slice("--bearer=".length);
|
|
108
|
+
} else if (a === "--header") {
|
|
109
|
+
const v = args[++i];
|
|
110
|
+
if (v) headerArgs.push(v);
|
|
111
|
+
} else if (a.startsWith("--header=")) {
|
|
112
|
+
headerArgs.push(a.slice("--header=".length));
|
|
113
|
+
} else if (a.startsWith("--")) {
|
|
114
|
+
} else {
|
|
115
|
+
positionals.push(a);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (bearer) headers["Authorization"] = `Bearer ${bearer}`;
|
|
119
|
+
for (const h of headerArgs) {
|
|
120
|
+
const idx = h.indexOf(":");
|
|
121
|
+
if (idx === -1) continue;
|
|
122
|
+
const key = h.slice(0, idx).trim();
|
|
123
|
+
const value = h.slice(idx + 1).trim();
|
|
124
|
+
if (key) headers[key] = value;
|
|
125
|
+
}
|
|
126
|
+
return { headers, allowStateChanging, positionals };
|
|
127
|
+
}
|
|
128
|
+
function resolveTarget(target) {
|
|
129
|
+
if (/^https?:\/\//i.test(target)) return target;
|
|
130
|
+
if (existsSync(target)) {
|
|
131
|
+
const abs = path.resolve(target);
|
|
132
|
+
if (abs.endsWith(".ts") || abs.endsWith(".mts") || abs.endsWith(".cts")) {
|
|
133
|
+
return { command: process.execPath, args: [tsxCli(), abs], serverRef: target };
|
|
134
|
+
}
|
|
135
|
+
return { command: process.execPath, args: [abs], serverRef: target };
|
|
136
|
+
}
|
|
137
|
+
return target;
|
|
138
|
+
}
|
|
139
|
+
function tsxCli() {
|
|
140
|
+
const require2 = createRequire(import.meta.url);
|
|
141
|
+
const pkgJsonPath = require2.resolve("tsx/package.json");
|
|
142
|
+
const dir = path.dirname(pkgJsonPath);
|
|
143
|
+
const bin = require2(pkgJsonPath).bin;
|
|
144
|
+
const rel = typeof bin === "string" ? bin : bin.tsx ?? "./dist/cli.mjs";
|
|
145
|
+
return path.join(dir, rel);
|
|
146
|
+
}
|
|
147
|
+
async function maybePin(bundle, json = false) {
|
|
148
|
+
if (!process.env.POLYGRAPH_API_URL) return;
|
|
149
|
+
const note = (line) => (json ? process.stderr : process.stdout).write(line);
|
|
150
|
+
try {
|
|
151
|
+
const cid = await pinBundle(bundle);
|
|
152
|
+
note(`\u2192 pinned ${cid}
|
|
153
|
+
`);
|
|
154
|
+
note(`\u2192 mint ${mintUrl({ cid, ref: bundle.serverRef, fp: bundle.toolDefsFingerprint, ver: bundle.resolvedVersion })}
|
|
155
|
+
`);
|
|
156
|
+
} catch (err) {
|
|
157
|
+
note(`\u2192 pin skipped: ${err instanceof Error ? err.message : String(err)}
|
|
158
|
+
`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
async function pinBundle(bundle) {
|
|
162
|
+
const res = await fetch(pinUrl(), {
|
|
163
|
+
method: "POST",
|
|
164
|
+
headers: { "content-type": "application/json" },
|
|
165
|
+
body: canonicalStringify(bundle)
|
|
166
|
+
});
|
|
167
|
+
if (!res.ok) throw new Error(`pin endpoint returned ${res.status}`);
|
|
168
|
+
const data = await res.json();
|
|
169
|
+
if (!data.cid) throw new Error("pin response missing cid");
|
|
170
|
+
return data.cid;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export {
|
|
174
|
+
attestationsUrl,
|
|
175
|
+
mintUrl,
|
|
176
|
+
runLitmusCli,
|
|
177
|
+
parseAuthFlags,
|
|
178
|
+
resolveTarget,
|
|
179
|
+
pinBundle
|
|
180
|
+
};
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mintUrl,
|
|
3
|
+
pinBundle,
|
|
4
|
+
resolveTarget
|
|
5
|
+
} from "./chunk-6QM4RK25.js";
|
|
6
|
+
import {
|
|
7
|
+
runLitmus
|
|
8
|
+
} from "./chunk-2K6T4FZX.js";
|
|
9
|
+
import {
|
|
10
|
+
CATEGORY_STATUS_UINT8,
|
|
11
|
+
METHODOLOGY_VERSION
|
|
12
|
+
} from "./chunk-SAZKXB35.js";
|
|
13
|
+
|
|
14
|
+
// ../onchain/src/networks.ts
|
|
15
|
+
var NETWORKS = {
|
|
16
|
+
"base-sepolia": {
|
|
17
|
+
chainId: 84532,
|
|
18
|
+
rpc: "https://sepolia.base.org",
|
|
19
|
+
eas: "0x4200000000000000000000000000000000000021",
|
|
20
|
+
schemaRegistry: "0x4200000000000000000000000000000000000020",
|
|
21
|
+
easscan: "https://base-sepolia.easscan.org"
|
|
22
|
+
},
|
|
23
|
+
base: {
|
|
24
|
+
chainId: 8453,
|
|
25
|
+
rpc: "https://mainnet.base.org",
|
|
26
|
+
eas: "0x4200000000000000000000000000000000000021",
|
|
27
|
+
schemaRegistry: "0x4200000000000000000000000000000000000020",
|
|
28
|
+
easscan: "https://base.easscan.org"
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
function selectedNetwork() {
|
|
32
|
+
return process.env.NEXT_PUBLIC_POLYGRAPH_NETWORK === "base" ? "base" : "base-sepolia";
|
|
33
|
+
}
|
|
34
|
+
function networkConfig(net = selectedNetwork()) {
|
|
35
|
+
return NETWORKS[net];
|
|
36
|
+
}
|
|
37
|
+
function rpcUrl(net = selectedNetwork()) {
|
|
38
|
+
const override = net === "base" ? process.env.BASE_MAINNET_RPC_URL : process.env.BASE_SEPOLIA_RPC_URL;
|
|
39
|
+
return override && override.length > 0 ? override : NETWORKS[net].rpc;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ../onchain/src/eas-sdk.ts
|
|
43
|
+
import { createRequire } from "module";
|
|
44
|
+
var require2 = createRequire(import.meta.url);
|
|
45
|
+
var sdk = require2(
|
|
46
|
+
"@ethereum-attestation-service/eas-sdk"
|
|
47
|
+
);
|
|
48
|
+
var { EAS, SchemaEncoder, SchemaRegistry } = sdk;
|
|
49
|
+
|
|
50
|
+
// ../onchain/src/eas.ts
|
|
51
|
+
var LITMUS_SCHEMA = "string serverRef,bytes32 toolDefsFingerprint,uint8 gradeC01,uint8 gradeC02,uint8 gradeC03,string overallGrade,string reportCID,string methodologyVersion,uint64 ranAt,string resolvedVersion";
|
|
52
|
+
function categoryUint8(bundle, code) {
|
|
53
|
+
const status = bundle.categories.find((c) => c.code === code)?.status;
|
|
54
|
+
return status ? CATEGORY_STATUS_UINT8[status] : CATEGORY_STATUS_UINT8.skipped;
|
|
55
|
+
}
|
|
56
|
+
function litmusFields(bundle, reportCID) {
|
|
57
|
+
return {
|
|
58
|
+
serverRef: bundle.serverRef,
|
|
59
|
+
toolDefsFingerprint: bundle.toolDefsFingerprint,
|
|
60
|
+
gradeC01: categoryUint8(bundle, "C-01"),
|
|
61
|
+
gradeC02: categoryUint8(bundle, "C-02"),
|
|
62
|
+
gradeC03: categoryUint8(bundle, "C-03"),
|
|
63
|
+
overallGrade: bundle.grade,
|
|
64
|
+
reportCID,
|
|
65
|
+
methodologyVersion: bundle.methodologyVersion || METHODOLOGY_VERSION,
|
|
66
|
+
ranAt: BigInt(Math.floor(Date.parse(bundle.ranAt) / 1e3)),
|
|
67
|
+
resolvedVersion: bundle.resolvedVersion ?? ""
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function encodeLitmusAttestation(bundle, reportCID) {
|
|
71
|
+
const f = litmusFields(bundle, reportCID);
|
|
72
|
+
const enc = new SchemaEncoder(LITMUS_SCHEMA);
|
|
73
|
+
return enc.encodeData([
|
|
74
|
+
{ name: "serverRef", value: f.serverRef, type: "string" },
|
|
75
|
+
{ name: "toolDefsFingerprint", value: f.toolDefsFingerprint, type: "bytes32" },
|
|
76
|
+
{ name: "gradeC01", value: f.gradeC01, type: "uint8" },
|
|
77
|
+
{ name: "gradeC02", value: f.gradeC02, type: "uint8" },
|
|
78
|
+
{ name: "gradeC03", value: f.gradeC03, type: "uint8" },
|
|
79
|
+
{ name: "overallGrade", value: f.overallGrade, type: "string" },
|
|
80
|
+
{ name: "reportCID", value: f.reportCID, type: "string" },
|
|
81
|
+
{ name: "methodologyVersion", value: f.methodologyVersion, type: "string" },
|
|
82
|
+
{ name: "ranAt", value: f.ranAt, type: "uint64" },
|
|
83
|
+
{ name: "resolvedVersion", value: f.resolvedVersion, type: "string" }
|
|
84
|
+
]);
|
|
85
|
+
}
|
|
86
|
+
function decodeLitmusAttestation(encoded) {
|
|
87
|
+
const enc = new SchemaEncoder(LITMUS_SCHEMA);
|
|
88
|
+
const out = {};
|
|
89
|
+
for (const item of enc.decodeData(encoded)) {
|
|
90
|
+
out[item.name] = item.value.value;
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ../onchain/src/read.ts
|
|
96
|
+
import { JsonRpcProvider, ZeroHash } from "ethers";
|
|
97
|
+
function litmusSchemaUID() {
|
|
98
|
+
const uid = process.env.NEXT_PUBLIC_EAS_SCHEMA_UID;
|
|
99
|
+
if (!uid) throw new Error("NEXT_PUBLIC_EAS_SCHEMA_UID is required \u2014 register the schema first.");
|
|
100
|
+
return uid;
|
|
101
|
+
}
|
|
102
|
+
async function readAttestation(uid) {
|
|
103
|
+
const cfg = networkConfig();
|
|
104
|
+
const provider = new JsonRpcProvider(rpcUrl(), cfg.chainId);
|
|
105
|
+
const eas = new EAS(cfg.eas);
|
|
106
|
+
eas.connect(provider);
|
|
107
|
+
const att = await eas.getAttestation(uid);
|
|
108
|
+
if (!att || att.uid === ZeroHash) return null;
|
|
109
|
+
if (String(att.schema).toLowerCase() !== litmusSchemaUID().toLowerCase()) return null;
|
|
110
|
+
const d = decodeLitmusAttestation(att.data);
|
|
111
|
+
return {
|
|
112
|
+
uid: att.uid,
|
|
113
|
+
serverRef: String(d.serverRef),
|
|
114
|
+
toolDefsFingerprint: String(d.toolDefsFingerprint),
|
|
115
|
+
overallGrade: String(d.overallGrade),
|
|
116
|
+
reportCID: String(d.reportCID),
|
|
117
|
+
resolvedVersion: d.resolvedVersion || null,
|
|
118
|
+
revoked: att.revocationTime > 0n,
|
|
119
|
+
attester: String(att.attester),
|
|
120
|
+
expirationTime: BigInt(att.expirationTime ?? 0n)
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// src/tools/run-litmus.ts
|
|
125
|
+
import { z } from "zod";
|
|
126
|
+
var RUN_LITMUS_TOOL_NAME = "run_litmus";
|
|
127
|
+
var RUN_LITMUS_TOOL_TITLE = "Run a behavioral litmus on an MCP server";
|
|
128
|
+
var RUN_LITMUS_TOOL_DESCRIPTION = [
|
|
129
|
+
`Run the open behavioral litmus (${METHODOLOGY_VERSION}) against an MCP server and return`,
|
|
130
|
+
"its grade. The harness connects like an agent would, fingerprints the tool",
|
|
131
|
+
"surface, and runs three probe categories: C-01 tool-output injection, C-02",
|
|
132
|
+
"permission overreach (egress in a hardened default-deny Docker sandbox, plus a",
|
|
133
|
+
"declared-permission honesty check), and C-03 sensitive-data handling (planted",
|
|
134
|
+
"canaries). It grades A\u2013F.",
|
|
135
|
+
"",
|
|
136
|
+
"This is ACTIVE: it launches the target server's code to exercise it (sandboxed",
|
|
137
|
+
"for egress when Docker is available). It is not a passive lookup \u2014 for that,",
|
|
138
|
+
"use `verify_attestation`. It needs no wallet or RPC.",
|
|
139
|
+
"",
|
|
140
|
+
"When POLYGRAPH_API_URL is configured the evidence is pinned and the result",
|
|
141
|
+
"includes a `mint` URL: open it in a browser, connect a wallet, and sign to",
|
|
142
|
+
"publish the grade onchain as an EAS attestation. Signing is intentionally not",
|
|
143
|
+
"headless.",
|
|
144
|
+
"",
|
|
145
|
+
"Input: server_ref \u2014 a registry ref (npm/@scope/server), an https:// MCP URL,",
|
|
146
|
+
"or a local path to an MCP entry file. If Docker is unavailable, C-02 is",
|
|
147
|
+
"skipped and the grade is capped at B for that run."
|
|
148
|
+
].join("\n");
|
|
149
|
+
var runLitmusInputShape = {
|
|
150
|
+
server_ref: z.string().min(1).max(512).describe("What to grade: a registry ref (npm/@scope/server), an https:// MCP URL, or a local path to an MCP entry file."),
|
|
151
|
+
pin: z.boolean().optional().describe("When true (default) and POLYGRAPH_API_URL is set, pin the evidence and return a mint hand-off URL. Set false to grade only.")
|
|
152
|
+
};
|
|
153
|
+
async function handleRunLitmus({ server_ref, pin }) {
|
|
154
|
+
try {
|
|
155
|
+
const bundle = await runLitmus(resolveTarget(server_ref));
|
|
156
|
+
const payload = { ...summarize(bundle), mint: await mintHandoff(bundle, pin) };
|
|
157
|
+
return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
|
|
158
|
+
} catch (err) {
|
|
159
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
160
|
+
return { isError: true, content: [{ type: "text", text: `run_litmus failed: ${message}` }] };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
async function mintHandoff(bundle, pin) {
|
|
164
|
+
if (pin === false || !process.env.POLYGRAPH_API_URL) {
|
|
165
|
+
return { available: false, reason: "Set POLYGRAPH_API_URL to pin the evidence and get a mint hand-off URL." };
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
const cid = await pinBundle(bundle);
|
|
169
|
+
return {
|
|
170
|
+
url: mintUrl({ cid, ref: bundle.serverRef, fp: bundle.toolDefsFingerprint, ver: bundle.resolvedVersion }),
|
|
171
|
+
cid,
|
|
172
|
+
instruction: "Open this URL in a browser, connect your wallet, and sign to mint the onchain EAS attestation. Signing cannot be done headlessly."
|
|
173
|
+
};
|
|
174
|
+
} catch (err) {
|
|
175
|
+
return { available: false, reason: `pin failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function summarize(b) {
|
|
179
|
+
const find = (code) => b.categories.find((c) => c.code === code);
|
|
180
|
+
const categories = ["C-01", "C-02", "C-03"].map((code) => {
|
|
181
|
+
const c = find(code);
|
|
182
|
+
const findings = c?.status === "fail" ? c.probes.flatMap((p) => p.findings).filter((f) => f.severity === "high").slice(0, 5).map((f) => ({ tool: f.tool, kind: f.kind, match: truncate(f.match, 120), host: f.host, port: f.port })) : [];
|
|
183
|
+
return { code, status: c?.status ?? "unknown", reason: c?.reason ?? null, findings };
|
|
184
|
+
});
|
|
185
|
+
const dockerSkipped = !b.harness.dockerAvailable || find("C-02")?.status === "skipped";
|
|
186
|
+
return {
|
|
187
|
+
grade: b.grade,
|
|
188
|
+
gradeRationale: b.gradeRationale,
|
|
189
|
+
fingerprint: b.toolDefsFingerprint,
|
|
190
|
+
serverRef: b.serverRef,
|
|
191
|
+
resolvedVersion: b.resolvedVersion,
|
|
192
|
+
ranAt: b.ranAt,
|
|
193
|
+
methodologyVersion: b.methodologyVersion,
|
|
194
|
+
categories,
|
|
195
|
+
...dockerSkipped ? { dockerSkipped: "C-02 (egress) was not run because Docker was unavailable; the grade is capped at B for this run." } : {}
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
function truncate(s, n) {
|
|
199
|
+
return s.length > n ? `${s.slice(0, n)}\u2026` : s;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export {
|
|
203
|
+
NETWORKS,
|
|
204
|
+
selectedNetwork,
|
|
205
|
+
networkConfig,
|
|
206
|
+
rpcUrl,
|
|
207
|
+
LITMUS_SCHEMA,
|
|
208
|
+
litmusFields,
|
|
209
|
+
encodeLitmusAttestation,
|
|
210
|
+
decodeLitmusAttestation,
|
|
211
|
+
litmusSchemaUID,
|
|
212
|
+
readAttestation,
|
|
213
|
+
RUN_LITMUS_TOOL_NAME,
|
|
214
|
+
RUN_LITMUS_TOOL_TITLE,
|
|
215
|
+
RUN_LITMUS_TOOL_DESCRIPTION,
|
|
216
|
+
runLitmusInputShape,
|
|
217
|
+
handleRunLitmus
|
|
218
|
+
};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// ../core/src/types.ts
|
|
2
|
+
var METHODOLOGY_VERSION = "litmus-v2";
|
|
3
|
+
var BUNDLE_SCHEMA_VERSION = "1.1.0";
|
|
4
|
+
var CATEGORY_STATUS_UINT8 = {
|
|
5
|
+
pass: 0,
|
|
6
|
+
fail: 1,
|
|
7
|
+
skipped: 2
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
// ../core/src/identity.ts
|
|
11
|
+
var REGISTRIES = /* @__PURE__ */ new Set(["npm", "pypi", "github"]);
|
|
12
|
+
var OWNER_RE = /^@?[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
13
|
+
var NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
14
|
+
var VERSION_RE = /^[A-Za-z0-9][A-Za-z0-9.+_-]*$/;
|
|
15
|
+
var ServerRefParseError = class extends Error {
|
|
16
|
+
constructor(ref, reason) {
|
|
17
|
+
super(`Invalid server ref "${ref}": ${reason}`);
|
|
18
|
+
this.name = "ServerRefParseError";
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
function parseServerRef(ref) {
|
|
22
|
+
const firstSlash = ref.indexOf("/");
|
|
23
|
+
if (firstSlash === -1) {
|
|
24
|
+
throw new ServerRefParseError(ref, "expected `{registry}/...`");
|
|
25
|
+
}
|
|
26
|
+
const registry = ref.slice(0, firstSlash);
|
|
27
|
+
if (!REGISTRIES.has(registry)) {
|
|
28
|
+
throw new ServerRefParseError(
|
|
29
|
+
ref,
|
|
30
|
+
`unknown registry "${registry}" (expected one of: ${[...REGISTRIES].join(", ")})`
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
const rest = ref.slice(firstSlash + 1);
|
|
34
|
+
const versionAt = rest.lastIndexOf("@");
|
|
35
|
+
let pathPart;
|
|
36
|
+
let version;
|
|
37
|
+
if (versionAt > 0) {
|
|
38
|
+
pathPart = rest.slice(0, versionAt);
|
|
39
|
+
version = rest.slice(versionAt + 1);
|
|
40
|
+
if (version.length === 0) {
|
|
41
|
+
throw new ServerRefParseError(ref, "empty version after `@`");
|
|
42
|
+
}
|
|
43
|
+
if (!VERSION_RE.test(version)) {
|
|
44
|
+
throw new ServerRefParseError(ref, "version contains disallowed characters");
|
|
45
|
+
}
|
|
46
|
+
} else {
|
|
47
|
+
pathPart = rest;
|
|
48
|
+
version = null;
|
|
49
|
+
}
|
|
50
|
+
const lastSlash = pathPart.lastIndexOf("/");
|
|
51
|
+
let owner;
|
|
52
|
+
let name;
|
|
53
|
+
if (lastSlash === -1) {
|
|
54
|
+
if (registry === "github") {
|
|
55
|
+
throw new ServerRefParseError(ref, "github requires `{owner}/{repo}`");
|
|
56
|
+
}
|
|
57
|
+
owner = null;
|
|
58
|
+
name = pathPart;
|
|
59
|
+
if (!name) {
|
|
60
|
+
throw new ServerRefParseError(ref, "empty name segment");
|
|
61
|
+
}
|
|
62
|
+
} else {
|
|
63
|
+
owner = pathPart.slice(0, lastSlash);
|
|
64
|
+
name = pathPart.slice(lastSlash + 1);
|
|
65
|
+
if (!owner || !name) {
|
|
66
|
+
throw new ServerRefParseError(ref, "empty owner or name segment");
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (owner !== null && !OWNER_RE.test(owner)) {
|
|
70
|
+
throw new ServerRefParseError(ref, "owner contains disallowed characters");
|
|
71
|
+
}
|
|
72
|
+
if (!NAME_RE.test(name)) {
|
|
73
|
+
throw new ServerRefParseError(ref, "name contains disallowed characters");
|
|
74
|
+
}
|
|
75
|
+
return { registry, owner, name, version };
|
|
76
|
+
}
|
|
77
|
+
function formatServerRef(parts) {
|
|
78
|
+
const base = parts.owner ? `${parts.registry}/${parts.owner}/${parts.name}` : `${parts.registry}/${parts.name}`;
|
|
79
|
+
return parts.version ? `${base}@${parts.version}` : base;
|
|
80
|
+
}
|
|
81
|
+
function serverKey(parts) {
|
|
82
|
+
return parts.owner ? `${parts.registry}/${parts.owner}/${parts.name}` : `${parts.registry}/${parts.name}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ../core/src/canonical.ts
|
|
86
|
+
function canonicalStringify(value) {
|
|
87
|
+
return JSON.stringify(sortDeep(value));
|
|
88
|
+
}
|
|
89
|
+
var MAX_CANONICAL_DEPTH = 200;
|
|
90
|
+
function sortDeep(value, depth = 0) {
|
|
91
|
+
if (depth > MAX_CANONICAL_DEPTH) {
|
|
92
|
+
throw new RangeError(`value nesting exceeds ${MAX_CANONICAL_DEPTH} levels`);
|
|
93
|
+
}
|
|
94
|
+
if (Array.isArray(value)) return value.map((v) => sortDeep(v, depth + 1));
|
|
95
|
+
if (value && typeof value === "object") {
|
|
96
|
+
const src = value;
|
|
97
|
+
const out = {};
|
|
98
|
+
for (const k of Object.keys(src).sort()) {
|
|
99
|
+
Object.defineProperty(out, k, {
|
|
100
|
+
value: sortDeep(src[k], depth + 1),
|
|
101
|
+
enumerable: true,
|
|
102
|
+
writable: true,
|
|
103
|
+
configurable: true
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
return value;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export {
|
|
112
|
+
METHODOLOGY_VERSION,
|
|
113
|
+
BUNDLE_SCHEMA_VERSION,
|
|
114
|
+
CATEGORY_STATUS_UINT8,
|
|
115
|
+
ServerRefParseError,
|
|
116
|
+
parseServerRef,
|
|
117
|
+
formatServerRef,
|
|
118
|
+
serverKey,
|
|
119
|
+
canonicalStringify
|
|
120
|
+
};
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
attestationsUrl,
|
|
4
|
+
runLitmusCli
|
|
5
|
+
} from "./chunk-6QM4RK25.js";
|
|
6
|
+
import {
|
|
7
|
+
parseServerRef,
|
|
8
|
+
serverKey
|
|
9
|
+
} from "./chunk-SAZKXB35.js";
|
|
10
|
+
|
|
11
|
+
// src/cli.ts
|
|
12
|
+
import { readFileSync } from "fs";
|
|
13
|
+
import { fileURLToPath } from "url";
|
|
14
|
+
import { dirname, join } from "path";
|
|
15
|
+
|
|
16
|
+
// ../cli/src/check.ts
|
|
17
|
+
function checkQuery(rawRef) {
|
|
18
|
+
try {
|
|
19
|
+
const parsed = parseServerRef(rawRef);
|
|
20
|
+
return { ref: serverKey(parsed), ver: parsed.version };
|
|
21
|
+
} catch {
|
|
22
|
+
return { ref: rawRef, ver: null };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
async function runCheck(args) {
|
|
26
|
+
const rawRef = args[0];
|
|
27
|
+
if (!rawRef) {
|
|
28
|
+
process.stderr.write("usage: polygraphso check <registry-ref[@version]>\n");
|
|
29
|
+
return 2;
|
|
30
|
+
}
|
|
31
|
+
const { ref, ver } = checkQuery(rawRef);
|
|
32
|
+
try {
|
|
33
|
+
const query = `?ref=${encodeURIComponent(ref)}${ver ? `&ver=${encodeURIComponent(ver)}` : ""}`;
|
|
34
|
+
const res = await fetch(`${attestationsUrl()}${query}`);
|
|
35
|
+
if (res.ok) {
|
|
36
|
+
const row = await res.json();
|
|
37
|
+
if (row?.attestation_uid) {
|
|
38
|
+
const version = row.resolved_version ? ` \xB7 version ${row.resolved_version}` : "";
|
|
39
|
+
process.stdout.write(
|
|
40
|
+
[
|
|
41
|
+
`\u2192 ${rawRef}`,
|
|
42
|
+
`\u2192 polygraph: ${row.grade ?? "?"}${version} \xB7 ${easscan(row.network, row.attestation_uid)}`,
|
|
43
|
+
""
|
|
44
|
+
].join("\n")
|
|
45
|
+
);
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
} catch {
|
|
50
|
+
}
|
|
51
|
+
process.stdout.write(
|
|
52
|
+
[
|
|
53
|
+
`\u2192 ${rawRef}`,
|
|
54
|
+
"\u2192 polygraph: not yet available",
|
|
55
|
+
`\u2192 run a behavioral litmus: polygraphso litmus ${rawRef}`,
|
|
56
|
+
""
|
|
57
|
+
].join("\n")
|
|
58
|
+
);
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
function easscan(network, uid) {
|
|
62
|
+
const host = network === "base" ? "base.easscan.org" : "base-sepolia.easscan.org";
|
|
63
|
+
return `https://${host}/attestation/view/${uid}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ../cli/src/list.ts
|
|
67
|
+
async function runList(_args) {
|
|
68
|
+
process.stdout.write("\u2192 no published grades yet (discovery lands in M3)\n");
|
|
69
|
+
return 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// src/cli.ts
|
|
73
|
+
var HELP = `polygraphso-litmus \u2014 behavioral litmus grades for MCP servers.
|
|
74
|
+
|
|
75
|
+
usage:
|
|
76
|
+
polygraphso-litmus litmus [--json] <registry-ref | https-url | path-to-mcp>
|
|
77
|
+
polygraphso-litmus check <registry-ref>
|
|
78
|
+
polygraphso-litmus list
|
|
79
|
+
polygraphso-litmus --version
|
|
80
|
+
polygraphso-litmus --help
|
|
81
|
+
|
|
82
|
+
examples:
|
|
83
|
+
polygraphso-litmus litmus npm/@modelcontextprotocol/server-filesystem
|
|
84
|
+
polygraphso-litmus litmus --json npm/@modelcontextprotocol/server-filesystem
|
|
85
|
+
|
|
86
|
+
Set POLYGRAPH_API_URL to pin the evidence and get a mint hand-off link.
|
|
87
|
+
More at https://polygraph.so
|
|
88
|
+
`;
|
|
89
|
+
function readVersion() {
|
|
90
|
+
try {
|
|
91
|
+
const path = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
|
|
92
|
+
return JSON.parse(readFileSync(path, "utf8")).version ?? "0.0.0";
|
|
93
|
+
} catch {
|
|
94
|
+
return "0.0.0";
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
async function main(argv) {
|
|
98
|
+
const cmd = argv[0];
|
|
99
|
+
if (!cmd || cmd === "--help" || cmd === "-h" || cmd === "help") {
|
|
100
|
+
process.stdout.write(HELP);
|
|
101
|
+
return 0;
|
|
102
|
+
}
|
|
103
|
+
if (cmd === "--version" || cmd === "-v") {
|
|
104
|
+
process.stdout.write(readVersion() + "\n");
|
|
105
|
+
return 0;
|
|
106
|
+
}
|
|
107
|
+
if (cmd === "litmus") return runLitmusCli(argv.slice(1));
|
|
108
|
+
if (cmd === "check") return runCheck(argv.slice(1));
|
|
109
|
+
if (cmd === "list") return runList(argv.slice(1));
|
|
110
|
+
process.stderr.write(`polygraphso-litmus: unknown command "${cmd}".
|
|
111
|
+
|
|
112
|
+
${HELP}`);
|
|
113
|
+
return 2;
|
|
114
|
+
}
|
|
115
|
+
main(process.argv.slice(2)).then((code) => process.exit(code)).catch((err) => {
|
|
116
|
+
process.stderr.write(`polygraphso-litmus: ${err instanceof Error ? err.message : String(err)}
|
|
117
|
+
`);
|
|
118
|
+
process.exit(1);
|
|
119
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Egress-sniff image — used for BOTH the sinkhole (--entrypoint /sink-entrypoint.sh,
|
|
2
|
+
# with NET_ADMIN) and the target run (--entrypoint npx, all caps dropped).
|
|
3
|
+
FROM node:22-slim
|
|
4
|
+
|
|
5
|
+
RUN apt-get update \
|
|
6
|
+
&& apt-get install -y --no-install-recommends iptables iproute2 ca-certificates \
|
|
7
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
8
|
+
|
|
9
|
+
COPY sinkhole.mjs /sinkhole.mjs
|
|
10
|
+
COPY sink-entrypoint.sh /sink-entrypoint.sh
|
|
11
|
+
RUN chmod +x /sink-entrypoint.sh
|
|
12
|
+
|
|
13
|
+
# No default CMD/ENTRYPOINT: callers set it (sink vs target) at `docker run`.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Sinkhole entrypoint. Runs with --cap-add=NET_ADMIN (the sink is trusted; the
|
|
3
|
+
# TARGET runs with all caps dropped). Redirect all inbound TCP to the sink port,
|
|
4
|
+
# then run the sinkhole with our own IP so DNS answers point back here.
|
|
5
|
+
set -e
|
|
6
|
+
SINK_IP="$(hostname -i 2>/dev/null | awk '{print $1}')"
|
|
7
|
+
export SINK_IP
|
|
8
|
+
iptables -t nat -A PREROUTING -p tcp -j REDIRECT --to-port 8443 2>/dev/null || true
|
|
9
|
+
exec node /sinkhole.mjs
|