@counterfact-labs/cli 0.2.3 → 0.2.5
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/README.md +7 -1
- package/bundle/client-release.json +1 -1
- package/dist/adapters/anthropic.js +11 -2
- package/dist/adapters/completion.js +3 -0
- package/dist/adapters/durable-write.js +9 -2
- package/dist/adapters/files.js +29 -13
- package/dist/adapters/git-ignore.js +28 -14
- package/dist/adapters/git.js +1 -1
- package/dist/adapters/gitignore-output.js +41 -0
- package/dist/adapters/models.js +1 -6
- package/dist/adapters/output-paths.js +5 -2
- package/dist/adapters/public-instructions.js +1 -1
- package/dist/adapters/relay-plan.js +4 -7
- package/dist/adapters/release.js +10 -4
- package/dist/adapters/repository-files.js +164 -4
- package/dist/adapters/repository-root.js +18 -2
- package/dist/adapters/results.js +8 -4
- package/dist/adapters/settings.js +27 -1
- package/dist/adapters/snapshot.js +86 -15
- package/dist/analysis-core/analysis/finding-audience-switch.js +1 -0
- package/dist/analysis-core/analysis/schemas.js +1 -1
- package/dist/analysis-core/analysis/source-notes/types.js +1 -1
- package/dist/analysis-core/chunks/377MLK4M.js +21 -0
- package/dist/analysis-core/chunks/3GUKUEEJ.js +1 -0
- package/dist/analysis-core/chunks/{Y5AUKKYG.js → 3JWEFCTJ.js} +1 -1
- package/dist/analysis-core/chunks/{3227KXHF.js → 4H23CSUM.js} +1 -1
- package/dist/analysis-core/chunks/5DGPLXYB.js +1 -0
- package/dist/analysis-core/chunks/5TATKWLG.js +1 -0
- package/dist/analysis-core/chunks/{EH3QQ3T4.js → 6B2HILSY.js} +1 -1
- package/dist/analysis-core/chunks/6QY5BV5B.js +14 -0
- package/dist/analysis-core/chunks/6U2IR4XB.js +1 -0
- package/dist/analysis-core/chunks/A7WJJMRY.js +1 -0
- package/dist/analysis-core/chunks/C6X5GDG4.js +1 -0
- package/dist/analysis-core/chunks/DLPVYWHZ.js +1 -0
- package/dist/analysis-core/chunks/DZZBQDGO.js +1 -0
- package/dist/analysis-core/chunks/F3R4J3ND.js +1 -0
- package/dist/analysis-core/chunks/FOKDE64Y.js +1 -0
- package/dist/analysis-core/chunks/JKWTHRUR.js +1 -0
- package/dist/analysis-core/chunks/K2GNDX3W.js +1 -0
- package/dist/analysis-core/chunks/KTNA2KTN.js +7 -0
- package/dist/analysis-core/chunks/KWECQJ6X.js +1 -0
- package/dist/analysis-core/chunks/{SL27GMEN.js → M6HYSTQD.js} +1 -1
- package/dist/analysis-core/chunks/{VFENG4MR.js → MJGB25S5.js} +1 -1
- package/dist/analysis-core/chunks/{ZAA44GPI.js → N7U5N42V.js} +1 -1
- package/dist/analysis-core/chunks/NIOS7U6C.js +41 -0
- package/dist/analysis-core/chunks/{QPXNXMLC.js → OPMA32A6.js} +1 -1
- package/dist/analysis-core/chunks/PECX3OXB.js +1 -0
- package/dist/analysis-core/chunks/QFY6A3MS.js +66 -0
- package/dist/analysis-core/chunks/SKVEBOGA.js +1 -0
- package/dist/analysis-core/chunks/{VER355VN.js → STI4JE7L.js} +1 -1
- package/dist/analysis-core/chunks/SVMEWGD3.js +1 -0
- package/dist/analysis-core/chunks/X7WZAA7S.js +1 -0
- package/dist/analysis-core/chunks/YDBO7P4X.js +1 -0
- package/dist/analysis-core/chunks/YUY436SM.js +1 -0
- package/dist/analysis-core/chunks/ZTOVBAG7.js +1 -0
- package/dist/analysis-core/contracts/file-safety.js +1 -0
- package/dist/analysis-core/contracts/relay-analysis.js +1 -0
- package/dist/analysis-core/contracts/requests.js +1 -1
- package/dist/analysis-core/conventions/analysis-fit.js +1 -0
- package/dist/analysis-core/conventions/file-admission.js +1 -1
- package/dist/analysis-core/data/finding-types.js +1 -1
- package/dist/analysis-core/errors.js +1 -1
- package/dist/analysis-core/findings/sites.js +1 -0
- package/dist/analysis-core/github/repo-key.js +1 -1
- package/dist/analysis-core/import/outside-loader-sources.js +1 -1
- package/dist/analysis-core/index.js +3 -3
- package/dist/analysis-core/llm/cache-policy.js +1 -1
- package/dist/analysis-core/llm/cost.js +1 -1
- package/dist/analysis-core/llm/output-limit.js +1 -1
- package/dist/analysis-core/llm/types.js +1 -0
- package/dist/analysis-core/requests/answer-formats.js +1 -1
- package/dist/analysis-core/requests/headline-request.js +1 -1
- package/dist/analysis-core/requests/local-plan.js +1 -1
- package/dist/analysis-core/requests/registry.js +1 -1
- package/dist/analysis-core/requests/template-version.js +1 -1
- package/dist/analysis-core/requests/tools.js +1 -1
- package/dist/analysis-core/settings.js +1 -1
- package/dist/analysis-core/stages/engine-entry.js +1 -0
- package/dist/analysis-core/stages/execution-ports.js +1 -0
- package/dist/bundle/seal/deserialise.js +9 -1
- package/dist/bundle/seal/engine.js +76 -0
- package/dist/bundle/seal/freeze.js +9 -0
- package/dist/bundle/seal/metadata.js +3 -0
- package/dist/bundle/seal/open-engine.js +26 -0
- package/dist/bundle/seal/open.js +2 -8
- package/dist/bundle/seal/signature.js +2 -2
- package/dist/cli/commands/analyze.js +39 -5
- package/dist/cli/commands/finding.js +9 -3
- package/dist/cli/context.js +4 -1
- package/dist/cli/development-relay.js +34 -0
- package/dist/cli/finding-display.js +39 -0
- package/dist/cli/finding-lookup.js +92 -0
- package/dist/cli/finding-settings.js +5 -0
- package/dist/cli/fix-requests.js +42 -0
- package/dist/cli/intake-payload.js +19 -11
- package/dist/cli/main.js +9 -4
- package/dist/cli/mcp.js +65 -18
- package/dist/cli/relay-check.js +47 -14
- package/dist/cli/relay-preparation.js +31 -6
- package/dist/cli/result-check.js +68 -0
- package/dist/cli/summary.js +10 -3
- package/dist/contract/agent-choice.js +15 -0
- package/dist/contract/agent-proposal.js +38 -0
- package/dist/contract/attestation.js +106 -0
- package/dist/contract/cli-refusals.js +5 -0
- package/dist/contract/cli-version.js +1 -1
- package/dist/contract/console.js +41 -16
- package/dist/contract/engine.js +44 -0
- package/dist/contract/findings.js +28 -0
- package/dist/contract/fix-requests.js +30 -0
- package/dist/contract/messages.js +6 -94
- package/dist/contract/public-report.js +1 -0
- package/dist/contract/runtime-environment.js +12 -1
- package/dist/core/analysis/run-to-end.js +7 -52
- package/dist/gatekeeper/attestation.js +1 -1
- package/dist/gatekeeper/channel-binding.js +15 -0
- package/dist/gatekeeper/cli-api.js +12 -2
- package/dist/gatekeeper/connection-exchange.js +18 -13
- package/dist/gatekeeper/console.js +11 -4
- package/dist/gatekeeper/model.js +2 -1
- package/dist/gatekeeper/production-attestation.js +23 -17
- package/dist/gatekeeper/trust/policy.js +4 -1
- package/dist/gatekeeper/trust/schema.js +4 -0
- package/dist/transport/kms.js +25 -0
- package/package.json +4 -3
- package/dist/adapters/quality.js +0 -6
- package/dist/adapters/report.js +0 -29
- package/dist/analysis-core/analysis/analysis-flags.js +0 -1
- package/dist/analysis-core/analysis/finding-quality/config.js +0 -1
- package/dist/analysis-core/analysis/finding-quality/policy.js +0 -1
- package/dist/analysis-core/analysis/headlines/attachment.js +0 -1
- package/dist/analysis-core/analysis/headlines/input.js +0 -1
- package/dist/analysis-core/chunks/6YCABS5T.js +0 -1
- package/dist/analysis-core/chunks/7QDOHWSY.js +0 -27
- package/dist/analysis-core/chunks/BZNHHLNP.js +0 -1
- package/dist/analysis-core/chunks/DTMHXKP6.js +0 -7
- package/dist/analysis-core/chunks/EQTIRIMU.js +0 -37
- package/dist/analysis-core/chunks/G322LST6.js +0 -1
- package/dist/analysis-core/chunks/IL2KRP3T.js +0 -1
- package/dist/analysis-core/chunks/JF4GYMOZ.js +0 -1
- package/dist/analysis-core/chunks/KEKTZDIK.js +0 -1
- package/dist/analysis-core/chunks/M7MMAUAK.js +0 -1
- package/dist/analysis-core/chunks/OFBJNHC6.js +0 -1
- package/dist/analysis-core/chunks/ORCYIIHB.js +0 -60
- package/dist/analysis-core/chunks/PVPEY7NF.js +0 -1
- package/dist/analysis-core/chunks/STZOC7LN.js +0 -1
- package/dist/analysis-core/chunks/T5TREZCQ.js +0 -1
- package/dist/analysis-core/chunks/TDNTU4TF.js +0 -1
- package/dist/analysis-core/chunks/U5HOEVPE.js +0 -1
- package/dist/analysis-core/chunks/UEWL3GMQ.js +0 -1
- package/dist/analysis-core/chunks/YVDCJN7D.js +0 -14
- package/dist/analysis-core/chunks/ZMUBMYWN.js +0 -1
- package/dist/core/analysis/finding-quality/config.js +0 -4
- package/dist/core/analysis/structural-run.js +0 -16
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { ANALYSIS_PROTOCOL, analysisCapabilities } from "../analysis-core/contracts/relay-analysis.js";
|
|
3
|
+
import { stableStringify } from "../analysis-core/llm/stable-json.js";
|
|
4
|
+
import { workloadHardware } from "../gatekeeper/trust/schema.js";
|
|
5
|
+
import { kmsKeyName } from "../transport/kms.js";
|
|
6
|
+
export const ENGINE_ABI = 1;
|
|
7
|
+
export const ENGINE_SPAWNER_CONTRACT = 1;
|
|
8
|
+
const hash = z.string().regex(/^[0-9a-f]{64}$/);
|
|
9
|
+
const version = z.string().min(1).max(128).regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/);
|
|
10
|
+
export const engineIdentity = z.strictObject({
|
|
11
|
+
abi: z.literal(ENGINE_ABI), protocol: z.literal(ANALYSIS_PROTOCOL), spawnerContract: z.literal(ENGINE_SPAWNER_CONTRACT),
|
|
12
|
+
engineVersion: version, coreVersion: version, logicCommit: z.string().regex(/^[0-9a-f]{40}$/),
|
|
13
|
+
programSha256: hash, closureSha256: hash,
|
|
14
|
+
});
|
|
15
|
+
export const engineKeyPolicy = z.strictObject({
|
|
16
|
+
wrappingKey: z.string().refine(value => { try {
|
|
17
|
+
kmsKeyName(value);
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return false;
|
|
22
|
+
} }),
|
|
23
|
+
workloadPolicySha256: hash, hardware: workloadHardware,
|
|
24
|
+
});
|
|
25
|
+
export const enginePin = engineIdentity.extend({ artifactSha256: hash, keyPolicy: engineKeyPolicy }).strict();
|
|
26
|
+
export const analysisRelease = z.strictObject({ engine: enginePin, wire: analysisCapabilities }).refine(value => value.wire.protocol === value.engine.protocol && value.wire.engineVersion === value.engine.engineVersion);
|
|
27
|
+
export function matchEngineKeyPolicy(keyPolicy, release) {
|
|
28
|
+
const policy = engineKeyPolicy.parse(keyPolicy);
|
|
29
|
+
if (release.bundleKey !== undefined && policy.wrappingKey !== kmsKeyName(release.bundleKey) ||
|
|
30
|
+
release.workloadPolicySha256 !== undefined && policy.workloadPolicySha256 !== release.workloadPolicySha256 ||
|
|
31
|
+
release.workloadPolicy !== undefined && !release.workloadPolicy.hardwareModels.includes(policy.hardware))
|
|
32
|
+
throw new Error("Invalid engine key policy");
|
|
33
|
+
}
|
|
34
|
+
export function samePin(left, right) {
|
|
35
|
+
if (!left || !right)
|
|
36
|
+
return left === right;
|
|
37
|
+
return stableStringify(enginePin.parse(left)) === stableStringify(enginePin.parse(right));
|
|
38
|
+
}
|
|
39
|
+
/** Historical releases carry no engine. New analysis evidence must be complete and agree at every boundary. */
|
|
40
|
+
export function matchAnalysisRelease(value, pin, bundleVersion) {
|
|
41
|
+
const parsed = analysisRelease.parse(value);
|
|
42
|
+
if (!samePin(parsed.engine, pin) || parsed.wire.bundleVersion !== bundleVersion)
|
|
43
|
+
throw new Error("Analysis release differs");
|
|
44
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const findingSequenceSchema = z.number().int().positive().max(Number.MAX_SAFE_INTEGER);
|
|
3
|
+
export const canonicalUuidSchema = z.uuid().transform(value => value.toLowerCase());
|
|
4
|
+
export const numberedFindingSchema = z.strictObject({
|
|
5
|
+
id: canonicalUuidSchema, seq: findingSequenceSchema,
|
|
6
|
+
});
|
|
7
|
+
export const numberedFindingsSchema = z.array(numberedFindingSchema).max(20_000)
|
|
8
|
+
.refine(values => new Set(values.map(value => value.id)).size === values.length
|
|
9
|
+
&& new Set(values.map(value => value.seq)).size === values.length);
|
|
10
|
+
/** Both intake and recovery status must refer only to their committed finding ids. */
|
|
11
|
+
export function matchingFindingNumbers(value) {
|
|
12
|
+
if (value.findings === undefined)
|
|
13
|
+
return true;
|
|
14
|
+
const ids = new Set(value.findingIds);
|
|
15
|
+
return value.findings.every(finding => ids.has(finding.id));
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Console route to implement: GET /api/cli/projects/{projectId}/findings/{seq}.
|
|
19
|
+
* Requires the signed-in bearer token and project access; no body and no charge.
|
|
20
|
+
* Params: { projectId: UUID, seq: positive decimal integer }. 200: { id: UUID, seq: number }.
|
|
21
|
+
* 404 means that finding does not exist in this project. The caller then loads
|
|
22
|
+
* the explanation from the existing GET /api/cli/findings/{id} route, whose
|
|
23
|
+
* response may add seq: number. Numbers are never resolved across projects.
|
|
24
|
+
*/
|
|
25
|
+
export const findingNumberParamsSchema = z.strictObject({
|
|
26
|
+
projectId: canonicalUuidSchema, seq: z.string().regex(/^[1-9][0-9]*$/).transform(Number).pipe(findingSequenceSchema),
|
|
27
|
+
});
|
|
28
|
+
export const findingNumberResponseSchema = numberedFindingSchema;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { canonicalUuidSchema, findingSequenceSchema } from "./findings.js";
|
|
3
|
+
/**
|
|
4
|
+
* Console routes to implement; both require the signed-in CLI bearer token.
|
|
5
|
+
* GET /api/cli/fix-requests has no body and returns 200 with a JSON array:
|
|
6
|
+
* [{ findingId: UUID, seq: positive integer, title: string, projectId: UUID,
|
|
7
|
+
* requestedAt: ISO-8601 UTC timestamp, repoKey?: string }]. Empty is [].
|
|
8
|
+
* repoKey is the finding's console repository key, including its scoped folder
|
|
9
|
+
* when present (for example fictional/support/agents/fern or local:fern).
|
|
10
|
+
* Older consoles may omit it. Only this user's
|
|
11
|
+
* unclaimed requests for accessible findings are returned. No charge or writes.
|
|
12
|
+
*
|
|
13
|
+
* POST /api/cli/fix-requests/{id}/claim takes JSON {} and returns 200 with
|
|
14
|
+
* { findingId: UUID, claimed: true }. The path id is the findingId from the list;
|
|
15
|
+
* there is one waiting request per user and finding, so no separate request id.
|
|
16
|
+
* Claiming is idempotent for that user, removes it from their waiting list and
|
|
17
|
+
* never starts analysis, applies edits or charges. Re-requesting a finding makes
|
|
18
|
+
* it waiting again. A missing or inaccessible request returns 404; unauthenticated
|
|
19
|
+
* requests return 401. The console owns authorization and atomic claiming.
|
|
20
|
+
*/
|
|
21
|
+
export const waitingFixSchema = z.object({
|
|
22
|
+
findingId: canonicalUuidSchema, seq: findingSequenceSchema, title: z.string().min(1).max(4096),
|
|
23
|
+
projectId: canonicalUuidSchema, requestedAt: z.iso.datetime(),
|
|
24
|
+
repoKey: z.string().min(1).max(4096).refine(value => value.trim().length > 0 && !/[\r\n\u2028\u2029]/.test(value)).optional(),
|
|
25
|
+
});
|
|
26
|
+
export const waitingFixesResponseSchema = z.array(waitingFixSchema).max(20_000)
|
|
27
|
+
.refine(values => new Set(values.map(value => value.findingId)).size === values.length);
|
|
28
|
+
export const claimFixRequestParamsSchema = z.strictObject({ id: canonicalUuidSchema });
|
|
29
|
+
export const claimFixRequestBodySchema = z.strictObject({});
|
|
30
|
+
export const claimFixRequestResponseSchema = z.strictObject({ findingId: canonicalUuidSchema, claimed: z.literal(true) });
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { analysisRelease } from "./engine.js";
|
|
2
3
|
import { executionRequest } from "../analysis-core/contracts/requests.js";
|
|
3
|
-
import {
|
|
4
|
+
import { compactJwtPattern, hexChallenge } from "./attestation.js";
|
|
5
|
+
export { PRODUCTION_EVIDENCE_VERSION, RELAY_CHANNEL_AUDIENCE, RELAY_PORT, RELAY_URL, RELAY_HEALTH_PATH, RELAY_HEALTH_URL, GOOGLE_ATTESTATION_ISSUER, MAX_ATTESTATION_BYTES, attestationChallenge, productionEvidence, evidenceBinding, googleAttestationClaims, observedAttestationClaims, probeAttestationClaims, attestationClaims } from "./attestation.js";
|
|
4
6
|
/**
|
|
5
7
|
* The messages that cross between the customer's machine and the sealed relay. This module is the only owner of
|
|
6
|
-
* their shape: the relay, the gatekeeper and the bundle tooling
|
|
8
|
+
* their analysis shape: the relay, the gatekeeper and the bundle tooling import these schemas and never redefine them.
|
|
9
|
+
* Public identity schemas are owned by attestation.ts and re-exported here for existing consumers.
|
|
7
10
|
* Design: docs/design/sealed-model-relay.md and docs/design/relay-contract.md.
|
|
8
11
|
*/
|
|
9
|
-
const compactJwtPattern = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
|
|
10
|
-
const hexChallenge = z.string().length(64).regex(/^[0-9a-f]+$/);
|
|
11
12
|
/** The wire adds routing identity to the core's data-only execution envelope. */
|
|
12
13
|
export const relayRequest = executionRequest.extend({
|
|
13
14
|
bundleVersion: z.string().min(1).max(128),
|
|
@@ -125,102 +126,13 @@ export const relayHealthResponse = z.discriminatedUnion("ok", [
|
|
|
125
126
|
code: z.enum(["bad_request", "unavailable"]),
|
|
126
127
|
}).strict(),
|
|
127
128
|
]);
|
|
128
|
-
export const PRODUCTION_EVIDENCE_VERSION = 1;
|
|
129
|
-
export const RELAY_CHANNEL_AUDIENCE = "https://relay.counterfactlabs.com";
|
|
130
|
-
// Passthrough forwarding keeps the non-root workload's listener port unchanged.
|
|
131
|
-
export const RELAY_PORT = 8443;
|
|
132
|
-
export const RELAY_URL = `${RELAY_CHANNEL_AUDIENCE}:${RELAY_PORT}`;
|
|
133
|
-
export const RELAY_HEALTH_PATH = "/health";
|
|
134
|
-
export const RELAY_HEALTH_URL = `${RELAY_URL}${RELAY_HEALTH_PATH}`;
|
|
135
|
-
export const GOOGLE_ATTESTATION_ISSUER = "https://confidentialcomputing.googleapis.com";
|
|
136
|
-
export const MAX_ATTESTATION_BYTES = 32 * 1024;
|
|
137
|
-
export const attestationChallenge = z.object({
|
|
138
|
-
version: z.literal(PRODUCTION_EVIDENCE_VERSION),
|
|
139
|
-
challenge: hexChallenge,
|
|
140
|
-
}).strict();
|
|
141
|
-
export const productionEvidence = z.object({
|
|
142
|
-
version: z.literal(PRODUCTION_EVIDENCE_VERSION),
|
|
143
|
-
token: z.string().max(MAX_ATTESTATION_BYTES).regex(compactJwtPattern),
|
|
144
|
-
}).strict();
|
|
145
|
-
/** Google's launcher accepts up to six caller nonces, each at most 74 characters. */
|
|
146
|
-
export const evidenceBinding = z.object({
|
|
147
|
-
audience: z.string().min(1).max(256).regex(/^[A-Za-z0-9:/._-]+$/),
|
|
148
|
-
nonces: z.array(z.string().min(1).max(74).regex(/^[A-Za-z0-9_:-]+$/)).min(1).max(6),
|
|
149
|
-
}).strict();
|
|
150
|
-
/** Native claims are interpreted only after the PKI chain and token signature are authenticated. */
|
|
151
|
-
export const googleAttestationClaims = z.object({
|
|
152
|
-
iss: z.literal(GOOGLE_ATTESTATION_ISSUER),
|
|
153
|
-
aud: z.string(),
|
|
154
|
-
sub: z.string(),
|
|
155
|
-
iat: z.number().int(),
|
|
156
|
-
nbf: z.number().int(),
|
|
157
|
-
exp: z.number().int(),
|
|
158
|
-
eat_nonce: z.union([z.string(), z.array(z.string()).min(1).max(6)]),
|
|
159
|
-
secboot: z.literal(true),
|
|
160
|
-
dbgstat: z.literal("disabled-since-boot"),
|
|
161
|
-
oemid: z.literal(11129),
|
|
162
|
-
hwmodel: z.string(),
|
|
163
|
-
swname: z.literal("CONFIDENTIAL_SPACE"),
|
|
164
|
-
swversion: z.array(z.string()).length(1),
|
|
165
|
-
google_service_accounts: z.array(z.string()).min(1),
|
|
166
|
-
development: z.never().optional(),
|
|
167
|
-
identity: z.never().optional(),
|
|
168
|
-
submods: z.object({
|
|
169
|
-
confidential_space: z.object({
|
|
170
|
-
support_attributes: z.array(z.string()),
|
|
171
|
-
monitoring_enabled: z.object({ memory: z.literal(false) }).passthrough(),
|
|
172
|
-
}).passthrough(),
|
|
173
|
-
container: z.object({
|
|
174
|
-
image_digest: z.string().regex(/^sha256:[0-9a-f]{64}$/),
|
|
175
|
-
image_signatures: z.array(z.object({
|
|
176
|
-
key_id: z.string(), signature: z.string().min(1),
|
|
177
|
-
signature_algorithm: z.literal("RSASSA_PSS_SHA256"),
|
|
178
|
-
}).passthrough()).min(1),
|
|
179
|
-
// Google omits both override claims when nothing was overridden; when present they must still be empty.
|
|
180
|
-
cmd_override: z.array(z.unknown()).length(0).optional(),
|
|
181
|
-
env_override: z.object({}).strict().optional(),
|
|
182
|
-
// Real tokens list the container's own environment; the relay's start-up rule decides which settings are allowed.
|
|
183
|
-
env: z.record(z.string(), z.string()).refine(env => {
|
|
184
|
-
try {
|
|
185
|
-
checkRuntimeEnvironment(env);
|
|
186
|
-
return true;
|
|
187
|
-
}
|
|
188
|
-
catch {
|
|
189
|
-
return false;
|
|
190
|
-
}
|
|
191
|
-
}),
|
|
192
|
-
}).passthrough(),
|
|
193
|
-
}).passthrough(),
|
|
194
|
-
}).passthrough();
|
|
195
|
-
/** Authenticated observations describe a refused workload without granting it an admitted identity. */
|
|
196
|
-
export const observedAttestationClaims = googleAttestationClaims.extend({
|
|
197
|
-
dbgstat: z.string(),
|
|
198
|
-
submods: googleAttestationClaims.shape.submods.extend({
|
|
199
|
-
container: googleAttestationClaims.shape.submods.shape.container.extend({
|
|
200
|
-
image_signatures: z.array(googleAttestationClaims.shape.submods.shape.container.shape.image_signatures.element),
|
|
201
|
-
cmd_override: z.array(z.unknown()).optional(),
|
|
202
|
-
env_override: z.record(z.string(), z.unknown()).optional(),
|
|
203
|
-
}),
|
|
204
|
-
}),
|
|
205
|
-
});
|
|
206
|
-
/**
|
|
207
|
-
* Local development claims. These cannot establish production identity; production uses native Google claims.
|
|
208
|
-
*/
|
|
209
|
-
export const attestationClaims = z.object({
|
|
210
|
-
iss: z.string().url(),
|
|
211
|
-
/** The relay image's digest. Must be one we published. */
|
|
212
|
-
imageDigest: z.string().regex(/^sha256:[0-9a-f]{64}$/),
|
|
213
|
-
/** Hash of the relay's channel public key, so the token is bound to this connection. */
|
|
214
|
-
channelKeyHash: z.string().regex(/^[0-9a-f]{64}$/),
|
|
215
|
-
exp: z.number().int(),
|
|
216
|
-
iat: z.number().int(),
|
|
217
|
-
}).passthrough();
|
|
218
129
|
const assetMetadata = z.object({
|
|
219
130
|
/** HMAC-SHA256 keyed by a secret stored only in the encrypted payload. */
|
|
220
131
|
fingerprint: z.string().regex(/^[0-9a-f]{64}$/),
|
|
221
132
|
size: z.number().int().nonnegative(),
|
|
222
133
|
}).strict();
|
|
223
134
|
export const bundleMetadata = z.object({
|
|
135
|
+
analysis: analysisRelease.optional(),
|
|
224
136
|
templates: z.record(z.string().min(1), assetMetadata.extend({
|
|
225
137
|
role: z.enum(["required", "optional"]),
|
|
226
138
|
answerFormats: z.array(z.string().min(1)).min(1),
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { projectPublicFinding, projectPublicReport, unredactedAnswer, publicReportSchema } from "../analysis-core/contracts/relay-analysis.js";
|
|
@@ -1,12 +1,23 @@
|
|
|
1
1
|
// The pinned distroless base image sets SSL_CERT_FILE to its bundled certificates. The relay accepts exactly that
|
|
2
2
|
// value, so the image works while no operator setting can point the relay at a different trust store.
|
|
3
3
|
export const BASE_CERTIFICATE_FILE = "/etc/ssl/certs/ca-certificates.crt";
|
|
4
|
+
export const runtimeIdentities = Object.freeze({ loader: 65532, engine: 65533 });
|
|
4
5
|
const fixedValues = Object.freeze({ SSL_CERT_FILE: BASE_CERTIFICATE_FILE });
|
|
6
|
+
export const relayRawClockSetting = Object.freeze({ name: "COUNTERFACT_RELAY_RAW_CLOCK", value: "1" });
|
|
7
|
+
export const allowedRuntimeOverrides = Object.freeze({
|
|
8
|
+
[relayRawClockSetting.name]: relayRawClockSetting.value,
|
|
9
|
+
});
|
|
10
|
+
/** Only the clock rollback setting may differ from the signed image's environment. */
|
|
11
|
+
export function allowsRuntimeOverrides(overrides) {
|
|
12
|
+
return Object.entries(overrides).every(([name, value]) => Object.hasOwn(allowedRuntimeOverrides, name) && allowedRuntimeOverrides[name] === value);
|
|
13
|
+
}
|
|
5
14
|
// Native startup consumes these data, so loader checks have the same owner as runtime checks.
|
|
6
15
|
export const runtimeEnvironmentPolicy = Object.freeze({
|
|
7
16
|
fixedValues,
|
|
17
|
+
forwardedValues: Object.entries(allowedRuntimeOverrides).map(([name, value]) => `${name}=${value}`),
|
|
8
18
|
forbiddenPrefixes: ["CF_RELAY_", "ANTHROPIC_", "GOOGLE_AUTH_", "LD_"],
|
|
9
|
-
forbiddenNames: ["GOOGLE_APPLICATION_CREDENTIALS", "NODE_OPTIONS", "
|
|
19
|
+
forbiddenNames: ["GOOGLE_APPLICATION_CREDENTIALS", "NODE_OPTIONS", "NODE_V8_COVERAGE", "NODE_COMPILE_CACHE", "NODE_REDIRECT_WARNINGS", "NODE_DEBUG", "NODE_DEBUG_NATIVE",
|
|
20
|
+
"NODE_USE_ENV_PROXY", "NODE_EXTRA_CA_CERTS",
|
|
10
21
|
"NODE_TLS_REJECT_UNAUTHORIZED", "SSL_CERT_DIR", "GLIBC_TUNABLES", "GCONV_PATH"],
|
|
11
22
|
forbiddenNamesIgnoringCase: ["http_proxy", "https_proxy", "all_proxy"],
|
|
12
23
|
});
|
|
@@ -1,55 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { mapStage, inspectStage, sourceNotesStage, headlinesStage, applyAnalysisDelta, buildAnalysisResult } from "../../analysis-core/index.js";
|
|
4
|
-
import { readFindingQualityConfig } from "./finding-quality/config.js";
|
|
5
|
-
import { buildLocalReport } from "../../adapters/report.js";
|
|
6
|
-
import { runStructures } from "./structural-run.js";
|
|
1
|
+
import { NO_PROGRESS } from "../../analysis-core/stages/execution-ports.js";
|
|
2
|
+
import { runEngineAnalysis } from "../../analysis-core/stages/engine-entry.js";
|
|
7
3
|
import { analysisExecution } from "../../adapters/models.js";
|
|
8
|
-
/**
|
|
4
|
+
/** Product startup selects ports; the shared runner owns the stage order. */
|
|
9
5
|
export async function runAnalysisToEnd(input, model, settings = {}, options = {}) {
|
|
10
|
-
const execution = await analysisExecution(model, options
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
if (!Object.keys(input.files).length)
|
|
15
|
-
throw new JobError("empty_folder", "No admitted source files were found.");
|
|
16
|
-
let prepared = { repos: [{ key: input.repoKey, kind: "fs", files: input.files, ...(input.root !== undefined ? { root: input.root } : {}) }],
|
|
17
|
-
warnings: input.warnings ?? [], ...(input.fragment ? { mapped: [input.fragment] } : {}) };
|
|
18
|
-
const ports = { readers: [], model: llm, instructions: execution.instructions, requestTools: async () => tools, context: {},
|
|
19
|
-
progress: { checkpoint: async () => { }, assertHeld: () => { }, elapsedMs: () => 0, log: () => { } } };
|
|
20
|
-
for (;;) {
|
|
21
|
-
const delta = await mapStage({ phase: "mapping", state: prepared, settings, execution: { scopeConcurrency: 4, forLocal: true,
|
|
22
|
-
preparation: { capUsd: 20, confirmPrice: false, qualityConfig } } }, ports);
|
|
23
|
-
prepared = applyAnalysisDelta(prepared, delta);
|
|
24
|
-
if (delta.next !== "mapping")
|
|
25
|
-
break;
|
|
26
|
-
}
|
|
27
|
-
prepared = { ...prepared, analysisFlags: { ...flags, sourceNotes: !options.findingsOnly && flags.sourceNotes },
|
|
28
|
-
findingQuality: { config: qualityConfig }, warnings: [...(prepared.warnings ?? []), ...qualityConfig.diagnostics] };
|
|
29
|
-
let phase = "inspecting";
|
|
30
|
-
ports.progress.checkpoint = async (fields, part) => {
|
|
31
|
-
prepared = { ...prepared, ...fields, ...(part ? { passResults: { ...prepared.passResults, [part.index]: part.result } } : {}) };
|
|
32
|
-
};
|
|
33
|
-
while (phase !== "structuring") {
|
|
34
|
-
const step = phase === "inspecting" ? inspectStage : sourceNotesStage;
|
|
35
|
-
const delta = await step({ phase, state: prepared, settings, execution: { scopeConcurrency: 4, passConcurrency: 4, forLocal: true } }, ports);
|
|
36
|
-
prepared = applyAnalysisDelta(prepared, delta);
|
|
37
|
-
phase = delta.next;
|
|
38
|
-
}
|
|
39
|
-
const output = () => buildAnalysisResult({ phase: "persisting", state: prepared, settings, execution: { scopeConcurrency: 4 } });
|
|
40
|
-
// Structural child references index the local display order, including optional ranking.
|
|
41
|
-
const report = buildLocalReport(input.repoKey, output(), prepared);
|
|
42
|
-
if (!options.findingsOnly) {
|
|
43
|
-
const findingsRaw = prepared.findingsRaw;
|
|
44
|
-
const structural = await runStructures(settings, llm, { repos: prepared.repos,
|
|
45
|
-
fragments: [{ repoKey: input.repoKey, fragment: prepared.mapped[0] }],
|
|
46
|
-
graph: report.graph, findings: report.findings, state: prepared }, { execution });
|
|
47
|
-
// Structural child references keep display order; wording uses the original cleaned order.
|
|
48
|
-
prepared = { ...structural.state, findingsRaw };
|
|
49
|
-
}
|
|
50
|
-
prepared = applyAnalysisDelta(prepared, await headlinesStage({ phase: "persisting", state: prepared, settings,
|
|
51
|
-
execution: { scopeConcurrency: 4, forLocal: true } }, ports));
|
|
52
|
-
const result = output();
|
|
53
|
-
await options.resultSink?.(result);
|
|
54
|
-
return buildLocalReport(input.repoKey, result, prepared);
|
|
6
|
+
const execution = await analysisExecution(model, options);
|
|
7
|
+
return runEngineAnalysis({ ...input, settings }, { ...execution,
|
|
8
|
+
progress: NO_PROGRESS,
|
|
9
|
+
}, options);
|
|
55
10
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash, verify } from "node:crypto";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { MAX_ATTESTATION_BYTES, attestationClaims } from "../contract/
|
|
3
|
+
import { MAX_ATTESTATION_BYTES, attestationClaims } from "../contract/attestation.js";
|
|
4
4
|
import { decodeBase64url } from "../transport/base64url.js";
|
|
5
5
|
export function attestationPolicy(settings, trust) {
|
|
6
6
|
return { ...trust, allowDevelopment: settings.CF_RELAY_DEV === "1" };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { PRODUCTION_EVIDENCE_VERSION, RELAY_CHANNEL_AUDIENCE, attestationChallenge } from "../contract/attestation.js";
|
|
2
|
+
import { AttestationError } from "./attestation.js";
|
|
3
|
+
/** Both peers supply bytes from their actual TLS socket; the transport owns freshness and socket reuse. */
|
|
4
|
+
export function channelEvidenceBinding(binding) {
|
|
5
|
+
try {
|
|
6
|
+
attestationChallenge.parse({ version: PRODUCTION_EVIDENCE_VERSION, challenge: binding.challenge });
|
|
7
|
+
if (!(binding.exportedKeyMaterial instanceof Uint8Array) || binding.exportedKeyMaterial.byteLength !== 32)
|
|
8
|
+
throw new Error();
|
|
9
|
+
return { audience: RELAY_CHANNEL_AUDIENCE,
|
|
10
|
+
nonces: [Buffer.from(binding.exportedKeyMaterial).toString("base64url"), binding.challenge] };
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
throw new AttestationError("Invalid production attestation channel binding");
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -4,6 +4,8 @@ import { consoleSchemeAllowed } from "../contract/console-url.js";
|
|
|
4
4
|
import { cliApiMessages } from "../contract/cli-api-messages.js";
|
|
5
5
|
import { emailSignInMessages } from "../contract/email-sign-in-messages.js";
|
|
6
6
|
import { CLI_BODY_LIMIT } from "../cli/api-types.js";
|
|
7
|
+
import { findingNumberParamsSchema } from "../contract/findings.js";
|
|
8
|
+
import { claimFixRequestBodySchema, claimFixRequestParamsSchema } from "../contract/fix-requests.js";
|
|
7
9
|
export { DEFAULT_CONSOLE_URL as DEFAULT_URL } from "../setup-pings/client.js";
|
|
8
10
|
export class ApiError extends Error {
|
|
9
11
|
status;
|
|
@@ -36,6 +38,14 @@ function retryAfterMs(value, rejectNegativeSeconds = false) {
|
|
|
36
38
|
return Number.isFinite(date) ? Math.max(0, date - Date.now()) : undefined;
|
|
37
39
|
}
|
|
38
40
|
function requestBody(path, body, emailRoute) {
|
|
41
|
+
if (path === "fix-requests" && body === undefined)
|
|
42
|
+
return undefined;
|
|
43
|
+
const lookup = /^projects\/([^/]+)\/findings\/([^/]+)$/.exec(path);
|
|
44
|
+
if (lookup && body === undefined && findingNumberParamsSchema.safeParse({ projectId: lookup[1], seq: lookup[2] }).success)
|
|
45
|
+
return undefined;
|
|
46
|
+
const claim = /^fix-requests\/([^/]+)\/claim$/.exec(path);
|
|
47
|
+
if (claim && claimFixRequestParamsSchema.safeParse({ id: claim[1] }).success && claimFixRequestBodySchema.safeParse(body).success)
|
|
48
|
+
return {};
|
|
39
49
|
if ((path === "balance" || path === "checks/mode" || /^findings\/[a-zA-Z0-9_-]+$/.test(path)) && body === undefined)
|
|
40
50
|
return undefined;
|
|
41
51
|
if (body && typeof body === "object" && !Array.isArray(body)) {
|
|
@@ -95,7 +105,7 @@ export class ApiClient {
|
|
|
95
105
|
throw new Error("COUNTERFACT_URL must be an HTTP or HTTPS address without credentials or query fields.");
|
|
96
106
|
this.baseUrl = url.href.replace(/\/+$/, "");
|
|
97
107
|
}
|
|
98
|
-
async request(path, body, authenticated = true) {
|
|
108
|
+
async request(path, body, authenticated = true, options = {}) {
|
|
99
109
|
const emailRoute = Object.hasOwn(emailRoutes, path) ? emailRoutes[path] : undefined;
|
|
100
110
|
const throwIfAborted = () => {
|
|
101
111
|
if (emailRoute && this.signal?.aborted)
|
|
@@ -119,7 +129,7 @@ export class ApiClient {
|
|
|
119
129
|
}
|
|
120
130
|
const controller = new AbortController();
|
|
121
131
|
const cancel = () => controller.abort(this.signal?.reason);
|
|
122
|
-
const timeout = setTimeout(() => controller.abort(), 120_000);
|
|
132
|
+
const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? 120_000);
|
|
123
133
|
timeout.unref();
|
|
124
134
|
this.signal?.addEventListener("abort", cancel, { once: true });
|
|
125
135
|
try {
|
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
import { RELAY_MAX_FRAME_BYTES, deliveryReceipt, relayAnswer, relayAnswerFrame, relayRequest } from "../contract/messages.js";
|
|
2
|
+
import { unredactedAnswer } from "../contract/public-report.js";
|
|
2
3
|
export class RelayConnectionError extends Error {
|
|
3
4
|
name = "RelayConnectionError";
|
|
4
5
|
constructor() { super("Relay connection failed"); }
|
|
5
6
|
}
|
|
7
|
+
/** Both carriers retry the same confirmation, never another paid request. */
|
|
8
|
+
export async function acknowledgeDelivery(options, confirmation) {
|
|
9
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
10
|
+
options.valid();
|
|
11
|
+
const receipt = await options.exchange("/delivery", confirmation, 1024);
|
|
12
|
+
if (receipt.status === 200) {
|
|
13
|
+
deliveryReceipt.parse(receipt.body);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
throw new RelayConnectionError();
|
|
18
|
+
}
|
|
6
19
|
/** Calls and delivery retries share the authenticated transport and run in input order. */
|
|
7
20
|
export function createRelayExchange(options) {
|
|
8
21
|
let queue = Promise.resolve();
|
|
@@ -14,26 +27,18 @@ export function createRelayExchange(options) {
|
|
|
14
27
|
throw new Error();
|
|
15
28
|
// Provider work can exceed a network-body timeout; evidence expiry still closes this exact socket.
|
|
16
29
|
const response = await options.exchange("/relay", request, RELAY_MAX_FRAME_BYTES, options.remainingEvidenceMs());
|
|
17
|
-
const
|
|
30
|
+
const legacy = options.development ? relayAnswer.safeParse(response.body) : undefined;
|
|
31
|
+
const frame = legacy?.success ? { answer: legacy.data, delivery: undefined } : relayAnswerFrame.parse(response.body);
|
|
18
32
|
if (frame.answer.nonce !== request.nonce)
|
|
19
33
|
throw new Error();
|
|
34
|
+
if (frame.answer.ok)
|
|
35
|
+
unredactedAnswer(frame.answer);
|
|
20
36
|
if (frame.delivery) {
|
|
21
37
|
const { confirmation } = frame.delivery;
|
|
22
38
|
if (confirmation.callId !== request.callId)
|
|
23
39
|
throw new Error();
|
|
24
40
|
// The relay enforces its deadline on its own clock; local clock drift must not discard a paid answer.
|
|
25
|
-
|
|
26
|
-
for (let attempt = 0; attempt < 2; attempt++) {
|
|
27
|
-
options.valid();
|
|
28
|
-
const receipt = await options.exchange("/delivery", confirmation, 1024);
|
|
29
|
-
if (receipt.status === 200) {
|
|
30
|
-
deliveryReceipt.parse(receipt.body);
|
|
31
|
-
acknowledged = true;
|
|
32
|
-
break;
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
if (!acknowledged)
|
|
36
|
-
throw new Error();
|
|
41
|
+
await acknowledgeDelivery(options, confirmation);
|
|
37
42
|
}
|
|
38
43
|
return frame.answer;
|
|
39
44
|
}
|
|
@@ -3,7 +3,7 @@ import { request as httpRequest } from "node:http";
|
|
|
3
3
|
import { request as httpsRequest } from "node:https";
|
|
4
4
|
import { CLI_VERSION } from "../contract/cli-version.js";
|
|
5
5
|
import { cliRefusals, FINISH_FAILURE_MESSAGE, RESERVATION_EXPIRED_MESSAGE, RESULTS_TOO_LARGE_MESSAGE, START_FAILURE_MESSAGE } from "../contract/cli-refusals.js";
|
|
6
|
-
import { cliRelayReservationRequestSchema, consoleCreatedSchema, consoleIntakeResultSchema, consoleReservationSchema, consoleStatusSchema, LOCAL_FINDINGS_BODY_LIMIT, localFindingsPayloadSchema, LOCAL_START_BODY_LIMIT, LOCAL_START_EXECUTION_HEADER, localStartRequestSchema, localStartResponseSchema } from "../contract/console.js";
|
|
6
|
+
import { cliRelayReservationRequestSchema, consoleCreatedSchema, consoleIntakeResultSchema, consoleReservationSchema, consoleStatusSchema, LOCAL_FINDINGS_BODY_LIMIT, localFindingsPayloadSchema, LOCAL_START_BODY_LIMIT, LOCAL_START_EXECUTION_HEADER, localStartRequestSchema, localStartResponseSchema, serializeLocalStartMetadata } from "../contract/console.js";
|
|
7
7
|
const RESPONSE_LIMIT = 64 * 1024;
|
|
8
8
|
const RESERVATION_BODY_LIMIT = 64 * 1024;
|
|
9
9
|
const messages = {
|
|
@@ -54,8 +54,15 @@ export class ConsoleClient {
|
|
|
54
54
|
throw new ConsoleError("invalid_configuration");
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
|
-
start(input) {
|
|
58
|
-
|
|
57
|
+
async start(input, metadata) {
|
|
58
|
+
let headers;
|
|
59
|
+
try {
|
|
60
|
+
headers = metadata === undefined ? {} : serializeLocalStartMetadata(metadata);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
throw new ConsoleError("invalid_request", "start");
|
|
64
|
+
}
|
|
65
|
+
return this.#send("/api/cli/checks", input, localStartRequestSchema, localStartResponseSchema, LOCAL_START_BODY_LIMIT, { statuses: [201], headers: { ...headers, [LOCAL_START_EXECUTION_HEADER.name]: LOCAL_START_EXECUTION_HEADER.value }, context: "start" });
|
|
59
66
|
}
|
|
60
67
|
create(input) {
|
|
61
68
|
return this.#lifecycle({ ...input, action: "create" }, consoleCreatedSchema);
|
|
@@ -115,7 +122,7 @@ export class ConsoleClient {
|
|
|
115
122
|
method: "POST", agent: false, rejectUnauthorized: true, maxHeaderSize: 16 * 1024,
|
|
116
123
|
headers: { "x-counterfact-cli-version": CLI_VERSION, authorization: `Bearer ${this.#token}`, "content-type": "application/json", accept: "application/json",
|
|
117
124
|
"content-length": body.length, "accept-encoding": "identity",
|
|
118
|
-
...
|
|
125
|
+
...options.headers },
|
|
119
126
|
}, response => {
|
|
120
127
|
const status = response.statusCode ?? 0;
|
|
121
128
|
const declared = response.headers["content-length"];
|
package/dist/gatekeeper/model.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { unredactedAnswer } from "../contract/public-report.js";
|
|
2
3
|
import { executionRequest } from "../analysis-core/contracts/requests.js";
|
|
3
4
|
export class ModelConfigurationError extends Error {
|
|
4
5
|
name = "ModelConfigurationError";
|
|
@@ -62,7 +63,7 @@ export function relayModel(options) {
|
|
|
62
63
|
catch {
|
|
63
64
|
// A failed counter must not discard an answer or expose the sink's error text.
|
|
64
65
|
}
|
|
65
|
-
return reply
|
|
66
|
+
return unredactedAnswer(reply);
|
|
66
67
|
},
|
|
67
68
|
};
|
|
68
69
|
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { constants, verify, X509Certificate } from "node:crypto";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { GOOGLE_ATTESTATION_ISSUER, MAX_ATTESTATION_BYTES, PRODUCTION_EVIDENCE_VERSION,
|
|
3
|
+
import { GOOGLE_ATTESTATION_ISSUER, MAX_ATTESTATION_BYTES, PRODUCTION_EVIDENCE_VERSION, evidenceBinding, googleAttestationClaims, observedAttestationClaims, probeAttestationClaims, productionEvidence } from "../contract/attestation.js";
|
|
4
4
|
import { decodeBase64url } from "../transport/base64url.js";
|
|
5
5
|
import { isApprovedHardware } from "./trust/hardware.js";
|
|
6
6
|
import { AttestationError } from "./attestation.js";
|
|
7
7
|
import { validateCertificatePath } from "./certificate-path.js";
|
|
8
8
|
import { validateProductionTrust, validateWorkloadPolicy } from "./trust/policy.js";
|
|
9
|
+
import { channelEvidenceBinding } from "./channel-binding.js";
|
|
10
|
+
export { channelEvidenceBinding } from "./channel-binding.js";
|
|
9
11
|
/** A published client needs every trust constraint configured before it can connect to production. */
|
|
10
12
|
export function assertProductionTrustConfigured(value) {
|
|
11
13
|
try {
|
|
@@ -20,19 +22,6 @@ const headerSchema = z.object({
|
|
|
20
22
|
alg: z.literal("RS256"), typ: z.literal("JWT").optional(), kid: z.string().max(256).optional(),
|
|
21
23
|
x5c: z.array(z.string().min(1).max(8192)).min(1).max(5),
|
|
22
24
|
}).strict();
|
|
23
|
-
/** Both peers supply bytes from their actual TLS socket; the transport owns freshness and socket reuse. */
|
|
24
|
-
export function channelEvidenceBinding(binding) {
|
|
25
|
-
try {
|
|
26
|
-
attestationChallenge.parse({ version: PRODUCTION_EVIDENCE_VERSION, challenge: binding.challenge });
|
|
27
|
-
if (!(binding.exportedKeyMaterial instanceof Uint8Array) || binding.exportedKeyMaterial.byteLength !== 32)
|
|
28
|
-
throw new Error();
|
|
29
|
-
return { audience: RELAY_CHANNEL_AUDIENCE,
|
|
30
|
-
nonces: [Buffer.from(binding.exportedKeyMaterial).toString("base64url"), binding.challenge] };
|
|
31
|
-
}
|
|
32
|
-
catch {
|
|
33
|
-
throw new AttestationError("Invalid production attestation channel binding");
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
25
|
/** The caller's binding comes from the channel/health/action owner, never from the token being verified. */
|
|
37
26
|
export async function verifyProductionEvidence(token, binding, trust, now = Math.floor(Date.now() / 1000)) {
|
|
38
27
|
let validated;
|
|
@@ -57,6 +46,20 @@ export async function verifyWorkloadEvidence(token, binding, policy, now = Math.
|
|
|
57
46
|
}
|
|
58
47
|
return verifyEvidence(token, binding, validated, now);
|
|
59
48
|
}
|
|
49
|
+
/** Bootstrap may bound this candidate before verification; it is never an authenticated time sample. */
|
|
50
|
+
export function unverifiedEvidenceIssueTime(token) {
|
|
51
|
+
try {
|
|
52
|
+
productionEvidence.parse({ version: PRODUCTION_EVIDENCE_VERSION, token });
|
|
53
|
+
const candidate = JSON.parse(decodeBase64url(token.split(".")[1]).toString("utf8"));
|
|
54
|
+
const iat = candidate?.iat;
|
|
55
|
+
if (typeof iat !== "number" || !Number.isSafeInteger(iat) || !Number.isSafeInteger(iat * 1000))
|
|
56
|
+
throw new Error();
|
|
57
|
+
return iat;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
throw new AttestationError("Malformed production attestation evidence");
|
|
61
|
+
}
|
|
62
|
+
}
|
|
60
63
|
async function authenticate(token, binding, roots, now) {
|
|
61
64
|
try {
|
|
62
65
|
productionEvidence.parse({ version: PRODUCTION_EVIDENCE_VERSION, token });
|
|
@@ -99,14 +102,17 @@ function validateClaimsBinding(claims, binding, now) {
|
|
|
99
102
|
}
|
|
100
103
|
/**
|
|
101
104
|
* Reviewed roots authenticate probe facts; this result cannot substitute for workload admission.
|
|
102
|
-
*
|
|
103
|
-
*
|
|
105
|
+
* Only the contract owner's probe shape may relax missing signature/support arrays. Caller schemas
|
|
106
|
+
* can impose stricter validation, but freshness, binding, issuer and all other claim rules stay fixed.
|
|
104
107
|
*/
|
|
105
108
|
export async function observeGoogleEvidence(token, binding, roots, now = Math.floor(Date.now() / 1000), claimsSchema = observedAttestationClaims) {
|
|
106
109
|
const payload = await authenticate(token, binding, roots, now);
|
|
107
110
|
try {
|
|
108
|
-
const
|
|
111
|
+
const rawClaims = JSON.parse(payload.toString("utf8"));
|
|
112
|
+
const claims = probeAttestationClaims.parse(rawClaims);
|
|
109
113
|
validateClaimsBinding(claims, binding, now);
|
|
114
|
+
// Isolate even passthrough fields from caller mutation and discard any rewritten parser output.
|
|
115
|
+
claimsSchema.parse(structuredClone(rawClaims));
|
|
110
116
|
return { claims };
|
|
111
117
|
}
|
|
112
118
|
catch {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { enginePin } from "../../contract/engine.js";
|
|
1
2
|
import { X509Certificate } from "node:crypto";
|
|
2
3
|
import { z } from "zod";
|
|
3
4
|
import { hardwareModels } from "./hardware.js";
|
|
@@ -30,7 +31,9 @@ export const workloadPolicy = z.object({
|
|
|
30
31
|
}).strict();
|
|
31
32
|
const productionTrust = workloadPolicy.extend({
|
|
32
33
|
imageDigests: z.array(z.string().regex(/^sha256:[0-9a-f]{64}$/)).min(1),
|
|
33
|
-
})
|
|
34
|
+
engineReleases: z.array(z.strictObject({ imageDigest: z.string().regex(/^sha256:[0-9a-f]{64}$/), engine: enginePin })).min(1).optional(),
|
|
35
|
+
}).refine(value => !value.engineReleases || value.engineReleases.every(release => value.imageDigests.includes(release.imageDigest)) &&
|
|
36
|
+
new Set(value.engineReleases.map(release => release.imageDigest)).size === value.engineReleases.length, "Engine release image differs");
|
|
34
37
|
export function validateWorkloadPolicy(value) {
|
|
35
38
|
return workloadPolicy.parse(value);
|
|
36
39
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const maxBytes = 64 * 1024;
|
|
2
|
+
export function kmsKeyName(value) {
|
|
3
|
+
if (!/^projects\/[a-z][a-z0-9-]{4,62}\/locations\/[a-z0-9-]+\/keyRings\/[A-Za-z0-9_-]+\/cryptoKeys\/[A-Za-z0-9_-]+$/.test(value)) {
|
|
4
|
+
throw new Error("Invalid KMS key configuration");
|
|
5
|
+
}
|
|
6
|
+
return value;
|
|
7
|
+
}
|
|
8
|
+
export function kmsKeyVersionName(value) {
|
|
9
|
+
kmsKeyForVersion(value);
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
export function kmsKeyForVersion(value) {
|
|
13
|
+
const match = /^(.*)\/cryptoKeyVersions\/[1-9][0-9]*$/.exec(value);
|
|
14
|
+
if (!match)
|
|
15
|
+
throw new Error("Invalid KMS key configuration");
|
|
16
|
+
return kmsKeyName(match[1]);
|
|
17
|
+
}
|
|
18
|
+
export function kmsBase64(value) {
|
|
19
|
+
if (typeof value !== "string" || value.length < 4 || value.length > maxBytes)
|
|
20
|
+
throw new Error();
|
|
21
|
+
const bytes = Buffer.from(value, "base64");
|
|
22
|
+
if (bytes.toString("base64") !== value)
|
|
23
|
+
throw new Error();
|
|
24
|
+
return bytes;
|
|
25
|
+
}
|