@appforge-ci/core 0.3.0 → 0.3.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.
Files changed (105) hide show
  1. package/dist/agent-readiness.d.ts +11 -3
  2. package/dist/agent-readiness.d.ts.map +1 -1
  3. package/dist/agent-readiness.js +15 -5
  4. package/dist/agent-readiness.js.map +1 -1
  5. package/dist/agent-readiness.test.js +34 -10
  6. package/dist/agent-readiness.test.js.map +1 -1
  7. package/dist/api-client.d.ts +39 -1
  8. package/dist/api-client.d.ts.map +1 -1
  9. package/dist/api-client.js +52 -0
  10. package/dist/api-client.js.map +1 -1
  11. package/dist/appforge-verify.test.d.ts +2 -0
  12. package/dist/appforge-verify.test.d.ts.map +1 -0
  13. package/dist/appforge-verify.test.js +958 -0
  14. package/dist/appforge-verify.test.js.map +1 -0
  15. package/dist/attestation-keys.d.ts +20 -0
  16. package/dist/attestation-keys.d.ts.map +1 -0
  17. package/dist/attestation-keys.js +27 -0
  18. package/dist/attestation-keys.js.map +1 -0
  19. package/dist/attestation-keys.test.d.ts +2 -0
  20. package/dist/attestation-keys.test.d.ts.map +1 -0
  21. package/dist/attestation-keys.test.js +45 -0
  22. package/dist/attestation-keys.test.js.map +1 -0
  23. package/dist/attestation-signing.d.ts +226 -0
  24. package/dist/attestation-signing.d.ts.map +1 -0
  25. package/dist/attestation-signing.js +276 -0
  26. package/dist/attestation-signing.js.map +1 -0
  27. package/dist/attestation-signing.test.d.ts +2 -0
  28. package/dist/attestation-signing.test.d.ts.map +1 -0
  29. package/dist/attestation-signing.test.js +250 -0
  30. package/dist/attestation-signing.test.js.map +1 -0
  31. package/dist/attestation-workerd.test.d.ts +2 -0
  32. package/dist/attestation-workerd.test.d.ts.map +1 -0
  33. package/dist/attestation-workerd.test.js +183 -0
  34. package/dist/attestation-workerd.test.js.map +1 -0
  35. package/dist/attestation.d.ts +169 -22
  36. package/dist/attestation.d.ts.map +1 -1
  37. package/dist/attestation.js +42 -16
  38. package/dist/attestation.js.map +1 -1
  39. package/dist/build-vpn.d.ts +11 -1
  40. package/dist/build-vpn.d.ts.map +1 -1
  41. package/dist/build-vpn.js +27 -0
  42. package/dist/build-vpn.js.map +1 -1
  43. package/dist/fleet-vpn.d.ts +64 -0
  44. package/dist/fleet-vpn.d.ts.map +1 -0
  45. package/dist/fleet-vpn.js +88 -0
  46. package/dist/fleet-vpn.js.map +1 -0
  47. package/dist/fleet-vpn.test.d.ts +2 -0
  48. package/dist/fleet-vpn.test.d.ts.map +1 -0
  49. package/dist/fleet-vpn.test.js +69 -0
  50. package/dist/fleet-vpn.test.js.map +1 -0
  51. package/dist/index.d.ts +6 -0
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +6 -0
  54. package/dist/index.js.map +1 -1
  55. package/dist/schemas-repo-url.test.d.ts +2 -0
  56. package/dist/schemas-repo-url.test.d.ts.map +1 -0
  57. package/dist/schemas-repo-url.test.js +67 -0
  58. package/dist/schemas-repo-url.test.js.map +1 -0
  59. package/dist/schemas.d.ts +18 -0
  60. package/dist/schemas.d.ts.map +1 -1
  61. package/dist/schemas.js +56 -10
  62. package/dist/schemas.js.map +1 -1
  63. package/dist/ssh-deploy-key.d.ts +59 -0
  64. package/dist/ssh-deploy-key.d.ts.map +1 -0
  65. package/dist/ssh-deploy-key.js +124 -0
  66. package/dist/ssh-deploy-key.js.map +1 -0
  67. package/dist/ssh-deploy-key.test.d.ts +2 -0
  68. package/dist/ssh-deploy-key.test.d.ts.map +1 -0
  69. package/dist/ssh-deploy-key.test.js +117 -0
  70. package/dist/ssh-deploy-key.test.js.map +1 -0
  71. package/dist/types.d.ts +57 -2
  72. package/dist/types.d.ts.map +1 -1
  73. package/dist/types.js +0 -5
  74. package/dist/types.js.map +1 -1
  75. package/dist/vpn-attestation-workerd.test.d.ts +2 -0
  76. package/dist/vpn-attestation-workerd.test.d.ts.map +1 -0
  77. package/dist/vpn-attestation-workerd.test.js +184 -0
  78. package/dist/vpn-attestation-workerd.test.js.map +1 -0
  79. package/dist/vpn-attestation.d.ts +1015 -0
  80. package/dist/vpn-attestation.d.ts.map +1 -0
  81. package/dist/vpn-attestation.js +963 -0
  82. package/dist/vpn-attestation.js.map +1 -0
  83. package/dist/vpn-attestation.test.d.ts +2 -0
  84. package/dist/vpn-attestation.test.d.ts.map +1 -0
  85. package/dist/vpn-attestation.test.js +613 -0
  86. package/dist/vpn-attestation.test.js.map +1 -0
  87. package/dist/vpn-egress-presets.d.ts +1 -1
  88. package/dist/vpn-egress-presets.d.ts.map +1 -1
  89. package/dist/vpn-egress-presets.js +2 -2
  90. package/dist/vpn-egress-presets.js.map +1 -1
  91. package/dist/vpn-sandbox.d.ts +67 -0
  92. package/dist/vpn-sandbox.d.ts.map +1 -0
  93. package/dist/vpn-sandbox.js +22 -0
  94. package/dist/vpn-sandbox.js.map +1 -0
  95. package/dist/vpn-sandbox.test.d.ts +2 -0
  96. package/dist/vpn-sandbox.test.d.ts.map +1 -0
  97. package/dist/vpn-sandbox.test.js +34 -0
  98. package/dist/vpn-sandbox.test.js.map +1 -0
  99. package/dist/vpn-ssh.d.ts +93 -0
  100. package/dist/vpn-ssh.d.ts.map +1 -1
  101. package/dist/vpn-ssh.js +213 -1
  102. package/dist/vpn-ssh.js.map +1 -1
  103. package/dist/vpn-ssh.test.js +200 -1
  104. package/dist/vpn-ssh.test.js.map +1 -1
  105. package/package.json +1 -1
@@ -0,0 +1,963 @@
1
+ import { z } from "zod";
2
+ import { canonicalJson, sha256Hex, toIsoUtcSeconds } from "./attestation.js";
3
+ import { canonicalVpnEgressPolicy, vpnEgressPolicyHash } from "./vpn-egress.js";
4
+ import { SANDBOX_PROFILE_VERSION } from "./vpn-sandbox.js";
5
+ /**
6
+ * Per-build signed attestation (regulated-VPN program, PR-M): what the document says, how it is
7
+ * derived, and the evidence bundle and printable page an auditor verifies offline with
8
+ * `scripts/appforge-verify.mjs`. Pure and runtime-neutral (WebCrypto only), so the Worker that
9
+ * assembles it (apps/admin-api through `@appforge/db`), the Worker that exports it (apps/api), the
10
+ * agent that reports the raw evidence, and the tests that feed the real verifier all share one
11
+ * definition.
12
+ *
13
+ * Who says what. Three parties contribute facts, and the document records which:
14
+ * - the SERVER (control plane): what it leased (the egress policy it sent, the tunnel's public
15
+ * configuration), the build row, the VpnEvent timeline, the audit chain head, and everything it
16
+ * can RECOMPUTE from raw data it holds (the connection log's digest, per-destination summary,
17
+ * totals and completeness);
18
+ * - the AGENT: what ran on the Mac (helper identity, sandbox profile hashes and the self-test
19
+ * matrix, hygiene), reported in one JSON document plus the raw connection log;
20
+ * - the HELPER: the connection log itself (metadata only), written by a process on the build host.
21
+ * Every value the agent claims that the server can also derive is compared, and a mismatch is
22
+ * recorded as a failing control test (the verifier exits 2 on those). Nothing is silently trusted,
23
+ * and nothing is ever dropped silently either: what could not be checked is listed in `crossChecks`
24
+ * and `gaps`.
25
+ *
26
+ * What it is not: macOS offers no remote attestation, so this proves what the server verified and
27
+ * what the Mac reported, not that the Mac told the truth. docs/attestation.md lists the limits.
28
+ */
29
+ // ---------------------------------------------------------------- constants
30
+ export const ATTESTATION_DOC_TYPE = "appforge.vpn.build-attestation";
31
+ export const ATTESTATION_DOC_VERSION = 1;
32
+ export const EVIDENCE_BUNDLE_FORMAT = "appforge-evidence-bundle";
33
+ /** The rule that turns the helper's raw log lines into the `records` a bundle carries. Part of the signed document. */
34
+ export const CONNECTION_RECORDS_NORMALIZATION = "appforge-conn-records-v1";
35
+ /**
36
+ * The largest connection log the control plane will hold and process (the agent keeps up to 50 MiB; a log
37
+ * past this is reported as not retained and the attestation says so). 8 MiB is about 27,000 records, an order
38
+ * of magnitude above a real build, and keeps one Worker request well inside its CPU and memory limits.
39
+ */
40
+ export const ATTESTATION_LOG_MAX_BYTES = 8 * 1024 * 1024;
41
+ /** Largest `POST /agents/vpn/attestation` body. */
42
+ export const ATTESTATION_EVIDENCE_MAX_BYTES = 256 * 1024;
43
+ /**
44
+ * How long after the control plane first sees a strict/regulated build finished the agent may still report its
45
+ * evidence. After it, the cron issues a `server_observed_only` attestation and a late report is refused.
46
+ */
47
+ export const ATTESTATION_AGENT_GRACE_SECONDS = 30 * 60;
48
+ /** Distinct (destination, route, decision) groups a document lists; beyond it the summary is cut and the document says so. */
49
+ export const ATTESTATION_MAX_DESTINATIONS = 1000;
50
+ /** Chain entries one evidence bundle carries at most (one page of the audit export). */
51
+ export const ATTESTATION_BUNDLE_MAX_CHAIN_ENTRIES = 1000;
52
+ export const ATTESTATION_ID_PREFIX = "att_";
53
+ export const attestationIdForBuild = (buildId) => `${ATTESTATION_ID_PREFIX}${buildId}`;
54
+ /** A build id as the control plane makes them (UUIDs) and as the agent's evidence directory and the helper accept them. */
55
+ export const ATTESTATION_BUILD_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
56
+ const ORG_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
57
+ /** Where a build's raw connection log lives in the artifacts bucket. */
58
+ export function connectionLogKey(orgId, buildId) {
59
+ if (!ORG_ID_RE.test(orgId) || !ATTESTATION_BUILD_ID_RE.test(buildId))
60
+ throw new Error("connectionLogKey: unsafe org or build id");
61
+ return `evidence/${orgId}/${buildId}/conn.jsonl`;
62
+ }
63
+ /** Release manifests, published next to the artifacts by scripts/r2-release.mjs. */
64
+ export function releaseManifestKey(prefix, version) {
65
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9.-]*$/.test(version))
66
+ throw new Error("releaseManifestKey: unsafe version");
67
+ return `${prefix}/${version}/manifest.json`;
68
+ }
69
+ /** Why a document is unsigned, in the words the API and the dashboard use. */
70
+ export const ATTESTATION_UNSIGNED_NOTE = "This attestation is NOT signed: the platform's signing key was not available when it was issued. It is registered in the audit chain, but nothing authenticates it, and it cannot be verified offline.";
71
+ // ---------------------------------------------------------------- small helpers
72
+ /** Printable ASCII only, clipped: nothing an agent or a log line says can carry control characters or lone surrogates into a signed document. */
73
+ function printable(v, max) {
74
+ if (typeof v !== "string")
75
+ return "";
76
+ return v.replace(/[^\x20-\x7e]/g, "?").slice(0, max);
77
+ }
78
+ function safeCount(v) {
79
+ return typeof v === "number" && Number.isSafeInteger(v) && v >= 0 ? v : 0;
80
+ }
81
+ const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
82
+ // ---------------------------------------------------------------- what the agent reports
83
+ const text = (max) => z.string().max(max).transform((s) => printable(s, max));
84
+ const hex64 = z.string().regex(/^[0-9a-f]{64}$/, "expected 64 lowercase hex characters");
85
+ /** A count that tolerates a float or an out-of-range number from an agent: it is clamped, never the reason evidence is refused. */
86
+ const count = z
87
+ .number()
88
+ .finite()
89
+ .transform((n) => Math.min(Number.MAX_SAFE_INTEGER, Math.max(0, Math.round(n))));
90
+ const selfTestResultSchema = z.object({
91
+ id: text(80).pipe(z.string().min(1)),
92
+ area: text(32),
93
+ title: text(200),
94
+ tier: z.enum(["core", "internet", "optional"]),
95
+ expect: z.enum(["blocked", "allowed", "known_gap"]),
96
+ status: z.enum(["pass", "fail", "inconclusive", "na", "known_gap_open", "known_gap_closed"]),
97
+ detail: text(300).optional(),
98
+ });
99
+ const sandboxEvidenceSchema = z.object({
100
+ profileVersion: count,
101
+ templateSha256: hex64,
102
+ renderedSha256: hex64,
103
+ paramsSha256: hex64,
104
+ loopbackDenyPorts: z.array(count).max(64),
105
+ orgId: text(64),
106
+ selfTest: z.object({
107
+ verdict: z.enum(["pass", "fail", "inconclusive"]),
108
+ counts: z.record(z.string().max(32), count).refine((r) => Object.keys(r).length <= 8, "too many count keys"),
109
+ startedAt: text(40),
110
+ durationMs: count,
111
+ results: z.array(selfTestResultSchema).max(300),
112
+ }),
113
+ scope: text(2000).optional(),
114
+ });
115
+ const connectionLogReportSchema = z.object({
116
+ /** The raw file reached the control plane (`PUT /agents/vpn/evidence/:buildId/conn-log`). */
117
+ uploaded: z.boolean(),
118
+ omitted: z.enum(["too_large", "not_kept", "upload_failed", "unreadable"]).optional(),
119
+ /** sha256 of the file's exact bytes, as the agent computed it. */
120
+ sha256: hex64.nullable(),
121
+ bytes: count,
122
+ lines: count,
123
+ complete: z.boolean(),
124
+ incompleteReasons: z.array(text(120)).max(20),
125
+ /** The agent's own summary, compared with what the control plane recomputes from the file. */
126
+ summary: z
127
+ .object({ total: count, allowed: count, denied: count, connected: count, failed: count, bytesUp: count, bytesDown: count })
128
+ .nullable(),
129
+ });
130
+ /**
131
+ * What an agent sends to `POST /agents/vpn/attestation` after a strict-egress or regulated build ends. Unknown
132
+ * top-level fields are dropped (a newer agent may send more), `hygiene` is kept as an opaque, size-bounded object
133
+ * because its shape is owned by a change that has not shipped everywhere.
134
+ */
135
+ export const agentVpnEvidenceSchema = z.object({
136
+ v: z.literal(1),
137
+ buildId: z.string().regex(ATTESTATION_BUILD_ID_RE),
138
+ agent: z.object({ version: text(40), os: text(80).optional(), arch: text(16).optional() }),
139
+ /** What the agent believes happened; the control plane's own build row is what the document records. */
140
+ outcome: z.object({ status: z.enum(["succeeded", "failed"]), failureKind: z.enum(["user", "infra"]).optional() }),
141
+ helper: z.object({ version: text(40).nullable(), sha256: hex64.nullable(), arch: z.enum(["arm64", "amd64"]).nullable() }),
142
+ egress: z.object({ mode: z.enum(["standard", "strict"]).nullable(), policyHash: hex64.nullable() }),
143
+ tunnel: z.object({ configDigest: hex64.nullable() }),
144
+ sandbox: sandboxEvidenceSchema.nullable(),
145
+ hygiene: z.record(z.unknown()).optional(),
146
+ connectionLog: connectionLogReportSchema.nullable(),
147
+ });
148
+ /**
149
+ * Per-(destination, route, decision) totals, sorted by `dest + route + decision` in UTF-16 code unit order.
150
+ * `scripts/appforge-verify.mjs` recomputes exactly this from a bundle's raw records, so the two must agree to
151
+ * the byte; the order is deliberately locale-independent (a collation order differs between ICU locales and
152
+ * would make one verifier disagree with another about the same evidence).
153
+ */
154
+ export function summariseConnections(records) {
155
+ const m = new Map();
156
+ for (const r of records) {
157
+ const k = [r.dest, r.route, r.decision].join("|");
158
+ const a = m.get(k) ?? { dest: r.dest, route: r.route, decision: r.decision, conns: 0, bytesUp: 0, bytesDown: 0 };
159
+ a.conns += 1;
160
+ a.bytesUp += r.bytesUp ?? 0;
161
+ a.bytesDown += r.bytesDown ?? 0;
162
+ m.set(k, a);
163
+ }
164
+ return [...m.values()].sort((x, y) => cmp(x.dest + x.route + x.decision, y.dest + y.route + y.decision));
165
+ }
166
+ /** The digest the document's `connections.logSha256` holds: sha256 over the canonical records, one per line, newline-terminated. */
167
+ export async function connectionRecordsDigest(records) {
168
+ return sha256Hex(records.map((r) => canonicalJson(r)).join("\n") + "\n");
169
+ }
170
+ const MAX_INCOMPLETE_REASONS = 20;
171
+ const MAX_BSEQ_TRACKED = 1_000_000;
172
+ const TALLY_COUNTS = ["conns", "allowed", "denied", "connected", "failed", "bytesUp", "bytesDown", "dropped", "forcedClosed"];
173
+ const isCountValue = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
174
+ /** A helper `summary` line as the agent validates it (packages/agent/src/lib/evidence.ts `parseHelperSummary`), or null when it is not well-formed. */
175
+ function parseTally(r) {
176
+ if (typeof r.buildId !== "string" || !ATTESTATION_BUILD_ID_RE.test(r.buildId) || typeof r.reason !== "string" || typeof r.ts !== "string")
177
+ return null;
178
+ if (typeof r.v !== "number" || typeof r.stream !== "boolean")
179
+ return null;
180
+ for (const f of TALLY_COUNTS)
181
+ if (!isCountValue(r[f]))
182
+ return null;
183
+ return {
184
+ buildId: r.buildId,
185
+ tally: {
186
+ reason: r.reason.replace(/[^a-z0-9_-]/gi, "?").slice(0, 32),
187
+ conns: r.conns,
188
+ allowed: r.allowed,
189
+ denied: r.denied,
190
+ connected: r.connected,
191
+ failed: r.failed,
192
+ bytesUp: r.bytesUp,
193
+ bytesDown: r.bytesDown,
194
+ dropped: r.dropped,
195
+ forcedClosed: r.forcedClosed,
196
+ stream: r.stream,
197
+ },
198
+ };
199
+ }
200
+ function token(v, max = 40) {
201
+ const s = typeof v === "string" ? v : "";
202
+ return /^[a-z0-9][a-z0-9._/-]*$/i.test(s) ? s.slice(0, max) : s === "" ? "" : "unknown";
203
+ }
204
+ /**
205
+ * Re-derives everything a document says about the connection log from the raw file, so the control plane
206
+ * never relies on the agent's summary. The completeness rules mirror the agent's own aggregator
207
+ * (packages/agent/src/lib/evidence.ts) so that, for a log nothing went wrong with, both say "complete"; a
208
+ * test runs the two on the same files. Trusts nothing in a line: malformed lines are counted, strings are
209
+ * reduced to printable ASCII and clipped, a record of another build is excluded and makes the log incomplete.
210
+ *
211
+ * `raw` is the file's text (UTF-8); callers bound its size (`ATTESTATION_LOG_MAX_BYTES`).
212
+ */
213
+ export async function analyzeConnectionLog(raw, buildId) {
214
+ const records = [];
215
+ const bseqs = new Set();
216
+ let bseqOverflow = false;
217
+ let highest = 0;
218
+ let duplicates = 0;
219
+ let malformed = 0;
220
+ let foreign = 0;
221
+ let authFailures = 0;
222
+ let sessionRequests = 0;
223
+ let droppedMarkers = 0;
224
+ let lines = 0;
225
+ let tally = null;
226
+ const totals = { allowed: 0, denied: 0, errors: 0, bytesUp: 0, bytesDown: 0 };
227
+ let connected = 0;
228
+ let up = 0;
229
+ let down = 0;
230
+ const tornTail = raw.length > 0 && !raw.endsWith("\n");
231
+ const parts = raw.split("\n");
232
+ // A last line with no newline is a torn write: hashed as part of the file, never parsed (the agent's aggregator does the same).
233
+ if (tornTail)
234
+ parts.pop();
235
+ for (const line of parts) {
236
+ if (line === "")
237
+ continue;
238
+ lines++;
239
+ let m;
240
+ try {
241
+ m = JSON.parse(line);
242
+ }
243
+ catch {
244
+ malformed++;
245
+ continue;
246
+ }
247
+ if (m === null || typeof m !== "object" || Array.isArray(m)) {
248
+ malformed++;
249
+ continue;
250
+ }
251
+ const r = m;
252
+ if (r.type === "summary") {
253
+ // The same validation as the agent's `parseHelperSummary`: a tally that is not well-formed is a malformed line, not a tally of zeros.
254
+ const t = parseTally(r);
255
+ if (!t)
256
+ malformed++;
257
+ else if (t.buildId !== buildId)
258
+ foreign++;
259
+ else
260
+ tally = t.tally;
261
+ continue;
262
+ }
263
+ if (r.type === "dropped") {
264
+ droppedMarkers += safeCount(r.count);
265
+ continue;
266
+ }
267
+ if (r.type !== "conn")
268
+ continue; // a line type from a newer helper: kept in the raw file, not understood here
269
+ const id = typeof r.buildId === "string" ? r.buildId : undefined;
270
+ const decision = r.decision === "allow" || r.decision === "deny" ? r.decision : undefined;
271
+ if (decision === undefined) {
272
+ malformed++;
273
+ continue;
274
+ }
275
+ if (id !== undefined && id !== buildId) {
276
+ foreign++;
277
+ continue;
278
+ }
279
+ if (id === undefined) {
280
+ // Unattributed: a failed login, or a request made with the session credential.
281
+ if (r.auth === "session")
282
+ sessionRequests++;
283
+ else if (token(r.reason) === "auth-failed")
284
+ authFailures += 1 + safeCount(r.suppressed);
285
+ else
286
+ malformed++;
287
+ continue;
288
+ }
289
+ // A record whose own number is missing or invalid is a malformed line AND still a connection: the agent's aggregator counts it
290
+ // in its totals, so this must too (the agent's totals are compared with these), with number 0.
291
+ let bseq = 0;
292
+ if (typeof r.bseq === "number" && Number.isSafeInteger(r.bseq) && r.bseq > 0) {
293
+ bseq = r.bseq;
294
+ if (bseqs.has(bseq))
295
+ duplicates++;
296
+ else if (bseqs.size < MAX_BSEQ_TRACKED)
297
+ bseqs.add(bseq);
298
+ else
299
+ bseqOverflow = true;
300
+ if (bseq > highest)
301
+ highest = bseq;
302
+ }
303
+ else {
304
+ malformed++;
305
+ }
306
+ const host = printable(r.host, 253) || "<invalid>";
307
+ const port = typeof r.port === "number" && Number.isSafeInteger(r.port) && r.port >= 0 && r.port <= 65535 ? r.port : 0;
308
+ const bytesUp = safeCount(r.bytesUp);
309
+ const bytesDown = safeCount(r.bytesDown);
310
+ const isConnected = r.connected === true;
311
+ const rec = {
312
+ n: records.length + 1,
313
+ bseq,
314
+ ts: printable(r.ts, 40),
315
+ dest: host.includes(":") ? `[${host}]:${port}` : `${host}:${port}`,
316
+ host,
317
+ port,
318
+ proto: token(r.proto, 16) || "unknown",
319
+ route: token(r.route) || "unknown",
320
+ decision,
321
+ reason: token(r.reason) || "unknown",
322
+ connected: isConnected,
323
+ bytesUp,
324
+ bytesDown,
325
+ durationMs: safeCount(r.durationMs),
326
+ };
327
+ const cls = token(r.class, 24);
328
+ if (cls)
329
+ rec.class = cls;
330
+ const failure = token(r.failure, 24);
331
+ if (failure)
332
+ rec.failure = failure;
333
+ records.push(rec);
334
+ if (decision === "allow") {
335
+ totals.allowed++;
336
+ if (isConnected)
337
+ connected++;
338
+ else
339
+ totals.errors++;
340
+ }
341
+ else {
342
+ totals.denied++;
343
+ }
344
+ up += bytesUp;
345
+ down += bytesDown;
346
+ }
347
+ totals.bytesUp = up;
348
+ totals.bytesDown = down;
349
+ // ---- completeness, the same rules as the agent's aggregator
350
+ const reasons = [];
351
+ const note = (why) => {
352
+ if (!reasons.includes(why) && reasons.length < MAX_INCOMPLETE_REASONS)
353
+ reasons.push(why);
354
+ };
355
+ const missing = bseqOverflow ? 0 : Math.max(0, highest - bseqs.size);
356
+ if (tornTail)
357
+ note("torn_final_line");
358
+ if (malformed > 0)
359
+ note(`malformed_lines:${malformed}`);
360
+ if (foreign > 0)
361
+ note(`foreign_records:${foreign}`);
362
+ if (duplicates > 0)
363
+ note(`duplicate_records:${duplicates}`);
364
+ if (missing > 0)
365
+ note(`missing_records:${missing}`);
366
+ if (droppedMarkers > 0)
367
+ note(`helper_dropped_records:${droppedMarkers}`);
368
+ let agrees = null;
369
+ if (!tally) {
370
+ note("helper_summary_missing");
371
+ }
372
+ else {
373
+ const mismatches = [];
374
+ if (tally.reason !== "unregistered")
375
+ note(`registration_ended:${tally.reason}`);
376
+ if (!tally.stream)
377
+ mismatches.push("stream");
378
+ if (records.length + tally.dropped !== tally.conns)
379
+ mismatches.push("conns");
380
+ if (tally.dropped > 0)
381
+ note(`helper_dropped_records:${tally.dropped}`);
382
+ if (missing !== tally.dropped && !bseqOverflow)
383
+ mismatches.push("missing");
384
+ if (tally.dropped === 0) {
385
+ if (totals.allowed !== tally.allowed)
386
+ mismatches.push("allowed");
387
+ if (totals.denied !== tally.denied)
388
+ mismatches.push("denied");
389
+ if (connected !== tally.connected)
390
+ mismatches.push("connected");
391
+ if (totals.errors !== tally.failed)
392
+ mismatches.push("failed");
393
+ if (up !== tally.bytesUp)
394
+ mismatches.push("bytesUp");
395
+ if (down !== tally.bytesDown)
396
+ mismatches.push("bytesDown");
397
+ }
398
+ else if (up > tally.bytesUp || down > tally.bytesDown) {
399
+ mismatches.push("bytes");
400
+ }
401
+ agrees = mismatches.length === 0;
402
+ if (!agrees)
403
+ note("helper_summary_mismatch");
404
+ }
405
+ const groups = summariseConnections(records);
406
+ const destinations = groups.length > ATTESTATION_MAX_DESTINATIONS ? [...groups].sort((a, b) => b.conns - a.conns || cmp(a.dest + a.route + a.decision, b.dest + b.route + b.decision)).slice(0, ATTESTATION_MAX_DESTINATIONS).sort((x, y) => cmp(x.dest + x.route + x.decision, y.dest + y.route + y.decision)) : groups;
407
+ return {
408
+ records,
409
+ destinations,
410
+ destinationsOmitted: groups.length - destinations.length,
411
+ totals,
412
+ connected,
413
+ logRecords: records.length,
414
+ logSha256: await connectionRecordsDigest(records),
415
+ rawLines: lines,
416
+ complete: reasons.length === 0,
417
+ incompleteReasons: reasons,
418
+ authFailures,
419
+ sessionCredentialRequests: sessionRequests,
420
+ droppedRecords: Math.max(droppedMarkers, tally?.dropped ?? 0),
421
+ helperTally: tally,
422
+ helperTallyAgrees: agrees,
423
+ };
424
+ }
425
+ export async function vpnPublicConfigDigest(job) {
426
+ const view = {
427
+ v: 1,
428
+ tunnelId: job.tunnelId,
429
+ keyVersion: job.keyVersion,
430
+ gatewayEndpoint: job.gatewayEndpoint,
431
+ gatewayPublicKey: job.gatewayPublicKey,
432
+ tunnelCidr: job.tunnelCidr,
433
+ localAddress: job.localAddress,
434
+ gatewayAddress: job.gatewayAddress,
435
+ lanCidrs: [...job.lanCidrs],
436
+ dnsServer: job.dnsServer ?? null,
437
+ allowedDomains: [...job.allowedDomains],
438
+ allowPrivateEndpoint: job.allowPrivateEndpoint === true,
439
+ egressPolicyHash: job.egress ? await vpnEgressPolicyHash(job.egress) : null,
440
+ };
441
+ return sha256Hex(canonicalJson(view));
442
+ }
443
+ async function wireguardKeyFingerprint(b64) {
444
+ if (!b64 || !/^[A-Za-z0-9+/]{43}=$/.test(b64))
445
+ return null;
446
+ try {
447
+ const bin = atob(b64);
448
+ const bytes = new Uint8Array(bin.length);
449
+ for (let i = 0; i < bin.length; i++)
450
+ bytes[i] = bin.charCodeAt(i);
451
+ return (await sha256Hex(bytes)).slice(0, 32);
452
+ }
453
+ catch {
454
+ return null;
455
+ }
456
+ }
457
+ export async function buildTunnelFacts(job, appforgePublicKey) {
458
+ return {
459
+ id: job.tunnelId,
460
+ name: job.tunnelName ? printable(job.tunnelName, 100) : null,
461
+ keyVersion: job.keyVersion,
462
+ gatewayEndpoint: printable(job.gatewayEndpoint, 300),
463
+ lanCidrs: job.lanCidrs.map((c) => printable(c, 64)).slice(0, 64),
464
+ tunnelCidr: printable(job.tunnelCidr, 64),
465
+ dnsServer: job.dnsServer ? printable(job.dnsServer, 64) : null,
466
+ allowedDomains: job.allowedDomains.map((d) => printable(d, 253)).slice(0, 100),
467
+ appforgePublicKeyFp: await wireguardKeyFingerprint(appforgePublicKey),
468
+ gatewayPublicKeyFp: await wireguardKeyFingerprint(job.gatewayPublicKey),
469
+ configDigest: await vpnPublicConfigDigest(job),
470
+ };
471
+ }
472
+ /**
473
+ * The policy exactly as the helper enforces it, in the form a document inlines, with the helper's own hash of it.
474
+ * Throws on an invalid policy (callers validated it when they built the lease).
475
+ */
476
+ export async function expandLeasedPolicy(policy) {
477
+ return { policy: JSON.parse(canonicalVpnEgressPolicy(policy)), helperHash: await vpnEgressPolicyHash(policy) };
478
+ }
479
+ // ---------------------------------------------------------------- release manifests
480
+ /**
481
+ * What `scripts/r2-release.mjs` publishes next to a release's artifacts (`<prefix>/<version>/manifest.json`):
482
+ * the sha256 of every artifact and a few release-level facts. The control plane compares what an agent reports
483
+ * (the helper binary it ran, the sandbox template it rendered) with these. A release cut before manifests existed
484
+ * has none, which is recorded as "not checked", never as a match.
485
+ */
486
+ export const releaseManifestSchema = z.object({
487
+ v: z.literal(1),
488
+ prefix: z.string().max(64),
489
+ version: z.string().max(64),
490
+ publishedAt: z.string().max(40),
491
+ artifacts: z.array(z.object({ name: z.string().max(128), sha256: hex64, bytes: count })).max(32),
492
+ meta: z.record(z.string().max(64), z.string().max(256)).optional(),
493
+ });
494
+ export function parseReleaseManifest(textBody) {
495
+ try {
496
+ const parsed = releaseManifestSchema.safeParse(JSON.parse(textBody));
497
+ return parsed.success ? parsed.data : null;
498
+ }
499
+ catch {
500
+ return null;
501
+ }
502
+ }
503
+ /** The helper artifact name `scripts/r2-release.mjs` publishes for an architecture. */
504
+ export const helperArtifactName = (arch) => `appforge-vpn-darwin-${arch}`;
505
+ /** The statements every document carries, so a reader never has to guess what it does not claim. */
506
+ export const ATTESTATION_LIMITS = [
507
+ "Connection records are metadata only: destination, port, decision, route and byte counts. Payloads, URLs, paths and headers are not recorded.",
508
+ "The connection log was written by the VPN helper on the build host and reported by the agent. The control plane verified its digest and re-derived every summary in this document from it, but macOS offers no remote attestation: this does not prove the build host told the truth.",
509
+ "A traffic class that bypasses the proxy (raw sockets, UDP, ICMP) is neither blocked nor recorded by the connection log; only the kernel sandbox constrains it, and only for a regulated build.",
510
+ "The self-test is a regression matrix of the sandbox's blocked classes on that host before the build, not a proof of confinement. Known gaps are listed in the sandbox scope.",
511
+ "The signature proves AppForge's signing key vouched for this document at its issue time. It does not make AppForge unable to rewrite its own history: keep the chain head you saw earlier and verify with --pin-head.",
512
+ "This is a technical record, not a certification (SOC 2, FIPS and similar assess an organisation's controls, not a build).",
513
+ ];
514
+ export const ATTESTATION_PROVENANCE = {
515
+ server: ["subject", "host.agentId", "tunnel", "policy.egressPolicy", "policy.egressPolicySha256", "timing", "isolation.exclusiveHostLock", "connections (re-derived from the raw log)", "crossChecks", "auditChain", "result.buildStatus"],
516
+ agent: ["helper", "policy.enforcement", "isolation.hygiene", "controlTests (agent self-test results)", "host.agentVersion", "result.agentReportedStatus"],
517
+ helper: ["connections (the raw log)"],
518
+ };
519
+ const VERDICT_FROM_STATUS = { pass: "pass", fail: "fail", inconclusive: "inconclusive" };
520
+ /**
521
+ * Turns the agent's self-test matrix into control-test results. Only checks the matrix itself counts are results:
522
+ * `pass`/`fail`, and an `inconclusive` core check (which cannot occur in a passing run). `na` checks, known-gap
523
+ * checks and inconclusive optional/internet checks are listed apart: the on-host matrix does not count them toward
524
+ * its verdict either, and presenting a documented gap as a "pass" would be false.
525
+ */
526
+ function selfTestToResults(results) {
527
+ const counted = [];
528
+ const notCounted = [];
529
+ const knownGaps = [];
530
+ for (const r of results) {
531
+ if (r.expect === "known_gap" || r.status === "known_gap_open" || r.status === "known_gap_closed") {
532
+ knownGaps.push({ id: r.id, title: r.title, status: r.status });
533
+ }
534
+ else if (r.status === "pass" || r.status === "fail" || (r.status === "inconclusive" && r.tier === "core")) {
535
+ counted.push({
536
+ id: r.id,
537
+ area: r.area,
538
+ title: r.title,
539
+ expect: r.expect,
540
+ verdict: VERDICT_FROM_STATUS[r.status],
541
+ reason: r.detail ?? (r.status === "pass" ? "as expected" : "no detail recorded"),
542
+ source: "agent-selftest",
543
+ });
544
+ }
545
+ else {
546
+ notCounted.push({ id: r.id, tier: r.tier, status: r.status });
547
+ }
548
+ }
549
+ counted.sort((a, b) => cmp(a.id, b.id));
550
+ return { counted, notCounted: notCounted.sort((a, b) => cmp(a.id, b.id)), knownGaps: knownGaps.sort((a, b) => cmp(a.id, b.id)) };
551
+ }
552
+ export function summariseControls(results) {
553
+ const s = { pass: 0, fail: 0, inconclusive: 0 };
554
+ for (const r of results) {
555
+ if (r.verdict === "pass" || r.verdict === "fail" || r.verdict === "inconclusive")
556
+ s[r.verdict]++;
557
+ }
558
+ return s;
559
+ }
560
+ function eventTime(events, type, which) {
561
+ const hits = events.filter((e) => e.type === type);
562
+ if (hits.length === 0)
563
+ return null;
564
+ return (which === "first" ? hits[0] : hits[hits.length - 1]).at;
565
+ }
566
+ const MAX_OPAQUE_DEPTH = 8;
567
+ const LONE_SURROGATES = /[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/g;
568
+ function opaqueValue(v, depth, state) {
569
+ if (depth > MAX_OPAQUE_DEPTH)
570
+ throw new Error("too deep");
571
+ if (v === null)
572
+ return null;
573
+ switch (typeof v) {
574
+ case "boolean":
575
+ return v;
576
+ case "string": {
577
+ const ok = v.replace(LONE_SURROGATES, "\ufffd");
578
+ if (ok !== v)
579
+ state.normalized = true;
580
+ return ok;
581
+ }
582
+ case "number": {
583
+ if (Number.isSafeInteger(v))
584
+ return v;
585
+ state.normalized = true; // canonical JSON has no floats: rounded (a non-finite or out-of-range number becomes null)
586
+ return Number.isFinite(v) && Number.isSafeInteger(Math.round(v)) ? Math.round(v) : null;
587
+ }
588
+ case "object": {
589
+ if (Array.isArray(v))
590
+ return v.map((x) => opaqueValue(x === undefined ? null : x, depth + 1, state));
591
+ const out = {};
592
+ for (const [k, x] of Object.entries(v)) {
593
+ if (x === undefined)
594
+ continue;
595
+ out[k.replace(LONE_SURROGATES, "\ufffd")] = opaqueValue(x, depth + 1, state);
596
+ }
597
+ return out;
598
+ }
599
+ default:
600
+ state.normalized = true;
601
+ return null;
602
+ }
603
+ }
604
+ /**
605
+ * A canonical-JSON-safe copy of an object whose shape this module does not own (the agent's hygiene evidence), or null when it is
606
+ * too large or too deep to carry. Numbers that are not safe integers are rounded and lone surrogates replaced (`normalized` says
607
+ * so): the evidence is kept, not dropped, because it cannot be hashed identically everywhere as sent.
608
+ */
609
+ export function sanitizeOpaque(value, maxBytes) {
610
+ try {
611
+ if (value === null || typeof value !== "object" || Array.isArray(value))
612
+ return null;
613
+ const state = { normalized: false };
614
+ const out = opaqueValue(value, 0, state);
615
+ if (canonicalJson(out).length > maxBytes)
616
+ return null;
617
+ return { value: out, normalized: state.normalized };
618
+ }
619
+ catch {
620
+ return null;
621
+ }
622
+ }
623
+ /**
624
+ * Assembles the attestation document for a build, in exactly the shape `scripts/appforge-verify.mjs` reads, and
625
+ * decides its completeness and verdict. Every comparison between a thing the agent claims and a thing the
626
+ * control plane knows becomes a `crossChecks` entry; a mismatch is also a failing control-test result, which
627
+ * makes the verifier exit 2. Throws only on a programming error (a value canonical JSON cannot represent).
628
+ */
629
+ export async function buildAttestationDocument(input) {
630
+ const { facts, evidence, log, rawLog } = input;
631
+ const regulated = facts.vpnClass === 2;
632
+ const degraded = evidence === null;
633
+ const gaps = [];
634
+ const crossChecks = [];
635
+ const serverResults = [];
636
+ const addCheck = (id, area, title, status, detail, expectText) => {
637
+ crossChecks.push({ id, status, detail });
638
+ if (status !== "not_checked") {
639
+ serverResults.push({ id, area, title, expect: expectText, verdict: status === "match" ? "pass" : "fail", reason: detail, source: "server-crosscheck" });
640
+ }
641
+ };
642
+ if (degraded)
643
+ gaps.push(input.degradedReason ?? "the agent did not report evidence for this build");
644
+ // ---- lease-time facts (server)
645
+ const lease = facts.lease;
646
+ if (!lease)
647
+ gaps.push("no lease-time record exists for this build: the policy and tunnel facts below could not be taken from what was sent to the agent");
648
+ const policy = lease?.policy ?? null;
649
+ const leaseHash = lease?.policyHash ?? null;
650
+ let egressPolicySha256;
651
+ if (policy) {
652
+ egressPolicySha256 = await sha256Hex(canonicalJson(policy));
653
+ if (leaseHash && (await vpnEgressPolicyHash(policy)) !== leaseHash) {
654
+ gaps.push("the stored lease record is internally inconsistent: its policy does not hash to its recorded policy hash");
655
+ }
656
+ }
657
+ // ---- agent claims vs the lease record
658
+ if (evidence) {
659
+ const claimedMode = evidence.egress.mode;
660
+ addCheck("egress-mode", "network", "the helper ran in strict-egress mode", claimedMode === "strict" ? "match" : claimedMode === null ? "not_checked" : "mismatch", claimedMode === "strict" ? "the agent reports a strict-egress helper" : claimedMode === null ? "the agent reported no egress mode" : `the agent reports ${claimedMode} mode for a strict-egress build`, "strict");
661
+ if (leaseHash) {
662
+ const claimed = evidence.egress.policyHash;
663
+ addCheck("policy-hash", "network", "the helper enforced the egress policy the control plane sent", claimed === null ? "not_checked" : claimed === leaseHash ? "match" : "mismatch", claimed === null ? "the agent reported no policy hash" : claimed === leaseHash ? "the policy hash the helper echoed equals the hash of the policy the control plane leased" : "the policy hash the helper echoed differs from the hash of the policy the control plane leased", "equal");
664
+ }
665
+ if (lease?.tunnel) {
666
+ const claimed = evidence.tunnel.configDigest;
667
+ addCheck("tunnel-config", "network", "the build ran against the tunnel configuration the control plane sent", claimed === null ? "not_checked" : claimed === lease.tunnel.configDigest ? "match" : "mismatch", claimed === null ? "the agent reported no tunnel configuration digest" : claimed === lease.tunnel.configDigest ? "the agent's key-free tunnel configuration digest equals the control plane's" : "the agent's tunnel configuration digest differs from what the control plane sent", "equal");
668
+ }
669
+ // ---- helper binary and sandbox template vs the release manifests
670
+ const helperVersion = evidence.helper.version;
671
+ const arch = evidence.helper.arch;
672
+ if (helperVersion && arch && evidence.helper.sha256) {
673
+ const entry = facts.manifests.vpnRelease?.version === helperVersion ? facts.manifests.vpnRelease.artifacts.find((a) => a.name === helperArtifactName(arch)) : undefined;
674
+ if (entry) {
675
+ addCheck("helper-release", "supply-chain", "the helper binary is the one published for its release", entry.sha256 === evidence.helper.sha256 ? "match" : "mismatch", entry.sha256 === evidence.helper.sha256 ? `sha256 equals the release manifest entry for ${helperArtifactName(arch)} ${helperVersion}` : `sha256 differs from the release manifest entry for ${helperArtifactName(arch)} ${helperVersion}`, "equal");
676
+ }
677
+ else {
678
+ crossChecks.push({ id: "helper-release", status: "not_checked", detail: `no release manifest is published for helper ${helperVersion}; the reported hash was not compared with a release` });
679
+ }
680
+ }
681
+ else {
682
+ crossChecks.push({ id: "helper-release", status: "not_checked", detail: "the agent did not report the helper's version, architecture and hash" });
683
+ if (regulated || claimedMode === "strict")
684
+ gaps.push("the agent did not report the helper binary's identity");
685
+ }
686
+ // ---- sandbox (regulated builds)
687
+ const sb = evidence.sandbox;
688
+ if (sb) {
689
+ addCheck("sandbox-profile-version", "sandbox", "the sandbox profile generation is the one the control plane asked for", sb.profileVersion === SANDBOX_PROFILE_VERSION ? "match" : "mismatch", sb.profileVersion === SANDBOX_PROFILE_VERSION ? `profile v${SANDBOX_PROFILE_VERSION}` : `the agent ran profile v${sb.profileVersion}, the control plane asked for v${SANDBOX_PROFILE_VERSION}`, "equal");
690
+ addCheck("sandbox-org", "sandbox", "the sandbox's caches were scoped to this build's organisation", sb.orgId === facts.orgId ? "match" : "mismatch", sb.orgId === facts.orgId ? "the sandbox was prepared for this organisation" : "the sandbox was prepared for a different organisation", "equal");
691
+ const agentVersion = evidence.agent.version;
692
+ const manifest = facts.manifests.agentRelease?.version === agentVersion ? facts.manifests.agentRelease : null;
693
+ const expectedTemplate = manifest?.meta?.sandboxTemplateSha256;
694
+ if (expectedTemplate) {
695
+ addCheck("sandbox-template", "supply-chain", "the sandbox profile template is the one published with the agent release", expectedTemplate === sb.templateSha256 ? "match" : "mismatch", expectedTemplate === sb.templateSha256 ? `template sha256 equals the release manifest entry for agent ${agentVersion}` : `template sha256 differs from the release manifest entry for agent ${agentVersion}`, "equal");
696
+ }
697
+ else {
698
+ crossChecks.push({ id: "sandbox-template", status: "not_checked", detail: `no release manifest with a sandbox template hash is published for agent ${agentVersion}; the reported hash was not compared with a release` });
699
+ }
700
+ if (sb.selfTest.verdict !== "pass") {
701
+ addCheck("sandbox-selftest", "sandbox", "the sandbox self-test passed before the build", "mismatch", `the self-test verdict was ${sb.selfTest.verdict}`, "pass");
702
+ }
703
+ }
704
+ else if (regulated) {
705
+ addCheck("sandbox-evidence", "sandbox", "a regulated build reports its sandbox evidence", "mismatch", "the agent reported no sandbox evidence for a regulated build", "present");
706
+ }
707
+ // ---- the connection log
708
+ const clog = evidence.connectionLog;
709
+ if (!clog || !clog.uploaded) {
710
+ gaps.push(`the connection log is not available${clog?.omitted ? ` (${clog.omitted})` : ""}`);
711
+ }
712
+ else if (!log || !rawLog) {
713
+ gaps.push("the connection log was reported as uploaded but the control plane holds none");
714
+ }
715
+ else {
716
+ if (clog.sha256 !== null) {
717
+ addCheck("log-digest", "evidence", "the connection log the control plane holds is the one the agent summarised", clog.sha256 === rawLog.sha256 ? "match" : "mismatch", clog.sha256 === rawLog.sha256 ? "the agent's sha256 of the file equals the digest the bucket verified at upload" : "the agent's sha256 of the file differs from the object the control plane holds", "equal");
718
+ }
719
+ if (clog.summary) {
720
+ const s = clog.summary;
721
+ const t = log.totals;
722
+ const same = s.total === log.logRecords && s.allowed === t.allowed && s.denied === t.denied && s.connected === log.connected && s.failed === t.errors && s.bytesUp === t.bytesUp && s.bytesDown === t.bytesDown;
723
+ addCheck("log-totals", "evidence", "the agent's connection summary equals what the control plane recomputes from the log", same ? "match" : "mismatch", same ? "connections, decisions and byte counts agree" : "the agent's connection counts or byte totals differ from the control plane's recomputation of the same file", "equal");
724
+ }
725
+ if (!log.complete)
726
+ gaps.push(`the connection log is incomplete (${log.incompleteReasons.slice(0, 5).join(", ")})`);
727
+ if (log.destinationsOmitted > 0)
728
+ gaps.push(`the connection summary lists ${ATTESTATION_MAX_DESTINATIONS} of ${ATTESTATION_MAX_DESTINATIONS + log.destinationsOmitted} destination groups`);
729
+ }
730
+ }
731
+ // ---- control tests
732
+ const agentResults = evidence?.sandbox ? selfTestToResults(evidence.sandbox.selfTest.results) : null;
733
+ const st = evidence?.sandbox?.selfTest;
734
+ const selfTestMeta = st ? { verdict: st.verdict, counts: st.counts, startedAt: st.startedAt, durationMs: st.durationMs } : null;
735
+ const results = [...(agentResults?.counted ?? []), ...serverResults];
736
+ const summary = summariseControls(results);
737
+ let controlsVerdict;
738
+ if (degraded || results.length === 0)
739
+ controlsVerdict = "not_evaluated";
740
+ else if (summary.fail > 0)
741
+ controlsVerdict = "violations_detected";
742
+ else if (regulated && agentResults && agentResults.counted.length > 0 && summary.inconclusive === 0)
743
+ controlsVerdict = "all_enforced";
744
+ else if (!regulated && summary.inconclusive === 0)
745
+ controlsVerdict = "network_policy_checked";
746
+ else
747
+ controlsVerdict = "not_evaluated";
748
+ // ---- connections section
749
+ const totals = log ? log.totals : null;
750
+ const clog = evidence?.connectionLog ?? null;
751
+ const connections = log && rawLog
752
+ ? {
753
+ totals: { allowed: log.totals.allowed, denied: log.totals.denied, errors: log.totals.errors, bytesUp: log.totals.bytesUp, bytesDown: log.totals.bytesDown },
754
+ destinations: log.destinations,
755
+ destinationsOmitted: log.destinationsOmitted,
756
+ logRecords: log.logRecords,
757
+ logSha256: log.logSha256,
758
+ normalization: CONNECTION_RECORDS_NORMALIZATION,
759
+ rawLogSha256: rawLog.sha256,
760
+ rawLogBytes: rawLog.bytes,
761
+ rawLogLines: log.rawLines,
762
+ logCompleteness: log.complete ? "complete" : "incomplete",
763
+ incompleteReasons: log.incompleteReasons,
764
+ authFailures: log.authFailures,
765
+ sessionCredentialRequests: log.sessionCredentialRequests,
766
+ droppedRecords: log.droppedRecords,
767
+ helperTallyAgrees: log.helperTallyAgrees,
768
+ note: "metadata only: destination, route, decision, bytes. No payloads, URLs, paths or headers.",
769
+ }
770
+ : {
771
+ totals: totals ?? { allowed: 0, denied: 0, errors: 0, bytesUp: 0, bytesDown: 0 },
772
+ destinations: [],
773
+ logRecords: 0,
774
+ logSha256: await connectionRecordsDigest([]),
775
+ normalization: CONNECTION_RECORDS_NORMALIZATION,
776
+ logCompleteness: "not_available",
777
+ incompleteReasons: clog?.omitted ? [clog.omitted] : [degraded ? "agent_did_not_report" : "log_not_held"],
778
+ note: degraded ? "no connection data: the agent never reported, so nothing here describes what the build connected to" : "the raw connection log is not held by the control plane; no per-destination data is recorded",
779
+ };
780
+ // ---- policy / enforcement
781
+ const sandbox = evidence?.sandbox ?? null;
782
+ const policyOut = {
783
+ mode: "strict",
784
+ ...(policy ? { egressPolicy: policy, egressPolicySha256: egressPolicySha256 } : {}),
785
+ helperPolicyHash: leaseHash,
786
+ enforcement: sandbox
787
+ ? {
788
+ mechanism: "seatbelt",
789
+ profileVersion: sandbox.profileVersion,
790
+ templateSha256: sandbox.templateSha256,
791
+ renderedSha256: sandbox.renderedSha256,
792
+ paramsSha256: sandbox.paramsSha256,
793
+ loopbackDenyPorts: sandbox.loopbackDenyPorts,
794
+ runAsDedicatedUser: false,
795
+ }
796
+ : null,
797
+ };
798
+ // ---- timing and isolation (server events)
799
+ const timing = {
800
+ queuedAt: facts.build.queuedAt,
801
+ leasedAt: lease?.leasedAt ?? null,
802
+ lockAcquiredAt: eventTime(facts.events, "lock_acquired", "first"),
803
+ handshakeAt: eventTime(facts.events, "handshake_ok", "first"),
804
+ buildStartedAt: facts.build.startedAt,
805
+ buildFinishedAt: facts.build.finishedAt,
806
+ lockReleasedAt: eventTime(facts.events, "lock_released", "last"),
807
+ tunnelDownEvents: facts.events.filter((e) => e.type === "tunnel_down").length,
808
+ handshakeFailedEvents: facts.events.filter((e) => e.type === "handshake_failed").length,
809
+ };
810
+ const isolation = {
811
+ exclusiveHostLock: facts.events.some((e) => e.type === "lock_acquired"),
812
+ note: "an exclusive-host lock separates tenants in time on a Mac; it is not kernel isolation",
813
+ };
814
+ const hygiene = evidence?.hygiene ? sanitizeOpaque(evidence.hygiene, 16 * 1024) : null;
815
+ if (evidence?.hygiene !== undefined && !hygiene)
816
+ gaps.push("the agent's hygiene evidence is too large or too deeply nested to carry and was left out");
817
+ if (hygiene) {
818
+ isolation.hygiene = hygiene.value;
819
+ if (hygiene.normalized)
820
+ isolation.hygieneNote = "the agent's hygiene evidence contained numbers that are not integers (rounded here) or unpaired surrogates (replaced): canonical JSON cannot carry them as sent";
821
+ }
822
+ // Decided last: every gap above (and the hygiene one) counts.
823
+ if (regulated && agentResults && agentResults.counted.length === 0)
824
+ gaps.push("the sandbox self-test reported no checks the matrix counts, so no isolation control is evidenced");
825
+ const completeness = degraded ? "server_observed_only" : gaps.length > 0 ? "partial" : "full";
826
+ const helper = evidence
827
+ ? {
828
+ version: evidence.helper.version,
829
+ sha256: evidence.helper.sha256,
830
+ arch: evidence.helper.arch,
831
+ reportedBy: "agent",
832
+ release: crossChecks.find((c) => c.id === "helper-release") ?? null,
833
+ }
834
+ : { reportedBy: "none", note: "no agent report: the helper's identity was not recorded" };
835
+ const doc = {
836
+ type: ATTESTATION_DOC_TYPE,
837
+ version: ATTESTATION_DOC_VERSION,
838
+ id: attestationIdForBuild(facts.buildId),
839
+ issuedAt: facts.issuedAt,
840
+ completeness,
841
+ subject: { orgId: facts.orgId, appId: facts.appId, buildId: facts.buildId, workflow: printable(facts.workflow, 200), branch: printable(facts.branch, 200), commitSha: facts.commitSha ? printable(facts.commitSha, 64) : null, tunnelId: facts.tunnelId },
842
+ host: { agentId: facts.agent.id, agentVersion: evidence?.agent.version ?? facts.agent.version, os: evidence?.agent.os ?? facts.agent.os, arch: evidence?.agent.arch ?? null },
843
+ helper,
844
+ tunnel: lease?.tunnel ? { ...lease.tunnel } : null,
845
+ policy: policyOut,
846
+ timing,
847
+ isolation,
848
+ connections,
849
+ ...(results.length > 0
850
+ ? {
851
+ controlTests: {
852
+ packVersion: "1",
853
+ phase: "preflight and post-build",
854
+ results,
855
+ summary,
856
+ resultsSha256: await sha256Hex(canonicalJson(results)),
857
+ ...(agentResults
858
+ ? {
859
+ selfTest: selfTestMeta,
860
+ notCounted: agentResults.notCounted,
861
+ knownGaps: agentResults.knownGaps,
862
+ }
863
+ : {}),
864
+ },
865
+ }
866
+ : {}),
867
+ crossChecks,
868
+ gaps,
869
+ scope: {
870
+ statement: regulated
871
+ ? `Regulated build: the build ran under a Seatbelt sandbox. ${sandbox?.scope ?? "The sandbox's scope statement was not reported."}`
872
+ : "Strict-egress build: network policy was enforced by the VPN helper. No kernel sandbox confined the build, so isolation of the build host is NOT claimed.",
873
+ limits: [...ATTESTATION_LIMITS],
874
+ },
875
+ provenance: Object.fromEntries(Object.entries(ATTESTATION_PROVENANCE).map(([k, v]) => [k, [...v]])),
876
+ auditChain: { headSeq: facts.chain.headSeq, headHash: facts.chain.headHash, firstEventSeq: facts.chain.firstEventSeq },
877
+ result: {
878
+ buildStatus: facts.build.status,
879
+ failureKind: facts.build.failureKind,
880
+ agentReportedStatus: evidence ? evidence.outcome.status : null,
881
+ controlsVerdict,
882
+ violations: summary.fail,
883
+ },
884
+ };
885
+ // Fails loudly (a programming error) if anything in the document cannot be hashed identically everywhere.
886
+ canonicalJson(doc);
887
+ return { doc, completeness, controlsVerdict, violations: summary.fail, gaps };
888
+ }
889
+ /** sha256 of the canonical document: what the chain's registration entry holds as `docSha256`. */
890
+ export async function attestationDocumentSha256(doc) {
891
+ return sha256Hex(canonicalJson(doc));
892
+ }
893
+ const HTML_ESC = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
894
+ const esc = (s) => String(s ?? "n/a").replace(/[&<>"']/g, (c) => HTML_ESC[c]);
895
+ const VERDICT_LABEL = { pass: "PASS", fail: "FAIL", inconclusive: "N/A" };
896
+ /**
897
+ * The printable page for a bundle. A TWIN of `renderEvidenceHtml` in scripts/appforge-verify.mjs, which cannot
898
+ * be imported here (it is a zero-dependency single file built on node:crypto, and these Workers do not enable
899
+ * Node compatibility): `--render-check` re-renders the visible page from its embedded JSON with that function
900
+ * and requires a byte-for-byte match, so this one must produce identical output, and packages/core's tests
901
+ * require it on many bundles. Only the JSON embedded in the page is authenticated; the visible text is a
902
+ * rendering of it.
903
+ */
904
+ export function renderEvidenceHtml(bundle, { docSha256 }) {
905
+ const d = bundle.attestation.doc;
906
+ const ct = d.controlTests ?? { summary: { pass: 0, fail: 0, inconclusive: 0 }, results: [] };
907
+ const sig = bundle.attestation.signatures[0] ?? {};
908
+ const embedded = JSON.stringify(bundle).replace(/</g, "\\u003c");
909
+ const rows = (arr, f) => (arr ?? []).map(f).join("");
910
+ const verdict = d.result?.controlsVerdict;
911
+ const headline = verdict === "all_enforced"
912
+ ? "All network and isolation controls were enforced for this build."
913
+ : verdict === "violations_detected"
914
+ ? "Control violations were detected. See the control tests below."
915
+ : "No control-test verdict is recorded for this build.";
916
+ return `<!doctype html>
917
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
918
+ <title>VPN build attestation ${esc(d.subject?.buildId)}</title>
919
+ <style>
920
+ :root{--bg:#fff;--fg:#14171a;--mut:#5b6670;--line:#d6dbe0;--ok:#0b6b2f;--bad:#a4161a;--na:#7a5a00;--card:#f6f8fa}
921
+ @media (prefers-color-scheme:dark){:root{--bg:#101316;--fg:#e8ecef;--mut:#98a4ae;--line:#2c343b;--ok:#4cc27a;--bad:#ff7b7f;--na:#e3b341;--card:#171c20}}
922
+ body{background:var(--bg);color:var(--fg);font:15px/1.5 -apple-system,system-ui,sans-serif;margin:0;padding:24px 16px}
923
+ main{max-width:920px;margin:0 auto}h1{font-size:22px;margin:0 0 4px}h2{font-size:16px;margin:28px 0 8px;border-bottom:1px solid var(--line);padding-bottom:4px}
924
+ .banner{border:2px solid var(--line);background:var(--card);padding:12px 14px;border-radius:6px;margin:12px 0}
925
+ table{border-collapse:collapse;width:100%;font-size:13px}th,td{border-bottom:1px solid var(--line);padding:5px 8px;text-align:left;vertical-align:top;word-break:break-word}th{color:var(--mut)}
926
+ code,.mono{font-family:ui-monospace,Menlo,monospace;font-size:12px}.PASS{color:var(--ok);font-weight:700}.FAIL{color:var(--bad);font-weight:700}.NA{color:var(--na);font-weight:700}
927
+ dl{display:grid;grid-template-columns:200px 1fr;gap:4px 12px;margin:0}dt{color:var(--mut)}dd{margin:0;word-break:break-all}
928
+ @media (max-width:600px){dl{grid-template-columns:1fr}}@media print{body{padding:0}tr{break-inside:avoid}}
929
+ </style></head><body><main>
930
+ <h1>VPN build attestation</h1>
931
+ <div class="mono">${esc(d.id)} &middot; issued ${esc(d.issuedAt)} &middot; signature key ${esc(sig.keyId)}</div>
932
+ <div class="banner"><b>${esc(headline)}</b><br>${esc(ct.summary?.pass)} passed &middot; ${esc(ct.summary?.fail)} failed &middot; ${esc(ct.summary?.inconclusive)} inconclusive. Completeness: ${esc(d.completeness)}.</div>
933
+ <h2>Subject</h2><dl>
934
+ <dt>Organisation</dt><dd>${esc(d.subject?.orgId)}</dd><dt>Build</dt><dd>${esc(d.subject?.buildId)} (app ${esc(d.subject?.appId)}, workflow ${esc(d.subject?.workflow)})</dd>
935
+ <dt>Commit</dt><dd>${esc(d.subject?.commitSha)}</dd><dt>Tunnel</dt><dd>${esc(d.subject?.tunnelId)}</dd></dl>
936
+ <h2>Network connections (metadata only)</h2>
937
+ <table><tr><th>Destination</th><th>Route</th><th>Decision</th><th>Conns</th><th>Bytes up</th><th>Bytes down</th></tr>
938
+ ${rows(d.connections?.destinations, (x) => `<tr><td class="mono">${esc(x.dest)}</td><td>${esc(x.route)}</td><td>${esc(x.decision)}</td><td>${esc(x.conns)}</td><td>${esc(x.bytesUp)}</td><td>${esc(x.bytesDown)}</td></tr>`)}</table>
939
+ <h2>Control tests</h2>
940
+ <table><tr><th>ID</th><th>Attempt</th><th>Expected</th><th>Result</th><th>Detail</th></tr>
941
+ ${rows(ct.results, (r) => `<tr><td class="mono">${esc(r.id)}</td><td>${esc(r.title)}</td><td>${esc(r.expect)}</td><td class="${VERDICT_LABEL[r.verdict] === "N/A" ? "NA" : VERDICT_LABEL[r.verdict] ?? ""}">${esc(VERDICT_LABEL[r.verdict] ?? r.verdict)}</td><td>${esc(r.reason)}</td></tr>`)}</table>
942
+ <h2>Tamper evidence</h2><dl>
943
+ <dt>Audit chain head</dt><dd>seq ${esc(d.auditChain?.headSeq)}, hash <code>${esc(d.auditChain?.headHash)}</code></dd>
944
+ <dt>Document sha256</dt><dd><code>${esc(docSha256)}</code></dd>
945
+ <dt>Signature</dt><dd>${esc(sig.alg)} by key <code>${esc(sig.keyId)}</code> over the canonical JSON document</dd></dl>
946
+ <h2>How to verify this offline</h2>
947
+ <p>Save this page and run <code>node appforge-verify.mjs attestation.html --keys appforge-attestation-keys.json</code>, then <code>--render-check</code>. Only the JSON embedded below is signed: the visible text above is a rendering of it, and <code>--render-check</code> proves the two match.</p>
948
+ <script type="application/json" id="appforge-evidence">${embedded}</script>
949
+ </main></body></html>`;
950
+ }
951
+ /**
952
+ * The bundle and, when asked, its printable page, from an attestation's stored parts. The page is rendered from a
953
+ * JSON round trip of the bundle, so the bytes the verifier re-renders from the embedded JSON are the bytes written
954
+ * here (JS reorders integer-like object keys on a parse, and the round trip applies that once, up front).
955
+ */
956
+ export async function renderEvidencePage(bundle) {
957
+ const roundTripped = JSON.parse(JSON.stringify(bundle));
958
+ return renderEvidenceHtml(roundTripped, { docSha256: await attestationDocumentSha256(roundTripped.attestation.doc) });
959
+ }
960
+ export function isoNowSeconds(now = new Date()) {
961
+ return toIsoUtcSeconds(now);
962
+ }
963
+ //# sourceMappingURL=vpn-attestation.js.map