@celilo/cli 2.3.0 → 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 +1 -1
- package/package.json +3 -3
- package/src/api/serve.ts +13 -1
- package/src/cli/commands/module-health.test.ts +35 -0
- package/src/cli/commands/module-health.ts +11 -3
- package/src/cli/commands/module-publish.test.ts +22 -0
- package/src/cli/commands/module-publish.ts +14 -12
- package/src/cli/commands/module-update.ts +69 -17
- package/src/cli/commands/module-upgrade-gate.test.ts +154 -0
- package/src/cli/commands/module-upgrade.ts +43 -3
- package/src/cli/commands/publish/helpers.ts +4 -3
- package/src/cli/commands/publish/index.ts +13 -1
- 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 +16 -3
- 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 +14 -5
- package/src/cli/commands/system-update.ts +11 -6
- package/src/cli/completion.ts +10 -1
- package/src/cli/fuel-gauge.ts +12 -4
- package/src/cli/index.ts +12 -0
- package/src/cli/json-output.test.ts +81 -0
- package/src/cli/types.ts +9 -0
- package/src/hooks/broker.test.ts +9 -3
- package/src/hooks/executor.test.ts +5 -2
- package/src/hooks/hook-jail-toolchain-reach.test.ts +8 -3
- package/src/hooks/hook-jail-unreachability.test.ts +10 -2
- package/src/hooks/hook-trespass.test.ts +13 -4
- 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 +19 -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/registry/client.test.ts +65 -0
- package/src/registry/client.ts +7 -7
- package/src/secrets/storage.test.ts +69 -3
- package/src/secrets/storage.ts +71 -1
- package/src/services/audit/health.test.ts +58 -0
- package/src/services/audit/health.ts +15 -3
- package/src/services/audit/index.test.ts +1 -1
- package/src/services/audit/interface-classification.test.ts +16 -5
- package/src/services/audit/interface-classification.ts +25 -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/build-bus/hook-dispatch-executor.test.ts +269 -0
- package/src/services/build-bus/hook-dispatch-mgmt.test.ts +102 -150
- 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 +106 -28
- 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/capability-compat.test.ts +90 -0
- package/src/services/capability-compat.ts +128 -0
- package/src/services/deploy-terraform.ts +38 -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 +37 -1
- package/src/services/fleet-checks.ts +31 -1
- package/src/services/health-runner.ts +43 -5
- package/src/services/module-deploy-prune.test.ts +89 -0
- package/src/services/module-deploy.ts +72 -125
- package/src/services/module-types-drift.test.ts +1 -1
- package/src/services/module-validator/capability-versions.test.ts +13 -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 +1 -1
- package/tsconfig.json +2 -13
|
@@ -17,9 +17,15 @@
|
|
|
17
17
|
* pure for testing. The aggregator wires `runAllHealthChecks` to it.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
+
import type { HealthWaiver } from '../../manifest/schema';
|
|
20
21
|
import type { HealthCheckResult } from '../health-runner';
|
|
21
22
|
import type { DriftFinding } from './types';
|
|
22
23
|
|
|
24
|
+
/** Render a waiver as the annotation carried on the `unmeasured` finding. */
|
|
25
|
+
export function waiverAnnotation(waiver: HealthWaiver): string {
|
|
26
|
+
return `WAIVED by ${waiver.by} (${waiver.at}): ${waiver.reason}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
23
29
|
export interface HealthAuditDeps {
|
|
24
30
|
results: HealthCheckResult[];
|
|
25
31
|
}
|
|
@@ -49,6 +55,12 @@ export async function auditHealth(deps: HealthAuditDeps): Promise<DriftFinding[]
|
|
|
49
55
|
const skipped = r.checks.filter((c) => c.status === 'skip');
|
|
50
56
|
const measured = r.checks.filter((c) => c.status !== 'skip');
|
|
51
57
|
|
|
58
|
+
// A waiver annotates an `unmeasured` finding; it never replaces it.
|
|
59
|
+
// The severity stays `unmeasured` so the fleet verdict stays UNKNOWN
|
|
60
|
+
// (D3) — zero unmeasured findings after a waiver would mean the
|
|
61
|
+
// waiver re-greened the board, which is the failure it guards.
|
|
62
|
+
const waiverNote = r.waiver ? ` — ${waiverAnnotation(r.waiver)}` : '';
|
|
63
|
+
|
|
52
64
|
if (r.status === 'no-checks') {
|
|
53
65
|
// Either the module declares no health_check hook, or the hook ran
|
|
54
66
|
// and every check reported skip. Neither measured anything.
|
|
@@ -57,13 +69,13 @@ export async function auditHealth(deps: HealthAuditDeps): Promise<DriftFinding[]
|
|
|
57
69
|
? skippedChecksFinding(
|
|
58
70
|
r,
|
|
59
71
|
'health_unmeasured',
|
|
60
|
-
`${r.moduleId}: all ${skipped.length} health checks skipped — nothing measured`,
|
|
72
|
+
`${r.moduleId}: all ${skipped.length} health checks skipped — nothing measured${waiverNote}`,
|
|
61
73
|
)
|
|
62
74
|
: {
|
|
63
75
|
category: 'health',
|
|
64
76
|
severity: 'unmeasured',
|
|
65
77
|
code: 'health_unmeasured',
|
|
66
|
-
message: `${r.moduleId}: no health_check hook — never measured`,
|
|
78
|
+
message: `${r.moduleId}: no health_check hook — never measured${waiverNote}`,
|
|
67
79
|
remediation: `Add a health_check hook to the ${r.moduleId} module manifest`,
|
|
68
80
|
actionable: false,
|
|
69
81
|
subject: r.moduleId,
|
|
@@ -77,7 +89,7 @@ export async function auditHealth(deps: HealthAuditDeps): Promise<DriftFinding[]
|
|
|
77
89
|
skippedChecksFinding(
|
|
78
90
|
r,
|
|
79
91
|
'health_checks_skipped',
|
|
80
|
-
`${r.moduleId}: ${skipped.length} of ${r.checks.length} health checks skipped`,
|
|
92
|
+
`${r.moduleId}: ${skipped.length} of ${r.checks.length} health checks skipped${waiverNote}`,
|
|
81
93
|
),
|
|
82
94
|
);
|
|
83
95
|
}
|
|
@@ -44,7 +44,7 @@ const emptyDeps = {
|
|
|
44
44
|
machinesReachable: { results: [] },
|
|
45
45
|
diskSpace: { results: [] },
|
|
46
46
|
transportReads: { statuses: [], now: new Date(), staleAfterMs: 30 * 60_000 },
|
|
47
|
-
trustedSources: { firewalls: [] },
|
|
47
|
+
trustedSources: { firewalls: [], unreachableFirewalls: [] },
|
|
48
48
|
publicDns: { records: [], probe: unusedPublicDnsProbe },
|
|
49
49
|
};
|
|
50
50
|
|
|
@@ -207,14 +207,25 @@ describe('describeClassification — the picture, not a finding', () => {
|
|
|
207
207
|
|
|
208
208
|
describe('auditInterfaceClassification — every firewall, flattened', () => {
|
|
209
209
|
test('reports across several firewalls, each subjected on its own hostname', () => {
|
|
210
|
-
const findings = auditInterfaceClassification(
|
|
211
|
-
|
|
212
|
-
liveFleetShape({ hostname: 'fw-branch' }),
|
|
213
|
-
|
|
210
|
+
const findings = auditInterfaceClassification({
|
|
211
|
+
unreachableFirewalls: [],
|
|
212
|
+
views: [liveFleetShape(), liveFleetShape({ hostname: 'fw-branch' })],
|
|
213
|
+
});
|
|
214
214
|
expect(findings.map((f) => f.subject).sort()).toEqual(['fw-branch', 'fw-main']);
|
|
215
215
|
});
|
|
216
216
|
|
|
217
217
|
test('no firewalls means no findings', () => {
|
|
218
|
-
expect(auditInterfaceClassification([])).toEqual([]);
|
|
218
|
+
expect(auditInterfaceClassification({ views: [], unreachableFirewalls: [] })).toEqual([]);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('a firewall whose interfaces could not be read is unmeasured, not silent (D7)', () => {
|
|
222
|
+
const findings = auditInterfaceClassification({
|
|
223
|
+
views: [liveFleetShape()],
|
|
224
|
+
unreachableFirewalls: ['fw-branch'],
|
|
225
|
+
});
|
|
226
|
+
const unmeasured = findings.filter((f) => f.severity === 'unmeasured');
|
|
227
|
+
expect(unmeasured).toHaveLength(1);
|
|
228
|
+
expect(unmeasured[0].subject).toBe('fw-branch');
|
|
229
|
+
expect(unmeasured[0].code).toBe('interface_classification_unmeasured');
|
|
219
230
|
});
|
|
220
231
|
});
|
|
@@ -142,8 +142,31 @@ export function auditFirewallInterfaces(view: FirewallInterfaceView): DriftFindi
|
|
|
142
142
|
}
|
|
143
143
|
|
|
144
144
|
/** Every firewall's findings, flattened. */
|
|
145
|
-
export
|
|
146
|
-
|
|
145
|
+
export interface InterfaceClassificationAuditDeps {
|
|
146
|
+
views: FirewallInterfaceView[];
|
|
147
|
+
/**
|
|
148
|
+
* Firewalls celilo knows about but whose interface state could not be read
|
|
149
|
+
* over SSH. Each is an `unmeasured` finding: an interface survey that
|
|
150
|
+
* cannot see a firewall has measured nothing about it (D7).
|
|
151
|
+
*/
|
|
152
|
+
unreachableFirewalls: string[];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function auditInterfaceClassification(
|
|
156
|
+
deps: InterfaceClassificationAuditDeps,
|
|
157
|
+
): DriftFinding[] {
|
|
158
|
+
const unreachable = deps.unreachableFirewalls.map((firewallIp): DriftFinding => {
|
|
159
|
+
return {
|
|
160
|
+
category: 'interface_classification',
|
|
161
|
+
severity: 'unmeasured',
|
|
162
|
+
code: 'interface_classification_unmeasured',
|
|
163
|
+
subject: firewallIp,
|
|
164
|
+
message: `${firewallIp}: interfaces could not be read, so classification is unknown`,
|
|
165
|
+
remediation: `Check SSH reachability of ${firewallIp}, then re-audit. This finding records that the interfaces were not read, not that they are classified.`,
|
|
166
|
+
actionable: true,
|
|
167
|
+
};
|
|
168
|
+
});
|
|
169
|
+
return [...unreachable, ...deps.views.flatMap(auditFirewallInterfaces)];
|
|
147
170
|
}
|
|
148
171
|
|
|
149
172
|
/**
|
|
@@ -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>();
|