Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions apps/api/src/lib/alerts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,15 @@ const SEND_TIMEOUT_MS = 30_000;
/** Failed port-22 checks in a row before a host counts as offline. */
const DOWN_AFTER = 2;

// One text goes to every service: lookalikes and zero-width spaces stop Slack `<!channel>`/`<url|x>`, `@channel` and markdown links without showing escapes in plain-text ones.
const defuse = (s: string) =>
s
.replaceAll("<", "‹")
.replaceAll(">", "›")
.replace(/(?!\n)\p{Cc}/gu, " ")
.replace(/(?<![A-Za-z0-9])@/g, "@\u200b")
.replaceAll("](", "]\u200b(");

const proxyEnv = () =>
Object.fromEntries(
Object.entries(process.env).filter(
Expand Down Expand Up @@ -213,7 +222,12 @@ export function alerter({
.get(String(id));
const hostName = (e: AuditInput) =>
(e.name as string | undefined) ?? host(e.host)?.name ?? String(e.host);
const who = (e: AuditInput) => String(e.email ?? e.sub ?? "someone");
// User-set text (task name, IdP-editable email): a newline could forge alert lines, a URL would autolink.
const userText = (s: unknown) =>
String(s)
.replace(/[\n\u2028\u2029]/g, " ")
.replaceAll("://", ":/\u200b/");
const who = (e: AuditInput) => userText(e.email ?? e.sub ?? "someone");

function securityMessage(e: AuditInput): Message {
const lines = [
Expand All @@ -230,7 +244,7 @@ export function alerter({
),
);
if (e.event === "session_kill" || e.event === "user_signout")
lines.push(`User: ${e.user}`);
lines.push(`User: ${userText(e.user)}`);
lines.push(new URL("/admin/audit", appUrl).href);
return {
title: `CertShell: ${SECURITY_TITLES[e.event]?.(e, hostName(e))}`,
Expand Down Expand Up @@ -262,9 +276,9 @@ export function alerter({
>("SELECT name, email, sub FROM runs WHERE id = ?")
.get(runId);
return {
title: `CertShell: task "${run?.name}" failed on ${failed.length} of ${hosts.length} host${hosts.length === 1 ? "" : "s"}`,
title: `CertShell: task "${userText(run?.name)}" failed on ${failed.length} of ${hosts.length} host${hosts.length === 1 ? "" : "s"}`,
body: [
`Run by ${run?.email ?? run?.sub}.`,
`Run by ${who({ event: "", email: run?.email, sub: run?.sub })}.`,
...failed.map(
(h) =>
`${h.login}@${h.host_name}: ${h.exit_code === null ? "didn't finish" : `exit ${h.exit_code}`}`,
Expand Down Expand Up @@ -314,6 +328,7 @@ export function alerter({
};
} else return;
if (!msg) return;
msg = { title: defuse(msg.title), body: defuse(msg.body) };
for (const row of rows()) {
const c = channelOf(row);
if (c.events.includes(event) && matches(c)) void send(row, msg);
Expand Down
85 changes: 65 additions & 20 deletions apps/api/test/alerts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { tmpdir } from "node:os";
import type { AlertChannel, AlertInput } from "@repo/shared";
import { alerter } from "../src/lib/alerts";
import { audit } from "../src/lib/audit";
import { createSession } from "../src/lib/sessions";
import { SESSION_COOKIE } from "../src/routes/auth";
import { hostKeyRejected } from "../src/ssh/terminal";
import { APP_URL, startApp } from "./helpers/app";

Expand Down Expand Up @@ -48,6 +50,19 @@ const call = (method: string, path: string, body?: unknown) =>
body: body === undefined ? undefined : JSON.stringify(body),
});

const post = async (path: string, body: unknown, cookie = ctx.cookie) =>
(await (
await ctx.app.request(`${APP_URL}/api/tasks${path}`, {
method: "POST",
headers: {
cookie,
origin: APP_URL,
"content-type": "application/json",
},
body: JSON.stringify(body),
})
).json()) as { id: number | string };

const input = (over: Partial<AlertInput> = {}): AlertInput => ({
name: "ops",
url: "ntfy://ntfy.sh/secret-topic",
Expand Down Expand Up @@ -307,6 +322,21 @@ test("terminal event: filtered by host tag or login; empty filter = every termin
]);
});

test("terminal event: mentions and links in the email arrive defused", async () => {
await setup();
await create({ events: ["terminal"] });
audit(ctx.db, {
event: "session_start",
sub: "u",
email: "<!here> _@channel [x](https://evil.test)\nfake\u0007line",
principal: "ws:h1:root",
session: "s1",
});
expect((await sent(1))[0]?.args[2]).toBe(
"CertShell: ‹!here› _@\u200bchannel [x]\u200b(https:/\u200b/evil.test) fake line opened root@h1",
);
});

test("task_failed: one alert when the last host finishes, only if one failed", async () => {
await setup({
command: ({ host, remote }) => ["sh", "-c", remote ?? "", host.id],
Expand All @@ -315,32 +345,19 @@ test("task_failed: one alert when the last host finishes, only if one failed", a
root: "certshell-admins",
});
await create({ events: ["task_failed"] });
const task = async (script: string) => {
const r = await ctx.app.request(`${APP_URL}/api/tasks`, {
method: "POST",
headers: {
cookie: ctx.cookie,
origin: APP_URL,
"content-type": "application/json",
},
body: JSON.stringify({
const task = async (script: string) =>
(
await post("", {
name: "Upgrade",
script,
targets: [
{ host: "h1", login: "root" },
{ host: "h2", login: "root" },
],
}),
});
return ((await r.json()) as { id: number }).id;
};
const run = async (id: number) => {
const r = await ctx.app.request(`${APP_URL}/api/tasks/${id}/run`, {
method: "POST",
headers: { cookie: ctx.cookie, origin: APP_URL },
});
return ((await r.json()) as { id: string }).id;
};
})
).id;
const run = async (id: number | string) =>
String((await post(`/${id}/run`, {})).id);

const done = async (runId: string) => {
for (let i = 0; i < 100; i++) {
Expand All @@ -367,6 +384,34 @@ test("task_failed: one alert when the last host finishes, only if one failed", a
);
});

test("task_failed: mentions and links in the task name and email arrive defused", async () => {
await setup({ command: () => ["sh", "-c", "exit 1"] });
await create({ events: ["task_failed"] });
const cookie = `${SESSION_COOKIE}=${createSession(ctx.db, {
iss: "https://id.test",
sub: "user-2",
email: "<!channel>\n_@here\u2028https://evil.test",
groups: ["certshell-admins"],
})}`;
const { id } = await post(
"",
{
name: "<!channel> <https://evil.test|x> https://evil.test/login",
script: "true",
targets: [{ host: "h1", login: "root" }],
},
cookie,
);
const runId = (await post(`/${id}/run`, {}, cookie)).id;
const [s] = await sent(1);
expect(s?.args[2]).toBe(
'CertShell: task "‹!channel› ‹https:/\u200b/evil.test|x› https:/\u200b/evil.test/login" failed on 1 of 1 host',
);
expect(s?.body).toBe(
`Run by ‹!channel› _@\u200bhere https:/\u200b/evil.test.\nroot@h1: exit 1\nhttps://ssh.test/tasks/runs/${runId}`,
);
});

test("reboot: alerted when host facts first show the flag, not again until it clears", async () => {
let reboot = "1";
await setup({ command: () => ["sh", "-c", `echo reboot=${reboot}`] });
Expand Down
Loading