@astrofoundry/pi-astro 0.19.0 → 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/package.json
CHANGED
package/skills/security/SKILL.md
CHANGED
|
@@ -14,8 +14,8 @@ The `security` tool is read-only. Every call is a fixed command on the guest; `[
|
|
|
14
14
|
| `status` | unit states and running containers |
|
|
15
15
|
| `attention` | JSON: `bouncer` (count, oldest pull age), `wazuh_agents` (active/total, list), `recent_high_alerts` (24 h, level ≥ 10), `health_issues` |
|
|
16
16
|
| `agents` | `agent_control -l` |
|
|
17
|
-
| `alerts <days> [ip]` |
|
|
18
|
-
| `decisions` | active decisions
|
|
17
|
+
| `alerts <days> [ip]` | summary: `count`, `uniqueIps`, `repeatIps`, `daily`, `topIps` (10), `scenarios`, `latest` (20 compact alerts: `at`, `ip`, `scenario`, `events`, `decisions`, `country`, `as`); with `ip`, `latest` holds every alert of that address |
|
|
18
|
+
| `decisions` | `count` and compact active decisions (`value`, `scope`, `type`, `duration`, `scenario`, `origin`) |
|
|
19
19
|
| `bouncers` | bouncers JSON (`name`, `last_pull`, `revoked`) |
|
|
20
20
|
| `metrics` | `cscli metrics -o json` |
|
|
21
21
|
| `wazuh-alerts <days> <minLevel>` | summary: count, groups by rule and agent, latest 20 (log line truncated to 200 chars) |
|
|
@@ -21,8 +21,8 @@ export const OBS_COMMANDS: Readonly<Record<string, { args: [number, number]; hel
|
|
|
21
21
|
status: { args: [0, 0], help: "unit states and running containers" },
|
|
22
22
|
attention: { args: [0, 0], help: "security-attention status: bouncer freshness, Wazuh agents, recent high alerts (JSON)" },
|
|
23
23
|
agents: { args: [0, 0], help: "Wazuh agent_control -l" },
|
|
24
|
-
alerts: { args: [1, 2], help: "alerts <days> [ip] CrowdSec alerts since N days,
|
|
25
|
-
decisions: { args: [0, 0], help: "active CrowdSec decisions (JSON)" },
|
|
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
26
|
bouncers: { args: [0, 0], help: "registered CrowdSec bouncers with last pull (JSON)" },
|
|
27
27
|
metrics: { args: [0, 0], help: "cscli metrics (JSON)" },
|
|
28
28
|
"wazuh-alerts": { args: [1, 1], help: "wazuh-alerts <days> <minLevel> Wazuh alerts since N days at or above a level, summarised" },
|
|
@@ -86,6 +86,122 @@ export function buildObsRemote(args: string[]): string {
|
|
|
86
86
|
return [name, ...rest].join(" ");
|
|
87
87
|
}
|
|
88
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
|
+
|
|
89
205
|
interface WazuhAlert {
|
|
90
206
|
timestamp?: string;
|
|
91
207
|
rule?: { id?: string; level?: number; description?: string };
|
|
@@ -186,12 +302,20 @@ export async function command(args: string[]): Promise<number> {
|
|
|
186
302
|
120_000,
|
|
187
303
|
);
|
|
188
304
|
if (result.code !== 0) throw new ServiceError(result.stderr.trim() || `${args[0]} exited ${result.code}`);
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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;
|
|
192
318
|
}
|
|
193
|
-
if (result.stdout.length > 0) printRaw(result.stdout);
|
|
194
|
-
return 0;
|
|
195
319
|
}
|
|
196
320
|
|
|
197
321
|
if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) {
|
|
@@ -9,7 +9,7 @@ import { API_PREFIXES, buildDmzRemote, validateApiPath, command as identityComma
|
|
|
9
9
|
import { UsageError } from "./lib/errors.ts";
|
|
10
10
|
import { safeRepoPath, safeWritePath } from "./lib/repo.ts";
|
|
11
11
|
import { buildPulsarRemote, parseQuery, unifiPath, command as networkCommand } from "./network/run.ts";
|
|
12
|
-
import { buildObsRemote, summariseWazuh, command as securityCommand } from "./security/run.ts";
|
|
12
|
+
import { buildObsRemote, compactDecisions, summariseAlerts, summariseWazuh, command as securityCommand } from "./security/run.ts";
|
|
13
13
|
|
|
14
14
|
describe("arcane wrapper", () => {
|
|
15
15
|
it("refuses only the wrapper's own setup paths", () => {
|
|
@@ -218,6 +218,39 @@ describe("security wrapper", () => {
|
|
|
218
218
|
expect(summariseWazuh(text, 30, 15, now).count).toBe(1);
|
|
219
219
|
});
|
|
220
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
|
+
|
|
221
254
|
it("prints help without touching config", async () => {
|
|
222
255
|
const write = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
|
223
256
|
expect(await securityCommand(["help"])).toBe(0);
|