@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrofoundry/pi-astro",
3
- "version": "0.19.0",
3
+ "version": "0.19.1",
4
4
  "description": "Personal pi customizations (extensions, subagents, skills, prompts, themes) for the pi coding agent.",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -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]` | CrowdSec alerts JSON (`source.ip`, `scenario`, `created_at`, `decisions`) |
18
- | `decisions` | active decisions JSON (`value`, `duration`, `scenario`, `origin`) |
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, optionally one source IP (JSON)" },
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
- if (minLevel !== null) {
190
- printJson(summariseWazuh(result.stdout, Number(args[1]), minLevel));
191
- return 0;
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);