@thotischner/observability-mcp 3.0.1 → 3.1.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/analysis/history.d.ts +36 -2
- package/dist/analysis/history.js +60 -2
- package/dist/analysis/history.test.js +46 -0
- package/dist/auth/csrf.d.ts +6 -0
- package/dist/auth/csrf.js +4 -0
- package/dist/auth/csrf.test.js +22 -0
- package/dist/auth/lockout.d.ts +72 -0
- package/dist/auth/lockout.js +134 -0
- package/dist/auth/lockout.test.d.ts +1 -0
- package/dist/auth/lockout.test.js +133 -0
- package/dist/auth/middleware.d.ts +5 -0
- package/dist/auth/middleware.js +6 -1
- package/dist/auth/middleware.test.js +31 -0
- package/dist/auth/password-policy.d.ts +52 -0
- package/dist/auth/password-policy.js +125 -0
- package/dist/auth/password-policy.test.d.ts +1 -0
- package/dist/auth/password-policy.test.js +111 -0
- package/dist/auth/revocation.d.ts +93 -0
- package/dist/auth/revocation.js +193 -0
- package/dist/auth/revocation.test.d.ts +1 -0
- package/dist/auth/revocation.test.js +136 -0
- package/dist/auth/session.d.ts +7 -0
- package/dist/auth/session.js +6 -0
- package/dist/auth/session.test.js +21 -0
- package/dist/conformance/mcp-2025-11-25.test.js +14 -0
- package/dist/connectors/interface.d.ts +5 -1
- package/dist/connectors/loki.d.ts +45 -1
- package/dist/connectors/loki.js +141 -8
- package/dist/connectors/loki.test.js +171 -1
- package/dist/index.js +244 -4
- package/dist/openapi.js +39 -0
- package/dist/openapi.test.js +1 -0
- package/dist/security/csp.d.ts +64 -0
- package/dist/security/csp.js +135 -0
- package/dist/security/csp.test.d.ts +1 -0
- package/dist/security/csp.test.js +97 -0
- package/dist/tools/query-logs-schema.test.d.ts +1 -0
- package/dist/tools/query-logs-schema.test.js +38 -0
- package/dist/tools/query-logs.d.ts +40 -0
- package/dist/tools/query-logs.js +69 -3
- package/dist/tools/validation.d.ts +13 -0
- package/dist/tools/validation.js +74 -0
- package/dist/tools/validation.test.js +54 -1
- package/dist/types.d.ts +48 -0
- package/dist/ui/index.html +42 -15
- package/package.json +1 -1
|
@@ -1,7 +1,177 @@
|
|
|
1
1
|
import { describe, it } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import { LokiConnector } from "./loki.js";
|
|
3
|
+
import { LokiConnector, logqlLabelFilters, levelFromStatus, escapeLogQLValue, buildAggregateLogQL, parseDurationSeconds, defaultBucketSeconds, } from "./loki.js";
|
|
4
4
|
const proto = LokiConnector.prototype;
|
|
5
|
+
function jsonRes(obj) {
|
|
6
|
+
return { ok: true, status: 200, statusText: "OK", json: async () => obj, text: async () => "" };
|
|
7
|
+
}
|
|
8
|
+
describe("Q-LOG1: logqlLabelFilters", () => {
|
|
9
|
+
it("returns empty string for undefined / empty", () => {
|
|
10
|
+
assert.equal(logqlLabelFilters(undefined), "");
|
|
11
|
+
assert.equal(logqlLabelFilters({}), "");
|
|
12
|
+
});
|
|
13
|
+
it("compiles a single filter", () => {
|
|
14
|
+
assert.equal(logqlLabelFilters({ method: "GET" }), ' | method="GET"');
|
|
15
|
+
});
|
|
16
|
+
it("compiles multiple filters, keys sorted for determinism", () => {
|
|
17
|
+
assert.equal(logqlLabelFilters({ status: "200", method: "GET", url: "/" }), ' | method="GET" | status="200" | url="/"');
|
|
18
|
+
});
|
|
19
|
+
it("escapes double quotes and backslashes in values", () => {
|
|
20
|
+
assert.equal(logqlLabelFilters({ path: 'a"b\\c' }), ' | path="a\\"b\\\\c"');
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
describe("Q-LOG1: levelFromStatus", () => {
|
|
24
|
+
it("maps 5xx → error", () => {
|
|
25
|
+
assert.equal(levelFromStatus(500), "error");
|
|
26
|
+
assert.equal(levelFromStatus("503"), "error");
|
|
27
|
+
assert.equal(levelFromStatus(599), "error");
|
|
28
|
+
});
|
|
29
|
+
it("maps 4xx → warn", () => {
|
|
30
|
+
assert.equal(levelFromStatus(404), "warn");
|
|
31
|
+
assert.equal(levelFromStatus("400"), "warn");
|
|
32
|
+
});
|
|
33
|
+
it("returns undefined for 2xx/3xx and non-numeric", () => {
|
|
34
|
+
assert.equal(levelFromStatus(200), undefined);
|
|
35
|
+
assert.equal(levelFromStatus(301), undefined);
|
|
36
|
+
assert.equal(levelFromStatus("abc"), undefined);
|
|
37
|
+
assert.equal(levelFromStatus(undefined), undefined);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
describe("Q-LOG1: escapeLogQLValue", () => {
|
|
41
|
+
it("escapes backslash then quote", () => {
|
|
42
|
+
assert.equal(escapeLogQLValue('he said "hi"\\'), 'he said \\"hi\\"\\\\');
|
|
43
|
+
});
|
|
44
|
+
it("escapes control chars (newline/return/tab) into LogQL escape sequences", () => {
|
|
45
|
+
assert.equal(escapeLogQLValue("a\nb\rc\td"), "a\\nb\\rc\\td");
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
describe("Q-LOG1: queryLogs LogQL assembly", () => {
|
|
49
|
+
async function captureQuery(params) {
|
|
50
|
+
const conn = new LokiConnector();
|
|
51
|
+
await conn.connect({ name: "loki", type: "loki", url: "http://loki:3100", enabled: true });
|
|
52
|
+
let captured = "";
|
|
53
|
+
const orig = globalThis.fetch;
|
|
54
|
+
globalThis.fetch = (async (url) => {
|
|
55
|
+
const u = String(url);
|
|
56
|
+
if (u.includes("/label/") && u.includes("/values"))
|
|
57
|
+
return jsonRes({ data: ["payment"] });
|
|
58
|
+
if (u.includes("/query_range")) {
|
|
59
|
+
captured = decodeURIComponent((u.match(/query=([^&]+)/) || [])[1] || "");
|
|
60
|
+
return jsonRes({ data: { result: [] } });
|
|
61
|
+
}
|
|
62
|
+
return jsonRes({ data: [] });
|
|
63
|
+
});
|
|
64
|
+
try {
|
|
65
|
+
await conn.queryLogs({ service: "payment", duration: "5m", ...params });
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
globalThis.fetch = orig;
|
|
69
|
+
}
|
|
70
|
+
return captured;
|
|
71
|
+
}
|
|
72
|
+
it("AND's label filters after | json, with level and line filter", async () => {
|
|
73
|
+
const q = await captureQuery({ level: "error", labels: { method: "GET", status: "200" }, query: "timeout" });
|
|
74
|
+
assert.equal(q, '{service_name="payment"} | json | level="error" | method="GET" | status="200" |~ `timeout`');
|
|
75
|
+
});
|
|
76
|
+
it("works with labels only (no level/query)", async () => {
|
|
77
|
+
const q = await captureQuery({ labels: { environment: "prod" } });
|
|
78
|
+
assert.equal(q, '{service_name="payment"} | json | environment="prod"');
|
|
79
|
+
});
|
|
80
|
+
it("plain query (no labels) is unchanged from prior behaviour", async () => {
|
|
81
|
+
const q = await captureQuery({});
|
|
82
|
+
assert.equal(q, '{service_name="payment"} | json');
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
describe("Q-LOG2: parseDurationSeconds / defaultBucketSeconds", () => {
|
|
86
|
+
it("parses m/h/d", () => {
|
|
87
|
+
assert.equal(parseDurationSeconds("5m"), 300);
|
|
88
|
+
assert.equal(parseDurationSeconds("2h"), 7200);
|
|
89
|
+
assert.equal(parseDurationSeconds("1d"), 86400);
|
|
90
|
+
assert.equal(parseDurationSeconds("bad"), null);
|
|
91
|
+
});
|
|
92
|
+
it("buckets to ~60 points, floored at 60s", () => {
|
|
93
|
+
assert.equal(defaultBucketSeconds(3600), 60); // 1h → 60s
|
|
94
|
+
assert.equal(defaultBucketSeconds(86400), 1440); // 24h → 1440s
|
|
95
|
+
assert.equal(defaultBucketSeconds(60), 60); // tiny window floors at 60s
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
describe("Q-LOG2: buildAggregateLogQL", () => {
|
|
99
|
+
const PIPE = '{service_name="app"} | json | method="GET"';
|
|
100
|
+
it("count_over_time with by → sum by + range mode + step", () => {
|
|
101
|
+
const r = buildAggregateLogQL(PIPE, { op: "count_over_time", by: ["url"], step: "15m" }, "1h");
|
|
102
|
+
assert.equal(r.mode, "range");
|
|
103
|
+
assert.equal(r.step, "900s");
|
|
104
|
+
assert.equal(r.logql, `sum by (url) (count_over_time(${PIPE} [900s]))`);
|
|
105
|
+
});
|
|
106
|
+
it("count_over_time without by → bare count_over_time, default step", () => {
|
|
107
|
+
const r = buildAggregateLogQL(PIPE, { op: "count_over_time" }, "1h");
|
|
108
|
+
assert.equal(r.mode, "range");
|
|
109
|
+
assert.equal(r.step, "60s");
|
|
110
|
+
assert.equal(r.logql, `count_over_time(${PIPE} [60s])`);
|
|
111
|
+
});
|
|
112
|
+
it("sum → instant total per group over the whole window", () => {
|
|
113
|
+
const r = buildAggregateLogQL(PIPE, { op: "sum", by: ["status"] }, "1h");
|
|
114
|
+
assert.equal(r.mode, "instant");
|
|
115
|
+
assert.equal(r.logql, `sum by (status) (count_over_time(${PIPE} [3600s]))`);
|
|
116
|
+
});
|
|
117
|
+
it("topk → instant topk(k, sum by) with default k=10", () => {
|
|
118
|
+
const r = buildAggregateLogQL(PIPE, { op: "topk", by: ["url"] }, "1h");
|
|
119
|
+
assert.equal(r.mode, "instant");
|
|
120
|
+
assert.equal(r.logql, `topk(10, sum by (url) (count_over_time(${PIPE} [3600s])))`);
|
|
121
|
+
});
|
|
122
|
+
it("topk honours explicit k", () => {
|
|
123
|
+
const r = buildAggregateLogQL(PIPE, { op: "topk", by: ["url"], k: 3 }, "30m");
|
|
124
|
+
assert.equal(r.logql, `topk(3, sum by (url) (count_over_time(${PIPE} [1800s])))`);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
describe("Q-LOG2: queryLogAggregate", () => {
|
|
128
|
+
async function run(agg) {
|
|
129
|
+
const conn = new LokiConnector();
|
|
130
|
+
await conn.connect({ name: "loki", type: "loki", url: "http://loki:3100", enabled: true });
|
|
131
|
+
let capturedUrl = "";
|
|
132
|
+
const orig = globalThis.fetch;
|
|
133
|
+
globalThis.fetch = (async (url) => {
|
|
134
|
+
const u = String(url);
|
|
135
|
+
if (u.includes("/label/") && u.includes("/values"))
|
|
136
|
+
return jsonRes({ data: ["app"] });
|
|
137
|
+
if (u.includes("/query_range")) {
|
|
138
|
+
capturedUrl = u;
|
|
139
|
+
return jsonRes({ data: { resultType: "matrix", result: [
|
|
140
|
+
{ metric: { url: "/" }, values: [[1000, "3"], [1060, "5"]] },
|
|
141
|
+
] } });
|
|
142
|
+
}
|
|
143
|
+
if (u.includes("/query")) {
|
|
144
|
+
capturedUrl = u;
|
|
145
|
+
return jsonRes({ data: { resultType: "vector", result: [
|
|
146
|
+
{ metric: { url: "/a" }, value: [2000, "7"] },
|
|
147
|
+
{ metric: { url: "/b" }, value: [2000, "12"] },
|
|
148
|
+
] } });
|
|
149
|
+
}
|
|
150
|
+
return jsonRes({ data: [] });
|
|
151
|
+
});
|
|
152
|
+
try {
|
|
153
|
+
return await conn.queryLogAggregate({ service: "app", duration: "1h", ...agg });
|
|
154
|
+
}
|
|
155
|
+
finally {
|
|
156
|
+
globalThis.fetch = orig;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
it("topk → instant vector parsed + sorted desc, note set", async () => {
|
|
160
|
+
const res = await run({ op: "topk", by: ["url"], k: 2 });
|
|
161
|
+
assert.equal(res.mode, "instant");
|
|
162
|
+
assert.equal(res.op, "topk");
|
|
163
|
+
assert.deepEqual(res.by, ["url"]);
|
|
164
|
+
assert.deepEqual(res.series.map((s) => [s.labels.url, s.value]), [["/b", 12], ["/a", 7]]);
|
|
165
|
+
assert.match(res.note, /limit/);
|
|
166
|
+
});
|
|
167
|
+
it("count_over_time → range matrix parsed into points", async () => {
|
|
168
|
+
const res = await run({ op: "count_over_time", by: ["url"], step: "1m" });
|
|
169
|
+
assert.equal(res.mode, "range");
|
|
170
|
+
assert.equal(res.step, "60s");
|
|
171
|
+
assert.equal(res.series.length, 1);
|
|
172
|
+
assert.deepEqual(res.series[0].points, [{ t: 1000000, value: 3 }, { t: 1060000, value: 5 }]);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
5
175
|
describe("LokiConnector", () => {
|
|
6
176
|
describe("parseLine", () => {
|
|
7
177
|
it("parses valid JSON", () => {
|
package/dist/index.js
CHANGED
|
@@ -19,6 +19,10 @@ import { buildSessionAttacher, buildRequireSession, } from "./auth/middleware.js
|
|
|
19
19
|
import { buildRequirePermissionFromEngine, hasPermission, listGrantedPermissions, DEFAULT_POLICY, } from "./auth/rbac.js";
|
|
20
20
|
import { resolveOidcConfig, buildOidcRuntime } from "./auth/oidc/runtime.js";
|
|
21
21
|
import { registerOidcRoutes } from "./auth/oidc/endpoints.js";
|
|
22
|
+
import { RevocationStore } from "./auth/revocation.js";
|
|
23
|
+
import { AccountLockout, lockoutConfigFromEnv, lockoutDisabledFromEnv, } from "./auth/lockout.js";
|
|
24
|
+
import { resolveSessionStore } from "./transport/sessionStore.js";
|
|
25
|
+
import { generateNonce, enforcedCsp, reportOnlyCsp, reportingEndpointsHeader, reportToHeader, summariseViolation, cspStrictReportFromEnv, CSP_NONCE_PLACEHOLDER, } from "./security/csp.js";
|
|
22
26
|
import { createScimStore } from "./scim/store.js";
|
|
23
27
|
import { registerScimRoutes } from "./scim/routes.js";
|
|
24
28
|
import { BuiltinPolicyEngine } from "./auth/policy/engine.js";
|
|
@@ -433,6 +437,10 @@ async function main() {
|
|
|
433
437
|
.string()
|
|
434
438
|
.optional()
|
|
435
439
|
.describe("Optional. Filter expression matched against the log message; regular expressions are supported. Omit to return all entries in the window."),
|
|
440
|
+
labels: z
|
|
441
|
+
.record(z.string(), z.string())
|
|
442
|
+
.optional()
|
|
443
|
+
.describe("Optional. Exact-match filters on backend-extracted log fields (e.g. {\"method\":\"GET\",\"status\":\"200\",\"url\":\"/\",\"environment\":\"prod\"}). All AND'd together and compiled to LogQL label filters applied after `| json`, so structured JSON fields become first-class selectors — far more reliable than regex on the raw message. Combine with `aggregate` to filter then group. Backends without label extraction ignore it."),
|
|
436
444
|
duration: z
|
|
437
445
|
.string()
|
|
438
446
|
.optional()
|
|
@@ -441,12 +449,34 @@ async function main() {
|
|
|
441
449
|
.enum(["error", "warn", "info", "debug"])
|
|
442
450
|
.optional()
|
|
443
451
|
.describe("Optional. Return only entries at this severity. Default: all levels."),
|
|
452
|
+
aggregate: z
|
|
453
|
+
.object({
|
|
454
|
+
op: z
|
|
455
|
+
.enum(["count_over_time", "sum", "topk"])
|
|
456
|
+
.describe("count_over_time = time series of counts per `step` bucket; sum = single total per group over the window; topk = the top `k` groups by total."),
|
|
457
|
+
by: z
|
|
458
|
+
.array(z.string())
|
|
459
|
+
.optional()
|
|
460
|
+
.describe("Label names to group by, e.g. [\"url\"] or [\"status\"]. Required for topk."),
|
|
461
|
+
k: z
|
|
462
|
+
.number()
|
|
463
|
+
.int()
|
|
464
|
+
.positive()
|
|
465
|
+
.optional()
|
|
466
|
+
.describe("For topk: how many top groups to return (1-1000)."),
|
|
467
|
+
step: z
|
|
468
|
+
.string()
|
|
469
|
+
.optional()
|
|
470
|
+
.describe("For count_over_time: bucket width as <number><unit> m|h|d (e.g. '15m'). Default auto-derived from duration."),
|
|
471
|
+
})
|
|
472
|
+
.optional()
|
|
473
|
+
.describe("Optional. Server-side aggregation pushed down to LogQL metric queries — returns grouped counts, not raw rows, so you get a number instead of a haystack (and never hit `limit`). Honours `labels`/`query` filters. Example: {\"op\":\"topk\",\"by\":[\"url\"],\"k\":10} for the busiest paths; {\"op\":\"count_over_time\",\"step\":\"15m\"} for a request-count time series."),
|
|
444
474
|
limit: z
|
|
445
475
|
.number()
|
|
446
476
|
.int()
|
|
447
477
|
.positive()
|
|
448
478
|
.optional()
|
|
449
|
-
.describe("Optional. Maximum number of log entries to return (most recent first). Default: 100."),
|
|
479
|
+
.describe("Optional. Maximum number of log entries to return (most recent first). Default: 100. Ignored when `aggregate` is set."),
|
|
450
480
|
bypass_redaction: z
|
|
451
481
|
.boolean()
|
|
452
482
|
.optional()
|
|
@@ -723,7 +753,19 @@ async function main() {
|
|
|
723
753
|
else if (requestedAuthMode !== "anonymous") {
|
|
724
754
|
authMisconfig(`unknown OMCP_AUTH=${requestedAuthMode}`);
|
|
725
755
|
}
|
|
726
|
-
|
|
756
|
+
// Session revocation blocklist (Q17). Only meaningful when sessions
|
|
757
|
+
// exist (basic / oidc); anonymous mode leaves it undefined so the
|
|
758
|
+
// middleware check is a pure no-op. OMCP_AUTH_REVOCATION_FILE persists
|
|
759
|
+
// the blocklist across restarts and shares it across replicas when it
|
|
760
|
+
// points at shared storage; unset = in-memory only.
|
|
761
|
+
let revocationStore;
|
|
762
|
+
if (authMode !== "anonymous") {
|
|
763
|
+
revocationStore = await RevocationStore.create({
|
|
764
|
+
path: process.env.OMCP_AUTH_REVOCATION_FILE?.trim() || undefined,
|
|
765
|
+
});
|
|
766
|
+
console.log(`[auth] session revocation blocklist active — backend=${revocationStore.persistent ? `file (${revocationStore.filePath})` : "memory"}, ${revocationStore.size} existing entr${revocationStore.size === 1 ? "y" : "ies"}`);
|
|
767
|
+
}
|
|
768
|
+
const authRuntime = { mode: authMode, session: sessionCfg, secretEphemeral, oidc: oidcRuntime, revocation: revocationStore };
|
|
727
769
|
// --- HTTP server ---
|
|
728
770
|
const app = express();
|
|
729
771
|
// Trust-proxy: when set, Express will read req.ip / req.secure from
|
|
@@ -757,13 +799,38 @@ async function main() {
|
|
|
757
799
|
// without the wildcard the body silently arrives empty and every
|
|
758
800
|
// SCIM POST/PATCH 400s. The wildcard also future-proofs other
|
|
759
801
|
// structured-suffix JSON content types.
|
|
760
|
-
|
|
802
|
+
// application/csp-report is the legacy media type browsers use for CSP
|
|
803
|
+
// violation reports (the modern Reporting API uses application/reports+json,
|
|
804
|
+
// already covered by the wildcard). Without it the report body arrives empty.
|
|
805
|
+
app.use(express.json({ limit: "1mb", type: ["application/json", "application/*+json", "application/csp-report"] }));
|
|
806
|
+
// Q20 — resolve the opt-in strict Report-Only CSP toggle once at boot.
|
|
807
|
+
// Default off: with ~200 inline handlers the report-only policy would
|
|
808
|
+
// emit a [Report Only] console message per handler on every page load.
|
|
809
|
+
const cspStrictReport = cspStrictReportFromEnv();
|
|
810
|
+
if (cspStrictReport) {
|
|
811
|
+
console.log("[csp] strict report-only policy ON (OMCP_CSP_STRICT_REPORT) — inline-handler violations will be reported to /api/csp-violations");
|
|
812
|
+
}
|
|
761
813
|
// Security headers
|
|
762
814
|
app.use((req, res, next) => {
|
|
763
815
|
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
764
816
|
res.setHeader("X-Frame-Options", "DENY");
|
|
765
817
|
res.setHeader("X-XSS-Protection", "1; mode=block");
|
|
766
818
|
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
819
|
+
// Q20 — Content-Security-Policy. A per-request nonce is minted and
|
|
820
|
+
// stashed on res.locals so the UI handler can stamp it into the two
|
|
821
|
+
// inline <script> blocks. The enforced policy keeps the UI working
|
|
822
|
+
// (script-src 'unsafe-inline' for the ~200 inline handlers) and is
|
|
823
|
+
// always on; the strict report-only policy is opt-in (it surfaces the
|
|
824
|
+
// inline-handler debt but is console-noisy). Both report to
|
|
825
|
+
// /api/csp-violations.
|
|
826
|
+
const nonce = generateNonce();
|
|
827
|
+
res.locals.cspNonce = nonce;
|
|
828
|
+
res.setHeader("Content-Security-Policy", enforcedCsp());
|
|
829
|
+
if (cspStrictReport) {
|
|
830
|
+
res.setHeader("Content-Security-Policy-Report-Only", reportOnlyCsp(nonce));
|
|
831
|
+
}
|
|
832
|
+
res.setHeader("Reporting-Endpoints", reportingEndpointsHeader());
|
|
833
|
+
res.setHeader("Report-To", reportToHeader());
|
|
767
834
|
// Dynamic API responses must never be served from the browser/proxy
|
|
768
835
|
// cache: after a mutation (e.g. installing a connector) the UI
|
|
769
836
|
// re-fetches these GETs immediately, and a heuristically-cached stale
|
|
@@ -814,6 +881,11 @@ async function main() {
|
|
|
814
881
|
const csrfCfg = {
|
|
815
882
|
bypassBearer: csrfBypassFromEnv(),
|
|
816
883
|
secureCookie: (r) => r.secure || r.headers["x-forwarded-proto"] === "https",
|
|
884
|
+
// CSP violation reports are unauthenticated browser POSTs that by
|
|
885
|
+
// construction carry no cookie + no custom header — exempt them from
|
|
886
|
+
// CSRF. The endpoint only records a sanitised summary, so accepting it
|
|
887
|
+
// cross-site is harmless.
|
|
888
|
+
skip: (r) => r.method === "POST" && (r.path === "/api/csp-violations" || r.originalUrl.split("?")[0] === "/api/csp-violations"),
|
|
817
889
|
};
|
|
818
890
|
app.use(buildCsrfIssuer(csrfCfg));
|
|
819
891
|
app.use("/api", buildCsrfEnforcer(csrfCfg));
|
|
@@ -944,6 +1016,36 @@ async function main() {
|
|
|
944
1016
|
.catch((err) => console.warn("AuditLog flushSinks failed:", err));
|
|
945
1017
|
});
|
|
946
1018
|
const audit = (resource, action) => buildAuditMiddleware({ audit: mgmtAudit, resource, action });
|
|
1019
|
+
// Q20 — CSP violation report sink. Unauthenticated browser POST (exempt
|
|
1020
|
+
// from CSRF via csrfCfg.skip), tightly rate-limited so a misbehaving or
|
|
1021
|
+
// hostile client can't flood the audit log, and only a sanitised summary
|
|
1022
|
+
// (directive / blocked-uri / document-uri) is recorded. Always 204 so the
|
|
1023
|
+
// browser never retries. The report-only strict policy is what drives most
|
|
1024
|
+
// of these today (the inline-handler debt) — they roll into mgmtAudit so an
|
|
1025
|
+
// operator can watch the migration surface shrink.
|
|
1026
|
+
const cspReportRateLimit = rateLimit({
|
|
1027
|
+
windowMs: 60_000,
|
|
1028
|
+
max: 60,
|
|
1029
|
+
standardHeaders: true,
|
|
1030
|
+
legacyHeaders: false,
|
|
1031
|
+
message: { error: "rate limited" },
|
|
1032
|
+
});
|
|
1033
|
+
app.post("/api/csp-violations", cspReportRateLimit, (req, res) => {
|
|
1034
|
+
const summary = summariseViolation(req.body);
|
|
1035
|
+
if (summary) {
|
|
1036
|
+
void mgmtAudit.record({
|
|
1037
|
+
actor: { sub: "browser:csp" },
|
|
1038
|
+
tenant: "default",
|
|
1039
|
+
resource: "settings",
|
|
1040
|
+
action: "read",
|
|
1041
|
+
method: "POST",
|
|
1042
|
+
path: "/api/csp-violations",
|
|
1043
|
+
status: 204,
|
|
1044
|
+
target: `${summary.directive} blocked ${summary.blockedUri}`.slice(0, 256),
|
|
1045
|
+
}).catch(() => { });
|
|
1046
|
+
}
|
|
1047
|
+
res.status(204).end();
|
|
1048
|
+
});
|
|
947
1049
|
// Plugin lifecycle hook registry — populated by the loader at boot
|
|
948
1050
|
// (one entry per manifest `hooks[]` entry) and mutable at runtime
|
|
949
1051
|
// when a connector is installed via /api/connectors/install. Each
|
|
@@ -1106,7 +1208,29 @@ async function main() {
|
|
|
1106
1208
|
res.end(await selfRegistry.metrics());
|
|
1107
1209
|
});
|
|
1108
1210
|
}
|
|
1109
|
-
// Serve Web UI
|
|
1211
|
+
// Serve Web UI. The index page is served dynamically so the per-request
|
|
1212
|
+
// CSP nonce can be stamped into its inline <script> blocks (the rest of
|
|
1213
|
+
// ui/ stays on express.static). Read once at boot; if the file is
|
|
1214
|
+
// missing we fall through to static, which 404s like before.
|
|
1215
|
+
let uiHtmlTemplate = null;
|
|
1216
|
+
try {
|
|
1217
|
+
uiHtmlTemplate = readFileSync(join(__dirname, "ui", "index.html"), "utf8");
|
|
1218
|
+
}
|
|
1219
|
+
catch {
|
|
1220
|
+
uiHtmlTemplate = null;
|
|
1221
|
+
}
|
|
1222
|
+
if (uiHtmlTemplate) {
|
|
1223
|
+
const template = uiHtmlTemplate;
|
|
1224
|
+
const serveIndex = (_req, res) => {
|
|
1225
|
+
const nonce = res.locals.cspNonce ?? "";
|
|
1226
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
1227
|
+
// Index is identity/nonce-specific — never let a proxy cache it.
|
|
1228
|
+
res.setHeader("Cache-Control", "no-store");
|
|
1229
|
+
res.send(template.split(CSP_NONCE_PLACEHOLDER).join(nonce));
|
|
1230
|
+
};
|
|
1231
|
+
app.get("/", serveIndex);
|
|
1232
|
+
app.get("/index.html", serveIndex);
|
|
1233
|
+
}
|
|
1110
1234
|
app.use(express.static(join(__dirname, "ui")));
|
|
1111
1235
|
// --- API endpoints for Web UI ---
|
|
1112
1236
|
// List sources with health status — tenant-scoped.
|
|
@@ -1286,6 +1410,10 @@ async function main() {
|
|
|
1286
1410
|
},
|
|
1287
1411
|
permissions: listGrantedPermissions(sess.roles, policyEngineToMap(policyEngine)),
|
|
1288
1412
|
exp: sess.exp,
|
|
1413
|
+
// The current session's revocation id. Surfaced so an admin can
|
|
1414
|
+
// copy it into POST /api/auth/revocations to kill a specific
|
|
1415
|
+
// session. Absent for legacy cookies issued before sid existed.
|
|
1416
|
+
sid: sess.sid,
|
|
1289
1417
|
// When the user signed in via OIDC, surface the IdP issuer
|
|
1290
1418
|
// URL so the UI can render an appropriate badge or link to
|
|
1291
1419
|
// an IdP-side profile page. Empty / absent in basic mode.
|
|
@@ -1810,6 +1938,19 @@ async function main() {
|
|
|
1810
1938
|
catch { /* ignore — first login will pick it up */ }
|
|
1811
1939
|
}
|
|
1812
1940
|
}
|
|
1941
|
+
// Q18 — per-username failed-login lockout with progressive backoff.
|
|
1942
|
+
// Complements the per-IP loginRateLimit above: that bounds a noisy
|
|
1943
|
+
// single source, this bounds a slow / distributed grind on one
|
|
1944
|
+
// account. Backed by the shared SessionStore so a Redis deployment
|
|
1945
|
+
// locks consistently across replicas (and self-cleans via TTL).
|
|
1946
|
+
// Basic mode only — OIDC delegates auth (and lockout) to the IdP.
|
|
1947
|
+
let lockout;
|
|
1948
|
+
if (authRuntime.mode === "basic" && !lockoutDisabledFromEnv()) {
|
|
1949
|
+
const lockoutStore = await resolveSessionStore();
|
|
1950
|
+
const lockoutCfg = lockoutConfigFromEnv();
|
|
1951
|
+
lockout = new AccountLockout(lockoutStore, lockoutCfg);
|
|
1952
|
+
console.log(`[auth] account lockout active — ${lockoutCfg.maxFailures} failures / ${lockoutCfg.windowSeconds}s → lock ${lockoutCfg.baseLockSeconds}s (×2 up to ${lockoutCfg.maxLockSeconds}s), backend=${lockoutStore.backend}`);
|
|
1953
|
+
}
|
|
1813
1954
|
app.post("/api/auth/login", loginRateLimit, async (req, res) => {
|
|
1814
1955
|
if (authRuntime.mode !== "basic" || !sessionCfg || !usersStore) {
|
|
1815
1956
|
res.status(503).json({ error: "auth mode does not accept logins" });
|
|
@@ -1823,11 +1964,57 @@ async function main() {
|
|
|
1823
1964
|
res.status(400).json({ error: "username and password are required" });
|
|
1824
1965
|
return;
|
|
1825
1966
|
}
|
|
1967
|
+
// Gate on the lock BEFORE the (expensive) scrypt verify so a locked
|
|
1968
|
+
// account can't be used to burn CPU. A locked account is a 429 with
|
|
1969
|
+
// Retry-After, never a credential oracle — the response is identical
|
|
1970
|
+
// whether or not the username exists.
|
|
1971
|
+
if (lockout) {
|
|
1972
|
+
const status = await lockout.check(username);
|
|
1973
|
+
if (status.locked) {
|
|
1974
|
+
res.setHeader("Retry-After", String(status.retryAfterSeconds ?? 0));
|
|
1975
|
+
res.status(429).json({
|
|
1976
|
+
error: "account temporarily locked due to repeated failed logins",
|
|
1977
|
+
retryAfterSeconds: status.retryAfterSeconds,
|
|
1978
|
+
});
|
|
1979
|
+
void mgmtAudit.record({
|
|
1980
|
+
actor: { sub: username },
|
|
1981
|
+
tenant: "default",
|
|
1982
|
+
resource: "users",
|
|
1983
|
+
action: "write",
|
|
1984
|
+
method: "POST",
|
|
1985
|
+
path: "/api/auth/login",
|
|
1986
|
+
status: 429,
|
|
1987
|
+
}).catch(() => { });
|
|
1988
|
+
return;
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1826
1991
|
const user = authenticate(username, password, usersStore);
|
|
1827
1992
|
if (!user) {
|
|
1993
|
+
if (lockout) {
|
|
1994
|
+
const after = await lockout.recordFailure(username);
|
|
1995
|
+
if (after.locked) {
|
|
1996
|
+
res.setHeader("Retry-After", String(after.retryAfterSeconds ?? 0));
|
|
1997
|
+
res.status(429).json({
|
|
1998
|
+
error: "account temporarily locked due to repeated failed logins",
|
|
1999
|
+
retryAfterSeconds: after.retryAfterSeconds,
|
|
2000
|
+
});
|
|
2001
|
+
void mgmtAudit.record({
|
|
2002
|
+
actor: { sub: username },
|
|
2003
|
+
tenant: "default",
|
|
2004
|
+
resource: "users",
|
|
2005
|
+
action: "write",
|
|
2006
|
+
method: "POST",
|
|
2007
|
+
path: "/api/auth/login",
|
|
2008
|
+
status: 429,
|
|
2009
|
+
}).catch(() => { });
|
|
2010
|
+
return;
|
|
2011
|
+
}
|
|
2012
|
+
}
|
|
1828
2013
|
res.status(401).json({ error: "invalid credentials" });
|
|
1829
2014
|
return;
|
|
1830
2015
|
}
|
|
2016
|
+
if (lockout)
|
|
2017
|
+
await lockout.recordSuccess(user.username);
|
|
1831
2018
|
const { cookie } = issueSession({ sub: user.username, name: user.name, roles: user.roles, tenant: user.tenant }, sessionCfg);
|
|
1832
2019
|
const secure = req.secure || (req.headers["x-forwarded-proto"] === "https");
|
|
1833
2020
|
res.setHeader("Set-Cookie", setCookieHeader(cookie, sessionCfg, { secure }));
|
|
@@ -1855,6 +2042,34 @@ async function main() {
|
|
|
1855
2042
|
registerOidcRoutes(app, { sessionCfg, oidc: oidcRuntime });
|
|
1856
2043
|
console.log("[auth] OIDC endpoints registered: /api/auth/oidc/{login,callback,logout}");
|
|
1857
2044
|
}
|
|
2045
|
+
// Q17 — session revocation blocklist. Admin-gated (same role tier as
|
|
2046
|
+
// user/role management). A revoked-but-unexpired cookie is rejected by
|
|
2047
|
+
// buildSessionAttacher on the next request. Revoke a single session by
|
|
2048
|
+
// `sid` (read it from /api/me or the audit log) or every current
|
|
2049
|
+
// session for a `sub` ("log this user out everywhere"). The blocklist
|
|
2050
|
+
// is the stateful complement to the otherwise-stateless cookie.
|
|
2051
|
+
app.post("/api/auth/revocations", need("users", "delete"), audit("users", "write"), async (req, res) => {
|
|
2052
|
+
if (!revocationStore) {
|
|
2053
|
+
res.status(503).json({ error: "revocation requires an auth mode (basic|oidc)" });
|
|
2054
|
+
return;
|
|
2055
|
+
}
|
|
2056
|
+
const body = (req.body || {});
|
|
2057
|
+
const sid = typeof body.sid === "string" && body.sid.trim() ? body.sid.trim() : undefined;
|
|
2058
|
+
const sub = typeof body.sub === "string" && body.sub.trim() ? body.sub.trim() : undefined;
|
|
2059
|
+
const reason = typeof body.reason === "string" ? body.reason.slice(0, 500) : undefined;
|
|
2060
|
+
if ((sid ? 1 : 0) + (sub ? 1 : 0) !== 1) {
|
|
2061
|
+
res.status(400).json({ error: "exactly one of `sid` or `sub` is required" });
|
|
2062
|
+
return;
|
|
2063
|
+
}
|
|
2064
|
+
const by = req.session?.sub;
|
|
2065
|
+
const entry = sid
|
|
2066
|
+
? await revocationStore.revokeSession(sid, { reason, by })
|
|
2067
|
+
: await revocationStore.revokeSubject(sub, { reason, by });
|
|
2068
|
+
res.status(201).json({ ok: true, revocation: entry });
|
|
2069
|
+
});
|
|
2070
|
+
app.get("/api/auth/revocations", need("users", "delete"), (_req, res) => {
|
|
2071
|
+
res.json({ revocations: revocationStore ? revocationStore.list() : [] });
|
|
2072
|
+
});
|
|
1858
2073
|
// Phase F21 / Q6: SCIM 2.0 — opt-in. OMCP_SCIM_TOKEN gates access.
|
|
1859
2074
|
// The store backend is chosen by createScimStore from
|
|
1860
2075
|
// OMCP_SCIM_BACKEND (file | redis). file (default) → OMCP_SCIM_STORE
|
|
@@ -2618,6 +2833,31 @@ async function main() {
|
|
|
2618
2833
|
tools: filteredTools,
|
|
2619
2834
|
});
|
|
2620
2835
|
});
|
|
2836
|
+
// Q21 — per-service anomaly-score sparklines for the Health tab. Reads
|
|
2837
|
+
// the in-process ring of the anomaly-history sink (last hour), tenant-
|
|
2838
|
+
// scoped. MUST be registered before "/api/health/:service" so the
|
|
2839
|
+
// literal path isn't captured as a service name. `enabled` is true once
|
|
2840
|
+
// any score exists; the UI falls back to its client-side trend otherwise.
|
|
2841
|
+
app.get("/api/health/anomaly-sparklines", (req, res) => {
|
|
2842
|
+
const sess = req.session;
|
|
2843
|
+
const callerTenant = sess?.tenant || "default";
|
|
2844
|
+
// Anonymous (single-tenant) mode: no tenant filter, see everything.
|
|
2845
|
+
const tenant = sess ? callerTenant : undefined;
|
|
2846
|
+
const records = anomalyHistory.recent({ tenant });
|
|
2847
|
+
const series = {};
|
|
2848
|
+
for (const r of records) {
|
|
2849
|
+
const t = Date.parse(r.ts);
|
|
2850
|
+
if (!Number.isFinite(t))
|
|
2851
|
+
continue;
|
|
2852
|
+
(series[r.service] ??= []).push({ t, score: r.score });
|
|
2853
|
+
}
|
|
2854
|
+
res.json({
|
|
2855
|
+
enabled: records.length > 0,
|
|
2856
|
+
remoteWrite: anomalyHistory.isEnabled(),
|
|
2857
|
+
windowMs: anomalyHistory.windowMs,
|
|
2858
|
+
series,
|
|
2859
|
+
});
|
|
2860
|
+
});
|
|
2621
2861
|
// Health endpoint for UI dashboard
|
|
2622
2862
|
app.get("/api/health/:service", async (req, res) => {
|
|
2623
2863
|
try {
|
package/dist/openapi.js
CHANGED
|
@@ -394,6 +394,45 @@ export function buildOpenApiSpec(version) {
|
|
|
394
394
|
responses: { "204": { description: "Cookie cleared." } },
|
|
395
395
|
},
|
|
396
396
|
},
|
|
397
|
+
"/api/auth/revocations": {
|
|
398
|
+
get: {
|
|
399
|
+
tags: ["auth"],
|
|
400
|
+
summary: "List session revocations (admin).",
|
|
401
|
+
description: "Returns the current revocation blocklist. Admin-gated (users:delete). Empty in anonymous mode.",
|
|
402
|
+
responses: {
|
|
403
|
+
"200": { description: "Array of revocation entries." },
|
|
404
|
+
"401": { description: "Authentication required." },
|
|
405
|
+
"403": { description: "Caller lacks the admin permission." },
|
|
406
|
+
},
|
|
407
|
+
},
|
|
408
|
+
post: {
|
|
409
|
+
tags: ["auth"],
|
|
410
|
+
summary: "Revoke a session or all of a subject's sessions (admin).",
|
|
411
|
+
description: "Adds an entry to the on-disk blocklist. Provide exactly one of `sid` (revoke one session — copy it from /api/me) or `sub` (log a user out everywhere — revokes every session issued so far; a fresh login afterwards is unaffected). The next request bearing a revoked cookie is treated as logged out.",
|
|
412
|
+
requestBody: {
|
|
413
|
+
required: true,
|
|
414
|
+
content: {
|
|
415
|
+
"application/json": {
|
|
416
|
+
schema: {
|
|
417
|
+
type: "object",
|
|
418
|
+
properties: {
|
|
419
|
+
sid: { type: "string", description: "Session id to revoke (from /api/me)." },
|
|
420
|
+
sub: { type: "string", description: "Subject whose current sessions to revoke." },
|
|
421
|
+
reason: { type: "string", description: "Optional free-text reason (truncated to 500 chars)." },
|
|
422
|
+
},
|
|
423
|
+
},
|
|
424
|
+
},
|
|
425
|
+
},
|
|
426
|
+
},
|
|
427
|
+
responses: {
|
|
428
|
+
"201": { description: "Revocation recorded; the entry is returned." },
|
|
429
|
+
"400": { description: "Neither or both of sid/sub supplied." },
|
|
430
|
+
"401": { description: "Authentication required." },
|
|
431
|
+
"403": { description: "Caller lacks the admin permission." },
|
|
432
|
+
"503": { description: "Server is in anonymous mode (no sessions to revoke)." },
|
|
433
|
+
},
|
|
434
|
+
},
|
|
435
|
+
},
|
|
397
436
|
"/api/auth/oidc/login": {
|
|
398
437
|
get: {
|
|
399
438
|
tags: ["auth"],
|
package/dist/openapi.test.js
CHANGED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content-Security-Policy for the management-plane Web UI.
|
|
3
|
+
*
|
|
4
|
+
* Two policies ship together, by design:
|
|
5
|
+
*
|
|
6
|
+
* - **Enforced** (`Content-Security-Policy`): a real, non-breaking policy.
|
|
7
|
+
* It locks down everything the UI doesn't need — no remote scripts
|
|
8
|
+
* (`script-src 'self'`), no plugins (`object-src 'none'`), no `<base>`
|
|
9
|
+
* hijack (`base-uri 'self'`), no framing (`frame-ancestors 'none'`),
|
|
10
|
+
* and same-origin-only XHR via `connect-src 'self'`. It keeps
|
|
11
|
+
* `'unsafe-inline'` for `script-src` because the single-file UI uses
|
|
12
|
+
* ~200 inline event-handler attributes (`onclick=`, …) that a nonce
|
|
13
|
+
* cannot cover — a nonce in `script-src` would *disable* `'unsafe-inline'`
|
|
14
|
+
* in CSP3 and break every button. So the enforced policy is a genuine
|
|
15
|
+
* improvement over no CSP without regressing the UI.
|
|
16
|
+
*
|
|
17
|
+
* - **Report-Only** (`Content-Security-Policy-Report-Only`): the strict
|
|
18
|
+
* target policy — `script-src 'self' 'nonce-…'`, no `'unsafe-inline'`.
|
|
19
|
+
* The two legitimate inline `<script>` blocks carry the per-request
|
|
20
|
+
* nonce, so this policy flags ONLY the inline event-handler debt. It
|
|
21
|
+
* blocks nothing; it just reports, giving an actionable migration list
|
|
22
|
+
* (move the handlers to addEventListener) before a future slice can
|
|
23
|
+
* promote the strict policy to enforced.
|
|
24
|
+
*
|
|
25
|
+
* It is **opt-in** (`OMCP_CSP_STRICT_REPORT=true`): with ~200 inline
|
|
26
|
+
* handlers it would otherwise emit a `[Report Only]` console message
|
|
27
|
+
* per handler on every page load — noise an operator with devtools
|
|
28
|
+
* open shouldn't eat by default. Enable it when you're actively
|
|
29
|
+
* working the migration. The enforced policy + reporting endpoint are
|
|
30
|
+
* always on regardless.
|
|
31
|
+
*
|
|
32
|
+
* Both policies report to `/api/csp-violations` via the modern Reporting
|
|
33
|
+
* API (`Reporting-Endpoints` + `report-to`) and the legacy `report-uri`.
|
|
34
|
+
*/
|
|
35
|
+
/** Placeholder substituted with the per-request nonce when serving the UI HTML. */
|
|
36
|
+
export declare const CSP_NONCE_PLACEHOLDER = "__CSP_NONCE__";
|
|
37
|
+
/** The named reporting group used in the Report-To / Reporting-Endpoints headers. */
|
|
38
|
+
export declare const CSP_REPORT_GROUP = "omcp-csp";
|
|
39
|
+
/** Where violation reports are POSTed. */
|
|
40
|
+
export declare const CSP_REPORT_PATH = "/api/csp-violations";
|
|
41
|
+
/** Fresh base64 nonce (128 bits). */
|
|
42
|
+
export declare function generateNonce(): string;
|
|
43
|
+
/** The enforced policy — non-breaking, keeps the UI working. */
|
|
44
|
+
export declare function enforcedCsp(): string;
|
|
45
|
+
/** The strict target policy, run in report-only mode against the nonce. */
|
|
46
|
+
export declare function reportOnlyCsp(nonce: string): string;
|
|
47
|
+
/** Whether the strict Report-Only policy is enabled. Default off — see
|
|
48
|
+
* the module header for why (console noise from ~200 inline handlers). */
|
|
49
|
+
export declare function cspStrictReportFromEnv(env?: NodeJS.ProcessEnv): boolean;
|
|
50
|
+
/** Value for the modern `Reporting-Endpoints` header. */
|
|
51
|
+
export declare function reportingEndpointsHeader(): string;
|
|
52
|
+
/** Value for the legacy `Report-To` header (Reporting API v0). */
|
|
53
|
+
export declare function reportToHeader(): string;
|
|
54
|
+
/**
|
|
55
|
+
* Normalise a posted CSP violation (either the legacy
|
|
56
|
+
* `application/csp-report` `{ "csp-report": {...} }` envelope or a modern
|
|
57
|
+
* Reporting-API `application/reports+json` array element) into a compact,
|
|
58
|
+
* log-safe summary. Returns null when the body isn't a recognisable report.
|
|
59
|
+
*/
|
|
60
|
+
export declare function summariseViolation(body: unknown): {
|
|
61
|
+
directive: string;
|
|
62
|
+
blockedUri: string;
|
|
63
|
+
documentUri: string;
|
|
64
|
+
} | null;
|