@astrofoundry/pi-astro 0.18.6 → 0.19.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/README.md +4 -4
- package/agents/arcane.md +1 -1
- package/agents/dns.md +23 -0
- package/agents/edge.md +22 -0
- package/agents/identity.md +1 -1
- package/agents/network.md +1 -1
- package/agents/security.md +21 -0
- package/package.json +1 -1
- package/skills/dns/SKILL.md +59 -0
- package/skills/edge/SKILL.md +54 -0
- package/skills/network/SKILL.md +1 -1
- package/skills/security/SKILL.md +51 -0
- package/specialists/AGENTS.md +10 -5
- package/specialists/README.md +36 -13
- package/specialists/arcane/run.ts +14 -123
- package/specialists/dns/run.ts +339 -0
- package/specialists/edge/run.ts +224 -0
- package/specialists/entry/remote/spc-edge-frontdoor-entry.sh +47 -0
- package/specialists/entry/remote/spc-edge-frontdoor-install.sh +20 -0
- package/specialists/entry/remote/spc-edge-frontdoor-sudoers +2 -0
- package/specialists/entry/remote/spc-security-obs-entry.sh +54 -0
- package/specialists/install/install.sh +2 -2
- package/specialists/lib/repo.ts +171 -0
- package/specialists/lib/ssh.ts +24 -22
- package/specialists/lib/tunnel.test.ts +44 -0
- package/specialists/lib/tunnel.ts +76 -0
- package/specialists/security/run.ts +323 -0
- package/specialists/wrappers.test.ts +164 -1
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
+
import { connect } from "node:net";
|
|
3
|
+
import { ServiceError } from "./errors.ts";
|
|
4
|
+
|
|
5
|
+
export interface TunnelOptions {
|
|
6
|
+
env: Record<string, string>;
|
|
7
|
+
/** Local port the tunnel process must start listening on. */
|
|
8
|
+
port: number;
|
|
9
|
+
readyTimeoutMs: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface Tunnel {
|
|
13
|
+
close(): Promise<void>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function portOpen(port: number): Promise<boolean> {
|
|
17
|
+
return new Promise((resolve) => {
|
|
18
|
+
const socket = connect({ port, host: "127.0.0.1" });
|
|
19
|
+
socket.once("connect", () => {
|
|
20
|
+
socket.destroy();
|
|
21
|
+
resolve(true);
|
|
22
|
+
});
|
|
23
|
+
socket.once("error", () => {
|
|
24
|
+
socket.destroy();
|
|
25
|
+
resolve(false);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function sleep(ms: number): Promise<void> {
|
|
31
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function stop(child: ChildProcess): Promise<void> {
|
|
35
|
+
if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve();
|
|
36
|
+
return new Promise((resolve) => {
|
|
37
|
+
const hardKill = setTimeout(() => child.kill("SIGKILL"), 3000);
|
|
38
|
+
child.once("exit", () => {
|
|
39
|
+
clearTimeout(hardKill);
|
|
40
|
+
resolve();
|
|
41
|
+
});
|
|
42
|
+
child.kill("SIGTERM");
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Starts a long-running process that opens a local listener (an IAP tunnel)
|
|
48
|
+
* and resolves once 127.0.0.1:port accepts connections. The caller closes it
|
|
49
|
+
* when the connection through it is done.
|
|
50
|
+
*/
|
|
51
|
+
export async function openTunnel(file: string, args: string[], options: TunnelOptions): Promise<Tunnel> {
|
|
52
|
+
const child = spawn(file, args, {
|
|
53
|
+
env: { PATH: "/usr/bin:/bin:/usr/sbin:/sbin", HOME: process.env.HOME ?? "", ...options.env },
|
|
54
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
55
|
+
});
|
|
56
|
+
const state = { stderr: "", ended: false, failure: "" };
|
|
57
|
+
child.stderr?.on("data", (chunk: Buffer) => {
|
|
58
|
+
state.stderr += chunk.toString("utf-8");
|
|
59
|
+
});
|
|
60
|
+
child.on("exit", (code) => {
|
|
61
|
+
state.ended = true;
|
|
62
|
+
state.failure = `exited with code ${code}`;
|
|
63
|
+
});
|
|
64
|
+
child.on("error", (err) => {
|
|
65
|
+
state.ended = true;
|
|
66
|
+
state.failure = err.message;
|
|
67
|
+
});
|
|
68
|
+
const deadline = Date.now() + options.readyTimeoutMs;
|
|
69
|
+
while (Date.now() < deadline) {
|
|
70
|
+
if (state.ended) throw new ServiceError(`${file} ${state.failure}: ${state.stderr.trim()}`);
|
|
71
|
+
if (await portOpen(options.port)) return { close: () => stop(child) };
|
|
72
|
+
await sleep(250);
|
|
73
|
+
}
|
|
74
|
+
await stop(child);
|
|
75
|
+
throw new ServiceError(`${file} did not listen on 127.0.0.1:${options.port} within ${options.readyTimeoutMs} ms: ${state.stderr.trim()}`);
|
|
76
|
+
}
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { readConfig } from "../lib/config.ts";
|
|
3
|
+
import { ServiceError, UsageError } from "../lib/errors.ts";
|
|
4
|
+
import { main } from "../lib/main.ts";
|
|
5
|
+
import { printJson, printRaw } from "../lib/output.ts";
|
|
6
|
+
import { specialistsHome } from "../lib/paths.ts";
|
|
7
|
+
import { secretPath } from "../lib/secrets.ts";
|
|
8
|
+
import { sshFixed } from "../lib/ssh.ts";
|
|
9
|
+
|
|
10
|
+
const SERVICE = "security";
|
|
11
|
+
|
|
12
|
+
interface SecurityConfig extends Record<string, string> {
|
|
13
|
+
obsHost: string;
|
|
14
|
+
obsUser: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const SHAPE = { obsHost: "string", obsUser: "string" } as const;
|
|
18
|
+
|
|
19
|
+
/** Fixed remote commands; the forced command on VM 106 accepts exactly these names. */
|
|
20
|
+
export const OBS_COMMANDS: Readonly<Record<string, { args: [number, number]; help: string }>> = {
|
|
21
|
+
status: { args: [0, 0], help: "unit states and running containers" },
|
|
22
|
+
attention: { args: [0, 0], help: "security-attention status: bouncer freshness, Wazuh agents, recent high alerts (JSON)" },
|
|
23
|
+
agents: { args: [0, 0], help: "Wazuh agent_control -l" },
|
|
24
|
+
alerts: { args: [1, 2], help: "alerts <days> [ip] CrowdSec alerts since N days: counts, daily, top IPs, scenarios, latest 20; with ip: every alert of that IP" },
|
|
25
|
+
decisions: { args: [0, 0], help: "active CrowdSec decisions, compact (JSON)" },
|
|
26
|
+
bouncers: { args: [0, 0], help: "registered CrowdSec bouncers with last pull (JSON)" },
|
|
27
|
+
metrics: { args: [0, 0], help: "cscli metrics (JSON)" },
|
|
28
|
+
"wazuh-alerts": { args: [1, 1], help: "wazuh-alerts <days> <minLevel> Wazuh alerts since N days at or above a level, summarised" },
|
|
29
|
+
"wazuh-log": { args: [1, 1], help: "wazuh-log <lines> tail of the Wazuh manager container log" },
|
|
30
|
+
"remote-hosts": { args: [0, 0], help: "hosts and programs under /var/log/remote" },
|
|
31
|
+
"remote-log": { args: [3, 3], help: "remote-log <host> <program> <lines> tail of /var/log/remote/<host>/<program>.log" },
|
|
32
|
+
journal: { args: [2, 2], help: "journal <unit> <since> e.g. journal crowdsec '1 hour ago'" },
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const OBS_UNITS = ["crowdsec", "rsyslog", "nftables", "docker", "security-attention", "update-home-whitelist", "ssh"];
|
|
36
|
+
const SINCE = /^[A-Za-z0-9 :\-+]{1,40}$/;
|
|
37
|
+
const HOSTNAME = /^[A-Za-z0-9][A-Za-z0-9.-]{0,62}$/;
|
|
38
|
+
const PROGRAM = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,62}$/;
|
|
39
|
+
const IPV4 = /^(\d{1,3})(\.\d{1,3}){3}$/;
|
|
40
|
+
|
|
41
|
+
const HELP = `security specialist (Wazuh, CrowdSec, rsyslog on VM 106)
|
|
42
|
+
|
|
43
|
+
<command> [args] fixed operations through the forced-command SSH key
|
|
44
|
+
${Object.entries(OBS_COMMANDS)
|
|
45
|
+
.map(([name, c]) => ` ${name.padEnd(14)} ${c.help}`)
|
|
46
|
+
.join("\n")}
|
|
47
|
+
|
|
48
|
+
Read-only. Bans, unbans, and rule changes are out of scope; describe them for the operator.`;
|
|
49
|
+
|
|
50
|
+
function positiveInt(value: string | undefined, what: string, max: number): number {
|
|
51
|
+
const n = Number(value);
|
|
52
|
+
if (!Number.isInteger(n) || n < 1 || n > max) throw new UsageError(`${what} must be an integer from 1 to ${max}`);
|
|
53
|
+
return n;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function buildObsRemote(args: string[]): string {
|
|
57
|
+
const [name, ...rest] = args;
|
|
58
|
+
const spec = name === undefined ? undefined : OBS_COMMANDS[name];
|
|
59
|
+
if (!spec) throw new UsageError(`unknown command: ${name ?? "(none)"}`);
|
|
60
|
+
if (rest.length < spec.args[0] || rest.length > spec.args[1]) {
|
|
61
|
+
throw new UsageError(`${name} takes ${spec.args[0]}${spec.args[1] > spec.args[0] ? ` to ${spec.args[1]}` : ""} argument(s)`);
|
|
62
|
+
}
|
|
63
|
+
switch (name) {
|
|
64
|
+
case "alerts":
|
|
65
|
+
positiveInt(rest[0], "days", 365);
|
|
66
|
+
if (rest[1] !== undefined && !IPV4.test(rest[1])) throw new UsageError("ip must be an IPv4 address");
|
|
67
|
+
break;
|
|
68
|
+
case "wazuh-alerts":
|
|
69
|
+
positiveInt(rest[0], "days", 30);
|
|
70
|
+
break;
|
|
71
|
+
case "wazuh-log":
|
|
72
|
+
positiveInt(rest[0], "lines", 2000);
|
|
73
|
+
break;
|
|
74
|
+
case "remote-log":
|
|
75
|
+
if (!HOSTNAME.test(rest[0])) throw new UsageError("host must be a hostname");
|
|
76
|
+
if (!PROGRAM.test(rest[1])) throw new UsageError("program must be a log name");
|
|
77
|
+
positiveInt(rest[2], "lines", 2000);
|
|
78
|
+
break;
|
|
79
|
+
case "journal":
|
|
80
|
+
if (!OBS_UNITS.includes(rest[0])) throw new UsageError(`unit must be one of ${OBS_UNITS.join(", ")}`);
|
|
81
|
+
if (!SINCE.test(rest[1])) throw new UsageError("since: letters, digits, spaces, colon, plus, minus only");
|
|
82
|
+
break;
|
|
83
|
+
default:
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
return [name, ...rest].join(" ");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface CrowdsecAlert {
|
|
90
|
+
id?: number;
|
|
91
|
+
created_at?: string;
|
|
92
|
+
scenario?: string;
|
|
93
|
+
events_count?: number;
|
|
94
|
+
source?: { ip?: string; range?: string; as_number?: string; as_name?: string; cn?: string };
|
|
95
|
+
decisions?: { type?: string; duration?: string; value?: string }[];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface CompactAlert {
|
|
99
|
+
id?: number;
|
|
100
|
+
at: string;
|
|
101
|
+
ip: string;
|
|
102
|
+
scenario: string;
|
|
103
|
+
events: number;
|
|
104
|
+
decisions: string[];
|
|
105
|
+
country?: string;
|
|
106
|
+
as?: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface AlertSummary {
|
|
110
|
+
days: number;
|
|
111
|
+
ip?: string;
|
|
112
|
+
count: number;
|
|
113
|
+
uniqueIps: number;
|
|
114
|
+
repeatIps: number;
|
|
115
|
+
daily: { date: string; alerts: number; ips: number }[];
|
|
116
|
+
topIps: { ip: string; count: number; last: string; as?: string }[];
|
|
117
|
+
scenarios: { scenario: string; count: number }[];
|
|
118
|
+
latest: CompactAlert[];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function parseJsonArray<T>(text: string, what: string): T[] {
|
|
122
|
+
let parsed: unknown;
|
|
123
|
+
try {
|
|
124
|
+
parsed = JSON.parse(text.trim().length === 0 ? "[]" : text);
|
|
125
|
+
} catch {
|
|
126
|
+
throw new ServiceError(`${what}: the guest returned no JSON`);
|
|
127
|
+
}
|
|
128
|
+
if (parsed === null) return [];
|
|
129
|
+
if (!Array.isArray(parsed)) throw new ServiceError(`${what}: expected a JSON array`);
|
|
130
|
+
return parsed as T[];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function compactAlert(a: CrowdsecAlert): CompactAlert {
|
|
134
|
+
return {
|
|
135
|
+
id: a.id,
|
|
136
|
+
at: a.created_at ?? "",
|
|
137
|
+
ip: a.source?.ip ?? "?",
|
|
138
|
+
scenario: a.scenario ?? "?",
|
|
139
|
+
events: a.events_count ?? 0,
|
|
140
|
+
decisions: (a.decisions ?? []).map((d) => `${d.type ?? "?"} ${d.duration ?? ""}`.trim()),
|
|
141
|
+
country: a.source?.cn,
|
|
142
|
+
as: a.source?.as_name === undefined ? undefined : `AS${a.source.as_number ?? "?"} ${a.source.as_name}`,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Compacts `cscli alerts list -o json` to counts, per-day activity, repeat sources, scenarios, and the latest alerts. */
|
|
147
|
+
export function summariseAlerts(text: string, days: number, ip?: string): AlertSummary {
|
|
148
|
+
const alerts = parseJsonArray<CrowdsecAlert>(text, "alerts").filter((a) => a.source?.ip !== undefined && (ip === undefined || a.source.ip === ip));
|
|
149
|
+
const byIp = new Map<string, CrowdsecAlert[]>();
|
|
150
|
+
const byDay = new Map<string, CrowdsecAlert[]>();
|
|
151
|
+
const byScenario = new Map<string, number>();
|
|
152
|
+
for (const a of alerts) {
|
|
153
|
+
const source = a.source?.ip ?? "?";
|
|
154
|
+
byIp.set(source, [...(byIp.get(source) ?? []), a]);
|
|
155
|
+
const day = (a.created_at ?? "").slice(0, 10);
|
|
156
|
+
byDay.set(day, [...(byDay.get(day) ?? []), a]);
|
|
157
|
+
byScenario.set(a.scenario ?? "?", (byScenario.get(a.scenario ?? "?") ?? 0) + 1);
|
|
158
|
+
}
|
|
159
|
+
const sorted = [...alerts].sort((x, y) => (y.created_at ?? "").localeCompare(x.created_at ?? ""));
|
|
160
|
+
return {
|
|
161
|
+
days,
|
|
162
|
+
ip,
|
|
163
|
+
count: alerts.length,
|
|
164
|
+
uniqueIps: byIp.size,
|
|
165
|
+
repeatIps: [...byIp.values()].filter((list) => list.length > 1).length,
|
|
166
|
+
daily: [...byDay.entries()].sort().map(([date, list]) => ({ date, alerts: list.length, ips: new Set(list.map((a) => a.source?.ip)).size })),
|
|
167
|
+
topIps: [...byIp.entries()]
|
|
168
|
+
.map(([source, list]) => ({
|
|
169
|
+
ip: source,
|
|
170
|
+
count: list.length,
|
|
171
|
+
last: list.map((a) => a.created_at ?? "").sort().at(-1) ?? "",
|
|
172
|
+
as: compactAlert(list[0]).as,
|
|
173
|
+
}))
|
|
174
|
+
.sort((x, y) => y.count - x.count || x.ip.localeCompare(y.ip))
|
|
175
|
+
.slice(0, 10),
|
|
176
|
+
scenarios: [...byScenario.entries()].map(([scenario, count]) => ({ scenario, count })).sort((x, y) => y.count - x.count),
|
|
177
|
+
latest: (ip === undefined ? sorted.slice(0, 20) : sorted).map(compactAlert),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
interface CrowdsecDecision {
|
|
182
|
+
id?: number;
|
|
183
|
+
value?: string;
|
|
184
|
+
scope?: string;
|
|
185
|
+
type?: string;
|
|
186
|
+
duration?: string;
|
|
187
|
+
scenario?: string;
|
|
188
|
+
origin?: string;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Keeps the fields an operator needs from `cscli decisions list -o json`. */
|
|
192
|
+
export function compactDecisions(text: string): { count: number; decisions: CrowdsecDecision[] } {
|
|
193
|
+
const decisions = parseJsonArray<CrowdsecDecision>(text, "decisions").map(({ id, value, scope, type, duration, scenario, origin }) => ({
|
|
194
|
+
id,
|
|
195
|
+
value,
|
|
196
|
+
scope,
|
|
197
|
+
type,
|
|
198
|
+
duration,
|
|
199
|
+
scenario,
|
|
200
|
+
origin,
|
|
201
|
+
}));
|
|
202
|
+
return { count: decisions.length, decisions };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
interface WazuhAlert {
|
|
206
|
+
timestamp?: string;
|
|
207
|
+
rule?: { id?: string; level?: number; description?: string };
|
|
208
|
+
agent?: { name?: string };
|
|
209
|
+
data?: { srcip?: string };
|
|
210
|
+
full_log?: string;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
interface WazuhGroup {
|
|
214
|
+
rule: string;
|
|
215
|
+
level: number;
|
|
216
|
+
description: string;
|
|
217
|
+
agent: string;
|
|
218
|
+
count: number;
|
|
219
|
+
last: string;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export interface WazuhSummary {
|
|
223
|
+
days: number;
|
|
224
|
+
minLevel: number;
|
|
225
|
+
count: number;
|
|
226
|
+
groups: WazuhGroup[];
|
|
227
|
+
latest: { timestamp: string; agent: string; rule: string; level: number; description: string; srcip?: string; log?: string }[];
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Filters newline-delimited Wazuh alerts by age and level, grouped by rule and agent. */
|
|
231
|
+
export function summariseWazuh(text: string, days: number, minLevel: number, now = Date.now()): WazuhSummary {
|
|
232
|
+
const cutoff = now - days * 86_400_000;
|
|
233
|
+
const alerts: WazuhAlert[] = [];
|
|
234
|
+
for (const line of text.split("\n")) {
|
|
235
|
+
const trimmed = line.trim();
|
|
236
|
+
if (trimmed.length === 0) continue;
|
|
237
|
+
let parsed: WazuhAlert;
|
|
238
|
+
try {
|
|
239
|
+
parsed = JSON.parse(trimmed) as WazuhAlert;
|
|
240
|
+
} catch {
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
const level = parsed.rule?.level ?? 0;
|
|
244
|
+
const time = parsed.timestamp === undefined ? Number.NaN : Date.parse(parsed.timestamp);
|
|
245
|
+
if (level < minLevel || Number.isNaN(time) || time < cutoff) continue;
|
|
246
|
+
alerts.push(parsed);
|
|
247
|
+
}
|
|
248
|
+
const groups = new Map<string, WazuhGroup>();
|
|
249
|
+
for (const a of alerts) {
|
|
250
|
+
const rule = a.rule?.id ?? "?";
|
|
251
|
+
const agent = a.agent?.name ?? "?";
|
|
252
|
+
const key = `${rule} ${agent}`;
|
|
253
|
+
const existing = groups.get(key);
|
|
254
|
+
const last = a.timestamp ?? "";
|
|
255
|
+
if (existing) {
|
|
256
|
+
existing.count++;
|
|
257
|
+
if (last > existing.last) existing.last = last;
|
|
258
|
+
} else {
|
|
259
|
+
groups.set(key, { rule, level: a.rule?.level ?? 0, description: a.rule?.description ?? "", agent, count: 1, last });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
alerts.sort((x, y) => (y.timestamp ?? "").localeCompare(x.timestamp ?? ""));
|
|
263
|
+
return {
|
|
264
|
+
days,
|
|
265
|
+
minLevel,
|
|
266
|
+
count: alerts.length,
|
|
267
|
+
groups: [...groups.values()].sort((x, y) => y.level - x.level || y.count - x.count),
|
|
268
|
+
latest: alerts.slice(0, 20).map((a) => ({
|
|
269
|
+
timestamp: a.timestamp ?? "",
|
|
270
|
+
agent: a.agent?.name ?? "?",
|
|
271
|
+
rule: a.rule?.id ?? "?",
|
|
272
|
+
level: a.rule?.level ?? 0,
|
|
273
|
+
description: a.rule?.description ?? "",
|
|
274
|
+
srcip: a.data?.srcip,
|
|
275
|
+
log: a.full_log === undefined ? undefined : a.full_log.slice(0, 200),
|
|
276
|
+
})),
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export async function command(args: string[]): Promise<number> {
|
|
281
|
+
if (args.length === 0 || args[0] === "--help" || args[0] === "help") {
|
|
282
|
+
printRaw(HELP);
|
|
283
|
+
return 0;
|
|
284
|
+
}
|
|
285
|
+
const config = readConfig<SecurityConfig>(SERVICE, SHAPE);
|
|
286
|
+
let minLevel: number | null = null;
|
|
287
|
+
let remoteArgs = args;
|
|
288
|
+
if (args[0] === "wazuh-alerts") {
|
|
289
|
+
if (args.length !== 3) throw new UsageError("wazuh-alerts <days> <minLevel>");
|
|
290
|
+
minLevel = positiveInt(args[2], "minLevel", 16);
|
|
291
|
+
remoteArgs = args.slice(0, 2);
|
|
292
|
+
}
|
|
293
|
+
const remote = buildObsRemote(remoteArgs);
|
|
294
|
+
const result = await sshFixed(
|
|
295
|
+
{
|
|
296
|
+
host: config.obsHost,
|
|
297
|
+
user: config.obsUser,
|
|
298
|
+
keyFile: secretPath(SERVICE, "SSH_KEY_OBS"),
|
|
299
|
+
knownHostsFile: join(specialistsHome(), "config", "known_hosts"),
|
|
300
|
+
},
|
|
301
|
+
remote,
|
|
302
|
+
120_000,
|
|
303
|
+
);
|
|
304
|
+
if (result.code !== 0) throw new ServiceError(result.stderr.trim() || `${args[0]} exited ${result.code}`);
|
|
305
|
+
switch (args[0]) {
|
|
306
|
+
case "wazuh-alerts":
|
|
307
|
+
printJson(summariseWazuh(result.stdout, Number(args[1]), minLevel ?? 0));
|
|
308
|
+
return 0;
|
|
309
|
+
case "alerts":
|
|
310
|
+
printJson(summariseAlerts(result.stdout, Number(args[1]), args[2]));
|
|
311
|
+
return 0;
|
|
312
|
+
case "decisions":
|
|
313
|
+
printJson(compactDecisions(result.stdout));
|
|
314
|
+
return 0;
|
|
315
|
+
default:
|
|
316
|
+
if (result.stdout.length > 0) printRaw(result.stdout);
|
|
317
|
+
return 0;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) {
|
|
322
|
+
await main(SERVICE, command);
|
|
323
|
+
}
|
|
@@ -2,10 +2,14 @@ import { mkdtempSync, rmSync } from "node:fs";
|
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
-
import { refusedPrefix,
|
|
5
|
+
import { refusedPrefix, command as arcaneCommand } from "./arcane/run.ts";
|
|
6
|
+
import { parseParams, parseRecordFilters, technitiumRequest, command as dnsCommand } from "./dns/run.ts";
|
|
7
|
+
import { buildVpsRemote, command as edgeCommand } from "./edge/run.ts";
|
|
6
8
|
import { API_PREFIXES, buildDmzRemote, validateApiPath, command as identityCommand } from "./identity/run.ts";
|
|
7
9
|
import { UsageError } from "./lib/errors.ts";
|
|
10
|
+
import { safeRepoPath, safeWritePath } from "./lib/repo.ts";
|
|
8
11
|
import { buildPulsarRemote, parseQuery, unifiPath, command as networkCommand } from "./network/run.ts";
|
|
12
|
+
import { buildObsRemote, compactDecisions, summariseAlerts, summariseWazuh, command as securityCommand } from "./security/run.ts";
|
|
9
13
|
|
|
10
14
|
describe("arcane wrapper", () => {
|
|
11
15
|
it("refuses only the wrapper's own setup paths", () => {
|
|
@@ -25,6 +29,10 @@ describe("arcane wrapper", () => {
|
|
|
25
29
|
for (const bad of ["", "/etc/passwd", "../x", "a/../../b", ".git/config", "a\0b"]) {
|
|
26
30
|
expect(() => safeRepoPath(dir, bad), bad).toThrow(UsageError);
|
|
27
31
|
}
|
|
32
|
+
expect(safeWritePath(dir, "00-frontdoor-vps/nginx.conf", "00-frontdoor-vps")).toBe("/repo/00-frontdoor-vps/nginx.conf");
|
|
33
|
+
expect(() => safeWritePath(dir, "00-frontdoor-vps", "00-frontdoor-vps")).toThrow(/limited to/);
|
|
34
|
+
expect(() => safeWritePath(dir, "02-pulsar-proxmox/dmz/config.yaml", "00-frontdoor-vps")).toThrow(/limited to/);
|
|
35
|
+
expect(() => safeWritePath(dir, "00-frontdoor-vps-other/x", "00-frontdoor-vps")).toThrow(/limited to/);
|
|
28
36
|
});
|
|
29
37
|
|
|
30
38
|
it("prints help without touching config", async () => {
|
|
@@ -99,6 +107,158 @@ describe("network wrapper", () => {
|
|
|
99
107
|
});
|
|
100
108
|
});
|
|
101
109
|
|
|
110
|
+
describe("dns wrapper", () => {
|
|
111
|
+
it("maps technitium commands to documented API paths", () => {
|
|
112
|
+
expect(technitiumRequest("catalog.invalid", ["zones"])).toEqual({ path: "/api/zones/list", params: {}, write: false });
|
|
113
|
+
expect(technitiumRequest("catalog.invalid", ["zones", "*.37pla.net"])).toEqual({ path: "/api/zones/list", params: { filterName: "*.37pla.net" }, write: false });
|
|
114
|
+
expect(technitiumRequest("catalog.invalid", ["records", "37pla.net"])).toEqual({
|
|
115
|
+
path: "/api/zones/records/get",
|
|
116
|
+
params: { domain: "37pla.net", zone: "37pla.net", listZone: "true" },
|
|
117
|
+
write: false,
|
|
118
|
+
});
|
|
119
|
+
expect(technitiumRequest("catalog.invalid", ["records", "37pla.net", "kuma.37pla.net"]).params.listZone).toBe("false");
|
|
120
|
+
expect(technitiumRequest("catalog.invalid", ["resolve", "auth.37pla.net"]).params).toEqual({ server: "this-server", domain: "auth.37pla.net", type: "A" });
|
|
121
|
+
expect(technitiumRequest("catalog.invalid", ["stats", "LastDay"]).params).toEqual({ type: "LastDay" });
|
|
122
|
+
expect(technitiumRequest("catalog.invalid", ["zone-create", "lab.test"])).toEqual({
|
|
123
|
+
path: "/api/zones/create",
|
|
124
|
+
params: { zone: "lab.test", type: "Primary", catalog: "catalog.invalid" },
|
|
125
|
+
write: true,
|
|
126
|
+
});
|
|
127
|
+
expect(technitiumRequest("catalog.invalid", ["record-add", "37pla.net", "kuma.37pla.net", "A", "ipAddress=10.0.40.30", "ttl=300"])).toEqual({
|
|
128
|
+
path: "/api/zones/records/add",
|
|
129
|
+
params: { ipAddress: "10.0.40.30", ttl: "300", zone: "37pla.net", domain: "kuma.37pla.net", type: "A" },
|
|
130
|
+
write: true,
|
|
131
|
+
});
|
|
132
|
+
expect(technitiumRequest("catalog.invalid", ["record-delete", "37pla.net", "kuma.37pla.net", "A", "ipAddress=10.0.40.30"]).path).toBe("/api/zones/records/delete");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("rejects malformed names, types, and parameters", () => {
|
|
136
|
+
expect(() => technitiumRequest("c", ["records", "bad zone"])).toThrow(/DNS name/);
|
|
137
|
+
expect(() => technitiumRequest("c", ["record-add", "z.test", "a.z.test", "a", "ipAddress=1.2.3.4"])).toThrow(/record type/);
|
|
138
|
+
expect(() => technitiumRequest("c", ["record-add", "z.test", "a.z.test", "A", "ipAddress"])).toThrow(/key=value/);
|
|
139
|
+
expect(() => technitiumRequest("c", ["record-add", "z.test", "a.z.test", "A", "token=x"])).toThrow(/parameter name/);
|
|
140
|
+
expect(() => technitiumRequest("c", ["record-add", "z.test", "a.z.test", "A", "zone=other"])).toThrow(/positional/);
|
|
141
|
+
expect(() => technitiumRequest("c", ["stats", "Forever"])).toThrow(/stats type/);
|
|
142
|
+
expect(() => technitiumRequest("c", ["zone-delete"])).toThrow(/1 argument/);
|
|
143
|
+
expect(() => technitiumRequest("c", ["settings"])).toThrow(/unknown technitium command/);
|
|
144
|
+
expect(parseParams(["comments=a=b"])).toEqual({ comments: "a=b" });
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("parses cloudflare record filters", () => {
|
|
148
|
+
const { zone, query } = parseRecordFilters(["37pla.net", "--type", "A", "--name", "kuma.37pla.net"]);
|
|
149
|
+
expect(zone).toBe("37pla.net");
|
|
150
|
+
expect(query.get("type")).toBe("A");
|
|
151
|
+
expect(query.get("name")).toBe("kuma.37pla.net");
|
|
152
|
+
expect(() => parseRecordFilters([])).toThrow(UsageError);
|
|
153
|
+
expect(() => parseRecordFilters(["37pla.net", "--proxied"])).toThrow(/unknown option/);
|
|
154
|
+
expect(() => parseRecordFilters(["37pla.net", "--type", "a"])).toThrow(/record type/);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("prints help without touching config", async () => {
|
|
158
|
+
const write = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
|
159
|
+
expect(await dnsCommand(["--help"])).toBe(0);
|
|
160
|
+
expect(String(write.mock.calls[0][0])).toContain("cloudflare record-add");
|
|
161
|
+
write.mockRestore();
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe("edge wrapper", () => {
|
|
166
|
+
it("maps vps commands and requires reboot confirmation", () => {
|
|
167
|
+
expect(buildVpsRemote(["status"])).toEqual({ remote: "status", stdin: false });
|
|
168
|
+
expect(buildVpsRemote(["deploy-nginx"])).toEqual({ remote: "deploy-nginx", stdin: true });
|
|
169
|
+
expect(buildVpsRemote(["journal", "wg-quick@wg0", "2 hours ago"])).toEqual({ remote: "journal wg-quick@wg0 2 hours ago", stdin: false });
|
|
170
|
+
expect(buildVpsRemote(["reboot", "--confirm"])).toEqual({ remote: "reboot", stdin: false });
|
|
171
|
+
expect(() => buildVpsRemote(["reboot"])).toThrow(/--confirm/);
|
|
172
|
+
expect(() => buildVpsRemote(["journal", "sshd", "1 hour ago"])).toThrow(/unit/);
|
|
173
|
+
expect(() => buildVpsRemote(["journal", "nginx", "x; rm -rf /"])).toThrow(/since/);
|
|
174
|
+
expect(() => buildVpsRemote(["wg", "extra"])).toThrow(/0 argument/);
|
|
175
|
+
expect(() => buildVpsRemote(["shell"])).toThrow(/unknown vps command/);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("prints help without touching config", async () => {
|
|
179
|
+
const write = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
|
180
|
+
expect(await edgeCommand([])).toBe(0);
|
|
181
|
+
expect(String(write.mock.calls[0][0])).toContain("deploy-nginx");
|
|
182
|
+
expect(String(write.mock.calls[0][0])).toContain("git write");
|
|
183
|
+
write.mockRestore();
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe("security wrapper", () => {
|
|
188
|
+
it("maps commands to fixed remote strings and validates arguments", () => {
|
|
189
|
+
expect(buildObsRemote(["attention"])).toBe("attention");
|
|
190
|
+
expect(buildObsRemote(["alerts", "7"])).toBe("alerts 7");
|
|
191
|
+
expect(buildObsRemote(["alerts", "7", "45.79.207.181"])).toBe("alerts 7 45.79.207.181");
|
|
192
|
+
expect(buildObsRemote(["remote-log", "frontdoor-1337", "nginx-stream", "200"])).toBe("remote-log frontdoor-1337 nginx-stream 200");
|
|
193
|
+
expect(buildObsRemote(["journal", "crowdsec", "1 hour ago"])).toBe("journal crowdsec 1 hour ago");
|
|
194
|
+
expect(() => buildObsRemote(["alerts", "0"])).toThrow(/days/);
|
|
195
|
+
expect(() => buildObsRemote(["alerts", "7", "evil.host"])).toThrow(/IPv4/);
|
|
196
|
+
expect(() => buildObsRemote(["remote-log", "../etc", "passwd", "10"])).toThrow(/host/);
|
|
197
|
+
expect(() => buildObsRemote(["remote-log", "frontdoor-1337", "nginx-stream", "5000"])).toThrow(/lines/);
|
|
198
|
+
expect(() => buildObsRemote(["journal", "sshd", "1 hour ago"])).toThrow(/unit/);
|
|
199
|
+
expect(() => buildObsRemote(["ban", "1.2.3.4"])).toThrow(/unknown command/);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("summarises wazuh alerts by age and level", () => {
|
|
203
|
+
const now = Date.parse("2026-09-16T12:00:00Z");
|
|
204
|
+
const line = (ts: string, level: number, id: string, agent: string) =>
|
|
205
|
+
JSON.stringify({ timestamp: ts, rule: { id, level, description: `rule ${id}` }, agent: { name: agent }, data: { srcip: "1.2.3.4" }, full_log: "x".repeat(300) });
|
|
206
|
+
const text = [
|
|
207
|
+
line("2026-09-16T11:00:00.000+0000", 12, "100010", "dmz"),
|
|
208
|
+
line("2026-09-16T10:00:00.000+0000", 12, "100010", "dmz"),
|
|
209
|
+
line("2026-09-16T09:00:00.000+0000", 5, "5501", "dmz"),
|
|
210
|
+
line("2026-09-10T09:00:00.000+0000", 15, "100011", "arcane"),
|
|
211
|
+
"not json",
|
|
212
|
+
].join("\n");
|
|
213
|
+
const summary = summariseWazuh(text, 2, 10, now);
|
|
214
|
+
expect(summary.count).toBe(2);
|
|
215
|
+
expect(summary.groups).toEqual([{ rule: "100010", level: 12, description: "rule 100010", agent: "dmz", count: 2, last: "2026-09-16T11:00:00.000+0000" }]);
|
|
216
|
+
expect(summary.latest[0].timestamp).toBe("2026-09-16T11:00:00.000+0000");
|
|
217
|
+
expect(summary.latest[0].log?.length).toBe(200);
|
|
218
|
+
expect(summariseWazuh(text, 30, 15, now).count).toBe(1);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("compacts crowdsec alerts and decisions", () => {
|
|
222
|
+
const alert = (id: number, at: string, ip: string, scenario: string) => ({
|
|
223
|
+
id,
|
|
224
|
+
created_at: at,
|
|
225
|
+
scenario,
|
|
226
|
+
events_count: 3,
|
|
227
|
+
source: { ip, range: `${ip}/24`, as_number: "63949", as_name: "Akamai", cn: "US" },
|
|
228
|
+
decisions: [{ type: "ban", duration: "4h", value: ip }],
|
|
229
|
+
});
|
|
230
|
+
const text = JSON.stringify([
|
|
231
|
+
alert(1, "2026-09-15T10:00:00Z", "45.79.207.181", "37pla/frontdoor-sni-scanning"),
|
|
232
|
+
alert(2, "2026-09-16T10:00:00Z", "45.79.207.181", "37pla/frontdoor-sni-scanning"),
|
|
233
|
+
alert(3, "2026-09-16T11:00:00Z", "203.0.113.9", "37pla/pomerium-http-probing"),
|
|
234
|
+
{ id: 4, created_at: "2026-09-16T12:00:00Z", scenario: "list", source: {} },
|
|
235
|
+
]);
|
|
236
|
+
const summary = summariseAlerts(text, 7);
|
|
237
|
+
expect(summary).toMatchObject({ days: 7, count: 3, uniqueIps: 2, repeatIps: 1 });
|
|
238
|
+
expect(summary.daily).toEqual([
|
|
239
|
+
{ date: "2026-09-15", alerts: 1, ips: 1 },
|
|
240
|
+
{ date: "2026-09-16", alerts: 2, ips: 2 },
|
|
241
|
+
]);
|
|
242
|
+
expect(summary.topIps[0]).toEqual({ ip: "45.79.207.181", count: 2, last: "2026-09-16T10:00:00Z", as: "AS63949 Akamai" });
|
|
243
|
+
expect(summary.scenarios[0]).toEqual({ scenario: "37pla/frontdoor-sni-scanning", count: 2 });
|
|
244
|
+
expect(summary.latest[0]).toMatchObject({ id: 3, ip: "203.0.113.9", decisions: ["ban 4h"], country: "US" });
|
|
245
|
+
expect(summariseAlerts(text, 7, "45.79.207.181").latest.map((a) => a.id)).toEqual([2, 1]);
|
|
246
|
+
expect(summariseAlerts("null", 7)).toMatchObject({ count: 0, uniqueIps: 0 });
|
|
247
|
+
expect(compactDecisions(JSON.stringify([{ id: 9, value: "203.0.113.9", scope: "Ip", type: "ban", duration: "3h59m", scenario: "x", origin: "crowdsec", extra: 1 }]))).toEqual({
|
|
248
|
+
count: 1,
|
|
249
|
+
decisions: [{ id: 9, value: "203.0.113.9", scope: "Ip", type: "ban", duration: "3h59m", scenario: "x", origin: "crowdsec" }],
|
|
250
|
+
});
|
|
251
|
+
expect(() => summariseAlerts("not json", 7)).toThrow(/no JSON/);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("prints help without touching config", async () => {
|
|
255
|
+
const write = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
|
256
|
+
expect(await securityCommand(["help"])).toBe(0);
|
|
257
|
+
expect(String(write.mock.calls[0][0])).toContain("remote-log <host> <program> <lines>");
|
|
258
|
+
write.mockRestore();
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
102
262
|
describe("wrapper config errors", () => {
|
|
103
263
|
let home: string;
|
|
104
264
|
const originalHome = process.env.HOME;
|
|
@@ -116,5 +276,8 @@ describe("wrapper config errors", () => {
|
|
|
116
276
|
it("fails clearly when the config file is absent", async () => {
|
|
117
277
|
await expect(arcaneCommand(["projects", "list"])).rejects.toThrow(/cannot read .*arcane\.json/);
|
|
118
278
|
await expect(networkCommand(["unifi", "sites"])).rejects.toThrow(/network\.json/);
|
|
279
|
+
await expect(dnsCommand(["technitium", "primary", "zones"])).rejects.toThrow(/dns\.json/);
|
|
280
|
+
await expect(edgeCommand(["probe"])).rejects.toThrow(/edge\.json/);
|
|
281
|
+
await expect(securityCommand(["attention"])).rejects.toThrow(/security\.json/);
|
|
119
282
|
});
|
|
120
283
|
});
|