@astrofoundry/pi-astro 0.26.0 → 0.26.2

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.26.0",
3
+ "version": "0.26.2",
4
4
  "description": "Personal pi customizations (extensions, subagents, skills, prompts, themes) for the pi coding agent.",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -23,6 +23,7 @@ Fetches `https://auth.37pla.net/.well-known/openid-configuration` through the VP
23
23
  | `bouncer` | bouncer unit status and the nftables set of blocked addresses |
24
24
  | `updates` | upgradable packages from the local apt index (not refreshed, so it can lag the mirror) and the packages that need a reboot |
25
25
  | `package <name>` | refreshes the apt index, then the installed version (`dpkg-query`) and the candidate with its origin (`apt-cache policy`) |
26
+ | `changelog <name>` | first 80 lines of the candidate's Debian changelog from the Debian servers; each security upload lists the CVEs it fixes, so this is the proof that a version fixes a CVE |
26
27
  | `auto-upgrades` | the apt timers with their next run, the last three days of `apt-daily` and `apt-daily-upgrade` journal, the unattended-upgrades log tail, and the last dpkg upgrades |
27
28
  | `deploy-nginx` | install the tracked `nginx.conf`: `nginx -t` on the staged file, install, `nginx -t`, reload |
28
29
  | `reboot --confirm` | `systemctl reboot` |
@@ -45,8 +46,8 @@ Removing a hostname is the same with a removed line; check first that no public
45
46
 
46
47
  Wazuh reports a CVE against a package (the security specialist passes `package`, installed `version`, and `condition`, the fixed version). The VPS patches itself: unattended-upgrades installs Debian security updates once a day and never reboots. Answer which of three states applies:
47
48
 
48
- 1. `package <name>`: if the installed version already satisfies the condition, it is patched; Wazuh clears the finding on its next inventory scan.
49
- 2. Otherwise, if the candidate satisfies the condition, it is scheduled: `auto-upgrades` shows when `apt-daily-upgrade.timer` runs next. Say so with the time.
49
+ 1. `package <name>`, then `changelog <name>`: if the installed version comes from the security origin and its changelog entry names the CVE, it is patched; say by which upload and, from `auto-upgrades` or the dpkg log, when. Wazuh may keep flagging the new version until its feed learns the fixed version; that is feed lag, not an open hole.
50
+ 2. Otherwise, if the candidate's changelog names the CVE, it is scheduled: `auto-upgrades` shows when `apt-daily-upgrade.timer` runs next. Say so with the time.
50
51
  3. Otherwise the fix is not in the index yet, or `auto-upgrades` shows a failed run: report the versions and the log lines and stop. Manual upgrades are the operator's.
51
52
 
52
53
  After the upgrade, `status` shows whether a reboot is pending; a library upgrade may also leave a running service on the old copy until it restarts.
@@ -19,7 +19,7 @@ The `security` tool is read-only. Every call is a fixed command on the guest; `[
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) |
22
- | `wazuh-vulns <days> [agent]` | vulnerability alerts only: per item `agent`, `cve`, `severity`, `score`, `status` (Active or Solved), `package`, installed `version`, `condition` (the fix, e.g. "Package less than 1.26.1-0+deb13u1"), `references`; plus `active` and `solved` counts |
22
+ | `wazuh-vulns <days> [agent]` | vulnerability alerts only: `open` (latest event per agent, package, installed version and CVE that is still Active), `openWithoutFixVersion`, `active` and `solved` counts, and `items` (every event, newest first) with `cve`, `severity`, `score`, `status`, `package`, installed `version`, `condition`, `references` |
23
23
  | `wazuh-log <lines>` | manager container log |
24
24
  | `remote-hosts` | `host/program.log` names in the archive |
25
25
  | `remote-log <host> <program> <lines>` | tail of one archived log, e.g. `remote-log frontdoor-1337 nginx-stream 200` |
@@ -38,7 +38,10 @@ The `security` tool is read-only. Every call is a fixed command on the guest; `[
38
38
  - A CrowdSec alert without a decision means the scenario fired but the profile did not ban (whitelisted source or below threshold).
39
39
  - Front-door stream log line: `client - [time] "sni" status bytes_sent bytes_received session_time "upstream"`; upstream `-` means the SNI was rejected.
40
40
  - Wazuh rule `100010` (level 10) is a pending Debian reboot; `100011` is the RADIUS CRL threshold.
41
- - Wazuh rules `23503` to `23506` are vulnerability findings by severity, `23507` marks one solved. A finding is not evidence of exploitation. Read it with `wazuh-vulns`: the `condition` names the fixed version, so no external lookup is needed.
41
+ - Wazuh rules `23503` to `23506` are vulnerability findings by severity; `23502` and `23507` mark one solved. A finding is not evidence of exploitation.
42
+ - Read findings with `wazuh-vulns` and judge by `open`, not by the alert that reached Discord: Discord shows level 10 and above, and a Solved event is level 3, so a CVE can be closed one second after the message that announced it. If the CVE is absent from `open`, it is closed; say when it was solved.
43
+ - `condition` "Package less than X" names the fixed version. `condition` "Package default status" means Wazuh's feed lists the source package as affected in this Debian release with no fixed version yet, so it flags every installed version, including a fresh security update. When the owner confirms the installed version equals the security-origin candidate and the package changelog names the CVE, the host is patched and the finding waits for Wazuh's content update; report it that way.
44
+ - After a package upgrade, Wazuh re-evaluates: Solved events for the old version and Active events for the new one arrive in the same second. Pair them by CVE before concluding anything.
42
45
 
43
46
  ## Who owns each agent host
44
47
 
@@ -56,7 +59,7 @@ The `security` tool is read-only. Every call is a fixed command on the guest; `[
56
59
 
57
60
  You observe; the owning specialist acts. When a finding concerns a host in the table, call that specialist through `subagent` with the facts (`agent`, `package`, installed `version`, `condition`, `cve`) and a precise question, then report its answer with your own. Do not tell the caller to run commands on a host that has an owner.
58
61
 
59
- Debian guests and the VPS patch themselves through unattended-upgrades once a day and never reboot on their own. For a vulnerability alert, ask the owner whether the package is already upgraded, will be at the next run (the fix is in the index), or is stuck (the fix is not in the index or the run failed). Only the last case needs the operator; say so plainly, with the versions.
62
+ Debian guests and the VPS patch themselves through unattended-upgrades once a day and never reboot on their own. For a vulnerability alert, ask the owner whether the package is already upgraded, will be at the next run (the fix is in the index), or is stuck (the fix is not in the index or the run failed), and to confirm from the package changelog that the installed version names the CVE. Only the stuck case needs the operator; say so plainly, with the versions. Answer with one of: patched (version, when, by what), scheduled (next run time), stuck (why), or feed lag (patched, Wazuh not updated yet).
60
63
 
61
64
  ## Operator actions (describe, do not perform)
62
65
 
@@ -41,7 +41,7 @@ Wrappers ship as TypeScript and run under Node 24 type stripping: erasable synta
41
41
  - Fixed remote commands: `DMZ_COMMANDS`, `PULSAR_COMMANDS`, `VPS_COMMANDS`, `OBS_COMMANDS`, `BACKUP_COMMANDS`, `HOST_COMMANDS`, and `INFERENCE_COMMANDS` must match the entry scripts in `entry/remote/` and their tracked copies in the homelab repository (`02-pulsar-proxmox/dmz/system/`, `02-pulsar-proxmox/pulsar/system/`, `00-frontdoor-vps/system/`, `02-pulsar-proxmox/observability/system/`, `02-pulsar-proxmox/hermes/system/`, `04-nexus-macstudio/system/`, and `host/` of the `astronaute77/arcane` repository). Pulsar's root `authorized_keys` carries one forced key per specialist (network, backup, proxmox, inference), each pinned to its own entry script. Change both sides in the same commit and redeploy the remote script. A new privileged step on the VPS also needs its exact line in `spc-edge-frontdoor-sudoers`.
42
42
  - Repository access is `lib/repo.ts` (`repoCommand`, `RepoSpec`, `trackedFile`): one clone per specialist under `~/.specialists/work/<checkoutName>` (`homelab-edge`, `homelab-identity`; never share a checkout between specialists), a write deploy key, and an optional `writeRoot` that limits `git write`. `edge` (`deploy-nginx`) and `identity` (`dmz deploy-config`) deploy only what origin has: `trackedFile` refuses a dirty, ahead, or behind checkout. The DMZ entry keeps the previous template and restores it when render or restart fails.
43
43
  - `network` firewall policy writes are `policyWrite`: JSON body from the agent, checked for the documented required fields, sent as `POST`, `PUT`, or `DELETE` through the same pinned request; UniFi validates the rest.
44
- - The IAP tunnel is `lib/tunnel.ts`: `gcloud compute start-iap-tunnel <instance> 22 --local-host-port=localhost:<port>` (a documented flag; the hidden `--listen-on-stdin` is not used), ready when the port accepts a connection, closed after the ssh call. `gcloud auth activate-service-account` runs before every tunnel with `CLOUDSDK_CONFIG` under `~/.specialists/work/gcloud` and `CLOUDSDK_PYTHON` from the config, because the Homebrew cask ships no interpreter.
44
+ - `edge` retries one SSH call through the IAP tunnel when the first connection is closed during setup (`isTransientSshFailure`: exit 255 with "closed by remote host" or a reset), except `reboot`. The IAP tunnel is `lib/tunnel.ts`: `gcloud compute start-iap-tunnel <instance> 22 --local-host-port=localhost:<port>` (a documented flag; the hidden `--listen-on-stdin` is not used), ready when the port accepts a connection, closed after the ssh call. `gcloud auth activate-service-account` runs before every tunnel with `CLOUDSDK_CONFIG` under `~/.specialists/work/gcloud` and `CLOUDSDK_PYTHON` from the config, because the Homebrew cask ships no interpreter.
45
45
  - `dns` builds every Technitium call from `TECHNITIUM_READS` and `TECHNITIUM_WRITES`; `key=value` record parameters pass through by name (`token` and `node` refused) because the API documents dozens of type-specific parameters. Writes are refused on the secondary in code: the catalog zone is the only replication path.
46
46
  - `security` parses Wazuh alert lines locally (`summariseWazuh`, `summariseVulns`) so the guest never needs `jq`; `wazuh-vulns` is a local view over the same `wazuh-alerts` dump, not a guest command.
47
47
  - TLS pinning for self-signed consoles is `lib/pinned.ts` (`pinnedRequest`); `network` and `proxmox` use it with a `*CertSha256` config value obtained once through the wrapper's `fingerprint` command.
@@ -8,7 +8,7 @@ A specialist is a Pi subagent that is the only way to operate one area of the ho
8
8
  | `astro.identity` | Zitadel; Pomerium, nginx, lego on VM 104; `02-pulsar-proxmox/dmz/` in the homelab repository | Any Zitadel API call (v2, management, admin, auth) with responses redacted; `dmz status|journal|config-template|render|pomerium-restart|nginx-reload|lego-renew|deploy-config`; `git` on the homelab checkout, writes limited to `02-pulsar-proxmox/dmz/` |
9
9
  | `astro.network` | UniFi, FreeRADIUS (LXC 108), Tailscale (LXC 105) | Read UniFi sites, devices, clients, networks, zones, firewall policies, WANs; create, update, delete firewall policies; RADIUS and Tailscale checks on Pulsar |
10
10
  | `astro.dns` | Technitium primary (Synapse) and secondary (LXC 107); Cloudflare zones `37pla.net`, `monadeo.com` | Read zones and records on both resolvers, resolve, stats; create and delete zones, add, update, delete records on the primary; Cloudflare zones and record add, update, delete |
11
- | `astro.edge` | Frontdoor VPS (`frontdoor-1337`) over an IAP tunnel; `00-frontdoor-vps/` in the homelab repository | `probe` the public path; `vps status|nginx-conf|nginx-test|journal|wg|bouncer|updates|package|auto-upgrades|deploy-nginx|reboot --confirm`; `git` on the homelab checkout, writes limited to `00-frontdoor-vps/` |
11
+ | `astro.edge` | Frontdoor VPS (`frontdoor-1337`) over an IAP tunnel; `00-frontdoor-vps/` in the homelab repository | `probe` the public path; `vps status|nginx-conf|nginx-test|journal|wg|bouncer|updates|package|changelog|auto-upgrades|deploy-nginx|reboot --confirm`; `git` on the homelab checkout, writes limited to `00-frontdoor-vps/` |
12
12
  | `astro.security` | Wazuh, CrowdSec, rsyslog archive on VM 106 | Read-only: `status`, `attention`, `agents`, `alerts`, `decisions`, `bouncers`, `metrics`, `wazuh-alerts`, `wazuh-vulns`, `wazuh-log`, `remote-hosts`, `remote-log`, `journal` |
13
13
  | `astro.backup` | vzdump, the shared Restic repository (Pulsar host config, Arcane app backups), GCS offsite from LXC 102 | Status, journals, and manual runs of every layer; `restic-snapshots|stats|ls`, `restic-restore` into `/var/tmp/spc-restore` on Pulsar, `restore-clean` |
14
14
  | `astro.proxmox` | Proxmox VE on Pulsar | API token: `guests`, `guest`, `get <path>`, `tasks`; `start`, `shutdown|reboot|stop --confirm`; snapshots create, delete, rollback; `set` of CPU, memory, options, network. Pulsar key: `host-status`, `host-journal`, `updates`, `guest-exec <vmid> <status|journal|df|updates>` |
@@ -61,6 +61,7 @@ export const VPS_COMMANDS: Readonly<Record<string, { args: number; help: string;
61
61
  bouncer: { args: 0, help: "crowdsec-firewall-bouncer status and the nftables block set" },
62
62
  updates: { args: 0, help: "upgradable packages (local index, not refreshed) and the packages that need a reboot" },
63
63
  package: { args: 1, help: "package <name> refresh the apt index, then installed and candidate versions of one package" },
64
+ changelog: { args: 1, help: "changelog <name> Debian changelog of the candidate version, first 80 lines; it names the CVEs each upload fixes" },
64
65
  "auto-upgrades": { args: 0, help: "unattended-upgrades: apt timers, last daily runs, recent log, dpkg upgrades" },
65
66
  "deploy-nginx": { args: 0, help: "install the tracked nginx.conf from the pushed homelab checkout (nginx -t, install, reload)", stdin: true },
66
67
  reboot: { args: 0, help: "systemctl reboot (needs --confirm)" },
@@ -84,6 +85,11 @@ ${REPO_HELP}
84
85
 
85
86
  deploy-nginx refuses when the checkout is dirty or differs from origin: commit and push first.`;
86
87
 
88
+ /** The first connection through a fresh IAP tunnel is sometimes closed before sshd answers; that failure is safe to retry once. */
89
+ export function isTransientSshFailure(result: { code: number; stderr: string }): boolean {
90
+ return result.code === 255 && /closed by remote host|connection reset|connection refused|kex_exchange_identification/i.test(result.stderr);
91
+ }
92
+
87
93
  export function buildVpsRemote(args: string[]): { remote: string; stdin: boolean } {
88
94
  const [name, ...rest] = args;
89
95
  const spec = name === undefined ? undefined : VPS_COMMANDS[name];
@@ -97,7 +103,7 @@ export function buildVpsRemote(args: string[]): { remote: string; stdin: boolean
97
103
  if (!VPS_UNITS.includes(rest[0])) throw new UsageError(`unit must be one of ${VPS_UNITS.join(", ")}`);
98
104
  if (!SINCE.test(rest[1])) throw new UsageError("since: letters, digits, spaces, colon, plus, minus only");
99
105
  }
100
- if (name === "package" && !PACKAGE.test(rest[0])) throw new UsageError("package: a Debian package name (lower-case letters, digits, + - .)");
106
+ if ((name === "package" || name === "changelog") && !PACKAGE.test(rest[0])) throw new UsageError(`${name}: a Debian package name (lower-case letters, digits, + - .)`);
101
107
  return { remote: [name, ...rest].join(" "), stdin: spec.stdin === true };
102
108
  }
103
109
 
@@ -137,20 +143,20 @@ async function overTunnel(config: EdgeConfig, remote: string, timeoutMs: number,
137
143
  ],
138
144
  { env: gcloudEnv(config), port: config.iapLocalPort, readyTimeoutMs: 45_000 },
139
145
  );
146
+ const target = {
147
+ host: "127.0.0.1",
148
+ port: config.iapLocalPort,
149
+ hostKeyAlias: config.instance,
150
+ user: config.sshUser,
151
+ keyFile: secretPath(SERVICE, "SSH_KEY_FRONTDOOR"),
152
+ knownHostsFile: join(specialistsHome(), "config", "known_hosts"),
153
+ };
140
154
  try {
141
- return await sshFixed(
142
- {
143
- host: "127.0.0.1",
144
- port: config.iapLocalPort,
145
- hostKeyAlias: config.instance,
146
- user: config.sshUser,
147
- keyFile: secretPath(SERVICE, "SSH_KEY_FRONTDOOR"),
148
- knownHostsFile: join(specialistsHome(), "config", "known_hosts"),
149
- },
150
- remote,
151
- timeoutMs,
152
- input,
153
- );
155
+ const first = await sshFixed(target, remote, timeoutMs, input);
156
+ // A connection closed during setup never ran the command; a reboot is the one command not to repeat.
157
+ if (!isTransientSshFailure(first) || remote === "reboot") return first;
158
+ await new Promise((resolve) => setTimeout(resolve, 2000));
159
+ return await sshFixed(target, remote, timeoutMs, input);
154
160
  } finally {
155
161
  await tunnel.close();
156
162
  }
@@ -42,6 +42,10 @@ case "$name" in
42
42
  dpkg-query -W -f='${Package} ${Version} ${db:Status-Abbrev}\n' "$pkg" 2>&1 || true
43
43
  echo "policy:"
44
44
  apt-cache policy "$pkg" ;;
45
+ changelog)
46
+ pkg="$rest"
47
+ printf '%s' "$pkg" | grep -Eq '^[a-z0-9][a-z0-9+.-]{0,63}$' || { echo "bad package name" >&2; exit 2; }
48
+ apt-get changelog "$pkg" 2>&1 | head -n 80 ;;
45
49
  auto-upgrades)
46
50
  systemctl list-timers apt-daily.timer apt-daily-upgrade.timer --all --no-pager
47
51
  echo "--- apt-daily and apt-daily-upgrade journal, 3 days:"
@@ -265,6 +265,10 @@ export interface VulnerabilitySummary {
265
265
  count: number;
266
266
  active: number;
267
267
  solved: number;
268
+ /** Latest event per agent, package, installed version and CVE whose status is still Active: the findings that are open now. */
269
+ open: VulnerabilityItem[];
270
+ /** Open findings whose condition is "Package default status": the feed knows no fixed version, so no version clears them. */
271
+ openWithoutFixVersion: number;
268
272
  items: VulnerabilityItem[];
269
273
  }
270
274
 
@@ -375,12 +379,20 @@ export function summariseVulns(text: string, days: number, agent: string | null,
375
379
  });
376
380
  }
377
381
  items.sort((x, y) => y.timestamp.localeCompare(x.timestamp));
382
+ const latest = new Map<string, VulnerabilityItem>();
383
+ for (const item of items) {
384
+ const key = `${item.agent}|${item.package}|${item.version}|${item.cve}`;
385
+ if (!latest.has(key)) latest.set(key, item);
386
+ }
387
+ const open = [...latest.values()].filter((i) => i.status.toLowerCase() === "active");
378
388
  return {
379
389
  days,
380
390
  agent,
381
391
  count: items.length,
382
392
  active: items.filter((i) => i.status.toLowerCase() === "active").length,
383
393
  solved: items.filter((i) => i.status.toLowerCase() === "solved").length,
394
+ open: open.slice(0, 50),
395
+ openWithoutFixVersion: open.filter((i) => i.condition === "Package default status").length,
384
396
  items: items.slice(0, 50),
385
397
  };
386
398
  }
@@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
5
  import { refusedPrefix, command as arcaneCommand } from "./arcane/run.ts";
6
6
  import { buildBackupRemote, command as backupCommand } from "./backup/run.ts";
7
7
  import { parseParams, parseRecordFilters, technitiumRequest, command as dnsCommand } from "./dns/run.ts";
8
- import { buildVpsRemote, command as edgeCommand } from "./edge/run.ts";
8
+ import { buildVpsRemote, command as edgeCommand, isTransientSshFailure } from "./edge/run.ts";
9
9
  import { API_PREFIXES, buildDmzRemote, validateApiPath, command as identityCommand } from "./identity/run.ts";
10
10
  import { buildInferenceRemote, compactReleases, command as inferenceCommand } from "./inference/run.ts";
11
11
  import { UsageError } from "./lib/errors.ts";
@@ -194,6 +194,11 @@ describe("edge wrapper", () => {
194
194
  expect(() => buildVpsRemote(["wg", "extra"])).toThrow(/0 argument/);
195
195
  expect(buildVpsRemote(["package", "libunbound8"])).toEqual({ remote: "package libunbound8", stdin: false });
196
196
  expect(buildVpsRemote(["auto-upgrades"])).toEqual({ remote: "auto-upgrades", stdin: false });
197
+ expect(buildVpsRemote(["changelog", "unbound"])).toEqual({ remote: "changelog unbound", stdin: false });
198
+ expect(() => buildVpsRemote(["changelog", "../x"])).toThrow(/changelog/);
199
+ expect(isTransientSshFailure({ code: 255, stderr: "Connection to 127.0.0.1 closed by remote host.\r\n" })).toBe(true);
200
+ expect(isTransientSshFailure({ code: 255, stderr: "Permission denied (publickey)." })).toBe(false);
201
+ expect(isTransientSshFailure({ code: 2, stderr: "refused: shell" })).toBe(false);
197
202
  expect(() => buildVpsRemote(["package", "Lib;rm"])).toThrow(/package/);
198
203
  expect(() => buildVpsRemote(["package"])).toThrow(/1 argument/);
199
204
  expect(() => buildVpsRemote(["shell"])).toThrow(/unknown vps command/);
@@ -263,6 +268,36 @@ describe("security wrapper", () => {
263
268
  expect(summary.items[0]).toMatchObject({ agent: "frontdoor-1337", cve: "CVE-2026-50252", score: 9.8, status: "Active", package: "libunbound8", version: "1.26.0-1", condition: "Package less than 1.26.1-0+deb13u1", references: ["https://a", "https://b"], level: 13 });
264
269
  expect(summary.items[1]).toMatchObject({ cve: "CVE-2016-4484", score: 6.8, status: "Fixed", references: ["https://x", "https://y"] });
265
270
  expect(summariseVulns([modern, legacy].join("\n"), 3, "dmz", now).items.map((i) => i.agent)).toEqual(["dmz"]);
271
+ const solvedLater = JSON.stringify({
272
+ timestamp: "2026-09-20T07:07:48.433+0000",
273
+ rule: { id: "23502", level: 3, description: "CVE-2026-50252 affecting libunbound8 was solved" },
274
+ agent: { name: "frontdoor-1337" },
275
+ data: { vulnerability: { cve: "CVE-2026-50252", status: "Solved", package: { name: "libunbound8", version: "1.26.0-1" } } },
276
+ });
277
+ const unfixed = JSON.stringify({
278
+ timestamp: "2026-09-20T07:07:47.672+0000",
279
+ rule: { id: "23504", level: 7, description: "CVE-2026-55990 affects libunbound8" },
280
+ agent: { name: "frontdoor-1337" },
281
+ data: { vulnerability: { cve: "CVE-2026-55990", severity: "Medium", status: "Active", package: { name: "libunbound8", version: "1.26.1-0+deb13u1", condition: "Package default status" } } },
282
+ });
283
+ // After an upgrade Wazuh closes the CVE on the old version and may reopen it on the new one; the version keeps them apart.
284
+ const activeNew = JSON.stringify({
285
+ timestamp: "2026-09-20T07:07:47.889+0000",
286
+ rule: { id: "23506", level: 13, description: "CVE-2026-50252 affects libunbound8" },
287
+ agent: { name: "frontdoor-1337" },
288
+ data: { vulnerability: { cve: "CVE-2026-50252", severity: "Critical", status: "Active", package: { name: "libunbound8", version: "1.26.1-0+deb13u1", condition: "Package default status" }, score: { base: 9.3 } } },
289
+ });
290
+ const solvedOld = JSON.stringify({
291
+ timestamp: "2026-09-20T07:07:48.433+0000",
292
+ rule: { id: "23502", level: 3, description: "CVE-2026-50252 affecting libunbound8 was solved" },
293
+ agent: { name: "frontdoor-1337" },
294
+ data: { vulnerability: { cve: "CVE-2026-50252", status: "Solved", package: { name: "libunbound8", version: "1.22.0-2+deb13u3" } } },
295
+ });
296
+ const paired = summariseVulns([modern, solvedLater, unfixed, activeNew, solvedOld].join("\n"), 3, null, now);
297
+ expect(paired.open.map((i) => `${i.cve}@${i.version}`)).toEqual(["CVE-2026-50252@1.26.1-0+deb13u1", "CVE-2026-55990@1.26.1-0+deb13u1"]);
298
+ expect(paired.openWithoutFixVersion).toBe(2);
299
+ expect(paired.active).toBe(3);
300
+ expect(paired.solved).toBe(2);
266
301
  expect(summariseVulns(modern, 1, null, now + 3 * 86_400_000).count).toBe(0);
267
302
  });
268
303