@celilo/cli 2.2.1 → 3.0.0
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/CELILO_CORE_MODULES.md +2 -1
- package/CELILO_SUBSYSTEMS.md +1 -1
- package/README.md +1 -1
- package/drizzle/0032_module_jail_policies.sql +28 -0
- package/drizzle/0033_build_bus_hook_runs.sql +37 -0
- package/drizzle/meta/_journal.json +15 -1
- package/package.json +3 -3
- package/src/__integration__/container-services-cli.integration.test.ts +1 -1
- package/src/api/serve.ts +13 -1
- package/src/api/sessions.test.ts +3 -3
- package/src/api-clients/proxmox.ts +10 -7
- package/src/cli/backup-rename.test.ts +3 -3
- package/src/cli/cli.test.ts +1 -1
- package/src/cli/commands/events.test.ts +2 -2
- package/src/cli/commands/firewall-interface-list.test.ts +2 -2
- package/src/cli/commands/machine-list.test.ts +57 -0
- package/src/cli/commands/machine-list.ts +35 -4
- package/src/cli/commands/module-health.test.ts +35 -0
- package/src/cli/commands/module-health.ts +12 -3
- package/src/cli/commands/module-import-registry.test.ts +1 -1
- package/src/cli/commands/module-jail.test.ts +242 -0
- package/src/cli/commands/module-jail.ts +227 -0
- package/src/cli/commands/module-list-jail.test.ts +136 -0
- package/src/cli/commands/module-list.ts +30 -3
- package/src/cli/commands/module-publish.test.ts +28 -6
- package/src/cli/commands/module-publish.ts +14 -12
- package/src/cli/commands/module-update.test.ts +22 -7
- package/src/cli/commands/module-update.ts +73 -18
- package/src/cli/commands/module-upgrade-gate.test.ts +154 -0
- package/src/cli/commands/module-upgrade.test.ts +115 -1
- package/src/cli/commands/module-upgrade.ts +66 -4
- package/src/cli/commands/module-verify.test.ts +1 -1
- package/src/cli/commands/publish/execute.ts +4 -1
- package/src/cli/commands/publish/helpers.ts +4 -3
- package/src/cli/commands/publish/index.ts +23 -1
- package/src/cli/commands/publish/module-registry.test.ts +24 -1
- package/src/cli/commands/publish/module-registry.ts +23 -4
- package/src/cli/commands/publish/plan.test.ts +64 -0
- package/src/cli/commands/publish/plan.ts +52 -19
- package/src/cli/commands/publish/types.ts +20 -0
- package/src/cli/commands/registry-owner.test.ts +1 -1
- package/src/cli/commands/registry-token.test.ts +1 -1
- package/src/cli/commands/subscribers-install-daemon.test.ts +44 -0
- package/src/cli/commands/subscribers-install-daemon.ts +107 -0
- package/src/cli/commands/subscribers-serve.test.ts +22 -0
- package/src/cli/commands/subscribers-serve.ts +22 -4
- package/src/cli/commands/system-audit.ts +34 -11
- package/src/cli/commands/system-doctor-remediation-gate.test.ts +295 -0
- package/src/cli/commands/system-doctor.test.ts +76 -10
- package/src/cli/commands/system-doctor.ts +34 -3
- package/src/cli/commands/system-init-deprecation.test.ts +1 -1
- package/src/cli/commands/system-update.ts +16 -6
- package/src/cli/completion.ts +18 -3
- package/src/cli/flag-surface-gate.test.ts +279 -0
- package/src/cli/fuel-gauge.ts +12 -4
- package/src/cli/index.ts +80 -2
- package/src/cli/json-output.test.ts +81 -0
- package/src/cli/parser.test.ts +37 -1
- package/src/cli/restore-command.test.ts +3 -3
- package/src/cli/restore-migration-failure.test.ts +2 -2
- package/src/cli/tui/audit-state.ts +2 -0
- package/src/cli/types.ts +9 -0
- package/src/config/paths.test.ts +19 -19
- package/src/db/client.test.ts +46 -1
- package/src/db/client.ts +26 -0
- package/src/db/schema.ts +63 -0
- package/src/hooks/broker.test.ts +9 -3
- package/src/hooks/capability-loader-firewall.test.ts +9 -1
- package/src/hooks/capability-loader.ts +8 -0
- package/src/hooks/executor.test.ts +91 -14
- package/src/hooks/executor.ts +31 -1
- package/src/hooks/hook-jail-toolchain-reach.test.ts +8 -3
- package/src/hooks/hook-jail-unreachability.test.ts +27 -20
- package/src/hooks/hook-trespass.test.ts +22 -6
- package/src/hooks/jail.test.ts +105 -17
- package/src/hooks/jail.ts +47 -10
- package/src/hooks/run-named-hook.ts +19 -16
- package/src/hooks/test-fixtures/artifact-writing-hook.ts +0 -1
- package/src/hooks/test-fixtures/capability-calling-hook.ts +20 -13
- package/src/hooks/test-fixtures/jail-probe-hook.ts +9 -1
- package/src/hooks/test-fixtures/jail-toolchain-hook.ts +7 -2
- package/src/hooks/test-fixtures/runaway-hook.ts +0 -1
- package/src/hooks/test-fixtures/sigterm-ignoring-hook.ts +0 -1
- package/src/hooks/test-fixtures/silent-hook.ts +0 -1
- package/src/hooks/test-fixtures/store-writing-hook.ts +20 -11
- package/src/hooks/test-fixtures/success-hook.ts +4 -4
- package/src/manifest/contracts/v1.ts +32 -14
- package/src/manifest/json-schema-roundtrip.test.ts +1 -1
- package/src/manifest/schema.ts +66 -16
- package/src/manifest/validate.test.ts +47 -0
- package/src/policy/capability-shape-baseline.ts +63 -21
- package/src/policy/capability-shape-drift.test.ts +53 -1
- package/src/policy/capability-shape.test.ts +105 -0
- package/src/policy/capability-shape.ts +283 -2
- package/src/policy/module-script-scan.ts +52 -68
- package/src/policy/no-hand-built-ssh.test.ts +5 -1
- package/src/policy/no-swallowed-refusal.test.ts +14 -14
- package/src/registry/client.test.ts +67 -2
- package/src/registry/client.ts +7 -7
- package/src/secrets/storage.test.ts +70 -4
- package/src/secrets/storage.ts +71 -1
- package/src/services/alerting/keys.test.ts +4 -0
- package/src/services/alerting/keys.ts +12 -2
- package/src/services/audit/health.test.ts +155 -2
- package/src/services/audit/health.ts +76 -2
- package/src/services/audit/index.test.ts +23 -1
- package/src/services/audit/index.ts +8 -2
- package/src/services/audit/interface-classification.test.ts +16 -5
- package/src/services/audit/interface-classification.ts +25 -2
- package/src/services/audit/jail-exemptions.test.ts +42 -0
- package/src/services/audit/jail-exemptions.ts +44 -0
- package/src/services/audit/module-integrity.test.ts +23 -1
- package/src/services/audit/module-integrity.ts +7 -2
- package/src/services/audit/module-versions.ts +5 -1
- package/src/services/audit/public-dns.test.ts +20 -0
- package/src/services/audit/public-dns.ts +7 -2
- package/src/services/audit/recurrence-gate.test.ts +225 -0
- package/src/services/audit/trusted-sources.test.ts +17 -0
- package/src/services/audit/trusted-sources.ts +21 -0
- package/src/services/audit/types.ts +2 -1
- package/src/services/backup-create.ts +9 -0
- package/src/services/backup-envelope-roundtrip.test.ts +1 -1
- package/src/services/backup-in-flight-refusal.test.ts +1 -1
- package/src/services/build-bus/hook-dispatch-executor.test.ts +269 -0
- package/src/services/build-bus/hook-dispatch-mgmt.test.ts +105 -105
- package/src/services/build-bus/hook-dispatch-path.test.ts +95 -0
- package/src/services/build-bus/hook-dispatch.test.ts +86 -116
- package/src/services/build-bus/hook-dispatch.ts +99 -121
- package/src/services/build-bus/hook-dispatcher.ts +143 -17
- package/src/services/build-bus/receiver-daemon.test.ts +189 -0
- package/src/services/build-bus/receiver-daemon.ts +355 -0
- package/src/services/build-bus/self-update.ts +156 -0
- package/src/services/bus-ensure-flow.test.ts +1 -1
- package/src/services/bus-interview-park.test.ts +2 -2
- package/src/services/bus-interview.test.ts +2 -2
- package/src/services/bus-secret-flow.test.ts +1 -1
- package/src/services/capability-compat.test.ts +90 -0
- package/src/services/capability-compat.ts +128 -0
- package/src/services/celilo-events.test.ts +1 -1
- package/src/services/celilo-mgmt-hooks.test.ts +23 -5
- package/src/services/container-service.test.ts +1 -1
- package/src/services/cross-module-read.test.ts +2 -2
- package/src/services/deploy-preflight.ts +7 -0
- package/src/services/deploy-terraform.ts +38 -1
- package/src/services/deployed-systems.ts +1 -1
- package/src/services/events-daemon.test.ts +57 -0
- package/src/services/events-daemon.ts +76 -0
- package/src/services/firewall-reach.ts +21 -8
- package/src/services/fleet-checks.test.ts +159 -4
- package/src/services/fleet-checks.ts +206 -3
- package/src/services/health-runner.test.ts +87 -2
- package/src/services/health-runner.ts +83 -16
- package/src/services/infrastructure-selector.test.ts +1 -1
- package/src/services/jail-exemptions.test.ts +125 -0
- package/src/services/jail-exemptions.ts +81 -0
- package/src/services/machine-pool.test.ts +1 -1
- package/src/services/module-deploy-prune.test.ts +89 -0
- package/src/services/module-deploy.ts +84 -129
- package/src/services/module-subscriptions.test.ts +2 -2
- package/src/services/module-types-drift.test.ts +1 -1
- package/src/services/module-validator/capability-versions.test.ts +13 -2
- package/src/services/network-discovery.test.ts +64 -1
- package/src/services/network-discovery.ts +30 -5
- package/src/services/responder-probe.test.ts +1 -1
- package/src/services/restore-from-file.test.ts +4 -4
- package/src/services/restore-preflight.test.ts +1 -1
- package/src/services/ssh-key-manager.test.ts +2 -2
- package/src/services/system-state-stage.test.ts +2 -2
- package/src/services/terraform-safety.test.ts +83 -0
- package/src/services/terraform-safety.ts +53 -0
- package/src/services/update/orchestrator.test.ts +3 -1
- package/src/test-utils/bus-responder.ts +1 -1
- package/tsconfig.json +2 -13
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the `jail_exemptions` audit category (per-module-jail-policy
|
|
3
|
+
* task 3.3/3.4).
|
|
4
|
+
*
|
|
5
|
+
* The severity contract is the point: an operator-recorded exemption is
|
|
6
|
+
* `todo`, never `drift`, so `system update` never treats it as work. And the
|
|
7
|
+
* negative half: a fleet with no exemptions contributes no findings.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, expect, test } from 'bun:test';
|
|
11
|
+
import { auditJailExemptions } from './jail-exemptions';
|
|
12
|
+
|
|
13
|
+
const EXEMPT = { moduleId: 'caddy', policy: 'off' as const, systemPolicy: 'auto' as const };
|
|
14
|
+
|
|
15
|
+
describe('auditJailExemptions (per-module-jail-policy task 3.3)', () => {
|
|
16
|
+
test('no exemptions produce no findings (the negative half of task 3.4)', async () => {
|
|
17
|
+
expect(await auditJailExemptions({ exemptions: [] })).toEqual([]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('each exemption is one finding, severity todo, never drift', async () => {
|
|
21
|
+
const findings = await auditJailExemptions({
|
|
22
|
+
exemptions: [EXEMPT, { moduleId: 'vault', policy: 'auto', systemPolicy: 'required' }],
|
|
23
|
+
});
|
|
24
|
+
expect(findings).toHaveLength(2);
|
|
25
|
+
for (const f of findings) {
|
|
26
|
+
expect(f.category).toBe('jail_exemptions');
|
|
27
|
+
expect(f.severity).toBe('todo');
|
|
28
|
+
expect(f.severity).not.toBe('drift');
|
|
29
|
+
expect(f.code).toBe('hook_jail_exemption');
|
|
30
|
+
expect(f.actionable).toBe(false);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('the finding names the module and both policies', async () => {
|
|
35
|
+
const [finding] = await auditJailExemptions({ exemptions: [EXEMPT] });
|
|
36
|
+
expect(finding).toBeDefined();
|
|
37
|
+
expect(finding?.subject).toBe('caddy');
|
|
38
|
+
expect(finding?.message).toContain('caddy');
|
|
39
|
+
expect(finding?.message).toContain("'off'");
|
|
40
|
+
expect(finding?.message).toContain("'auto'");
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-module hook jail exemption check (per-module-jail-policy task 3.3).
|
|
3
|
+
*
|
|
4
|
+
* One finding per module the operator exempted from the fleet's jail policy.
|
|
5
|
+
* Severity `todo`, deliberately NOT `drift`: the operator chose this, so it
|
|
6
|
+
* is a list of recorded exceptions to revisit, not a system that moved away
|
|
7
|
+
* from a desired state. `todo` never escalates the verdict past READY, which
|
|
8
|
+
* is what keeps `system update` from treating an exemption as work to do.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { JailExemption } from '../jail-exemptions';
|
|
12
|
+
import type { DriftFinding } from './types';
|
|
13
|
+
|
|
14
|
+
export interface JailExemptionsAuditDeps {
|
|
15
|
+
/**
|
|
16
|
+
* The exemptions, from the shared collector (`services/jail-exemptions.ts`).
|
|
17
|
+
* A fleet with none contributes nothing — that is the normal state, and
|
|
18
|
+
* emitting a "no exemptions" finding would be a surface reporting an
|
|
19
|
+
* exemption nobody set (task 3.4).
|
|
20
|
+
*/
|
|
21
|
+
exemptions: JailExemption[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function auditJailExemptions(deps: JailExemptionsAuditDeps): Promise<DriftFinding[]> {
|
|
25
|
+
return deps.exemptions.map((exemption) => ({
|
|
26
|
+
category: 'jail_exemptions' as const,
|
|
27
|
+
severity: 'todo' as const,
|
|
28
|
+
code: 'hook_jail_exemption',
|
|
29
|
+
subject: exemption.moduleId,
|
|
30
|
+
message: `${exemption.moduleId} runs under jail policy '${exemption.policy}', weaker than the system's '${exemption.systemPolicy}'`,
|
|
31
|
+
details: [
|
|
32
|
+
'Its hooks run with the weaker posture while the rest of the fleet jails.',
|
|
33
|
+
'The operator recorded this per-module policy with `celilo module jail`.',
|
|
34
|
+
'Exemptions are deliberate, which is why this is todo and not drift —',
|
|
35
|
+
'but an exemption nobody remembers is how a jail quietly stops covering',
|
|
36
|
+
'the fleet. Revisit it: either the reason still holds, or clear it.',
|
|
37
|
+
'',
|
|
38
|
+
`To review: celilo module jail ${exemption.moduleId}`,
|
|
39
|
+
`To clear: celilo module jail ${exemption.moduleId} --clear`,
|
|
40
|
+
].join('\n'),
|
|
41
|
+
remediation: `Clear it with 'celilo module jail ${exemption.moduleId} --clear' if the module now jails cleanly`,
|
|
42
|
+
actionable: false,
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
@@ -58,7 +58,9 @@ describe('auditModuleIntegrity', () => {
|
|
|
58
58
|
expect(findings).toHaveLength(1);
|
|
59
59
|
expect(findings[0]?.code).toBe('module_integrity_stale_baseline');
|
|
60
60
|
expect(findings[0]?.severity).toBe('unmeasured');
|
|
61
|
-
expect(findings[0]?.remediation).toBe(
|
|
61
|
+
expect(findings[0]?.remediation).toBe(
|
|
62
|
+
'no automatic remediation: the baseline predates version stamping',
|
|
63
|
+
);
|
|
62
64
|
});
|
|
63
65
|
|
|
64
66
|
test('file drift under a TRUSTWORTHY baseline is drift, and lists every file', () => {
|
|
@@ -122,4 +124,24 @@ describe('auditModuleIntegrity', () => {
|
|
|
122
124
|
expect(f.actionable).toBe(true);
|
|
123
125
|
}
|
|
124
126
|
});
|
|
127
|
+
|
|
128
|
+
test('a stale baseline says there is no command, it does not prescribe one (celilo#1308)', () => {
|
|
129
|
+
const findings = auditModuleIntegrity({
|
|
130
|
+
results: [
|
|
131
|
+
{
|
|
132
|
+
...clean('c'),
|
|
133
|
+
success: false,
|
|
134
|
+
violations: [
|
|
135
|
+
{ type: 'stale-baseline', path: 'x', message: 'baseline is v1, manifest v2' },
|
|
136
|
+
],
|
|
137
|
+
},
|
|
138
|
+
],
|
|
139
|
+
});
|
|
140
|
+
expect(findings).toHaveLength(1);
|
|
141
|
+
expect(findings[0].code).toBe('module_integrity_stale_baseline');
|
|
142
|
+
// `module update` takes a source path, not the module id this finding
|
|
143
|
+
// carries, and no other verb restamps a baseline. Name the gap.
|
|
144
|
+
expect(findings[0].remediation).not.toMatch(/celilo /);
|
|
145
|
+
expect(findings[0].actionable).toBe(false);
|
|
146
|
+
});
|
|
125
147
|
});
|
|
@@ -52,8 +52,13 @@ export function auditModuleIntegrity(deps: ModuleIntegrityAuditDeps): DriftFindi
|
|
|
52
52
|
severity: 'unmeasured',
|
|
53
53
|
code: 'module_integrity_stale_baseline',
|
|
54
54
|
message: `${result.moduleId}: ${violation.message}`,
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
// No command restamps a baseline. `module update` takes a module
|
|
56
|
+
// source PATH, not an id (celilo#1308: prescribing it with the id
|
|
57
|
+
// named an argument form that does not exist), and `module upgrade`
|
|
58
|
+
// deploys the registry version instead. Say so rather than name a
|
|
59
|
+
// command that cannot run.
|
|
60
|
+
remediation: 'no automatic remediation: the baseline predates version stamping',
|
|
61
|
+
actionable: false,
|
|
57
62
|
subject: result.moduleId,
|
|
58
63
|
});
|
|
59
64
|
}
|
|
@@ -84,7 +84,11 @@ export async function auditModuleVersions(deps: ModuleVersionsAuditDeps): Promis
|
|
|
84
84
|
const [reason] = errorBuckets.keys();
|
|
85
85
|
findings.push({
|
|
86
86
|
category: 'module_versions',
|
|
87
|
-
|
|
87
|
+
// Same fact as the per-module rejection below: the registry did not
|
|
88
|
+
// answer, so nothing was measured. D7 severity, not drift — this
|
|
89
|
+
// aggregate used to say "drift" and rendered the fleet DRIFT when
|
|
90
|
+
// the truth was "I could not ask".
|
|
91
|
+
severity: 'unmeasured',
|
|
88
92
|
code: 'registry_unreachable',
|
|
89
93
|
message: `Registry unreachable — ${rejected.length} module${rejected.length === 1 ? '' : 's'} unchecked (${reason})`,
|
|
90
94
|
remediation: 'Check network connectivity to the registry, then press R to re-audit.',
|
|
@@ -139,6 +139,9 @@ describe('auditPublicDns', () => {
|
|
|
139
139
|
// Distinguishable from "the site is unreachable" — this says nothing is
|
|
140
140
|
// currently checking, which is the state the outage hid behind.
|
|
141
141
|
expect(findings[0].message).toContain('unverifiable');
|
|
142
|
+
// D7: "could not tell" is unmeasured, not drift. It must stop the verdict
|
|
143
|
+
// at UNKNOWN, never render as an ordinary divergence.
|
|
144
|
+
expect(findings[0].severity).toBe('unmeasured');
|
|
142
145
|
});
|
|
143
146
|
|
|
144
147
|
test('a run that obtained evidence resets the count', async () => {
|
|
@@ -189,6 +192,23 @@ describe('auditPublicDns', () => {
|
|
|
189
192
|
expect(findings.map((f) => f.code)).toEqual(['public_dns_missing']);
|
|
190
193
|
});
|
|
191
194
|
|
|
195
|
+
test('a name the resolver will not answer for is unmeasured once it stops being a blip (D7)', async () => {
|
|
196
|
+
const args = () => ({
|
|
197
|
+
records: [declared('nexus.lunacycle.net')],
|
|
198
|
+
probe: probe({
|
|
199
|
+
answers: { 'nexus.lunacycle.net': { kind: 'undetermined' as const, reason: 'timeout' } },
|
|
200
|
+
}),
|
|
201
|
+
undeterminedThreshold: 1,
|
|
202
|
+
now: NOW,
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const findings = await auditPublicDns(args());
|
|
206
|
+
expect(findings.map((f) => f.code)).toEqual(['public_dns_unverifiable']);
|
|
207
|
+
// The resolver did not answer, so the truth about this name is unknown —
|
|
208
|
+
// not fine, not broken. It must stop the verdict at UNKNOWN.
|
|
209
|
+
expect(findings[0].severity).toBe('unmeasured');
|
|
210
|
+
});
|
|
211
|
+
|
|
192
212
|
test('an empty ledger never touches the network', async () => {
|
|
193
213
|
const findings = await auditPublicDns({
|
|
194
214
|
records: [],
|
|
@@ -172,7 +172,10 @@ export async function auditPublicDns(deps: PublicDnsAuditDeps): Promise<DriftFin
|
|
|
172
172
|
if (runs >= threshold) {
|
|
173
173
|
findings.push({
|
|
174
174
|
category: 'public_dns',
|
|
175
|
-
|
|
175
|
+
// The vantage point is gone: nothing was measured this run, and D7
|
|
176
|
+
// says that is `unmeasured`, not `drift`. This finding was `drift`
|
|
177
|
+
// before D7 existed; its own text already said what unmeasured means.
|
|
178
|
+
severity: 'unmeasured',
|
|
176
179
|
code: 'public_dns_unverifiable',
|
|
177
180
|
message: `Public DNS has been unverifiable for ${runs} consecutive checks`,
|
|
178
181
|
details: `The echo service (${deps.probe.echoService}) could not be reached, so there is no\nexpectation to compare public DNS against. Latest reason: ${ingress.reason}.\n\nThis is NOT a report that the fleet is reachable, and not a report that it\nis unreachable — it is a report that nothing is currently checking. The\noutage this check exists for ran for nine days behind exactly this silence.`,
|
|
@@ -196,7 +199,9 @@ export async function auditPublicDns(deps: PublicDnsAuditDeps): Promise<DriftFin
|
|
|
196
199
|
if (runs >= threshold) {
|
|
197
200
|
findings.push({
|
|
198
201
|
category: 'public_dns',
|
|
199
|
-
|
|
202
|
+
// The resolver would not answer for this name, so whether it resolves
|
|
203
|
+
// publicly is unknown — not fine, not broken. Unmeasured per D7.
|
|
204
|
+
severity: 'unmeasured',
|
|
200
205
|
code: 'public_dns_unverifiable',
|
|
201
206
|
message: `${record.fqdn}: public reachability unverifiable for ${runs} consecutive checks`,
|
|
202
207
|
details: `${deps.probe.resolver} did not answer for this name. Latest reason: ${resolution.reason}.\nWhether the name resolves publicly is currently unknown — which is not the\nsame as it being fine.`,
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The recurrence gate (celilo#1326, part two).
|
|
3
|
+
*
|
|
4
|
+
* D7's rule: every claim celilo makes about the fleet is measured against the
|
|
5
|
+
* thing it describes, or reported as `unmeasured`. There is no third option,
|
|
6
|
+
* because contributing nothing renders the category green and the verdict
|
|
7
|
+
* READY — the defect shape #1326 exists to close.
|
|
8
|
+
*
|
|
9
|
+
* A category can silently violate that rule three ways: someone adds a new
|
|
10
|
+
* DriftCategory with no unmeasured path, someone rewires a category's
|
|
11
|
+
* "could not reach" case to invent a finding of the wrong severity, or a
|
|
12
|
+
* refactor drops the path entirely. This gate catches all three by walking
|
|
13
|
+
* EVERY category — computed from the type, not from a list someone maintains
|
|
14
|
+
* by hand — and demanding each either:
|
|
15
|
+
*
|
|
16
|
+
* - `probed`: its audit function, given deps that cannot reach their
|
|
17
|
+
* subject, produces at least one `unmeasured` finding; or
|
|
18
|
+
* - `allow-listed`: the category structurally always reaches its subject,
|
|
19
|
+
* with the reason recorded here.
|
|
20
|
+
*
|
|
21
|
+
* `Record<DriftCategory, GateEntry>` is the enforcement: a category added to
|
|
22
|
+
* the union without a gate entry is a type error, and the author has to
|
|
23
|
+
* answer the question this test exists to ask.
|
|
24
|
+
*
|
|
25
|
+
* WATCHING IT FAIL (Rule 7.6): revert ce-prr5's `health.ts` change (the
|
|
26
|
+
* `no-checks` branch in auditHealth) and the `health` entry fails — health
|
|
27
|
+
* produces nothing for a module with no hook, which is exactly how six
|
|
28
|
+
* modules rendered READY while never having been measured.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { describe, expect, test } from 'bun:test';
|
|
32
|
+
import { ALL_CATEGORIES } from '../../cli/tui/audit-state';
|
|
33
|
+
import type { DbClient } from '../../db/client';
|
|
34
|
+
import { auditCliVersion } from './cli-version';
|
|
35
|
+
import { auditDiskSpace } from './disk-space';
|
|
36
|
+
import { auditHealth } from './health';
|
|
37
|
+
import { auditInterfaceClassification } from './interface-classification';
|
|
38
|
+
import { auditModuleIntegrity } from './module-integrity';
|
|
39
|
+
import { auditModuleVersions } from './module-versions';
|
|
40
|
+
import { auditPublicDns } from './public-dns';
|
|
41
|
+
import { auditSchema } from './schema';
|
|
42
|
+
import { auditTerraformPlan } from './terraform-plan';
|
|
43
|
+
import { auditTrustedSources } from './trusted-sources';
|
|
44
|
+
import type { DriftCategory, DriftFinding, DriftSeverity } from './types';
|
|
45
|
+
|
|
46
|
+
type GateEntry = {
|
|
47
|
+
/** The probe: call the category's audit with deps that cannot reach their subject. */
|
|
48
|
+
readonly probe?: () => Promise<DriftFinding[]> | DriftFinding[];
|
|
49
|
+
/** Why the category structurally always reaches its subject. */
|
|
50
|
+
readonly because?: string;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const GATE: Record<DriftCategory, GateEntry> = {
|
|
54
|
+
cli_version: {
|
|
55
|
+
probe: () => auditCliVersion({ installedVersion: '9.9.9', fetcher: async () => null }),
|
|
56
|
+
because: 'probed: the npm registry can be unreachable, and that is unmeasured',
|
|
57
|
+
},
|
|
58
|
+
schema: {
|
|
59
|
+
probe: () =>
|
|
60
|
+
auditSchema({
|
|
61
|
+
journal: () => null,
|
|
62
|
+
applied: () => [],
|
|
63
|
+
db: {} as DbClient,
|
|
64
|
+
}),
|
|
65
|
+
because: 'probed: a missing migration journal means nothing was compared',
|
|
66
|
+
},
|
|
67
|
+
capability_abi: {
|
|
68
|
+
because:
|
|
69
|
+
'compares capability versions claimed in installed module manifests against the framework registry and deployed providers, all read from celilo own DB and local manifests — the subject is celilo itself and is always reachable',
|
|
70
|
+
},
|
|
71
|
+
browser_pin: {
|
|
72
|
+
because:
|
|
73
|
+
'compares a bundled client playwright revision against the locally provisioned browser binary, both on this machine — always reachable',
|
|
74
|
+
},
|
|
75
|
+
terraform_plan: {
|
|
76
|
+
probe: () =>
|
|
77
|
+
auditTerraformPlan({
|
|
78
|
+
modules: [{ id: 'm', terraformDir: '/tmp/does-not-matter', envVars: {} }],
|
|
79
|
+
run: async () => ({ exitCode: 0, stdout: '', stderr: '' }),
|
|
80
|
+
}),
|
|
81
|
+
because: 'probed: terraform exiting zero with an unreadable summary is not a clean plan',
|
|
82
|
+
},
|
|
83
|
+
module_versions: {
|
|
84
|
+
probe: () =>
|
|
85
|
+
auditModuleVersions({
|
|
86
|
+
installed: [{ id: 'm', version: '1.0.0' }],
|
|
87
|
+
fetcher: () => Promise.reject(new Error('registry unreachable')),
|
|
88
|
+
}),
|
|
89
|
+
because: 'probed: a registry that did not answer is not a version difference',
|
|
90
|
+
},
|
|
91
|
+
module_configs: {
|
|
92
|
+
because:
|
|
93
|
+
'walks manifest variables against config rows in celilo DB — the subject is celilo itself and is always reachable',
|
|
94
|
+
},
|
|
95
|
+
health: {
|
|
96
|
+
probe: () => auditHealth({ results: [{ moduleId: 'm', status: 'no-checks', checks: [] }] }),
|
|
97
|
+
because: 'probed: a module with no hook, or whose checks all skip, has measured nothing',
|
|
98
|
+
},
|
|
99
|
+
backups: {
|
|
100
|
+
because:
|
|
101
|
+
'reads recorded backup outcomes and effective cadences from celilo DB — the subject is celilo itself and is always reachable; a missing backup is drift, not silence',
|
|
102
|
+
},
|
|
103
|
+
abandoned_operations: {
|
|
104
|
+
because: 'counts reclaimed operation rows in celilo DB — always reachable',
|
|
105
|
+
},
|
|
106
|
+
undeployed_modules: {
|
|
107
|
+
because: 'reads module lifecycle states from celilo DB — always reachable',
|
|
108
|
+
},
|
|
109
|
+
unconfigured_modules: {
|
|
110
|
+
because: 'reads config-row counts from celilo DB — always reachable',
|
|
111
|
+
},
|
|
112
|
+
services_credentials: {
|
|
113
|
+
because:
|
|
114
|
+
'reads credential envelopes from celilo DB and decrypts with the local master key; a failed decrypt is itself a measurement (blocked), never silence',
|
|
115
|
+
},
|
|
116
|
+
secrets_decryptable: {
|
|
117
|
+
because:
|
|
118
|
+
'walks encrypted rows in celilo DB and decrypts; a failed decrypt is itself a measurement (blocked), never silence',
|
|
119
|
+
},
|
|
120
|
+
services_reachable: {
|
|
121
|
+
because:
|
|
122
|
+
'the probe outcome IS the measurement: an unreachable service is reachable=false and a finding, so every probe measures its subject',
|
|
123
|
+
},
|
|
124
|
+
machines_reachable: {
|
|
125
|
+
because:
|
|
126
|
+
'the SSH probe outcome IS the measurement: an unreachable machine is reachable=false and a finding, so every probe measures its subject',
|
|
127
|
+
},
|
|
128
|
+
public_dns: {
|
|
129
|
+
probe: () =>
|
|
130
|
+
auditPublicDns({
|
|
131
|
+
records: [{ fqdn: 'git.celilo.computer', companion: false, lastAssertedAt: new Date(0) }],
|
|
132
|
+
probe: {
|
|
133
|
+
resolver: '1.1.1.1',
|
|
134
|
+
echoService: 'https://echo.example',
|
|
135
|
+
observeIngress: async () => ({ kind: 'undetermined', reason: 'ECONNREFUSED' }),
|
|
136
|
+
resolve: async () => {
|
|
137
|
+
throw new Error('not reached: the vantage is down');
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
undeterminedThreshold: 1,
|
|
141
|
+
now: new Date(0),
|
|
142
|
+
}),
|
|
143
|
+
because: 'probed: the off-fleet echo vantage is external and can be down; unmeasured',
|
|
144
|
+
},
|
|
145
|
+
disk_space: {
|
|
146
|
+
probe: () =>
|
|
147
|
+
auditDiskSpace({
|
|
148
|
+
results: [
|
|
149
|
+
{ hostname: 'box', ipAddress: '10.0.0.1', usedPercent: null, message: 'df failed' },
|
|
150
|
+
],
|
|
151
|
+
}),
|
|
152
|
+
because: 'probed: a df that could not run measured nothing about the disk',
|
|
153
|
+
},
|
|
154
|
+
transport_reads: {
|
|
155
|
+
because:
|
|
156
|
+
'reads the notification poller recorded read outcomes from celilo DB — always reachable; not happening is itself the drift finding',
|
|
157
|
+
},
|
|
158
|
+
trusted_sources: {
|
|
159
|
+
probe: () => auditTrustedSources({ firewalls: [], unreachableFirewalls: ['10.0.0.254'] }),
|
|
160
|
+
because: 'probed: a firewall whose live ruleset cannot be read is unknown, not clean',
|
|
161
|
+
},
|
|
162
|
+
interface_classification: {
|
|
163
|
+
probe: () => auditInterfaceClassification({ views: [], unreachableFirewalls: ['10.0.0.254'] }),
|
|
164
|
+
because: 'probed: a firewall whose interfaces cannot be read is unknown, not clean',
|
|
165
|
+
},
|
|
166
|
+
module_integrity: {
|
|
167
|
+
probe: () =>
|
|
168
|
+
auditModuleIntegrity({
|
|
169
|
+
results: [
|
|
170
|
+
{ success: false, moduleId: 'm', violations: [], error: 'No integrity data found.' },
|
|
171
|
+
],
|
|
172
|
+
}),
|
|
173
|
+
because: 'probed: no baseline row, or a stale one, means nothing was verified',
|
|
174
|
+
},
|
|
175
|
+
detect_without_converge: {
|
|
176
|
+
because:
|
|
177
|
+
'static analysis of module manifests and system-config rows in celilo DB — always reachable',
|
|
178
|
+
},
|
|
179
|
+
jail_exemptions: {
|
|
180
|
+
because:
|
|
181
|
+
'reads recorded per-module jail exemptions from celilo DB — the subject is celilo itself and is always reachable; no exemptions is the normal state, not silence',
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const ALLOW_LISTED = Object.entries(GATE).filter(([, e]) => !e.probe);
|
|
186
|
+
|
|
187
|
+
describe('recurrence gate: every DriftCategory measures its subject or says why it cannot fail to', () => {
|
|
188
|
+
test('the gate covers every category in the union (compile-time) and matches the TUI list (run-time)', async () => {
|
|
189
|
+
// `GATE` is `Record<DriftCategory, GateEntry>`, so the compiler rejects a
|
|
190
|
+
// new category until it gets an entry. This run-time line additionally
|
|
191
|
+
// catches the reverse: an entry for a category no longer in the union.
|
|
192
|
+
const gateCategories = Object.keys(GATE) as DriftCategory[];
|
|
193
|
+
expect(gateCategories.sort()).toEqual([...ALL_CATEGORIES].sort());
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
for (const [category, entry] of Object.entries(GATE) as [DriftCategory, GateEntry][]) {
|
|
197
|
+
if (!entry.probe) {
|
|
198
|
+
test(`${category}: allow-listed — ${entry.because}`, () => {
|
|
199
|
+
expect(entry.because).toBeTruthy();
|
|
200
|
+
});
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
test(`${category}: cannot reach its subject and still reports unmeasured`, async () => {
|
|
205
|
+
const probe = entry.probe;
|
|
206
|
+
if (!probe) expect.unreachable();
|
|
207
|
+
const findings = await probe();
|
|
208
|
+
const severities: DriftSeverity[] = findings.map((f) => f.severity);
|
|
209
|
+
expect(severities).toContain('unmeasured');
|
|
210
|
+
// The gate exists because silence renders as READY. If an unmeasured
|
|
211
|
+
// path ever degrades back to drift, the verdict distinction D7 added
|
|
212
|
+
// is gone and this is the line that says so.
|
|
213
|
+
const unmeasured = findings.find((f) => f.severity === 'unmeasured') as DriftFinding;
|
|
214
|
+
expect(unmeasured.category).toBe(category);
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
test('the allow-list is not a dumping ground: 14 categories, each individually justified', () => {
|
|
219
|
+
// Not a magic number to defend — a tripwire. If this count changes, the
|
|
220
|
+
// diff is the review: someone moved a category in or out of the set of
|
|
221
|
+
// "structurally always reaches its subject", and the reason strings
|
|
222
|
+
// above are where they have to argue it.
|
|
223
|
+
expect(ALLOW_LISTED).toHaveLength(14);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
@@ -59,6 +59,7 @@ describe('parsing reach rules from a live ruleset', () => {
|
|
|
59
59
|
describe('unowned-trusted-network audit', () => {
|
|
60
60
|
it('reports a network celilo does not recognise', async () => {
|
|
61
61
|
const findings = await auditTrustedSources({
|
|
62
|
+
unreachableFirewalls: [],
|
|
62
63
|
firewalls: [
|
|
63
64
|
{
|
|
64
65
|
firewallIp: '192.168.0.254',
|
|
@@ -78,6 +79,7 @@ describe('unowned-trusted-network audit', () => {
|
|
|
78
79
|
// The point of the change: once a module owns the VPN and registers its
|
|
79
80
|
// subnet, the rule IS the rendered output and there is nothing to report.
|
|
80
81
|
const findings = await auditTrustedSources({
|
|
82
|
+
unreachableFirewalls: [],
|
|
81
83
|
firewalls: [
|
|
82
84
|
{
|
|
83
85
|
firewallIp: '192.168.0.254',
|
|
@@ -96,6 +98,7 @@ describe('unowned-trusted-network audit', () => {
|
|
|
96
98
|
ZONES,
|
|
97
99
|
);
|
|
98
100
|
const findings = await auditTrustedSources({
|
|
101
|
+
unreachableFirewalls: [],
|
|
99
102
|
firewalls: [{ firewallIp: '192.168.0.254', live, known: [CONTROL_PLANE] }],
|
|
100
103
|
});
|
|
101
104
|
|
|
@@ -104,6 +107,7 @@ describe('unowned-trusted-network audit', () => {
|
|
|
104
107
|
|
|
105
108
|
it('names the interface when trust is granted by interface, and says why it cannot be registered as-is', async () => {
|
|
106
109
|
const findings = await auditTrustedSources({
|
|
110
|
+
unreachableFirewalls: [],
|
|
107
111
|
firewalls: [
|
|
108
112
|
{
|
|
109
113
|
firewallIp: '192.168.0.254',
|
|
@@ -120,6 +124,7 @@ describe('unowned-trusted-network audit', () => {
|
|
|
120
124
|
|
|
121
125
|
it('a firewall with no live reach beyond what celilo composes is clean', async () => {
|
|
122
126
|
const findings = await auditTrustedSources({
|
|
127
|
+
unreachableFirewalls: [],
|
|
123
128
|
firewalls: [
|
|
124
129
|
{
|
|
125
130
|
firewallIp: '192.168.0.254',
|
|
@@ -134,4 +139,16 @@ describe('unowned-trusted-network audit', () => {
|
|
|
134
139
|
|
|
135
140
|
expect(findings).toEqual([]);
|
|
136
141
|
});
|
|
142
|
+
|
|
143
|
+
it('a firewall whose ruleset could not be read is unmeasured, not silent (D7)', async () => {
|
|
144
|
+
const findings = await auditTrustedSources({
|
|
145
|
+
unreachableFirewalls: ['192.168.0.254'],
|
|
146
|
+
firewalls: [],
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
expect(findings).toHaveLength(1);
|
|
150
|
+
expect(findings[0].severity).toBe('unmeasured');
|
|
151
|
+
expect(findings[0].code).toBe('trusted_sources_unmeasured');
|
|
152
|
+
expect(findings[0].subject).toBe('192.168.0.254');
|
|
153
|
+
});
|
|
137
154
|
});
|
|
@@ -37,6 +37,13 @@ export interface FirewallReachState {
|
|
|
37
37
|
|
|
38
38
|
export interface TrustedSourcesAuditDeps {
|
|
39
39
|
firewalls: FirewallReachState[];
|
|
40
|
+
/**
|
|
41
|
+
* Firewalls celilo knows about but whose live ruleset could not be read
|
|
42
|
+
* (`iptables-save` failed over SSH). Each is an `unmeasured` finding, not
|
|
43
|
+
* silence: a check that cannot see a firewall has not measured that
|
|
44
|
+
* firewall, and silence renders as READY (D7).
|
|
45
|
+
*/
|
|
46
|
+
unreachableFirewalls: string[];
|
|
40
47
|
}
|
|
41
48
|
|
|
42
49
|
/**
|
|
@@ -79,6 +86,20 @@ function originKey(origin: ReachOrigin): string {
|
|
|
79
86
|
export async function auditTrustedSources(deps: TrustedSourcesAuditDeps): Promise<DriftFinding[]> {
|
|
80
87
|
const findings: DriftFinding[] = [];
|
|
81
88
|
|
|
89
|
+
// Unreachable firewalls first, so the report leads with what could not be
|
|
90
|
+
// measured at all before anything it did measure.
|
|
91
|
+
for (const firewallIp of deps.unreachableFirewalls) {
|
|
92
|
+
findings.push({
|
|
93
|
+
category: 'trusted_sources',
|
|
94
|
+
severity: 'unmeasured',
|
|
95
|
+
code: 'trusted_sources_unmeasured',
|
|
96
|
+
message: `${firewallIp}: live ruleset could not be read, so trusted-source drift is unknown`,
|
|
97
|
+
remediation: `Check SSH reachability of ${firewallIp}, then re-audit. This finding records that the ruleset was not read, not that it is clean.`,
|
|
98
|
+
actionable: true,
|
|
99
|
+
subject: firewallIp,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
82
103
|
for (const firewall of deps.firewalls) {
|
|
83
104
|
const known = new Set(firewall.known);
|
|
84
105
|
const reported = new Set<string>();
|
|
@@ -45,7 +45,8 @@ export type DriftCategory =
|
|
|
45
45
|
| 'trusted_sources'
|
|
46
46
|
| 'interface_classification'
|
|
47
47
|
| 'module_integrity'
|
|
48
|
-
| 'detect_without_converge'
|
|
48
|
+
| 'detect_without_converge'
|
|
49
|
+
| 'jail_exemptions';
|
|
49
50
|
|
|
50
51
|
/**
|
|
51
52
|
* `unmeasured` is a check that could not reach its subject
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
import type { Cadence } from './cadence';
|
|
36
36
|
import { materializeCrossModuleRoot, moduleHasCrossModuleRead } from './cross-module-read';
|
|
37
37
|
import { getModuleSystems } from './deployed-systems';
|
|
38
|
+
import { listMachines } from './machine-pool';
|
|
38
39
|
import { parseStoredConfigValue } from './module-config';
|
|
39
40
|
import {
|
|
40
41
|
completeOperation,
|
|
@@ -367,6 +368,14 @@ export async function createModuleBackup(
|
|
|
367
368
|
|
|
368
369
|
const staged = stageSystemState(join(tempDir, 'system-state'));
|
|
369
370
|
hookInputs.system_state_root = staged.root;
|
|
371
|
+
|
|
372
|
+
// The machine inventory rides the same privilege. on_backup used to
|
|
373
|
+
// shell `celilo machine list --json` for this, which the hook jail kills
|
|
374
|
+
// (no celilo binary, celilo#1225) and which never worked before that:
|
|
375
|
+
// the CLI had no --json flag, so the hook's JSON.parse failed and its
|
|
376
|
+
// catch wrote [] into every envelope. listMachines() is the same read
|
|
377
|
+
// the CLI performs, offered as staged data instead of a spawn.
|
|
378
|
+
hookInputs.machine_pool = JSON.stringify(await listMachines());
|
|
370
379
|
if (!staged.masterKeyStaged) {
|
|
371
380
|
logger.warn(
|
|
372
381
|
`master.key not found at ${getMasterKeyPath()} — secrets in the DB snapshot will be unreadable on restore.`,
|
|
@@ -66,7 +66,7 @@ describe('backup envelope round-trip', () => {
|
|
|
66
66
|
afterEach(() => {
|
|
67
67
|
closeDb();
|
|
68
68
|
resetTestDbPath();
|
|
69
|
-
process.env.CELILO_MASTER_KEY_PATH
|
|
69
|
+
delete process.env.CELILO_MASTER_KEY_PATH;
|
|
70
70
|
try {
|
|
71
71
|
rmSync(dir, { recursive: true, force: true });
|
|
72
72
|
} catch {
|
|
@@ -36,7 +36,7 @@ describe('backup/restore in-flight refusal', () => {
|
|
|
36
36
|
afterEach(() => {
|
|
37
37
|
closeDb();
|
|
38
38
|
resetTestDbPath();
|
|
39
|
-
process.env.CELILO_MASTER_KEY_PATH
|
|
39
|
+
delete process.env.CELILO_MASTER_KEY_PATH;
|
|
40
40
|
try {
|
|
41
41
|
rmSync(dir, { recursive: true, force: true });
|
|
42
42
|
} catch {
|