@thotischner/observability-mcp 1.3.4 → 1.4.1

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.
Files changed (44) hide show
  1. package/dist/cli/index.d.ts +2 -0
  2. package/dist/cli/index.js +370 -0
  3. package/dist/cli/lib.d.ts +95 -0
  4. package/dist/cli/lib.js +185 -0
  5. package/dist/cli/lib.test.d.ts +1 -0
  6. package/dist/cli/lib.test.js +134 -0
  7. package/dist/config/loader.test.js +3 -3
  8. package/dist/connectors/hub.d.ts +48 -0
  9. package/dist/connectors/hub.js +51 -0
  10. package/dist/connectors/hub.test.d.ts +1 -0
  11. package/dist/connectors/hub.test.js +52 -0
  12. package/dist/connectors/install.d.ts +24 -0
  13. package/dist/connectors/install.js +100 -0
  14. package/dist/connectors/install.test.d.ts +1 -0
  15. package/dist/connectors/install.test.js +58 -0
  16. package/dist/connectors/loader.d.ts +48 -0
  17. package/dist/connectors/loader.js +222 -0
  18. package/dist/connectors/loki.js +14 -6
  19. package/dist/connectors/loki.test.js +27 -0
  20. package/dist/connectors/registry.d.ts +3 -0
  21. package/dist/connectors/registry.js +16 -16
  22. package/dist/connectors/tls.test.js +3 -3
  23. package/dist/connectors/verify.d.ts +19 -0
  24. package/dist/connectors/verify.js +87 -0
  25. package/dist/connectors/verify.test.d.ts +1 -0
  26. package/dist/connectors/verify.test.js +63 -0
  27. package/dist/index.js +389 -26
  28. package/dist/metrics/instrument-connector.d.ts +8 -0
  29. package/dist/metrics/instrument-connector.js +41 -0
  30. package/dist/metrics/self.d.ts +12 -0
  31. package/dist/metrics/self.js +61 -0
  32. package/dist/openapi.d.ts +2 -0
  33. package/dist/openapi.js +186 -0
  34. package/dist/sdk/index.d.ts +52 -0
  35. package/dist/sdk/index.js +13 -0
  36. package/dist/sdk/manifest-schema.d.ts +28 -0
  37. package/dist/sdk/manifest-schema.js +47 -0
  38. package/dist/sdk/manifest-schema.test.d.ts +1 -0
  39. package/dist/sdk/manifest-schema.test.js +50 -0
  40. package/dist/tools/get-service-health.js +3 -2
  41. package/dist/ui/index.html +687 -115
  42. package/dist/util/sanitize.d.ts +1 -0
  43. package/dist/util/sanitize.js +6 -0
  44. package/package.json +21 -8
@@ -0,0 +1,222 @@
1
+ import { readdirSync, readFileSync, existsSync, statSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { manifestSchema } from "../sdk/manifest-schema.js";
5
+ import { PrometheusConnector } from "./prometheus.js";
6
+ import { LokiConnector } from "./loki.js";
7
+ import { sanitizeForLog } from "../util/sanitize.js";
8
+ import { instrumentConnector } from "../metrics/instrument-connector.js";
9
+ import { loadTrustRoot, verifyIntegrity, verifyManifestSignature, PluginVerificationError, } from "./verify.js";
10
+ /**
11
+ * Resolves which connector implementations the server should know about,
12
+ * applying three sources in order (later overrides earlier):
13
+ * 1. builtin shim — Prometheus/Loki bundled with the server
14
+ * 2. filesystem — every subdir of PLUGINS_DIR with a valid package.json
15
+ * 3. config-pinned — `plugins:` block in sources.yaml (not yet wired)
16
+ *
17
+ * The legacy `connectorFactories` map in registry.ts can be replaced
18
+ * with this loader's output without changing observable behaviour.
19
+ */
20
+ export class PluginLoader {
21
+ connectors = new Map();
22
+ pluginsDir;
23
+ disabled;
24
+ // Fail-closed verification for filesystem plugins. Builtins are part
25
+ // of the trusted image and are never gated. Default off so existing
26
+ // deployments are unchanged; recommended on in prod/airgapped (the
27
+ // Helm chart sets it).
28
+ verify;
29
+ trustRootPath;
30
+ trustRoot;
31
+ constructor(opts = {}) {
32
+ this.pluginsDir = opts.pluginsDir
33
+ ?? process.env.PLUGINS_DIR
34
+ ?? "/app/plugins";
35
+ // Per-plugin disable via env: PLUGINS_DISABLED="prometheus,loki"
36
+ const envDisabled = (process.env.PLUGINS_DISABLED ?? "")
37
+ .split(",")
38
+ .map((s) => s.trim())
39
+ .filter(Boolean);
40
+ this.disabled = new Set([...(opts.disabled ?? []), ...envDisabled]);
41
+ this.verify = opts.verify ?? /^(1|true|yes)$/i.test(process.env.VERIFY_PLUGINS ?? "");
42
+ this.trustRootPath = opts.trustRoot ?? process.env.PLUGIN_TRUST_ROOT;
43
+ }
44
+ async load() {
45
+ this.loadBuiltins();
46
+ if (this.verify) {
47
+ if (!this.trustRootPath) {
48
+ console.warn("VERIFY_PLUGINS is on but PLUGIN_TRUST_ROOT is unset — refusing to load any filesystem plugins (fail-closed). Builtins remain available.");
49
+ return;
50
+ }
51
+ try {
52
+ this.trustRoot = loadTrustRoot(this.trustRootPath);
53
+ console.log("Plugin verification enabled; trust root loaded from %s", sanitizeForLog(this.trustRootPath));
54
+ }
55
+ catch (err) {
56
+ console.warn("VERIFY_PLUGINS is on but trust root failed to load (%s) — refusing to load any filesystem plugins (fail-closed). Builtins remain available.", sanitizeForLog(String(err)));
57
+ return;
58
+ }
59
+ }
60
+ await this.loadFilesystem();
61
+ }
62
+ list() {
63
+ return Array.from(this.connectors.values());
64
+ }
65
+ get(name) {
66
+ return this.connectors.get(name);
67
+ }
68
+ has(name) {
69
+ return this.connectors.has(name);
70
+ }
71
+ supportedTypes() {
72
+ return Array.from(this.connectors.keys());
73
+ }
74
+ /** Create a fresh instance of a connector. Returns undefined for unknown types. */
75
+ create(name) {
76
+ const entry = this.connectors.get(name);
77
+ if (!entry)
78
+ return undefined;
79
+ const c = entry.factory();
80
+ if (c instanceof Promise) {
81
+ // For now connectors are sync-constructed; if a plugin returns a
82
+ // Promise we await it lazily in the consumer. Document if/when
83
+ // this becomes a real pattern.
84
+ throw new Error(`Connector ${name} returned a Promise; async factories not yet wired`);
85
+ }
86
+ return instrumentConnector(c);
87
+ }
88
+ loadBuiltins() {
89
+ this.register({
90
+ name: "prometheus",
91
+ source: "builtin",
92
+ factory: () => new PrometheusConnector(),
93
+ });
94
+ this.register({
95
+ name: "loki",
96
+ source: "builtin",
97
+ factory: () => new LokiConnector(),
98
+ });
99
+ }
100
+ async loadFilesystem() {
101
+ const dir = this.pluginsDir;
102
+ if (!existsSync(dir))
103
+ return;
104
+ let entries;
105
+ try {
106
+ entries = readdirSync(dir);
107
+ }
108
+ catch {
109
+ return;
110
+ }
111
+ for (const entry of entries) {
112
+ const pluginRoot = join(dir, entry);
113
+ try {
114
+ if (!statSync(pluginRoot).isDirectory())
115
+ continue;
116
+ await this.loadFilesystemPlugin(pluginRoot);
117
+ }
118
+ catch (err) {
119
+ console.warn("Failed to load plugin %s: %s", sanitizeForLog(entry), sanitizeForLog(String(err)));
120
+ }
121
+ }
122
+ }
123
+ async loadFilesystemPlugin(pluginRoot) {
124
+ const pkgPath = join(pluginRoot, "package.json");
125
+ if (!existsSync(pkgPath))
126
+ return;
127
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
128
+ const marker = pkg.observabilityMcp;
129
+ if (!marker || marker.kind !== "connector" || !marker.name)
130
+ return;
131
+ let manifest;
132
+ let manifestPath;
133
+ let manifestBytes;
134
+ if (marker.manifest) {
135
+ manifestPath = resolve(pluginRoot, marker.manifest);
136
+ if (existsSync(manifestPath)) {
137
+ manifestBytes = readFileSync(manifestPath);
138
+ const raw = JSON.parse(manifestBytes.toString("utf8"));
139
+ const parsed = manifestSchema.safeParse(raw);
140
+ if (!parsed.success) {
141
+ const issues = parsed.error.issues
142
+ .map((i) => `${i.path.join(".")}: ${i.message}`)
143
+ .join("; ");
144
+ console.warn("Plugin %s has invalid manifest.json — %s; skipping", sanitizeForLog(marker.name), sanitizeForLog(issues));
145
+ return;
146
+ }
147
+ manifest = parsed.data;
148
+ if (manifest.name !== marker.name) {
149
+ console.warn("Plugin %s package.json marker name does not match manifest.json (%s); skipping", sanitizeForLog(marker.name), sanitizeForLog(manifest.name));
150
+ return;
151
+ }
152
+ }
153
+ }
154
+ const entryFile = pkg.main || "index.js";
155
+ const entryPath = resolve(pluginRoot, entryFile);
156
+ if (!existsSync(entryPath)) {
157
+ console.warn("Plugin %s missing entry file %s", sanitizeForLog(marker.name), sanitizeForLog(entryFile));
158
+ return;
159
+ }
160
+ // Fail-closed verification gate. A plugin only loads under
161
+ // VERIFY_PLUGINS if it ships a manifest whose `integrity` matches
162
+ // the entry file AND a detached `<manifest>.sig` that verifies
163
+ // against the trust root. Everything is local — airgapped-safe.
164
+ if (this.verify) {
165
+ if (!manifest || !manifestPath || !manifestBytes) {
166
+ console.warn("VERIFY_PLUGINS: plugin %s has no manifest.json — skipping (fail-closed)", sanitizeForLog(marker.name));
167
+ return;
168
+ }
169
+ const sigPath = manifestPath + ".sig";
170
+ if (!existsSync(sigPath)) {
171
+ console.warn("VERIFY_PLUGINS: plugin %s missing manifest signature %s — skipping (fail-closed)", sanitizeForLog(marker.name), sanitizeForLog(marker.manifest + ".sig"));
172
+ return;
173
+ }
174
+ try {
175
+ verifyManifestSignature(manifestBytes, readFileSync(sigPath), this.trustRoot);
176
+ verifyIntegrity(entryPath, manifest.integrity);
177
+ }
178
+ catch (err) {
179
+ const detail = err instanceof PluginVerificationError ? err.message : String(err);
180
+ console.warn("VERIFY_PLUGINS: plugin %s failed verification (%s) — skipping (fail-closed)", sanitizeForLog(marker.name), sanitizeForLog(detail));
181
+ return;
182
+ }
183
+ console.log("VERIFY_PLUGINS: plugin %s signature + integrity OK", sanitizeForLog(marker.name));
184
+ }
185
+ const mod = await import(pathToFileURL(entryPath).href);
186
+ const factory = mod.default ?? mod.createConnector;
187
+ if (typeof factory !== "function") {
188
+ console.warn("Plugin %s has no default export factory", sanitizeForLog(marker.name));
189
+ return;
190
+ }
191
+ this.register({
192
+ name: marker.name,
193
+ source: "filesystem",
194
+ manifest,
195
+ factory,
196
+ });
197
+ console.log('Connector plugin "%s" loaded from %s', sanitizeForLog(marker.name), sanitizeForLog(pluginRoot));
198
+ }
199
+ register(entry) {
200
+ if (this.disabled.has(entry.name)) {
201
+ console.log("Connector %s disabled via PLUGINS_DISABLED; skipping", sanitizeForLog(entry.name));
202
+ return;
203
+ }
204
+ // Later sources override earlier ones; current call order is
205
+ // builtin → filesystem → config-pinned, matching the design doc.
206
+ this.connectors.set(entry.name, entry);
207
+ }
208
+ }
209
+ /**
210
+ * Singleton loader populated at server startup. The registry consults
211
+ * this for connector creation. Tests may swap in their own instance
212
+ * with `setPluginLoader`.
213
+ */
214
+ let activeLoader = null;
215
+ export function getPluginLoader() {
216
+ if (!activeLoader)
217
+ activeLoader = new PluginLoader();
218
+ return activeLoader;
219
+ }
220
+ export function setPluginLoader(loader) {
221
+ activeLoader = loader;
222
+ }
@@ -54,13 +54,19 @@ export class LokiConnector {
54
54
  }
55
55
  async disconnect() { }
56
56
  async listServices() {
57
- // Probe each candidate label and merge values. Loki streams may identify
58
- // services via service_name, service, job, app, or container depending on
59
- // the shipper configuration. Walking all candidates ensures historical
60
- // streams remain reachable when label conventions change over time.
57
+ // Candidate labels are ordered by preference (service_name, service,
58
+ // job, app, container). The FIRST label that yields any values wins —
59
+ // we do not union across labels. Unioning duplicated every service:
60
+ // one real container is simultaneously `service="api-gateway"` and
61
+ // `container="myproj-api-gateway-1"`, and a co-located shipper can add
62
+ // unrelated `container` values (e.g. other compose/k8s containers on
63
+ // the same Docker host). The ordered fallback still keeps streams
64
+ // reachable on backends that only carry a low-priority label.
61
65
  const seen = new Map();
62
66
  for (const label of this.serviceLabels) {
63
67
  const values = await this.getLabelValues(label);
68
+ if (values.length === 0)
69
+ continue;
64
70
  for (const raw of values) {
65
71
  // Docker's loki.source.docker writes container names with a leading '/'
66
72
  // (Docker API Names[0] convention). Strip it for display so the name
@@ -75,6 +81,7 @@ export class LokiConnector {
75
81
  });
76
82
  }
77
83
  }
84
+ break; // first non-empty label is authoritative
78
85
  }
79
86
  return Array.from(seen.values());
80
87
  }
@@ -200,8 +207,9 @@ export class LokiConnector {
200
207
  return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
201
208
  }
202
209
  escapeLogQLRegex(value) {
203
- // Escape backticks which would break the LogQL regex delimiter
204
- return value.replace(/`/g, "\\`");
210
+ // Escape backslash first (so we don't double-escape sequences we add),
211
+ // then the backtick that delimits LogQL regex literals.
212
+ return value.replace(/\\/g, "\\\\").replace(/`/g, "\\`");
205
213
  }
206
214
  buildAuthHeaders() {
207
215
  if (!this.auth || this.auth.type === "none")
@@ -108,4 +108,31 @@ describe("LokiConnector", () => {
108
108
  assert.equal(proto.escapeLogQLRegex("error`test`"), "error\\`test\\`");
109
109
  });
110
110
  });
111
+ describe("listServices", () => {
112
+ function withLabelValues(map) {
113
+ const c = new LokiConnector();
114
+ c.serviceLabels = ["service_name", "service", "job", "app", "container"];
115
+ c.getLabelValues = async (label) => map[label] ?? [];
116
+ return c;
117
+ }
118
+ it("first non-empty label wins — does NOT union container aliases", async () => {
119
+ const c = withLabelValues({
120
+ service: ["api-gateway", "payment-service"],
121
+ // co-located shipper noise that must NOT leak in:
122
+ container: ["myproj-api-gateway-1", "k8s_POD_api-gateway_demo_x"],
123
+ });
124
+ const names = (await c.listServices()).map((s) => s.name).sort();
125
+ assert.deepEqual(names, ["api-gateway", "payment-service"]);
126
+ });
127
+ it("falls back to a lower-priority label only when higher ones are empty", async () => {
128
+ const c = withLabelValues({ container: ["/svc-a", "/svc-b"] });
129
+ const svcs = await c.listServices();
130
+ assert.deepEqual(svcs.map((s) => s.name).sort(), ["svc-a", "svc-b"]);
131
+ assert.equal(svcs[0].labels.discoveredVia, "container");
132
+ });
133
+ it("returns empty when no candidate label has values", async () => {
134
+ const c = withLabelValues({});
135
+ assert.deepEqual(await c.listServices(), []);
136
+ });
137
+ });
111
138
  });
@@ -1,9 +1,12 @@
1
1
  import type { ObservabilityConnector } from "./interface.js";
2
2
  import type { Config, ConnectorHealth, SignalType, SourceConfig } from "../types.js";
3
+ import { type PluginLoader } from "./loader.js";
3
4
  export declare function getSupportedTypes(): string[];
4
5
  export declare class ConnectorRegistry {
5
6
  private connectors;
6
7
  private sourceConfigs;
8
+ private loader;
9
+ constructor(loader?: PluginLoader);
7
10
  initialize(config: Config): Promise<void>;
8
11
  private connectSource;
9
12
  addSource(source: SourceConfig): Promise<void>;
@@ -1,15 +1,15 @@
1
- import { PrometheusConnector } from "./prometheus.js";
2
- import { LokiConnector } from "./loki.js";
3
- const connectorFactories = {
4
- prometheus: () => new PrometheusConnector(),
5
- loki: () => new LokiConnector(),
6
- };
1
+ import { getPluginLoader } from "./loader.js";
2
+ import { sanitizeForLog } from "../util/sanitize.js";
7
3
  export function getSupportedTypes() {
8
- return Object.keys(connectorFactories);
4
+ return getPluginLoader().supportedTypes();
9
5
  }
10
6
  export class ConnectorRegistry {
11
7
  connectors = new Map();
12
8
  sourceConfigs = new Map();
9
+ loader;
10
+ constructor(loader = getPluginLoader()) {
11
+ this.loader = loader;
12
+ }
13
13
  async initialize(config) {
14
14
  for (const source of config.sources) {
15
15
  this.sourceConfigs.set(source.name, source);
@@ -19,19 +19,20 @@ export class ConnectorRegistry {
19
19
  }
20
20
  }
21
21
  async connectSource(source) {
22
- const factory = connectorFactories[source.type];
23
- if (!factory) {
24
- console.warn(`Unknown connector type: ${source.type}, skipping ${source.name}`);
22
+ const connector = this.loader.create(source.type);
23
+ const safeName = sanitizeForLog(source.name);
24
+ const safeType = sanitizeForLog(source.type);
25
+ if (!connector) {
26
+ console.warn("Unknown connector type: %s, skipping %s", safeType, safeName);
25
27
  return;
26
28
  }
27
- const connector = factory();
28
29
  try {
29
30
  await connector.connect(source);
30
31
  this.connectors.set(source.name, connector);
31
- console.log(`Connector "${source.name}" (${source.type}) connected`);
32
+ console.log('Connector "%s" (%s) connected', safeName, safeType);
32
33
  }
33
34
  catch (err) {
34
- console.error(`Failed to connect "${source.name}":`, err);
35
+ console.error('Failed to connect "%s":', safeName, err);
35
36
  }
36
37
  }
37
38
  async addSource(source) {
@@ -53,11 +54,10 @@ export class ConnectorRegistry {
53
54
  await this.addSource(source);
54
55
  }
55
56
  async testConnection(source) {
56
- const factory = connectorFactories[source.type];
57
- if (!factory) {
57
+ const connector = this.loader.create(source.type);
58
+ if (!connector) {
58
59
  return { status: "down", latencyMs: 0, message: `Unknown type: ${source.type}` };
59
60
  }
60
- const connector = factory();
61
61
  try {
62
62
  await connector.connect(source);
63
63
  const health = await connector.healthCheck();
@@ -1,11 +1,11 @@
1
1
  import { describe, it, before, after } from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import { Agent } from "node:https";
4
- import { writeFileSync, mkdirSync, rmSync } from "node:fs";
4
+ import { writeFileSync, mkdtempSync, rmSync } from "node:fs";
5
5
  import { join } from "node:path";
6
6
  import { tmpdir } from "node:os";
7
7
  import { buildTlsAgent } from "./tls.js";
8
- const TMP_DIR = join(tmpdir(), "tls-test-" + Date.now());
8
+ let TMP_DIR;
9
9
  function makeConfig(overrides = {}) {
10
10
  return { name: "test", type: "prometheus", url: "https://localhost:9090", enabled: true, ...overrides };
11
11
  }
@@ -38,7 +38,7 @@ describe("buildTlsAgent", () => {
38
38
  });
39
39
  describe("with certificate files", () => {
40
40
  before(() => {
41
- mkdirSync(TMP_DIR, { recursive: true });
41
+ TMP_DIR = mkdtempSync(join(tmpdir(), "tls-test-"));
42
42
  writeFileSync(join(TMP_DIR, "ca.pem"), "-----BEGIN CERTIFICATE-----\nfake-ca\n-----END CERTIFICATE-----\n");
43
43
  writeFileSync(join(TMP_DIR, "client.pem"), "-----BEGIN CERTIFICATE-----\nfake-client\n-----END CERTIFICATE-----\n");
44
44
  writeFileSync(join(TMP_DIR, "client-key.pem"), "-----BEGIN PRIVATE KEY-----\nfake-key\n-----END PRIVATE KEY-----\n");
@@ -0,0 +1,19 @@
1
+ import { type KeyObject } from "node:crypto";
2
+ export declare class PluginVerificationError extends Error {
3
+ }
4
+ /** Parse a PEM public key into a KeyObject. Throws PluginVerificationError. */
5
+ export declare function loadTrustRoot(pemPath: string): KeyObject;
6
+ /** "sha256-<base64>" digest of a buffer, matching the manifest `integrity` form. */
7
+ export declare function sha256Integrity(data: Buffer): string;
8
+ /**
9
+ * Constant-time-ish compare of the entry file against the manifest's
10
+ * declared integrity digest. Throws on mismatch.
11
+ */
12
+ export declare function verifyIntegrity(entryPath: string, integrity: string | undefined): void;
13
+ /**
14
+ * Verify a detached signature over the raw manifest bytes against the
15
+ * trust root. Signature is read as base64 (whitespace tolerated, e.g. a
16
+ * `manifest.json.sig` produced by `openssl dgst -sign ... | base64`).
17
+ * Throws PluginVerificationError if the signature does not verify.
18
+ */
19
+ export declare function verifyManifestSignature(manifestBytes: Buffer, signatureBytes: Buffer, trustRoot: KeyObject): void;
@@ -0,0 +1,87 @@
1
+ import { createHash, createPublicKey, verify as cryptoVerify } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+ // Airgapped-friendly plugin verification.
4
+ //
5
+ // No network, no external binary (no cosign): a plugin is trusted when
6
+ // 1. its entry file hashes to the manifest's `integrity` digest, and
7
+ // 2. the manifest bytes carry a detached signature that verifies
8
+ // against a locally-configured trust-root public key.
9
+ //
10
+ // Ed25519 and RSA/EC (PKCS#8 / SPKI PEM) trust roots are supported.
11
+ export class PluginVerificationError extends Error {
12
+ }
13
+ /** Parse a PEM public key into a KeyObject. Throws PluginVerificationError. */
14
+ export function loadTrustRoot(pemPath) {
15
+ let pem;
16
+ try {
17
+ pem = readFileSync(pemPath, "utf8");
18
+ }
19
+ catch (err) {
20
+ throw new PluginVerificationError(`trust root unreadable at ${pemPath}: ${String(err)}`);
21
+ }
22
+ try {
23
+ return createPublicKey(pem);
24
+ }
25
+ catch (err) {
26
+ throw new PluginVerificationError(`trust root is not a valid PEM public key: ${String(err)}`);
27
+ }
28
+ }
29
+ /** "sha256-<base64>" digest of a buffer, matching the manifest `integrity` form. */
30
+ export function sha256Integrity(data) {
31
+ return "sha256-" + createHash("sha256").update(data).digest("base64");
32
+ }
33
+ /**
34
+ * Constant-time-ish compare of the entry file against the manifest's
35
+ * declared integrity digest. Throws on mismatch.
36
+ */
37
+ export function verifyIntegrity(entryPath, integrity) {
38
+ if (!integrity) {
39
+ throw new PluginVerificationError("manifest has no integrity digest");
40
+ }
41
+ let bytes;
42
+ try {
43
+ bytes = readFileSync(entryPath);
44
+ }
45
+ catch (err) {
46
+ throw new PluginVerificationError(`entry file unreadable: ${String(err)}`);
47
+ }
48
+ const actual = sha256Integrity(bytes);
49
+ if (actual.length !== integrity.length || actual !== integrity) {
50
+ throw new PluginVerificationError(`entry file integrity mismatch (manifest=${integrity} actual=${actual})`);
51
+ }
52
+ }
53
+ /**
54
+ * Verify a detached signature over the raw manifest bytes against the
55
+ * trust root. Signature is read as base64 (whitespace tolerated, e.g. a
56
+ * `manifest.json.sig` produced by `openssl dgst -sign ... | base64`).
57
+ * Throws PluginVerificationError if the signature does not verify.
58
+ */
59
+ export function verifyManifestSignature(manifestBytes, signatureBytes, trustRoot) {
60
+ const sig = decodeSignature(signatureBytes);
61
+ // Ed25519/Ed448 take algorithm=null; RSA/EC sign over a SHA-256 digest.
62
+ const keyType = trustRoot.asymmetricKeyType;
63
+ const algorithm = keyType === "ed25519" || keyType === "ed448" ? null : "sha256";
64
+ let ok = false;
65
+ try {
66
+ ok = cryptoVerify(algorithm, manifestBytes, trustRoot, sig);
67
+ }
68
+ catch (err) {
69
+ throw new PluginVerificationError(`signature verification errored: ${String(err)}`);
70
+ }
71
+ if (!ok) {
72
+ throw new PluginVerificationError("manifest signature does not match trust root");
73
+ }
74
+ }
75
+ // Accept either raw DER bytes or a base64/armored .sig file.
76
+ function decodeSignature(raw) {
77
+ const text = raw.toString("utf8").trim();
78
+ if (/^[A-Za-z0-9+/\s=]+$/.test(text) && text.length > 0) {
79
+ const compact = text.replace(/\s+/g, "");
80
+ const b = Buffer.from(compact, "base64");
81
+ // Round-trip check: if it re-encodes cleanly it was really base64.
82
+ if (b.length > 0 && b.toString("base64").replace(/=+$/, "") === compact.replace(/=+$/, "")) {
83
+ return b;
84
+ }
85
+ }
86
+ return raw;
87
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,63 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { generateKeyPairSync, sign as cryptoSign, createPublicKey } from "node:crypto";
4
+ import { mkdtempSync, writeFileSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { sha256Integrity, verifyIntegrity, verifyManifestSignature, loadTrustRoot, PluginVerificationError, } from "./verify.js";
8
+ function tmp() {
9
+ return mkdtempSync(join(tmpdir(), "verify-"));
10
+ }
11
+ test("sha256Integrity produces sha256-<base64> and round-trips", () => {
12
+ const digest = sha256Integrity(Buffer.from("hello"));
13
+ assert.match(digest, /^sha256-[A-Za-z0-9+/]+=*$/);
14
+ assert.equal(sha256Integrity(Buffer.from("hello")), digest);
15
+ assert.notEqual(sha256Integrity(Buffer.from("hellp")), digest);
16
+ });
17
+ test("verifyIntegrity passes on match, throws on mismatch and missing", () => {
18
+ const dir = tmp();
19
+ const entry = join(dir, "index.js");
20
+ writeFileSync(entry, "export default () => ({});\n");
21
+ const good = sha256Integrity(Buffer.from("export default () => ({});\n"));
22
+ assert.doesNotThrow(() => verifyIntegrity(entry, good));
23
+ assert.throws(() => verifyIntegrity(entry, "sha256-AAAA"), PluginVerificationError);
24
+ assert.throws(() => verifyIntegrity(entry, undefined), PluginVerificationError);
25
+ assert.throws(() => verifyIntegrity(join(dir, "nope.js"), good), PluginVerificationError);
26
+ });
27
+ test("Ed25519: valid signature verifies, tampered manifest is rejected", () => {
28
+ const { publicKey, privateKey } = generateKeyPairSync("ed25519");
29
+ const manifest = Buffer.from('{"name":"prometheus","schemaVersion":1}');
30
+ const sig = cryptoSign(null, manifest, privateKey);
31
+ assert.doesNotThrow(() => verifyManifestSignature(manifest, sig, publicKey));
32
+ // base64-armored signature (the form `openssl ... | base64` emits)
33
+ const armored = Buffer.from(sig.toString("base64"));
34
+ assert.doesNotThrow(() => verifyManifestSignature(manifest, armored, publicKey));
35
+ // tampered bytes
36
+ assert.throws(() => verifyManifestSignature(Buffer.from('{"name":"evil"}'), sig, publicKey), PluginVerificationError);
37
+ });
38
+ test("RSA trust root path (algorithm=sha256) verifies", () => {
39
+ const { publicKey, privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
40
+ const manifest = Buffer.from('{"name":"loki"}');
41
+ const sig = cryptoSign("sha256", manifest, privateKey);
42
+ assert.doesNotThrow(() => verifyManifestSignature(manifest, sig, publicKey));
43
+ });
44
+ test("signature from a different key is rejected", () => {
45
+ const a = generateKeyPairSync("ed25519");
46
+ const b = generateKeyPairSync("ed25519");
47
+ const manifest = Buffer.from("payload");
48
+ const sig = cryptoSign(null, manifest, a.privateKey);
49
+ assert.throws(() => verifyManifestSignature(manifest, sig, b.publicKey), PluginVerificationError);
50
+ });
51
+ test("loadTrustRoot parses PEM and rejects garbage", () => {
52
+ const dir = tmp();
53
+ const { publicKey } = generateKeyPairSync("ed25519");
54
+ const pem = publicKey.export({ type: "spki", format: "pem" });
55
+ const p = join(dir, "trust.pem");
56
+ writeFileSync(p, pem);
57
+ const loaded = loadTrustRoot(p);
58
+ assert.equal(loaded.export({ type: "spki", format: "pem" }), createPublicKey(pem).export({ type: "spki", format: "pem" }));
59
+ const bad = join(dir, "bad.pem");
60
+ writeFileSync(bad, "not a key");
61
+ assert.throws(() => loadTrustRoot(bad), PluginVerificationError);
62
+ assert.throws(() => loadTrustRoot(join(dir, "missing.pem")), PluginVerificationError);
63
+ });