@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.
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +370 -0
- package/dist/cli/lib.d.ts +95 -0
- package/dist/cli/lib.js +185 -0
- package/dist/cli/lib.test.d.ts +1 -0
- package/dist/cli/lib.test.js +134 -0
- package/dist/config/loader.test.js +3 -3
- package/dist/connectors/hub.d.ts +48 -0
- package/dist/connectors/hub.js +51 -0
- package/dist/connectors/hub.test.d.ts +1 -0
- package/dist/connectors/hub.test.js +52 -0
- package/dist/connectors/install.d.ts +24 -0
- package/dist/connectors/install.js +100 -0
- package/dist/connectors/install.test.d.ts +1 -0
- package/dist/connectors/install.test.js +58 -0
- package/dist/connectors/loader.d.ts +48 -0
- package/dist/connectors/loader.js +222 -0
- package/dist/connectors/loki.js +14 -6
- package/dist/connectors/loki.test.js +27 -0
- package/dist/connectors/registry.d.ts +3 -0
- package/dist/connectors/registry.js +16 -16
- package/dist/connectors/tls.test.js +3 -3
- package/dist/connectors/verify.d.ts +19 -0
- package/dist/connectors/verify.js +87 -0
- package/dist/connectors/verify.test.d.ts +1 -0
- package/dist/connectors/verify.test.js +63 -0
- package/dist/index.js +389 -26
- package/dist/metrics/instrument-connector.d.ts +8 -0
- package/dist/metrics/instrument-connector.js +41 -0
- package/dist/metrics/self.d.ts +12 -0
- package/dist/metrics/self.js +61 -0
- package/dist/openapi.d.ts +2 -0
- package/dist/openapi.js +186 -0
- package/dist/sdk/index.d.ts +52 -0
- package/dist/sdk/index.js +13 -0
- package/dist/sdk/manifest-schema.d.ts +28 -0
- package/dist/sdk/manifest-schema.js +47 -0
- package/dist/sdk/manifest-schema.test.d.ts +1 -0
- package/dist/sdk/manifest-schema.test.js +50 -0
- package/dist/tools/get-service-health.js +3 -2
- package/dist/ui/index.html +687 -115
- package/dist/util/sanitize.d.ts +1 -0
- package/dist/util/sanitize.js +6 -0
- 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
|
+
}
|
package/dist/connectors/loki.js
CHANGED
|
@@ -54,13 +54,19 @@ export class LokiConnector {
|
|
|
54
54
|
}
|
|
55
55
|
async disconnect() { }
|
|
56
56
|
async listServices() {
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
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
|
|
204
|
-
|
|
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 {
|
|
2
|
-
import {
|
|
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
|
|
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
|
|
23
|
-
|
|
24
|
-
|
|
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(
|
|
32
|
+
console.log('Connector "%s" (%s) connected', safeName, safeType);
|
|
32
33
|
}
|
|
33
34
|
catch (err) {
|
|
34
|
-
console.error(
|
|
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
|
|
57
|
-
if (!
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|