@appforge-ci/core 0.2.3 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-readiness.d.ts +240 -1
- package/dist/agent-readiness.d.ts.map +1 -1
- package/dist/agent-readiness.js +304 -1
- package/dist/agent-readiness.js.map +1 -1
- package/dist/agent-readiness.test.js +277 -19
- package/dist/agent-readiness.test.js.map +1 -1
- package/dist/api-client.d.ts +80 -3
- package/dist/api-client.d.ts.map +1 -1
- package/dist/api-client.js +79 -0
- package/dist/api-client.js.map +1 -1
- package/dist/api-client.test.js +22 -0
- package/dist/api-client.test.js.map +1 -1
- package/dist/appforge-verify.test.d.ts +2 -0
- package/dist/appforge-verify.test.d.ts.map +1 -0
- package/dist/appforge-verify.test.js +958 -0
- package/dist/appforge-verify.test.js.map +1 -0
- package/dist/attestation-keys.d.ts +20 -0
- package/dist/attestation-keys.d.ts.map +1 -0
- package/dist/attestation-keys.js +27 -0
- package/dist/attestation-keys.js.map +1 -0
- package/dist/attestation-keys.test.d.ts +2 -0
- package/dist/attestation-keys.test.d.ts.map +1 -0
- package/dist/attestation-keys.test.js +45 -0
- package/dist/attestation-keys.test.js.map +1 -0
- package/dist/attestation-signing.d.ts +226 -0
- package/dist/attestation-signing.d.ts.map +1 -0
- package/dist/attestation-signing.js +276 -0
- package/dist/attestation-signing.js.map +1 -0
- package/dist/attestation-signing.test.d.ts +2 -0
- package/dist/attestation-signing.test.d.ts.map +1 -0
- package/dist/attestation-signing.test.js +250 -0
- package/dist/attestation-signing.test.js.map +1 -0
- package/dist/attestation-workerd.test.d.ts +2 -0
- package/dist/attestation-workerd.test.d.ts.map +1 -0
- package/dist/attestation-workerd.test.js +183 -0
- package/dist/attestation-workerd.test.js.map +1 -0
- package/dist/attestation.d.ts +412 -0
- package/dist/attestation.d.ts.map +1 -0
- package/dist/attestation.js +353 -0
- package/dist/attestation.js.map +1 -0
- package/dist/attestation.test.d.ts +2 -0
- package/dist/attestation.test.d.ts.map +1 -0
- package/dist/attestation.test.js +218 -0
- package/dist/attestation.test.js.map +1 -0
- package/dist/build-vpn.d.ts +20 -0
- package/dist/build-vpn.d.ts.map +1 -0
- package/dist/build-vpn.js +50 -0
- package/dist/build-vpn.js.map +1 -0
- package/dist/fleet-vpn.d.ts +64 -0
- package/dist/fleet-vpn.d.ts.map +1 -0
- package/dist/fleet-vpn.js +88 -0
- package/dist/fleet-vpn.js.map +1 -0
- package/dist/fleet-vpn.test.d.ts +2 -0
- package/dist/fleet-vpn.test.d.ts.map +1 -0
- package/dist/fleet-vpn.test.js +69 -0
- package/dist/fleet-vpn.test.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -1
- package/dist/schemas-repo-url.test.d.ts +2 -0
- package/dist/schemas-repo-url.test.d.ts.map +1 -0
- package/dist/schemas-repo-url.test.js +67 -0
- package/dist/schemas-repo-url.test.js.map +1 -0
- package/dist/schemas.d.ts +360 -7
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +148 -12
- package/dist/schemas.js.map +1 -1
- package/dist/ssh-deploy-key.d.ts +59 -0
- package/dist/ssh-deploy-key.d.ts.map +1 -0
- package/dist/ssh-deploy-key.js +124 -0
- package/dist/ssh-deploy-key.js.map +1 -0
- package/dist/ssh-deploy-key.test.d.ts +2 -0
- package/dist/ssh-deploy-key.test.d.ts.map +1 -0
- package/dist/ssh-deploy-key.test.js +117 -0
- package/dist/ssh-deploy-key.test.js.map +1 -0
- package/dist/types.d.ts +204 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +0 -5
- package/dist/types.js.map +1 -1
- package/dist/vpn-attestation-workerd.test.d.ts +2 -0
- package/dist/vpn-attestation-workerd.test.d.ts.map +1 -0
- package/dist/vpn-attestation-workerd.test.js +184 -0
- package/dist/vpn-attestation-workerd.test.js.map +1 -0
- package/dist/vpn-attestation.d.ts +1015 -0
- package/dist/vpn-attestation.d.ts.map +1 -0
- package/dist/vpn-attestation.js +963 -0
- package/dist/vpn-attestation.js.map +1 -0
- package/dist/vpn-attestation.test.d.ts +2 -0
- package/dist/vpn-attestation.test.d.ts.map +1 -0
- package/dist/vpn-attestation.test.js +613 -0
- package/dist/vpn-attestation.test.js.map +1 -0
- package/dist/vpn-egress-presets.d.ts +142 -0
- package/dist/vpn-egress-presets.d.ts.map +1 -0
- package/dist/vpn-egress-presets.js +318 -0
- package/dist/vpn-egress-presets.js.map +1 -0
- package/dist/vpn-egress-presets.test.d.ts +2 -0
- package/dist/vpn-egress-presets.test.d.ts.map +1 -0
- package/dist/vpn-egress-presets.test.js +210 -0
- package/dist/vpn-egress-presets.test.js.map +1 -0
- package/dist/vpn-egress.d.ts +57 -0
- package/dist/vpn-egress.d.ts.map +1 -0
- package/dist/vpn-egress.js +126 -0
- package/dist/vpn-egress.js.map +1 -0
- package/dist/vpn-egress.test.d.ts +2 -0
- package/dist/vpn-egress.test.d.ts.map +1 -0
- package/dist/vpn-egress.test.js +133 -0
- package/dist/vpn-egress.test.js.map +1 -0
- package/dist/vpn-sandbox.d.ts +67 -0
- package/dist/vpn-sandbox.d.ts.map +1 -0
- package/dist/vpn-sandbox.js +22 -0
- package/dist/vpn-sandbox.js.map +1 -0
- package/dist/vpn-sandbox.test.d.ts +2 -0
- package/dist/vpn-sandbox.test.d.ts.map +1 -0
- package/dist/vpn-sandbox.test.js +34 -0
- package/dist/vpn-sandbox.test.js.map +1 -0
- package/dist/vpn-ssh.d.ts +138 -0
- package/dist/vpn-ssh.d.ts.map +1 -0
- package/dist/vpn-ssh.js +320 -0
- package/dist/vpn-ssh.js.map +1 -0
- package/dist/vpn-ssh.test.d.ts +2 -0
- package/dist/vpn-ssh.test.d.ts.map +1 -0
- package/dist/vpn-ssh.test.js +274 -0
- package/dist/vpn-ssh.test.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createHash, createPrivateKey, createPublicKey } from "node:crypto";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { AUDIT_ENTRY_KINDS, auditCheckpointStatement, auditEntryHash, auditEntryPayloadSchema, auditGenesisHash, bytesToBase64, canonicalJson, createEd25519Signer, ed25519KeyId, ed25519SeedToPkcs8, sha256Hex, signAuditCheckpoint, verifyAuditChain, } from "./attestation.js";
|
|
10
|
+
import { signAttestationDocument } from "./attestation-signing.js";
|
|
11
|
+
import { ATTESTATION_LIMITS, ATTESTATION_MAX_DESTINATIONS, agentVpnEvidenceSchema, analyzeConnectionLog, attestationDocumentSha256, buildAttestationDocument, buildTunnelFacts, connectionLogKey, connectionRecordsDigest, expandLeasedPolicy, parseReleaseManifest, releaseManifestKey, renderEvidenceHtml, renderEvidencePage, summariseConnections, vpnPublicConfigDigest, } from "./vpn-attestation.js";
|
|
12
|
+
import { SANDBOX_PROFILE_VERSION } from "./vpn-sandbox.js";
|
|
13
|
+
const V = (await import(new URL("../../../scripts/appforge-verify.mjs", import.meta.url).href));
|
|
14
|
+
const clone = (o) => JSON.parse(JSON.stringify(o));
|
|
15
|
+
// ---------------------------------------------------------------- a throwaway signing key
|
|
16
|
+
async function testKey(label) {
|
|
17
|
+
const pkcs8 = ed25519SeedToPkcs8(new Uint8Array(createHash("sha256").update(`vpn-attestation-test:${label}`).digest()));
|
|
18
|
+
const priv = createPrivateKey({ key: Buffer.from(pkcs8), format: "der", type: "pkcs8" });
|
|
19
|
+
const raw = new Uint8Array(createPublicKey(priv).export({ format: "der", type: "spki" }).subarray(-32));
|
|
20
|
+
const kid = await ed25519KeyId(raw);
|
|
21
|
+
const entry = { kid, alg: "Ed25519", publicKey: bytesToBase64(raw), notBefore: "2026-01-01T00:00:00Z", status: "active" };
|
|
22
|
+
return { signer: await createEd25519Signer(kid, pkcs8), entry, kid };
|
|
23
|
+
}
|
|
24
|
+
const KEY = await testKey("server");
|
|
25
|
+
const KEYS = [KEY.entry];
|
|
26
|
+
// ---------------------------------------------------------------- helper log fixtures
|
|
27
|
+
const BUILD = "bld_0001";
|
|
28
|
+
const ORG = "org_acme";
|
|
29
|
+
function conn(bseq, over = {}) {
|
|
30
|
+
return JSON.stringify({
|
|
31
|
+
v: 1, type: "conn", seq: bseq + 100, bseq, ts: `2026-10-02T14:00:0${bseq % 10}.123Z`, buildId: BUILD, auth: "build", proto: "socks5",
|
|
32
|
+
host: "pub.dev", port: 443, decision: "allow", reason: "allowlist", route: "direct", connected: true, bytesUp: 100 * bseq, bytesDown: 1000 * bseq, durationMs: 10 + bseq, ...over,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
const tallyLine = (over = {}) => JSON.stringify({ v: 1, type: "summary", ts: "2026-10-02T14:03:00.000Z", buildId: BUILD, reason: "unregistered", conns: 6, allowed: 4, denied: 2, connected: 4, failed: 0, bytesUp: 100 * (1 + 2 + 3 + 4), bytesDown: 1000 * (1 + 2 + 3 + 4), dropped: 0, forcedClosed: 0, stream: true, ...over });
|
|
36
|
+
/** Six requests: four allowed (three to pub.dev through the direct route, one to the LAN through the tunnel) and two refused. */
|
|
37
|
+
function goodLog() {
|
|
38
|
+
const lines = [
|
|
39
|
+
conn(1),
|
|
40
|
+
conn(2),
|
|
41
|
+
conn(3, { host: "10.20.0.5", port: 443, route: "tunnel", reason: "lan" }),
|
|
42
|
+
conn(4, { host: "pub.dev" }),
|
|
43
|
+
conn(5, { host: "127.0.0.1", port: 52730, decision: "deny", reason: "hard-deny", route: "none", class: "loopback", connected: false, bytesUp: 0, bytesDown: 0 }),
|
|
44
|
+
conn(6, { host: "169.254.169.254", port: 80, decision: "deny", reason: "hard-deny", route: "none", class: "link-local", connected: false, bytesUp: 0, bytesDown: 0 }),
|
|
45
|
+
];
|
|
46
|
+
return lines.join("\n") + "\n" + tallyLine() + "\n";
|
|
47
|
+
}
|
|
48
|
+
// ---------------------------------------------------------------- connection log analysis
|
|
49
|
+
test("analyzeConnectionLog: a clean log is complete, agrees with the helper's tally and matches the verifier's own recomputation", async () => {
|
|
50
|
+
const a = await analyzeConnectionLog(goodLog(), BUILD);
|
|
51
|
+
assert.equal(a.complete, true, a.incompleteReasons.join(","));
|
|
52
|
+
assert.equal(a.helperTallyAgrees, true);
|
|
53
|
+
assert.equal(a.logRecords, 6);
|
|
54
|
+
assert.deepEqual(a.totals, { allowed: 4, denied: 2, errors: 0, bytesUp: 100 * (1 + 2 + 3 + 4), bytesDown: 1000 * (1 + 2 + 3 + 4) });
|
|
55
|
+
assert.equal(a.connected, 4);
|
|
56
|
+
assert.equal(a.records[2].dest, "10.20.0.5:443");
|
|
57
|
+
assert.equal(a.records[4].class, "loopback");
|
|
58
|
+
// The verifier recomputes the per-destination summary and the digest from the records alone.
|
|
59
|
+
assert.deepEqual(V.summariseConnections(a.records), a.destinations);
|
|
60
|
+
assert.equal(V.sha256hex(a.records.map((r) => V.canonicalize(r)).join("\n") + "\n"), a.logSha256);
|
|
61
|
+
assert.equal(a.logSha256, await connectionRecordsDigest(a.records));
|
|
62
|
+
});
|
|
63
|
+
test("analyzeConnectionLog: every way a log can be short of complete is named, and none of them is silent", async () => {
|
|
64
|
+
const lines = goodLog().trimEnd().split("\n");
|
|
65
|
+
const recs = lines.slice(0, 6);
|
|
66
|
+
const tally = lines[6];
|
|
67
|
+
const run = (text) => analyzeConnectionLog(text, BUILD);
|
|
68
|
+
// A lost record: record number 3 never arrived, and the helper's own tally still says six.
|
|
69
|
+
let a = await run([recs[0], recs[1], ...recs.slice(3), tally].join("\n") + "\n");
|
|
70
|
+
assert.equal(a.complete, false);
|
|
71
|
+
assert.ok(a.incompleteReasons.includes("missing_records:1"), a.incompleteReasons.join());
|
|
72
|
+
assert.ok(a.incompleteReasons.includes("helper_summary_mismatch"));
|
|
73
|
+
assert.equal(a.helperTallyAgrees, false);
|
|
74
|
+
// The helper says it dropped one and the gap is exactly that one: incomplete, but consistent with the helper.
|
|
75
|
+
a = await run([recs[0], recs[1], ...recs.slice(3), JSON.stringify({ v: 1, type: "dropped", count: 1, firstSeq: 102, lastSeq: 102 }), tallyLine({ dropped: 1, conns: 6, allowed: 3, denied: 2, connected: 3, bytesUp: 100 * (1 + 2 + 4 + 5 + 6) + 300, bytesDown: 1000 * (1 + 2 + 4) + 3000 })].join("\n") + "\n");
|
|
76
|
+
assert.ok(a.incompleteReasons.includes("helper_dropped_records:1"), a.incompleteReasons.join());
|
|
77
|
+
assert.equal(a.droppedRecords, 1);
|
|
78
|
+
// No tally at all (the helper died before summarising).
|
|
79
|
+
a = await run(recs.join("\n") + "\n");
|
|
80
|
+
assert.deepEqual(a.incompleteReasons, ["helper_summary_missing"]);
|
|
81
|
+
assert.equal(a.helperTallyAgrees, null);
|
|
82
|
+
// Another build's record in this build's file, a duplicate, a malformed line and a torn last line.
|
|
83
|
+
a = await run([...recs, conn(2), conn(9, { buildId: "bld_other" }), "{not json", tally].join("\n") + "\n" + '{"v":1,"type":"conn"');
|
|
84
|
+
for (const reason of ["foreign_records:1", "duplicate_records:1", "malformed_lines:1", "torn_final_line"])
|
|
85
|
+
assert.ok(a.incompleteReasons.includes(reason), `${reason} in ${a.incompleteReasons.join()}`);
|
|
86
|
+
assert.equal(a.logRecords, 7, "the six records and the duplicate are counted; the foreign record and the malformed lines are not");
|
|
87
|
+
// A registration that ended some other way than the build's own unregister.
|
|
88
|
+
a = await run(recs.join("\n") + "\n" + tallyLine({ reason: "expired" }) + "\n");
|
|
89
|
+
assert.ok(a.incompleteReasons.includes("registration_ended:expired"));
|
|
90
|
+
// The helper's tally disagreeing with the file in any figure is named.
|
|
91
|
+
for (const [field, value] of [["allowed", 9], ["denied", 9], ["connected", 9], ["failed", 9], ["bytesUp", 1], ["bytesDown", 1], ["conns", 99]]) {
|
|
92
|
+
a = await run(recs.join("\n") + "\n" + tallyLine({ [field]: value }) + "\n");
|
|
93
|
+
assert.equal(a.helperTallyAgrees, false, `a wrong ${field} in the tally must be noticed`);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
test("analyzeConnectionLog: a record with no usable number is a malformed line that is still a connection, and a malformed tally is malformed, not zeros (as the agent's aggregator treats both)", async () => {
|
|
97
|
+
const noNumber = JSON.stringify({ ...JSON.parse(conn(2)), bseq: "two" });
|
|
98
|
+
const text = [conn(1), noNumber, conn(3), tallyLine({ conns: 3, allowed: 3, denied: 0, connected: 3, bytesUp: 100 + 200 + 300, bytesDown: 1000 + 2000 + 3000 })].join("\n") + "\n";
|
|
99
|
+
const a = await analyzeConnectionLog(text, BUILD);
|
|
100
|
+
assert.equal(a.logRecords, 3, "the numberless record is counted");
|
|
101
|
+
assert.equal(a.records[1].bseq, 0);
|
|
102
|
+
assert.ok(a.incompleteReasons.includes("malformed_lines:1"), a.incompleteReasons.join());
|
|
103
|
+
assert.ok(a.incompleteReasons.includes("missing_records:1"), "number 2 never arrived as far as the numbering can tell");
|
|
104
|
+
assert.deepEqual(a.totals, { allowed: 3, denied: 0, errors: 0, bytesUp: 600, bytesDown: 6000 });
|
|
105
|
+
// A tally missing a field, with a wrong type, or naming no valid build is a malformed line; one for another build is foreign.
|
|
106
|
+
const good = JSON.parse(tallyLine());
|
|
107
|
+
const withTally = async (t) => analyzeConnectionLog([conn(1), JSON.stringify(t)].join("\n") + "\n", BUILD);
|
|
108
|
+
for (const [name, t] of [
|
|
109
|
+
["a missing count", (({ conns: _c, ...rest }) => rest)(good)],
|
|
110
|
+
["a count that is a string", { ...good, conns: "6" }],
|
|
111
|
+
["a negative count", { ...good, dropped: -1 }],
|
|
112
|
+
["a stream flag that is not a boolean", { ...good, stream: "yes" }],
|
|
113
|
+
["no version", (({ v: _v, ...rest }) => rest)(good)],
|
|
114
|
+
["no build id", (({ buildId: _b, ...rest }) => rest)(good)],
|
|
115
|
+
["a build id that cannot be one", { ...good, buildId: "../x" }],
|
|
116
|
+
]) {
|
|
117
|
+
const r = await withTally(t);
|
|
118
|
+
assert.ok(r.incompleteReasons.includes("malformed_lines:1"), `${name}: ${r.incompleteReasons.join()}`);
|
|
119
|
+
assert.ok(r.incompleteReasons.includes("helper_summary_missing"), name);
|
|
120
|
+
assert.equal(r.helperTallyAgrees, null, name);
|
|
121
|
+
}
|
|
122
|
+
const other = await withTally({ ...good, buildId: "bld_other" });
|
|
123
|
+
assert.ok(other.incompleteReasons.includes("foreign_records:1"));
|
|
124
|
+
});
|
|
125
|
+
test("analyzeConnectionLog: unattributed lines are counted apart, and hostile text cannot reach a signed document", async () => {
|
|
126
|
+
const hostile = "evil\u0000\u001b[2J.example\ud800";
|
|
127
|
+
const text = [
|
|
128
|
+
conn(1, { host: hostile }),
|
|
129
|
+
JSON.stringify({ v: 1, type: "conn", ts: "x", auth: "none", decision: "deny", reason: "auth-failed", suppressed: 4 }),
|
|
130
|
+
JSON.stringify({ v: 1, type: "conn", auth: "session", decision: "allow", reason: "allowlist", host: "pub.dev", port: 443 }),
|
|
131
|
+
conn(2, { host: "2001:db8::1", port: 8443 }),
|
|
132
|
+
conn(3, { route: "x y", reason: "<script>", proto: "z".repeat(99), port: 999999, bytesUp: -5, bytesDown: 1.5 }),
|
|
133
|
+
tallyLine({ conns: 3, allowed: 3, denied: 0, connected: 3, bytesUp: 100 + 200 + 300, bytesDown: 1000 + 2000 + 3000 }),
|
|
134
|
+
].join("\n") + "\n";
|
|
135
|
+
const a = await analyzeConnectionLog(text, BUILD);
|
|
136
|
+
assert.equal(a.logRecords, 3);
|
|
137
|
+
assert.equal(a.authFailures, 5);
|
|
138
|
+
assert.equal(a.sessionCredentialRequests, 1);
|
|
139
|
+
assert.match(a.records[0].host, /^[\x20-\x7e]+$/, "printable ASCII only");
|
|
140
|
+
assert.equal(a.records[1].dest, "[2001:db8::1]:8443", "an IPv6 literal is bracketed");
|
|
141
|
+
assert.equal(a.records[2].route, "unknown");
|
|
142
|
+
assert.equal(a.records[2].reason, "unknown");
|
|
143
|
+
assert.equal(a.records[2].port, 0);
|
|
144
|
+
assert.equal(a.records[2].bytesUp, 0, "a negative count is not a count");
|
|
145
|
+
assert.equal(a.records[2].bytesDown, 0, "a float is not a count");
|
|
146
|
+
// Whatever came in, the records are canonical-JSON safe (a signed document can hold them).
|
|
147
|
+
for (const r of a.records)
|
|
148
|
+
canonicalJson(r);
|
|
149
|
+
});
|
|
150
|
+
test("summariseConnections sorts by code units, never by locale, and the verifier does the same", () => {
|
|
151
|
+
// In every ICU locale ':' collates before digits; in code units ':' (0x3a) is after '0' (0x30).
|
|
152
|
+
const recs = ["10.0.0.1:443", "10.0.0.10:443", "a.example:443", "B.example:443", "Z.example:443", "z.example:443"].map((dest) => ({ dest, route: "tunnel", decision: "allow", bytesUp: 1, bytesDown: 2 }));
|
|
153
|
+
const order = summariseConnections(recs).map((d) => d.dest);
|
|
154
|
+
assert.deepEqual(order, ["10.0.0.10:443", "10.0.0.1:443", "B.example:443", "Z.example:443", "a.example:443", "z.example:443"]);
|
|
155
|
+
assert.deepEqual(V.summariseConnections(recs).map((d) => d.dest), order);
|
|
156
|
+
assert.deepEqual(V.summariseConnections(recs), summariseConnections(recs));
|
|
157
|
+
// Totals add up per group, and the bytes of a record with none are zero.
|
|
158
|
+
const merged = summariseConnections([...recs, ...recs.slice(0, 1)]);
|
|
159
|
+
assert.deepEqual(merged.find((d) => d.dest === "10.0.0.1:443"), { dest: "10.0.0.1:443", route: "tunnel", decision: "allow", conns: 2, bytesUp: 2, bytesDown: 4 });
|
|
160
|
+
});
|
|
161
|
+
test("a log with more distinct destinations than a document lists keeps the busiest, in the verifier's order, and says it omitted some", async () => {
|
|
162
|
+
const n = ATTESTATION_MAX_DESTINATIONS + 25;
|
|
163
|
+
const lines = Array.from({ length: n }, (_, i) => conn(i + 1, { host: `h${String(i).padStart(5, "0")}.example`, bytesUp: 1, bytesDown: 1 }));
|
|
164
|
+
lines.push(...Array.from({ length: 3 }, (_, k) => conn(n + k + 1, { host: "h00007.example", bytesUp: 1, bytesDown: 1 })));
|
|
165
|
+
const a = await analyzeConnectionLog(lines.join("\n") + "\n", BUILD);
|
|
166
|
+
assert.equal(a.destinations.length, ATTESTATION_MAX_DESTINATIONS);
|
|
167
|
+
assert.equal(a.destinationsOmitted, 25);
|
|
168
|
+
assert.ok(a.destinations.some((d) => d.dest === "h00007.example:443" && d.conns === 4), "the busiest destination is kept");
|
|
169
|
+
const sorted = [...a.destinations].sort((x, y) => (x.dest + x.route + x.decision < y.dest + y.route + y.decision ? -1 : 1));
|
|
170
|
+
assert.deepEqual(a.destinations, sorted);
|
|
171
|
+
});
|
|
172
|
+
test("the verifier's verdict on a bundle does not depend on the verifier's locale (a Swedish or Estonian collation puts letters in another order)", async () => {
|
|
173
|
+
// t, u and z sort differently under et_EE (z between s and t) than in code units; the document lists destinations in code-unit order.
|
|
174
|
+
const hosts = ["z.example", "t.example", "s.example", "u.example", "10.0.0.1", "10.0.0.10", "B.example", "a.example"];
|
|
175
|
+
const log = hosts.map((host, i) => conn(i + 1, { host })).join("\n") + "\n" + tallyLine({ conns: hosts.length, allowed: hosts.length, denied: 0, connected: hosts.length, bytesUp: hosts.reduce((n, _, i) => n + 100 * (i + 1), 0), bytesDown: hosts.reduce((n, _, i) => n + 1000 * (i + 1), 0) }) + "\n";
|
|
176
|
+
const analysis = await analyzeConnectionLog(log, BUILD);
|
|
177
|
+
assert.ok(analysis.complete, analysis.incompleteReasons.join());
|
|
178
|
+
const r = await issue({ log: analysis, evidence: sampleEvidence({ connectionLog: { uploaded: true, sha256: "55".repeat(32), bytes: 1000, lines: 9, complete: true, incompleteReasons: [], summary: { total: hosts.length, allowed: hosts.length, denied: 0, connected: hosts.length, failed: 0, bytesUp: analysis.totals.bytesUp, bytesDown: analysis.totals.bytesDown } } }) });
|
|
179
|
+
assert.equal(r.verdict.exit, 0, r.verdict.problems.join(";"));
|
|
180
|
+
const dir = mkdtempSync(join(tmpdir(), "appforge-locale-"));
|
|
181
|
+
try {
|
|
182
|
+
writeFileSync(join(dir, "bundle.json"), JSON.stringify(r.bundle));
|
|
183
|
+
writeFileSync(join(dir, "keys.json"), JSON.stringify(KEYS));
|
|
184
|
+
for (const locale of ["en_US.UTF-8", "et_EE.UTF-8", "sv_SE.UTF-8", "C"]) {
|
|
185
|
+
const res = spawnSync(process.execPath, [fileURLToPath(new URL("../../../scripts/appforge-verify.mjs", import.meta.url)), join(dir, "bundle.json"), "--keys", join(dir, "keys.json")], { encoding: "utf8", env: { ...process.env, LC_ALL: locale, LANG: locale } });
|
|
186
|
+
assert.equal(res.status, 0, `${locale}: ${res.stdout}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
finally {
|
|
190
|
+
rmSync(dir, { recursive: true, force: true });
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
// ---------------------------------------------------------------- the agent's report
|
|
194
|
+
test("the agent evidence schema clamps numbers and strips unknown fields instead of refusing evidence, and refuses what it cannot trust", () => {
|
|
195
|
+
const base = sampleEvidence();
|
|
196
|
+
const loose = clone(base);
|
|
197
|
+
loose.extraTopLevel = { anything: 1 };
|
|
198
|
+
loose.sandbox.selfTest.durationMs = 1234.7;
|
|
199
|
+
loose.sandbox.selfTest.results[0].title = "tab\u0000andbidi";
|
|
200
|
+
const parsed = agentVpnEvidenceSchema.parse(loose);
|
|
201
|
+
assert.equal("extraTopLevel" in parsed, false);
|
|
202
|
+
assert.equal(parsed.sandbox.selfTest.durationMs, 1235);
|
|
203
|
+
assert.match(parsed.sandbox.selfTest.results[0].title, /^[\x20-\x7e]+$/);
|
|
204
|
+
for (const [name, mutate] of [
|
|
205
|
+
["a build id with a slash", (e) => { e.buildId = "../x"; }],
|
|
206
|
+
["a policy hash that is not hex", (e) => { e.egress.policyHash = "nope"; }],
|
|
207
|
+
["a helper arch that does not exist", (e) => { e.helper.arch = "riscv"; }],
|
|
208
|
+
["a wrong version marker", (e) => { e.v = 2; }],
|
|
209
|
+
["more than 300 self-test results", (e) => { e.sandbox.selfTest.results = Array.from({ length: 301 }, () => e.sandbox.selfTest.results[0]); }],
|
|
210
|
+
["a status the matrix cannot produce", (e) => { e.sandbox.selfTest.results[0].status = "maybe"; }],
|
|
211
|
+
]) {
|
|
212
|
+
const bad = clone(base);
|
|
213
|
+
mutate(bad);
|
|
214
|
+
assert.equal(agentVpnEvidenceSchema.safeParse(bad).success, false, name);
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
// ---------------------------------------------------------------- the document, through the real verifier
|
|
218
|
+
const LAN_POLICY = await expandLeasedPolicy({ allow: [{ host: "pub.dev" }, { host: "*.googleapis.com", ports: [443] }] });
|
|
219
|
+
const TUNNEL = await buildTunnelFacts({ tunnelId: "tun_1", tunnelName: "HQ", keyVersion: 2, gatewayEndpoint: "vpn.acme.example:51820", gatewayPublicKey: "A".repeat(43) + "=", tunnelCidr: "169.254.200.0/30", localAddress: "169.254.200.2", gatewayAddress: "169.254.200.1", lanCidrs: ["10.20.0.0/16"], dnsServer: "10.20.0.2", allowedDomains: ["corp.acme.example"], allowPrivateEndpoint: false, egress: { allow: [{ host: "pub.dev" }, { host: "*.googleapis.com", ports: [443] }] } }, "B".repeat(43) + "=");
|
|
220
|
+
function sampleEvidence(over = {}) {
|
|
221
|
+
const results = [
|
|
222
|
+
["net-direct-tcp", "network", "direct TCP connect to a public address", "blocked"],
|
|
223
|
+
["net-udp", "network", "UDP to a public resolver", "blocked"],
|
|
224
|
+
["files-agent-config", "files", "read the agent's config file", "blocked"],
|
|
225
|
+
["proxy-loopback", "loopback", "reach the helper's loopback proxy port", "allowed"],
|
|
226
|
+
].map(([id, area, title, expect]) => ({ id: id, area: area, title: title, tier: "core", expect: expect, status: "pass", detail: "denied inside, permitted outside" }));
|
|
227
|
+
results.push({ id: "mach-keychain", area: "mach", title: "the login keychain", tier: "core", expect: "blocked", status: "pass", detail: "denied inside, permitted outside" });
|
|
228
|
+
const extra = [
|
|
229
|
+
{ id: "ssh-agent-socket", area: "unix", title: "the user's ssh-agent socket", tier: "optional", expect: "blocked", status: "na", detail: "no ssh-agent here" },
|
|
230
|
+
{ id: "gap-same-user-procargs", area: "process", title: "argv/env of same-user processes", tier: "core", expect: "known_gap", status: "known_gap_open", detail: "documented gap" },
|
|
231
|
+
{ id: "net-internet-1", area: "network", title: "real traffic to a public resolver", tier: "internet", expect: "blocked", status: "inconclusive", detail: "offline host" },
|
|
232
|
+
];
|
|
233
|
+
return agentVpnEvidenceSchema.parse({
|
|
234
|
+
v: 1,
|
|
235
|
+
buildId: BUILD,
|
|
236
|
+
agent: { version: "0.3.1", os: "macOS 26.0", arch: "arm64" },
|
|
237
|
+
outcome: { status: "succeeded" },
|
|
238
|
+
helper: { version: "0.3.1", sha256: "11".repeat(32), arch: "arm64" },
|
|
239
|
+
egress: { mode: "strict", policyHash: LAN_POLICY.helperHash },
|
|
240
|
+
tunnel: { configDigest: TUNNEL.configDigest },
|
|
241
|
+
sandbox: {
|
|
242
|
+
profileVersion: SANDBOX_PROFILE_VERSION,
|
|
243
|
+
templateSha256: "22".repeat(32),
|
|
244
|
+
renderedSha256: "33".repeat(32),
|
|
245
|
+
paramsSha256: "44".repeat(32),
|
|
246
|
+
loopbackDenyPorts: [5900, 22],
|
|
247
|
+
orgId: ORG,
|
|
248
|
+
selfTest: { verdict: "pass", counts: { pass: 5, fail: 0, inconclusive: 1, na: 1, known_gap_open: 1, known_gap_closed: 0 }, startedAt: "2026-10-02T14:00:00.000Z", durationMs: 8000, results: [...results, ...extra] },
|
|
249
|
+
scope: "Seatbelt blocklist profile; known gaps: same-user procargs.",
|
|
250
|
+
},
|
|
251
|
+
connectionLog: { uploaded: true, sha256: "55".repeat(32), bytes: 1000, lines: 7, complete: true, incompleteReasons: [], summary: { total: 6, allowed: 4, denied: 2, connected: 4, failed: 0, bytesUp: 1000, bytesDown: 10000 } },
|
|
252
|
+
...over,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
function sampleFacts(over = {}) {
|
|
256
|
+
return {
|
|
257
|
+
orgId: ORG, appId: "app_1", buildId: BUILD, workflow: "vpn-compliance-check", branch: "main", commitSha: "0123456789abcdef0123456789abcdef01234567", tunnelId: "tun_1", vpnClass: 2,
|
|
258
|
+
build: { status: "succeeded", failureKind: null, queuedAt: "2026-10-02T13:59:00Z", startedAt: "2026-10-02T14:00:06Z", finishedAt: "2026-10-02T14:03:42Z" },
|
|
259
|
+
agent: { id: "agt_1", version: "0.3.1", os: "macOS 26.0" },
|
|
260
|
+
lease: { leasedAt: "2026-10-02T14:00:03Z", policy: LAN_POLICY.policy, policyHash: LAN_POLICY.helperHash, tunnel: TUNNEL },
|
|
261
|
+
events: [
|
|
262
|
+
{ type: "lock_acquired", at: "2026-10-02T14:00:01Z" },
|
|
263
|
+
{ type: "handshake_ok", at: "2026-10-02T14:00:05Z" },
|
|
264
|
+
{ type: "tunnel_up", at: "2026-10-02T14:00:05Z" },
|
|
265
|
+
{ type: "lock_released", at: "2026-10-02T14:03:45Z" },
|
|
266
|
+
],
|
|
267
|
+
chain: { headSeq: 3, headHash: "00".repeat(32), firstEventSeq: 1 },
|
|
268
|
+
issuedAt: "2026-10-02T14:03:50Z",
|
|
269
|
+
manifests: { vpnRelease: null, agentRelease: null },
|
|
270
|
+
...over,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* What the server does in miniature: three earlier chain entries, the document built against that head, the registration
|
|
275
|
+
* entry, a signed checkpoint, the signed document; then the real verifier on the bundle.
|
|
276
|
+
*/
|
|
277
|
+
async function issue(opts = {}) {
|
|
278
|
+
const entries = [];
|
|
279
|
+
const append = async (kind, ref, at, data) => {
|
|
280
|
+
const prev = entries[entries.length - 1];
|
|
281
|
+
const seq = (prev?.seq ?? 0) + 1;
|
|
282
|
+
const prevHash = prev?.hash ?? (await auditGenesisHash(ORG));
|
|
283
|
+
const payload = auditEntryPayloadSchema.parse({ v: 1, seq, orgId: ORG, kind, ref, at, data });
|
|
284
|
+
entries.push({ seq, prevHash, hash: await auditEntryHash(prevHash, payload), kind, refId: ref, payload });
|
|
285
|
+
};
|
|
286
|
+
for (const [i, type] of ["lock_acquired", "handshake_ok", "tunnel_up"].entries()) {
|
|
287
|
+
await append("vpn_event", `evt_${i}`, "2026-10-02T14:00:05Z", { type, tunnelId: "tun_1", buildId: BUILD, agentId: "agt_1", detail: null });
|
|
288
|
+
}
|
|
289
|
+
const head = entries[entries.length - 1];
|
|
290
|
+
const evidence = opts.evidence === undefined ? sampleEvidence() : opts.evidence;
|
|
291
|
+
const log = opts.log === undefined ? await analyzeConnectionLog(goodLog(), BUILD) : opts.log;
|
|
292
|
+
const rawLog = opts.rawLog === undefined ? (log ? { sha256: "55".repeat(32), bytes: 1000 } : null) : opts.rawLog;
|
|
293
|
+
const built = await buildAttestationDocument({
|
|
294
|
+
facts: sampleFacts({ ...(opts.facts ?? {}), chain: { headSeq: head.seq, headHash: head.hash, firstEventSeq: 1 } }),
|
|
295
|
+
evidence,
|
|
296
|
+
log,
|
|
297
|
+
rawLog,
|
|
298
|
+
degradedReason: opts.degradedReason,
|
|
299
|
+
});
|
|
300
|
+
const signer = opts.signer === undefined ? KEY.signer : opts.signer;
|
|
301
|
+
assert.ok(signer, "these tests sign");
|
|
302
|
+
const sig = await signAttestationDocument(signer, built.doc);
|
|
303
|
+
await append("attestation", built.doc.id, built.doc.issuedAt, { buildId: BUILD, docSha256: await attestationDocumentSha256(built.doc), keyId: signer.keyId, completeness: built.completeness });
|
|
304
|
+
const reg = entries[entries.length - 1];
|
|
305
|
+
const cp = { orgId: ORG, seq: reg.seq, hash: reg.hash, issuedAt: built.doc.issuedAt, keyId: signer.keyId, signature: await signAuditCheckpoint(signer, auditCheckpointStatement({ orgId: ORG, seq: reg.seq, hash: reg.hash, issuedAt: built.doc.issuedAt })) };
|
|
306
|
+
const bundle = {
|
|
307
|
+
format: "appforge-evidence-bundle",
|
|
308
|
+
version: 1,
|
|
309
|
+
attestation: { doc: built.doc, signatures: [sig] },
|
|
310
|
+
auditChain: { orgId: ORG, anchor: null, entries, checkpoints: [cp] },
|
|
311
|
+
...(log && rawLog ? { connectionLog: { encoding: "records", normalization: "appforge-conn-records-v1", records: log.records } } : {}),
|
|
312
|
+
};
|
|
313
|
+
opts.mutate?.(bundle);
|
|
314
|
+
return { built, bundle, verdict: V.verifyBundle(bundle, clone(KEYS), {}) };
|
|
315
|
+
}
|
|
316
|
+
test("a regulated build with complete, consistent evidence verifies authentic and complete (exit 0), and every derived number recomputes", async () => {
|
|
317
|
+
const r = await issue();
|
|
318
|
+
assert.deepEqual(r.verdict.problems, []);
|
|
319
|
+
assert.equal(r.verdict.exit, 0);
|
|
320
|
+
assert.ok(r.verdict.checks.every(([, ok]) => ok), JSON.stringify(r.verdict.checks.filter(([, ok]) => !ok)));
|
|
321
|
+
assert.equal(r.built.completeness, "full");
|
|
322
|
+
assert.equal(r.built.controlsVerdict, "all_enforced");
|
|
323
|
+
assert.equal(r.built.violations, 0);
|
|
324
|
+
const doc = r.built.doc;
|
|
325
|
+
assert.equal(doc.policy.egressPolicySha256, V.sha256hex(V.canonicalize(doc.policy.egressPolicy)));
|
|
326
|
+
assert.equal(doc.policy.helperPolicyHash, LAN_POLICY.helperHash);
|
|
327
|
+
assert.deepEqual(doc.controlTests.summary, V.summariseControls(doc.controlTests.results));
|
|
328
|
+
assert.equal(doc.controlTests.resultsSha256, V.sha256hex(V.canonicalize(doc.controlTests.results)));
|
|
329
|
+
// The counted results are the matrix's pass checks plus the server's comparisons; the rest is listed apart.
|
|
330
|
+
const ids = doc.controlTests.results.map((x) => x.id);
|
|
331
|
+
for (const id of ["net-direct-tcp", "net-udp", "files-agent-config", "proxy-loopback", "mach-keychain", "egress-mode", "policy-hash", "tunnel-config", "sandbox-profile-version", "sandbox-org", "log-digest", "log-totals"])
|
|
332
|
+
assert.ok(ids.includes(id), `result ${id} in ${ids.join()}`);
|
|
333
|
+
assert.deepEqual(doc.controlTests.knownGaps.map((g) => g.id), ["gap-same-user-procargs"]);
|
|
334
|
+
assert.deepEqual(doc.controlTests.notCounted.map((g) => g.id), ["net-internet-1", "ssh-agent-socket"]);
|
|
335
|
+
assert.ok(!ids.includes("gap-same-user-procargs"), "a documented gap is never presented as a PASS");
|
|
336
|
+
// What was not checked against a release is said so, and is not a violation.
|
|
337
|
+
assert.deepEqual(doc.crossChecks.filter((c) => c.status === "not_checked").map((c) => c.id), ["helper-release", "sandbox-template"]);
|
|
338
|
+
assert.equal(doc.result.controlsVerdict, "all_enforced");
|
|
339
|
+
assert.equal(doc.scope.limits.length, ATTESTATION_LIMITS.length);
|
|
340
|
+
canonicalJson(doc);
|
|
341
|
+
});
|
|
342
|
+
test("the cross-checks against a published release manifest: a match passes, a mismatch is a recorded violation (exit 2), a missing manifest is 'not checked'", async () => {
|
|
343
|
+
const manifestFor = (helperSha, templateSha) => ({
|
|
344
|
+
vpnRelease: { v: 1, prefix: "vpn-releases", version: "0.3.1", publishedAt: "2026-10-01T00:00:00Z", artifacts: [{ name: "appforge-vpn-darwin-arm64", sha256: helperSha, bytes: 5_000_000 }, { name: "appforge-vpn-darwin-amd64", sha256: "99".repeat(32), bytes: 5_000_000 }] },
|
|
345
|
+
agentRelease: { v: 1, prefix: "agent-releases", version: "0.3.1", publishedAt: "2026-10-01T00:00:00Z", artifacts: [{ name: "agent.mjs", sha256: "77".repeat(32), bytes: 1_000_000 }], meta: { sandboxTemplateSha256: templateSha } },
|
|
346
|
+
});
|
|
347
|
+
let r = await issue({ facts: { manifests: manifestFor("11".repeat(32), "22".repeat(32)) } });
|
|
348
|
+
assert.equal(r.verdict.exit, 0, r.verdict.problems.join(";"));
|
|
349
|
+
assert.deepEqual(r.built.doc.crossChecks.filter((c) => c.status === "not_checked"), []);
|
|
350
|
+
r = await issue({ facts: { manifests: manifestFor("ee".repeat(32), "22".repeat(32)) } });
|
|
351
|
+
assert.equal(r.verdict.exit, 2, "an authentic document that records a violation");
|
|
352
|
+
assert.equal(r.built.controlsVerdict, "violations_detected");
|
|
353
|
+
assert.deepEqual(r.built.doc.controlTests.results.filter((x) => x.verdict === "fail").map((x) => x.id), ["helper-release"]);
|
|
354
|
+
r = await issue({ facts: { manifests: manifestFor("11".repeat(32), "ee".repeat(32)) } });
|
|
355
|
+
assert.equal(r.verdict.exit, 2);
|
|
356
|
+
assert.deepEqual(r.built.doc.controlTests.results.filter((x) => x.verdict === "fail").map((x) => x.id), ["sandbox-template"]);
|
|
357
|
+
// A manifest for another version proves nothing about this one.
|
|
358
|
+
const wrongVersion = manifestFor("ee".repeat(32), "ee".repeat(32));
|
|
359
|
+
wrongVersion.vpnRelease.version = "0.2.9";
|
|
360
|
+
wrongVersion.agentRelease.version = "0.2.9";
|
|
361
|
+
r = await issue({ facts: { manifests: wrongVersion } });
|
|
362
|
+
assert.equal(r.verdict.exit, 0);
|
|
363
|
+
assert.equal(r.built.doc.crossChecks.filter((c) => c.status === "not_checked").length, 2);
|
|
364
|
+
});
|
|
365
|
+
test("every disagreement between what the agent claims and what the server knows becomes a failing control test, never a silent pass", async () => {
|
|
366
|
+
const cases = [
|
|
367
|
+
["a policy hash the helper echoed that is not the leased policy's", (e) => { e.egress.policyHash = "ab".repeat(32); }, "policy-hash"],
|
|
368
|
+
["a helper in standard mode on a strict build", (e) => { e.egress.mode = "standard"; }, "egress-mode"],
|
|
369
|
+
["a tunnel configuration digest the server did not send", (e) => { e.tunnel.configDigest = "cd".repeat(32); }, "tunnel-config"],
|
|
370
|
+
["a sandbox profile generation the server did not ask for", (e) => { e.sandbox.profileVersion = SANDBOX_PROFILE_VERSION + 1; }, "sandbox-profile-version"],
|
|
371
|
+
["a sandbox prepared for another organisation", (e) => { e.sandbox.orgId = "org_other"; }, "sandbox-org"],
|
|
372
|
+
["a sandbox self-test that did not pass", (e) => { e.sandbox.selfTest.verdict = "fail"; e.sandbox.selfTest.results[0].status = "fail"; }, "sandbox-selftest"],
|
|
373
|
+
["a raw log digest that is not the object the server holds", (e) => { e.connectionLog.sha256 = "ee".repeat(32); }, "log-digest"],
|
|
374
|
+
["connection totals that differ from the server's recomputation", (e) => { e.connectionLog.summary.denied = 99; }, "log-totals"],
|
|
375
|
+
];
|
|
376
|
+
for (const [name, mutate, id] of cases) {
|
|
377
|
+
const e = sampleEvidence();
|
|
378
|
+
mutate(e);
|
|
379
|
+
const r = await issue({ evidence: e });
|
|
380
|
+
assert.equal(r.verdict.exit, 2, `${name}: ${r.verdict.problems.join(";")}`);
|
|
381
|
+
assert.equal(r.built.controlsVerdict, "violations_detected", name);
|
|
382
|
+
assert.ok(r.built.doc.controlTests.results.some((x) => x.id === id && x.verdict === "fail"), `${name}: a failing result ${id} in ${r.built.doc.controlTests.results.map((x) => `${x.id}:${x.verdict}`).join()}`);
|
|
383
|
+
assert.ok(r.built.violations >= 1, name);
|
|
384
|
+
assert.equal(r.built.doc.result.violations, r.built.violations);
|
|
385
|
+
}
|
|
386
|
+
// A regulated build that reports no sandbox evidence at all is itself a violation.
|
|
387
|
+
const none = await issue({ evidence: sampleEvidence({ sandbox: null }) });
|
|
388
|
+
assert.equal(none.verdict.exit, 2);
|
|
389
|
+
assert.ok(none.built.doc.controlTests.results.some((x) => x.id === "sandbox-evidence" && x.verdict === "fail"));
|
|
390
|
+
});
|
|
391
|
+
test("a regulated build whose sandbox self-test counted no checks is not 'all enforced': nothing evidences isolation", async () => {
|
|
392
|
+
const empty = sampleEvidence();
|
|
393
|
+
empty.sandbox.selfTest.results = [];
|
|
394
|
+
const r = await issue({ evidence: empty });
|
|
395
|
+
assert.equal(r.built.controlsVerdict, "not_evaluated");
|
|
396
|
+
assert.ok(r.built.gaps.some((g) => /no checks the matrix counts/.test(g)));
|
|
397
|
+
assert.equal(r.built.completeness, "partial");
|
|
398
|
+
assert.equal(r.verdict.exit, 3);
|
|
399
|
+
});
|
|
400
|
+
test("a strict-egress build without a sandbox is checked on the network policy only, and the document does not claim isolation", async () => {
|
|
401
|
+
const r = await issue({ facts: { vpnClass: 1 }, evidence: sampleEvidence({ sandbox: null }) });
|
|
402
|
+
assert.equal(r.verdict.exit, 0, r.verdict.problems.join(";"));
|
|
403
|
+
assert.equal(r.built.controlsVerdict, "network_policy_checked");
|
|
404
|
+
assert.equal(r.built.doc.policy.enforcement, null);
|
|
405
|
+
assert.match(r.built.doc.scope.statement, /isolation of the build host is NOT claimed/);
|
|
406
|
+
const html = await renderEvidencePage(r.bundle);
|
|
407
|
+
assert.doesNotMatch(html, /All network and isolation controls were enforced/);
|
|
408
|
+
});
|
|
409
|
+
test("evidence that is present but incomplete is 'partial' and exits 3, with the reasons in the document", async () => {
|
|
410
|
+
// The helper's ring dropped a record.
|
|
411
|
+
const lines = goodLog().trimEnd().split("\n");
|
|
412
|
+
const dropped = await analyzeConnectionLog([lines[0], lines[1], ...lines.slice(3, 6), JSON.stringify({ v: 1, type: "dropped", count: 1, firstSeq: 102, lastSeq: 102 }), tallyLine({ dropped: 1, allowed: 3, connected: 3, bytesUp: 100 * (1 + 2 + 4) + 0, bytesDown: 1000 * (1 + 2 + 4) })].join("\n") + "\n", BUILD);
|
|
413
|
+
assert.equal(dropped.complete, false);
|
|
414
|
+
let r = await issue({ log: dropped, evidence: sampleEvidence({ connectionLog: { uploaded: true, sha256: "55".repeat(32), bytes: 1000, lines: 7, complete: false, incompleteReasons: ["helper_dropped_records:1"], summary: null } }) });
|
|
415
|
+
assert.equal(r.built.completeness, "partial");
|
|
416
|
+
assert.equal(r.verdict.exit, 3, r.verdict.problems.join(";"));
|
|
417
|
+
assert.ok(r.built.gaps.some((g) => /connection log is incomplete/.test(g)));
|
|
418
|
+
assert.equal(r.built.doc.connections.logCompleteness, "incomplete");
|
|
419
|
+
// No raw log at all (too large, or the upload failed).
|
|
420
|
+
r = await issue({ log: null, rawLog: null, evidence: sampleEvidence({ connectionLog: { uploaded: false, omitted: "too_large", sha256: "55".repeat(32), bytes: 99_000_000, lines: 300000, complete: true, incompleteReasons: [], summary: null } }) });
|
|
421
|
+
assert.equal(r.built.completeness, "partial");
|
|
422
|
+
assert.equal(r.verdict.exit, 3);
|
|
423
|
+
assert.equal(r.built.doc.connections.logCompleteness, "not_available");
|
|
424
|
+
assert.ok(r.built.gaps.some((g) => /connection log is not available \(too_large\)/.test(g)));
|
|
425
|
+
// The bundle then simply has no raw records: the verifier says so and still checks the rest.
|
|
426
|
+
assert.ok(r.verdict.notes.some((n) => /raw connection log not included/.test(n)));
|
|
427
|
+
});
|
|
428
|
+
test("a document issued with no agent report is degraded: server-observed facts only, exit 3, and it claims no connection data and no control verdict", async () => {
|
|
429
|
+
const r = await issue({ evidence: null, log: null, rawLog: null, degradedReason: "the agent did not report evidence within 30 minutes of the build ending" });
|
|
430
|
+
assert.equal(r.built.completeness, "server_observed_only");
|
|
431
|
+
assert.equal(r.built.controlsVerdict, "not_evaluated");
|
|
432
|
+
assert.equal(r.verdict.exit, 3, r.verdict.problems.join(";"));
|
|
433
|
+
assert.equal(r.built.doc.controlTests, undefined);
|
|
434
|
+
assert.equal(r.built.doc.helper.reportedBy, "none");
|
|
435
|
+
assert.deepEqual(r.built.doc.connections.destinations, []);
|
|
436
|
+
assert.match(String(r.built.doc.connections.note), /never reported/);
|
|
437
|
+
assert.deepEqual(r.built.gaps, ["the agent did not report evidence within 30 minutes of the build ending"]);
|
|
438
|
+
// What the server did observe is there.
|
|
439
|
+
assert.equal(r.built.doc.timing.lockAcquiredAt, "2026-10-02T14:00:01Z");
|
|
440
|
+
assert.equal(r.built.doc.timing.lockReleasedAt, "2026-10-02T14:03:45Z");
|
|
441
|
+
assert.equal(r.built.doc.result.agentReportedStatus, null);
|
|
442
|
+
assert.equal(r.built.doc.policy.egressPolicySha256, V.sha256hex(V.canonicalize(LAN_POLICY.policy)));
|
|
443
|
+
assert.match((await renderEvidencePage(r.bundle)), /No control-test verdict is recorded/);
|
|
444
|
+
});
|
|
445
|
+
test("without a lease-time record the document says so instead of inventing the policy", async () => {
|
|
446
|
+
const r = await issue({ facts: { lease: null } });
|
|
447
|
+
assert.equal(r.built.doc.policy.egressPolicy, undefined);
|
|
448
|
+
assert.equal(r.built.doc.policy.egressPolicySha256, undefined);
|
|
449
|
+
assert.equal(r.built.doc.tunnel, null);
|
|
450
|
+
assert.equal(r.built.completeness, "partial");
|
|
451
|
+
assert.ok(r.built.gaps.some((g) => /no lease-time record/.test(g)));
|
|
452
|
+
assert.equal(r.verdict.exit, 3);
|
|
453
|
+
});
|
|
454
|
+
test("a stored lease record whose policy does not hash to its recorded hash is reported, not trusted", async () => {
|
|
455
|
+
const bad = { ...sampleFacts().lease, policyHash: "ab".repeat(32) };
|
|
456
|
+
const r = await issue({ facts: { lease: bad } });
|
|
457
|
+
assert.ok(r.built.gaps.some((g) => /internally inconsistent/.test(g)));
|
|
458
|
+
});
|
|
459
|
+
test("agent hygiene evidence rides along as the opaque object it is: numbers canonical JSON cannot carry are rounded (and say so), only an oversize or too-deep one is left out with a gap", async () => {
|
|
460
|
+
const ok = await issue({ evidence: sampleEvidence({ hygiene: { sweptJobDirs: 3, strays: [], note: "x" } }) });
|
|
461
|
+
assert.deepEqual(ok.built.doc.isolation.hygiene, { note: "x", strays: [], sweptJobDirs: 3 });
|
|
462
|
+
assert.equal(ok.built.doc.isolation.hygieneNote, undefined);
|
|
463
|
+
assert.equal(ok.verdict.exit, 0);
|
|
464
|
+
// Floats are rounded, a lone surrogate replaced, a non-finite number becomes null: kept, with a note, still verifiable.
|
|
465
|
+
const floaty = await issue({ evidence: sampleEvidence({ hygiene: { ratio: 0.5, seconds: 12.4, nested: { list: [1.6, "a\ud800b"] } } }) });
|
|
466
|
+
assert.deepEqual(floaty.built.doc.isolation.hygiene, { nested: { list: [2, "a\ufffdb"] }, ratio: 1, seconds: 12 });
|
|
467
|
+
assert.match(floaty.built.doc.isolation.hygieneNote, /not integers/);
|
|
468
|
+
assert.equal(floaty.built.completeness, "full", "a normalised hygiene object is still evidence, not a gap");
|
|
469
|
+
assert.equal(floaty.verdict.exit, 0, floaty.verdict.problems.join(";"));
|
|
470
|
+
// Too large, or nested beyond what is carried: left out, with a gap and a partial document.
|
|
471
|
+
const huge = await issue({ evidence: sampleEvidence({ hygiene: { blob: "x".repeat(20_000) } }) });
|
|
472
|
+
assert.equal(huge.built.doc.isolation.hygiene, undefined);
|
|
473
|
+
assert.ok(huge.built.gaps.some((g) => /too large or too deeply nested/.test(g)));
|
|
474
|
+
assert.equal(huge.built.completeness, "partial");
|
|
475
|
+
let deep = 1;
|
|
476
|
+
for (let i = 0; i < 12; i++)
|
|
477
|
+
deep = { d: deep };
|
|
478
|
+
const nested = await issue({ evidence: sampleEvidence({ hygiene: deep }) });
|
|
479
|
+
assert.equal(nested.built.doc.isolation.hygiene, undefined);
|
|
480
|
+
assert.equal(nested.built.completeness, "partial");
|
|
481
|
+
});
|
|
482
|
+
// ---------------------------------------------------------------- tampering is still caught for documents this code writes
|
|
483
|
+
test("the verifier rejects every tampering of an authentic document built here (it is the same evidence format the server writes)", async () => {
|
|
484
|
+
const good = await issue();
|
|
485
|
+
assert.equal(good.verdict.exit, 0);
|
|
486
|
+
const verify = (b) => V.verifyBundle(clone(b), clone(KEYS), {});
|
|
487
|
+
let b = clone(good.bundle);
|
|
488
|
+
b.attestation.doc.result.controlsVerdict = "all_enforced";
|
|
489
|
+
b.attestation.doc.controlTests.results.find((r) => r.id === "policy-hash").reason = "edited";
|
|
490
|
+
assert.equal(verify(b).exit, 1, "an edited result");
|
|
491
|
+
b = clone(good.bundle);
|
|
492
|
+
b.connectionLog.records[0].bytesUp += 1;
|
|
493
|
+
assert.equal(verify(b).exit, 1, "an edited raw record");
|
|
494
|
+
b = clone(good.bundle);
|
|
495
|
+
b.attestation.doc.connections.destinations.pop();
|
|
496
|
+
assert.equal(verify(b).exit, 1, "an edited summary");
|
|
497
|
+
b = clone(good.bundle);
|
|
498
|
+
b.auditChain.checkpoints = [];
|
|
499
|
+
assert.equal(verify(b).exit, 1, "no checkpoint");
|
|
500
|
+
b = clone(good.bundle);
|
|
501
|
+
b.attestation.doc.subject.buildId = "bld_other";
|
|
502
|
+
assert.equal(verify(b).exit, 1, "a replayed document");
|
|
503
|
+
// An unsigned document has no bundle the verifier accepts.
|
|
504
|
+
b = clone(good.bundle);
|
|
505
|
+
b.attestation.signatures = [];
|
|
506
|
+
assert.equal(verify(b).exit, 1, "unsigned");
|
|
507
|
+
});
|
|
508
|
+
// ---------------------------------------------------------------- the printable page
|
|
509
|
+
test("the printable page is a byte-for-byte twin of the verifier's renderer, and --render-check proves it", async () => {
|
|
510
|
+
const variants = [
|
|
511
|
+
await issue(),
|
|
512
|
+
await issue({ evidence: Object.assign(sampleEvidence(), { egress: { mode: "strict", policyHash: "ab".repeat(32) } }) }),
|
|
513
|
+
await issue({ evidence: null, log: null, rawLog: null, degradedReason: "no report" }),
|
|
514
|
+
await issue({ facts: { vpnClass: 1 }, evidence: sampleEvidence({ sandbox: null }) }),
|
|
515
|
+
// Hostile text everywhere an attacker-influenced string can reach a page.
|
|
516
|
+
await issue({ facts: { workflow: "</script><script>alert(1)</script>", branch: "a&b<c>\"d'", commitSha: null, tunnelId: null } }),
|
|
517
|
+
];
|
|
518
|
+
for (const [i, v] of variants.entries()) {
|
|
519
|
+
const html = await renderEvidencePage(v.bundle);
|
|
520
|
+
const expected = V.renderEvidenceHtml(JSON.parse(JSON.stringify(v.bundle)), { docSha256: V.sha256hex(V.canonicalize(v.bundle.attestation.doc)) });
|
|
521
|
+
assert.equal(html, expected, `variant ${i}: the page must equal the verifier's own rendering`);
|
|
522
|
+
assert.deepEqual(V.renderCheck(html), [], `variant ${i}`);
|
|
523
|
+
assert.equal(V.detectInput(html).kind, "html");
|
|
524
|
+
assert.deepEqual(V.detectInput(html).bundle, JSON.parse(JSON.stringify(v.bundle)));
|
|
525
|
+
assert.equal(V.verifyBundle(V.detectInput(html).bundle, clone(KEYS), {}).exit, v.verdict.exit, `variant ${i}: the embedded JSON verifies as the bundle does`);
|
|
526
|
+
}
|
|
527
|
+
assert.doesNotMatch(await renderEvidencePage(variants[4].bundle), /<script>alert/);
|
|
528
|
+
// Editing the visible text is invisible to the JSON but not to --render-check.
|
|
529
|
+
const html = await renderEvidencePage(variants[0].bundle);
|
|
530
|
+
assert.notEqual(V.renderCheck(html.replace("All network and isolation controls were enforced", "TRUST US")).length, 0);
|
|
531
|
+
});
|
|
532
|
+
test("the page is rendered from a JSON round trip of the bundle, so a value JSON cannot carry cannot make it diverge from the verifier's re-render", async () => {
|
|
533
|
+
const v = await issue();
|
|
534
|
+
const bundle = clone(v.bundle);
|
|
535
|
+
// Integer-like keys are ordered first by JS however they were inserted; the page must still survive them.
|
|
536
|
+
bundle.attestation.doc.result = { z: 1, "2": "two", a: 3, "1": "one", ...bundle.attestation.doc.result };
|
|
537
|
+
assert.deepEqual(V.renderCheck(await renderEvidencePage(bundle)), []);
|
|
538
|
+
// A value JSON turns into something else (undefined is dropped, a non-finite number becomes null): the page shows what the embedded JSON says.
|
|
539
|
+
const odd = clone(v.bundle);
|
|
540
|
+
odd.attestation.doc.auditChain.headSeq = Number.NaN;
|
|
541
|
+
const html = await renderEvidencePage(odd);
|
|
542
|
+
assert.deepEqual(V.renderCheck(html), []);
|
|
543
|
+
assert.match(html, /seq n\/a, hash/, "NaN is shown as the embedded JSON has it (null), not as 'NaN'");
|
|
544
|
+
// Called on the raw in-memory bundle, the twin would print the NaN: renderEvidencePage exists to avoid that.
|
|
545
|
+
assert.match(renderEvidenceHtml(odd, { docSha256: "0".repeat(64) }), /seq NaN, hash/);
|
|
546
|
+
});
|
|
547
|
+
// ---------------------------------------------------------------- chain kinds, tunnel facts, manifests, keys
|
|
548
|
+
test("the chain's known kinds are the verifier's, and an unknown kind still fails verification", async () => {
|
|
549
|
+
assert.deepEqual([...AUDIT_ENTRY_KINDS].sort(), ["attestation", "gap", "vpn_event"]);
|
|
550
|
+
const data = {
|
|
551
|
+
vpn_event: { type: "tunnel_up", tunnelId: null, buildId: null, agentId: null, detail: null },
|
|
552
|
+
attestation: { buildId: BUILD, docSha256: "ab".repeat(32), keyId: null, completeness: "full" },
|
|
553
|
+
gap: { buildId: BUILD, reason: "agent_did_not_report", detail: "x" },
|
|
554
|
+
};
|
|
555
|
+
const chain = [];
|
|
556
|
+
for (const kind of AUDIT_ENTRY_KINDS) {
|
|
557
|
+
const seq = chain.length + 1;
|
|
558
|
+
const prevHash = chain[chain.length - 1]?.hash ?? (await auditGenesisHash(ORG));
|
|
559
|
+
const payload = auditEntryPayloadSchema.parse({ v: 1, seq, orgId: ORG, kind, ref: `r${seq}`, at: "2026-10-02T14:00:00Z", data: data[kind] });
|
|
560
|
+
chain.push({ seq, prevHash, hash: await auditEntryHash(prevHash, payload), payload });
|
|
561
|
+
}
|
|
562
|
+
assert.equal((await verifyAuditChain({ orgId: ORG, entries: chain })).ok, true);
|
|
563
|
+
// The verifier accepts each kind core knows (structure only: nothing is signed here, so it is incomplete, not failed) ...
|
|
564
|
+
const lines = [{ type: "header", format: "appforge-audit-export", version: 1, orgId: ORG }, ...chain.map((c, i) => ({ type: "entry", ...c, kind: c.payload.kind, refId: `r${i + 1}` })), { type: "trailer", next: null, entries: chain.length, headSeq: chain.length, headHash: chain[chain.length - 1].hash }];
|
|
565
|
+
const res = V.verifyExport(lines.map((l) => JSON.stringify(l)).join("\n") + "\n", clone(KEYS), {});
|
|
566
|
+
assert.deepEqual(res.problems, []);
|
|
567
|
+
assert.equal(res.exit, 3);
|
|
568
|
+
// ... and rejects one neither knows, on both sides.
|
|
569
|
+
const prevHash = chain[chain.length - 1].hash;
|
|
570
|
+
const alien = { v: 1, seq: 4, orgId: ORG, kind: "mystery", ref: "x", at: "2026-10-02T14:00:00Z", data: {} };
|
|
571
|
+
assert.equal((await verifyAuditChain({ orgId: ORG, entries: [...chain, { seq: 4, prevHash, hash: await auditEntryHash(prevHash, alien), payload: alien }] })).ok, false);
|
|
572
|
+
assert.throws(() => auditEntryPayloadSchema.parse(alien));
|
|
573
|
+
// A malformed attestation entry is refused by core's schema (the verifier checks the fields it relies on).
|
|
574
|
+
assert.throws(() => auditEntryPayloadSchema.parse({ ...alien, kind: "attestation", data: { buildId: BUILD } }));
|
|
575
|
+
assert.throws(() => auditEntryPayloadSchema.parse({ ...alien, kind: "attestation", data: { buildId: BUILD, docSha256: "short", keyId: null, completeness: "full" } }));
|
|
576
|
+
});
|
|
577
|
+
test("the key-free tunnel digest is stable, order-sensitive where it should be, and changes with every configuration field", async () => {
|
|
578
|
+
const base = { tunnelId: "tun_1", keyVersion: 2, gatewayEndpoint: "vpn.acme.example:51820", gatewayPublicKey: "A".repeat(43) + "=", tunnelCidr: "169.254.200.0/30", localAddress: "169.254.200.2", gatewayAddress: "169.254.200.1", lanCidrs: ["10.20.0.0/16"], dnsServer: "10.20.0.2", allowedDomains: ["corp.acme.example"], allowPrivateEndpoint: false };
|
|
579
|
+
const d0 = await vpnPublicConfigDigest(base);
|
|
580
|
+
assert.match(d0, /^[0-9a-f]{64}$/);
|
|
581
|
+
assert.equal(d0, await vpnPublicConfigDigest({ ...base }));
|
|
582
|
+
assert.equal(d0, await vpnPublicConfigDigest({ ...base, allowPrivateEndpoint: undefined }), "an absent lab-mode flag is false");
|
|
583
|
+
for (const [k, v] of Object.entries({ keyVersion: 3, gatewayEndpoint: "x:1", gatewayPublicKey: "B".repeat(43) + "=", tunnelCidr: "169.254.201.0/30", dnsServer: null, lanCidrs: ["10.21.0.0/16"], allowedDomains: [], allowPrivateEndpoint: true, tunnelId: "tun_2", egress: { allow: [{ host: "pub.dev" }] } })) {
|
|
584
|
+
assert.notEqual(await vpnPublicConfigDigest({ ...base, [k]: v }), d0, `a change of ${k} must change the digest`);
|
|
585
|
+
}
|
|
586
|
+
// A secret in the job is not part of it (it cannot be: the input type has no key fields).
|
|
587
|
+
const withKeys = { ...base, privateKey: "SECRET", presharedKey: "SECRET2" };
|
|
588
|
+
assert.equal(await vpnPublicConfigDigest(withKeys), d0);
|
|
589
|
+
const facts = await buildTunnelFacts({ ...base, tunnelName: "HQ" }, "B".repeat(43) + "=");
|
|
590
|
+
assert.equal(facts.configDigest, d0);
|
|
591
|
+
assert.match(facts.appforgePublicKeyFp ?? "", /^[0-9a-f]{32}$/);
|
|
592
|
+
assert.equal((await buildTunnelFacts(base, null)).appforgePublicKeyFp, null);
|
|
593
|
+
assert.ok(!JSON.stringify(facts).includes("SECRET"));
|
|
594
|
+
});
|
|
595
|
+
test("release manifests and object keys are validated, and an unsafe id never reaches a key", () => {
|
|
596
|
+
const good = { v: 1, prefix: "vpn-releases", version: "0.3.1", publishedAt: "2026-10-01T00:00:00Z", artifacts: [{ name: "appforge-vpn-darwin-arm64", sha256: "ab".repeat(32), bytes: 10 }] };
|
|
597
|
+
assert.deepEqual(parseReleaseManifest(JSON.stringify(good))?.artifacts[0]?.name, "appforge-vpn-darwin-arm64");
|
|
598
|
+
for (const bad of ["", "{", "[]", JSON.stringify({ ...good, v: 2 }), JSON.stringify({ ...good, artifacts: [{ name: "x", sha256: "short", bytes: 1 }] })])
|
|
599
|
+
assert.equal(parseReleaseManifest(bad), null, bad);
|
|
600
|
+
assert.equal(releaseManifestKey("vpn-releases", "0.3.1"), "vpn-releases/0.3.1/manifest.json");
|
|
601
|
+
assert.throws(() => releaseManifestKey("vpn-releases", "../x"));
|
|
602
|
+
assert.equal(connectionLogKey("org_1", "bld-1"), "evidence/org_1/bld-1/conn.jsonl");
|
|
603
|
+
for (const [o, b] of [["org/../x", "b"], ["o", "../b"], ["", "b"], ["o", ""], ["o", "b/c"]])
|
|
604
|
+
assert.throws(() => connectionLogKey(o, b));
|
|
605
|
+
});
|
|
606
|
+
test("sha256 of a canonical document is what the chain registers", async () => {
|
|
607
|
+
const r = await issue();
|
|
608
|
+
const reg = r.bundle.auditChain.entries[r.bundle.auditChain.entries.length - 1].payload;
|
|
609
|
+
assert.equal(reg.data.docSha256, await attestationDocumentSha256(r.bundle.attestation.doc));
|
|
610
|
+
assert.equal(reg.data.docSha256, await sha256Hex(canonicalJson(r.bundle.attestation.doc)));
|
|
611
|
+
assert.equal(reg.data.docSha256, V.sha256hex(V.canonicalize(r.bundle.attestation.doc)));
|
|
612
|
+
});
|
|
613
|
+
//# sourceMappingURL=vpn-attestation.test.js.map
|