@apexops/capabilities 0.10.0-next.4 → 0.11.0-next.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/archetype-source.d.ts +49 -0
- package/dist/archetype-source.d.ts.map +1 -0
- package/dist/archetype-source.js +421 -0
- package/dist/archetype-source.js.map +1 -0
- package/dist/bound-envelope-transport.js +1 -1
- package/dist/bound-envelope-transport.js.map +1 -1
- package/dist/executable-resolution.d.ts +21 -0
- package/dist/executable-resolution.d.ts.map +1 -0
- package/dist/executable-resolution.js +87 -0
- package/dist/executable-resolution.js.map +1 -0
- package/dist/governance-baseline.d.ts +87 -0
- package/dist/governance-baseline.d.ts.map +1 -0
- package/dist/governance-baseline.js +639 -0
- package/dist/governance-baseline.js.map +1 -0
- package/dist/governance-setup.d.ts +13 -0
- package/dist/governance-setup.d.ts.map +1 -0
- package/dist/governance-setup.js +231 -0
- package/dist/governance-setup.js.map +1 -0
- package/dist/iac-generation.d.ts.map +1 -1
- package/dist/iac-generation.js +56 -20
- package/dist/iac-generation.js.map +1 -1
- package/dist/iac-normalizers.d.ts.map +1 -1
- package/dist/iac-normalizers.js +19 -1
- package/dist/iac-normalizers.js.map +1 -1
- package/dist/iac.d.ts +30 -2
- package/dist/iac.d.ts.map +1 -1
- package/dist/iac.js.map +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/native-iac-providers.d.ts +20 -4
- package/dist/native-iac-providers.d.ts.map +1 -1
- package/dist/native-iac-providers.js +591 -27
- package/dist/native-iac-providers.js.map +1 -1
- package/dist/pack-manager.d.ts.map +1 -1
- package/dist/pack-manager.js +15 -14
- package/dist/pack-manager.js.map +1 -1
- package/dist/policy-validation.d.ts +68 -0
- package/dist/policy-validation.d.ts.map +1 -0
- package/dist/policy-validation.js +653 -0
- package/dist/policy-validation.js.map +1 -0
- package/dist/process-runner.d.ts.map +1 -1
- package/dist/process-runner.js +10 -1
- package/dist/process-runner.js.map +1 -1
- package/dist/rename-retry.d.ts +12 -0
- package/dist/rename-retry.d.ts.map +1 -0
- package/dist/rename-retry.js +27 -0
- package/dist/rename-retry.js.map +1 -0
- package/dist/repository-publish.d.ts +17 -0
- package/dist/repository-publish.d.ts.map +1 -0
- package/dist/repository-publish.js +124 -0
- package/dist/repository-publish.js.map +1 -0
- package/dist/terraform-provider-schema.d.ts.map +1 -1
- package/dist/terraform-provider-schema.js +6 -2
- package/dist/terraform-provider-schema.js.map +1 -1
- package/dist/test/archetype-source.test.d.ts +2 -0
- package/dist/test/archetype-source.test.d.ts.map +1 -0
- package/dist/test/archetype-source.test.js +258 -0
- package/dist/test/archetype-source.test.js.map +1 -0
- package/dist/test/executable-resolution.test.d.ts +2 -0
- package/dist/test/executable-resolution.test.d.ts.map +1 -0
- package/dist/test/executable-resolution.test.js +87 -0
- package/dist/test/executable-resolution.test.js.map +1 -0
- package/dist/test/governance-baseline.test.d.ts +2 -0
- package/dist/test/governance-baseline.test.d.ts.map +1 -0
- package/dist/test/governance-baseline.test.js +841 -0
- package/dist/test/governance-baseline.test.js.map +1 -0
- package/dist/test/governance-setup.test.d.ts +2 -0
- package/dist/test/governance-setup.test.d.ts.map +1 -0
- package/dist/test/governance-setup.test.js +160 -0
- package/dist/test/governance-setup.test.js.map +1 -0
- package/dist/test/iac-generation.test.js +203 -14
- package/dist/test/iac-generation.test.js.map +1 -1
- package/dist/test/native-iac-providers.test.js +1482 -5
- package/dist/test/native-iac-providers.test.js.map +1 -1
- package/dist/test/policy-validation.test.d.ts +2 -0
- package/dist/test/policy-validation.test.d.ts.map +1 -0
- package/dist/test/policy-validation.test.js +1061 -0
- package/dist/test/policy-validation.test.js.map +1 -0
- package/dist/test/rename-retry.test.d.ts +2 -0
- package/dist/test/rename-retry.test.d.ts.map +1 -0
- package/dist/test/rename-retry.test.js +25 -0
- package/dist/test/rename-retry.test.js.map +1 -0
- package/dist/test/repository-publish.test.d.ts +2 -0
- package/dist/test/repository-publish.test.d.ts.map +1 -0
- package/dist/test/repository-publish.test.js +123 -0
- package/dist/test/repository-publish.test.js.map +1 -0
- package/dist/test/terraform-provider-schema.test.js +12 -6
- package/dist/test/terraform-provider-schema.test.js.map +1 -1
- package/package.json +3 -3
|
@@ -1,11 +1,27 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { lstat, mkdir, mkdtemp, open, opendir, readFile, realpath, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
6
|
+
import { Value } from "@sinclair/typebox/value";
|
|
7
|
+
import { hasValidPolicyValidation, NATIVE_VALIDATION_COMMANDS, calculateNativeValidationCommandHash, calculateNativeValidationReceiptHash, calculatePolicyValidationDigest, hasActionablePolicyMappings, hasValidNativeValidationReceipt, PolicyPropertyMapV1Schema, LogicalResourceManifestV1Schema, IacBindingV1Schema, } from "@apexops/contracts";
|
|
4
8
|
import { BicepCommandAdapter, TerraformCommandAdapter, } from "./command-plans.js";
|
|
5
9
|
import { authorizeDeploymentPreview, IacProviderError, sha256, } from "./iac.js";
|
|
6
10
|
import { normalizeAzureStackResources, normalizeAzureWhatIf, normalizeTerraformPlan, parseJsonProcessOutput, selectAzureDeploymentStack, } from "./iac-normalizers.js";
|
|
7
11
|
import { secretFreeProperties } from "./secret-redaction.js";
|
|
8
12
|
import { LocalEncryptedPlanTransport } from "./local-plan-transport.js";
|
|
13
|
+
import { validatePolicyProperties, validateStorageSecurityBindings, validateBicepResourceParity, validateBicepStorageDiagnostics, validateBicepStorageBaseline, } from "./policy-validation.js";
|
|
14
|
+
export function nativePolicyValidationBinding(receipt) {
|
|
15
|
+
return {
|
|
16
|
+
track: receipt.track,
|
|
17
|
+
sourceHash: receipt.sourceHash,
|
|
18
|
+
policyMapHash: receipt.policyMapHash,
|
|
19
|
+
policyMapContentHash: receipt.policyMapContentHash,
|
|
20
|
+
logicalResourceManifestHash: receipt.logicalResourceManifestHash,
|
|
21
|
+
inputHash: receipt.inputHash,
|
|
22
|
+
receiptHash: receipt.receiptHash,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
9
25
|
function terraformStateIdentity(value) {
|
|
10
26
|
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
11
27
|
return {};
|
|
@@ -20,6 +36,120 @@ function terraformStateIdentity(value) {
|
|
|
20
36
|
function terraformStateIdentityOutput(stdout) {
|
|
21
37
|
return stdout.trim().length === 0 ? {} : terraformStateIdentity(parseJsonProcessOutput("terraform-state", stdout));
|
|
22
38
|
}
|
|
39
|
+
const maxGeneratedSourceBytes = 16 * 1024 * 1024;
|
|
40
|
+
function pathEscapes(parent, child) {
|
|
41
|
+
const relation = relative(parent, child);
|
|
42
|
+
return relation === "" || relation === ".." || relation.startsWith(`..${sep}`) || isAbsolute(relation);
|
|
43
|
+
}
|
|
44
|
+
function sourceBindingError() {
|
|
45
|
+
return new IacProviderError("PREVIEW_HASH_MISMATCH", "Native preview source does not match the accepted generated tree");
|
|
46
|
+
}
|
|
47
|
+
async function readGeneratedSource(source, accepted, outputs = new Set()) {
|
|
48
|
+
try {
|
|
49
|
+
const rootPath = resolve(source.rootPath);
|
|
50
|
+
if (!isAbsolute(source.rootPath) || !/^[a-f0-9]{64}$/.test(source.treeHash))
|
|
51
|
+
throw sourceBindingError();
|
|
52
|
+
const rootStat = await lstat(rootPath);
|
|
53
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
|
|
54
|
+
throw sourceBindingError();
|
|
55
|
+
}
|
|
56
|
+
const rootRealPath = await realpath(rootPath);
|
|
57
|
+
let bytes = 0;
|
|
58
|
+
let entries = 0;
|
|
59
|
+
const files = [];
|
|
60
|
+
const fileHashes = new Map();
|
|
61
|
+
const fileContents = new Map();
|
|
62
|
+
const visit = async (directory, depth) => {
|
|
63
|
+
if (depth > 64)
|
|
64
|
+
throw sourceBindingError();
|
|
65
|
+
const handle = await opendir(directory);
|
|
66
|
+
for await (const entry of handle) {
|
|
67
|
+
if (++entries > 4096)
|
|
68
|
+
throw sourceBindingError();
|
|
69
|
+
const filePath = resolve(directory, entry.name);
|
|
70
|
+
if (pathEscapes(rootPath, filePath) || pathEscapes(rootRealPath, await realpath(filePath))) {
|
|
71
|
+
throw sourceBindingError();
|
|
72
|
+
}
|
|
73
|
+
const metadata = await lstat(filePath);
|
|
74
|
+
if (metadata.isSymbolicLink() || (metadata.isFile() && metadata.nlink !== 1))
|
|
75
|
+
throw sourceBindingError();
|
|
76
|
+
const path = relative(rootPath, filePath).split(sep).join("/");
|
|
77
|
+
const isAccepted = accepted?.paths.has(path) === true;
|
|
78
|
+
if (accepted !== undefined && !isAccepted && outputs.has(filePath)) {
|
|
79
|
+
if (path === ".terraform" ? metadata.isDirectory() : metadata.isFile())
|
|
80
|
+
continue;
|
|
81
|
+
throw sourceBindingError();
|
|
82
|
+
}
|
|
83
|
+
if (metadata.isDirectory()) {
|
|
84
|
+
await visit(filePath, depth + 1);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (!metadata.isFile() || metadata.size > maxGeneratedSourceBytes - bytes)
|
|
88
|
+
throw sourceBindingError();
|
|
89
|
+
const input = await open(filePath, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
90
|
+
try {
|
|
91
|
+
const opened = await input.stat();
|
|
92
|
+
if (!opened.isFile() || opened.nlink !== 1)
|
|
93
|
+
throw sourceBindingError();
|
|
94
|
+
const chunks = [];
|
|
95
|
+
for (;;) {
|
|
96
|
+
const chunk = Buffer.alloc(Math.min(64 * 1024, maxGeneratedSourceBytes - bytes + 1));
|
|
97
|
+
const { bytesRead } = await input.read(chunk);
|
|
98
|
+
if (bytesRead === 0)
|
|
99
|
+
break;
|
|
100
|
+
bytes += bytesRead;
|
|
101
|
+
if (bytes > maxGeneratedSourceBytes)
|
|
102
|
+
throw sourceBindingError();
|
|
103
|
+
chunks.push(chunk.subarray(0, bytesRead));
|
|
104
|
+
}
|
|
105
|
+
const buffer = Buffer.concat(chunks);
|
|
106
|
+
const content = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(buffer);
|
|
107
|
+
files.push({ path, content });
|
|
108
|
+
fileHashes.set(path, createHash("sha256").update(buffer).digest("hex"));
|
|
109
|
+
fileContents.set(path, buffer);
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
await input.close();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
await visit(rootPath, 0);
|
|
117
|
+
files.sort((left, right) => left.path.localeCompare(right.path));
|
|
118
|
+
if (sha256(files) !== source.treeHash)
|
|
119
|
+
throw sourceBindingError();
|
|
120
|
+
return {
|
|
121
|
+
rootPath,
|
|
122
|
+
treeHash: source.treeHash,
|
|
123
|
+
paths: new Set(files.map(({ path }) => path)),
|
|
124
|
+
fileHashes,
|
|
125
|
+
fileContents,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
throw sourceBindingError();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
export async function assertGeneratedSourceUnchanged(source) {
|
|
133
|
+
await readGeneratedSource(source);
|
|
134
|
+
}
|
|
135
|
+
async function bindGeneratedSource(request, cwd, templateFile, parametersFile, accepted, outputs) {
|
|
136
|
+
if (request.generatedSource === undefined)
|
|
137
|
+
return undefined;
|
|
138
|
+
const source = await readGeneratedSource({ ...request.generatedSource }, accepted, outputs);
|
|
139
|
+
if (resolve(cwd ?? process.cwd()) !== source.rootPath)
|
|
140
|
+
throw sourceBindingError();
|
|
141
|
+
if (templateFile !== undefined && resolve(source.rootPath, templateFile) !== resolve(source.rootPath, "main.bicep")) {
|
|
142
|
+
throw sourceBindingError();
|
|
143
|
+
}
|
|
144
|
+
for (const file of [templateFile, parametersFile]) {
|
|
145
|
+
if (file === undefined)
|
|
146
|
+
continue;
|
|
147
|
+
const path = relative(source.rootPath, resolve(source.rootPath, file)).split(sep).join("/");
|
|
148
|
+
if (!source.paths.has(path))
|
|
149
|
+
throw sourceBindingError();
|
|
150
|
+
}
|
|
151
|
+
return source;
|
|
152
|
+
}
|
|
23
153
|
class NativeProviderBase {
|
|
24
154
|
runner;
|
|
25
155
|
now;
|
|
@@ -29,6 +159,7 @@ class NativeProviderBase {
|
|
|
29
159
|
#maxOutputBytes;
|
|
30
160
|
#operations = new Map();
|
|
31
161
|
#executionEvidence = new Map();
|
|
162
|
+
#policyValidations = new Map();
|
|
32
163
|
bindingStore;
|
|
33
164
|
constructor(options) {
|
|
34
165
|
this.runner = options.runner;
|
|
@@ -49,6 +180,263 @@ class NativeProviderBase {
|
|
|
49
180
|
});
|
|
50
181
|
return result.stdout;
|
|
51
182
|
}
|
|
183
|
+
async validateBoundSource(request, track, cwd, templateFile) {
|
|
184
|
+
if (request.generatedSource === undefined || cwd() === undefined)
|
|
185
|
+
throw sourceBindingError();
|
|
186
|
+
const generatedSource = { ...request.generatedSource };
|
|
187
|
+
const commands = NATIVE_VALIDATION_COMMANDS[track];
|
|
188
|
+
let receipt;
|
|
189
|
+
let policyInput;
|
|
190
|
+
let storageBindings;
|
|
191
|
+
let parityManifest;
|
|
192
|
+
let parityBinding;
|
|
193
|
+
let diagnosticsTargets;
|
|
194
|
+
try {
|
|
195
|
+
if (track === "bicep" && request.resourceParityBinding !== undefined) {
|
|
196
|
+
calculatePolicyValidationDigest(request.resourceParityBinding);
|
|
197
|
+
parityBinding = structuredClone(request.resourceParityBinding);
|
|
198
|
+
if (!Value.Check(IacBindingV1Schema, parityBinding) ||
|
|
199
|
+
request.resourceParityManifest === undefined ||
|
|
200
|
+
parityBinding.track !== track ||
|
|
201
|
+
parityBinding.projectId !== request.projectId ||
|
|
202
|
+
parityBinding.runId !== request.runId ||
|
|
203
|
+
parityBinding.intentHash !== request.inputHash)
|
|
204
|
+
throw new Error();
|
|
205
|
+
}
|
|
206
|
+
if (track === "bicep" && request.storageDiagnosticsTargets !== undefined) {
|
|
207
|
+
calculatePolicyValidationDigest(request.storageDiagnosticsTargets);
|
|
208
|
+
diagnosticsTargets = structuredClone(request.storageDiagnosticsTargets);
|
|
209
|
+
if (Object.keys(diagnosticsTargets).length > 1000)
|
|
210
|
+
throw new Error();
|
|
211
|
+
}
|
|
212
|
+
if (track === "bicep" && request.resourceParityManifest !== undefined) {
|
|
213
|
+
calculatePolicyValidationDigest(request.resourceParityManifest);
|
|
214
|
+
parityManifest = structuredClone(request.resourceParityManifest);
|
|
215
|
+
if (!Value.Check(LogicalResourceManifestV1Schema, parityManifest) ||
|
|
216
|
+
parityManifest.track !== track ||
|
|
217
|
+
parityManifest.projectId !== request.projectId ||
|
|
218
|
+
parityManifest.runId !== request.runId ||
|
|
219
|
+
parityManifest.resources.length > 1000)
|
|
220
|
+
throw new Error();
|
|
221
|
+
}
|
|
222
|
+
if (track === "bicep" && request.storageSecurityBindings !== undefined) {
|
|
223
|
+
calculatePolicyValidationDigest(request.storageSecurityBindings);
|
|
224
|
+
storageBindings = structuredClone(request.storageSecurityBindings);
|
|
225
|
+
if (Object.keys(storageBindings).length > 1000)
|
|
226
|
+
throw new Error();
|
|
227
|
+
}
|
|
228
|
+
if (request.policyValidation !== undefined) {
|
|
229
|
+
calculatePolicyValidationDigest(request.policyValidation);
|
|
230
|
+
policyInput = structuredClone(request.policyValidation);
|
|
231
|
+
if (!Value.Check(PolicyPropertyMapV1Schema, policyInput.policyMap) ||
|
|
232
|
+
calculatePolicyValidationDigest(policyInput.policyMap) !== request.policyHash ||
|
|
233
|
+
policyInput.policyMap.projectId !== request.projectId ||
|
|
234
|
+
policyInput.policyMap.runId !== request.runId)
|
|
235
|
+
throw new Error();
|
|
236
|
+
}
|
|
237
|
+
const body = {
|
|
238
|
+
schemaVersion: "1.0.0",
|
|
239
|
+
projectId: request.projectId,
|
|
240
|
+
runId: request.runId,
|
|
241
|
+
track,
|
|
242
|
+
sourceHash: request.sourceHash,
|
|
243
|
+
treeHash: generatedSource.treeHash,
|
|
244
|
+
policyHash: request.policyHash,
|
|
245
|
+
inputHash: request.inputHash,
|
|
246
|
+
...(policyInput !== undefined && !hasActionablePolicyMappings(policyInput.policyMap)
|
|
247
|
+
? {
|
|
248
|
+
policyApplicability: {
|
|
249
|
+
status: "no-actionable-mappings",
|
|
250
|
+
policyMapContentHash: calculatePolicyValidationDigest(policyInput.policyMap),
|
|
251
|
+
},
|
|
252
|
+
}
|
|
253
|
+
: {}),
|
|
254
|
+
outcome: "pass",
|
|
255
|
+
commands: commands.map((command) => ({
|
|
256
|
+
validatorId: command.validatorId,
|
|
257
|
+
commandHash: calculateNativeValidationCommandHash(command),
|
|
258
|
+
exitCode: 0,
|
|
259
|
+
signal: null,
|
|
260
|
+
timedOut: false,
|
|
261
|
+
outputTruncated: false,
|
|
262
|
+
})),
|
|
263
|
+
};
|
|
264
|
+
receipt = { ...body, receiptHash: calculateNativeValidationReceiptHash(body) };
|
|
265
|
+
if (!hasValidNativeValidationReceipt(receipt, body))
|
|
266
|
+
throw new Error();
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
throw new IacProviderError("NATIVE_VALIDATION_INPUT_INVALID", "Native validation inputs are invalid");
|
|
270
|
+
}
|
|
271
|
+
const source = await bindGeneratedSource({ generatedSource }, cwd(), templateFile);
|
|
272
|
+
if (source === undefined)
|
|
273
|
+
throw sourceBindingError();
|
|
274
|
+
if (track === "terraform") {
|
|
275
|
+
const cache = resolve(source.rootPath, ".terraform");
|
|
276
|
+
const existing = await lstat(cache).catch((error) => {
|
|
277
|
+
if (error.code === "ENOENT")
|
|
278
|
+
return undefined;
|
|
279
|
+
throw sourceBindingError();
|
|
280
|
+
});
|
|
281
|
+
if (existing !== undefined)
|
|
282
|
+
throw sourceBindingError();
|
|
283
|
+
}
|
|
284
|
+
let scratchRoot;
|
|
285
|
+
try {
|
|
286
|
+
try {
|
|
287
|
+
scratchRoot = await mkdtemp(resolve(await realpath(tmpdir()), "apex-native-validation-"));
|
|
288
|
+
for (const path of source.paths) {
|
|
289
|
+
const destination = resolve(scratchRoot, path);
|
|
290
|
+
if (!destination.startsWith(`${scratchRoot}${sep}`))
|
|
291
|
+
throw sourceBindingError();
|
|
292
|
+
await mkdir(dirname(destination), { recursive: true, mode: 0o700 });
|
|
293
|
+
await writeFile(destination, source.fileContents.get(path), { flag: "wx", mode: 0o600 });
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
throw sourceBindingError();
|
|
298
|
+
}
|
|
299
|
+
const scratch = await readGeneratedSource({ rootPath: scratchRoot, treeHash: source.treeHash });
|
|
300
|
+
const outputs = new Set();
|
|
301
|
+
if (track === "terraform") {
|
|
302
|
+
outputs.add(resolve(scratchRoot, ".terraform"));
|
|
303
|
+
outputs.add(resolve(scratchRoot, ".terraform.lock.hcl"));
|
|
304
|
+
}
|
|
305
|
+
const verify = async () => {
|
|
306
|
+
if (cwd() === undefined)
|
|
307
|
+
throw sourceBindingError();
|
|
308
|
+
await bindGeneratedSource({ generatedSource }, cwd(), templateFile);
|
|
309
|
+
if (track === "terraform") {
|
|
310
|
+
for (const path of [".terraform", ".terraform.lock.hcl"]) {
|
|
311
|
+
if (source.paths.has(path))
|
|
312
|
+
continue;
|
|
313
|
+
const metadata = await lstat(resolve(source.rootPath, path)).catch((error) => {
|
|
314
|
+
if (error.code === "ENOENT")
|
|
315
|
+
return undefined;
|
|
316
|
+
throw sourceBindingError();
|
|
317
|
+
});
|
|
318
|
+
if (metadata !== undefined)
|
|
319
|
+
throw sourceBindingError();
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
await readGeneratedSource(scratch, scratch, outputs);
|
|
323
|
+
};
|
|
324
|
+
let compiledTemplate;
|
|
325
|
+
for (const command of commands) {
|
|
326
|
+
await verify();
|
|
327
|
+
try {
|
|
328
|
+
const result = await this.runner.run({
|
|
329
|
+
executable: command.executable,
|
|
330
|
+
args: [...command.args],
|
|
331
|
+
cwd: scratchRoot,
|
|
332
|
+
timeoutMs: this.#timeoutMs,
|
|
333
|
+
maxOutputBytes: this.#maxOutputBytes,
|
|
334
|
+
});
|
|
335
|
+
if (result.exitCode !== 0 ||
|
|
336
|
+
result.signal !== null ||
|
|
337
|
+
result.timedOut !== false ||
|
|
338
|
+
result.outputTruncated !== false)
|
|
339
|
+
throw new Error();
|
|
340
|
+
if (command.validatorId === "bicep:build")
|
|
341
|
+
compiledTemplate = result.stdout;
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
throw new IacProviderError("NATIVE_VALIDATION_FAILED", "Native validation command failed");
|
|
345
|
+
}
|
|
346
|
+
finally {
|
|
347
|
+
await verify();
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
if (parityManifest !== undefined) {
|
|
351
|
+
receipt.resourceParity = validateBicepResourceParity({
|
|
352
|
+
sourceHash: receipt.sourceHash,
|
|
353
|
+
manifest: parityManifest,
|
|
354
|
+
...(parityBinding === undefined ? {} : { binding: parityBinding }),
|
|
355
|
+
json: compiledTemplate ?? "",
|
|
356
|
+
});
|
|
357
|
+
const { receiptHash: previousHash, ...updated } = receipt;
|
|
358
|
+
if (!previousHash)
|
|
359
|
+
throw sourceBindingError();
|
|
360
|
+
receipt.receiptHash = calculateNativeValidationReceiptHash(updated);
|
|
361
|
+
}
|
|
362
|
+
if (parityManifest !== undefined && parityBinding !== undefined) {
|
|
363
|
+
receipt.securityBaseline = validateBicepStorageBaseline({
|
|
364
|
+
sourceHash: receipt.sourceHash,
|
|
365
|
+
manifest: parityManifest,
|
|
366
|
+
binding: parityBinding,
|
|
367
|
+
json: compiledTemplate ?? "",
|
|
368
|
+
});
|
|
369
|
+
const { receiptHash: previousHash, ...updated } = receipt;
|
|
370
|
+
if (!previousHash)
|
|
371
|
+
throw sourceBindingError();
|
|
372
|
+
receipt.receiptHash = calculateNativeValidationReceiptHash(updated);
|
|
373
|
+
}
|
|
374
|
+
if (diagnosticsTargets !== undefined) {
|
|
375
|
+
receipt.storageDiagnostics = Object.fromEntries(Object.entries(diagnosticsTargets).map(([logicalId, target]) => [
|
|
376
|
+
logicalId,
|
|
377
|
+
validateBicepStorageDiagnostics({
|
|
378
|
+
...target,
|
|
379
|
+
sourceHash: receipt.sourceHash,
|
|
380
|
+
json: compiledTemplate ?? "",
|
|
381
|
+
}),
|
|
382
|
+
]));
|
|
383
|
+
const { receiptHash: previousHash, ...updated } = receipt;
|
|
384
|
+
if (!previousHash)
|
|
385
|
+
throw sourceBindingError();
|
|
386
|
+
receipt.receiptHash = calculateNativeValidationReceiptHash(updated);
|
|
387
|
+
}
|
|
388
|
+
if (track === "bicep" && policyInput !== undefined && hasActionablePolicyMappings(policyInput.policyMap)) {
|
|
389
|
+
let policyValidation;
|
|
390
|
+
try {
|
|
391
|
+
policyValidation = validatePolicyProperties({
|
|
392
|
+
...policyInput,
|
|
393
|
+
track,
|
|
394
|
+
sourceHash: receipt.sourceHash,
|
|
395
|
+
policyMapHash: receipt.policyHash,
|
|
396
|
+
json: compiledTemplate ?? "",
|
|
397
|
+
});
|
|
398
|
+
if (policyValidation.outcome !== "pass")
|
|
399
|
+
throw new Error();
|
|
400
|
+
}
|
|
401
|
+
catch {
|
|
402
|
+
throw new IacProviderError("NATIVE_VALIDATION_FAILED", "Native policy property validation failed");
|
|
403
|
+
}
|
|
404
|
+
finally {
|
|
405
|
+
await verify();
|
|
406
|
+
}
|
|
407
|
+
receipt.policyValidation = policyValidation;
|
|
408
|
+
const { receiptHash, ...updated } = receipt;
|
|
409
|
+
if (!receiptHash)
|
|
410
|
+
throw new IacProviderError("NATIVE_VALIDATION_FAILED", "Native validation receipt is missing");
|
|
411
|
+
receipt = { ...updated, receiptHash: calculateNativeValidationReceiptHash(updated) };
|
|
412
|
+
}
|
|
413
|
+
if (storageBindings !== undefined && Object.keys(storageBindings).length > 0) {
|
|
414
|
+
const observations = validateStorageSecurityBindings({
|
|
415
|
+
track,
|
|
416
|
+
bindings: storageBindings,
|
|
417
|
+
sourceHash: receipt.sourceHash,
|
|
418
|
+
json: compiledTemplate ?? "",
|
|
419
|
+
});
|
|
420
|
+
const storageSecurity = Object.fromEntries(Object.entries(observations).map(([logicalId, observation]) => {
|
|
421
|
+
return [logicalId, { ...observation, results: [...observation.results] }];
|
|
422
|
+
}));
|
|
423
|
+
await verify();
|
|
424
|
+
const { receiptHash, ...body } = receipt;
|
|
425
|
+
if (!receiptHash)
|
|
426
|
+
throw new IacProviderError("NATIVE_VALIDATION_FAILED", "Native validation receipt is missing");
|
|
427
|
+
const updated = { ...body, storageSecurity };
|
|
428
|
+
receipt = { ...updated, receiptHash: calculateNativeValidationReceiptHash(updated) };
|
|
429
|
+
}
|
|
430
|
+
return receipt;
|
|
431
|
+
}
|
|
432
|
+
finally {
|
|
433
|
+
if (scratchRoot !== undefined) {
|
|
434
|
+
await rm(scratchRoot, { recursive: true, force: true }).catch(() => {
|
|
435
|
+
throw sourceBindingError();
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
52
440
|
async authorize(track, operation, preview, approval, suppliedAuthority, latestPreviewHash) {
|
|
53
441
|
const authority = await this.currentAuthority();
|
|
54
442
|
if (authority.head !== suppliedAuthority.head ||
|
|
@@ -100,6 +488,58 @@ class NativeProviderBase {
|
|
|
100
488
|
executionEvidence(operationId) {
|
|
101
489
|
return this.#executionEvidence.get(operationId);
|
|
102
490
|
}
|
|
491
|
+
policyValidation(previewHash) {
|
|
492
|
+
const receipt = this.#policyValidations.get(previewHash);
|
|
493
|
+
return receipt === undefined ? undefined : structuredClone(receipt);
|
|
494
|
+
}
|
|
495
|
+
evaluatePolicy(track, request, json, blockers) {
|
|
496
|
+
const input = request.policyValidation;
|
|
497
|
+
if (input === undefined || !hasActionablePolicyMappings(input.policyMap))
|
|
498
|
+
return {};
|
|
499
|
+
const receipt = validatePolicyProperties({
|
|
500
|
+
...input,
|
|
501
|
+
track,
|
|
502
|
+
sourceHash: request.iacHash,
|
|
503
|
+
policyMapHash: request.policyHash,
|
|
504
|
+
json,
|
|
505
|
+
});
|
|
506
|
+
if (receipt.projectId !== request.projectId || receipt.runId !== request.runId) {
|
|
507
|
+
blockers.push("Policy validation project/run does not match the preview");
|
|
508
|
+
}
|
|
509
|
+
for (const result of receipt.results) {
|
|
510
|
+
if (result.outcome !== "pass") {
|
|
511
|
+
blockers.push(`Policy validation mapping ${result.mappingIndex}: ${result.outcome} (${result.reason})`);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
return {
|
|
515
|
+
policyValidation: receipt,
|
|
516
|
+
policyValidationBinding: nativePolicyValidationBinding(receipt),
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
rememberPolicy(preview, binding) {
|
|
520
|
+
if (binding.policyValidation !== undefined) {
|
|
521
|
+
this.#policyValidations.set(preview.previewHash, structuredClone(binding.policyValidation));
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
verifyPolicy(preview, binding) {
|
|
525
|
+
const receipt = binding.policyValidation;
|
|
526
|
+
const expected = binding.policyValidationBinding;
|
|
527
|
+
if (receipt === undefined && expected === undefined)
|
|
528
|
+
return;
|
|
529
|
+
if (receipt === undefined ||
|
|
530
|
+
expected === undefined ||
|
|
531
|
+
!hasValidPolicyValidation(receipt, expected) ||
|
|
532
|
+
receipt.receiptHash !== expected.receiptHash ||
|
|
533
|
+
receipt.track !== preview.track ||
|
|
534
|
+
receipt.sourceHash !== preview.iacHash ||
|
|
535
|
+
receipt.policyMapHash !== preview.policyHash ||
|
|
536
|
+
receipt.projectId !== preview.projectId ||
|
|
537
|
+
receipt.runId !== preview.runId ||
|
|
538
|
+
preview.operation !== "apply" ||
|
|
539
|
+
receipt.outcome !== "pass") {
|
|
540
|
+
throw new IacProviderError("PREVIEW_HASH_MISMATCH", "Policy validation receipt does not match the preview");
|
|
541
|
+
}
|
|
542
|
+
}
|
|
103
543
|
previewKey(request, operation) {
|
|
104
544
|
return `${request.projectId}\u0000${request.runId}\u0000${operation}`;
|
|
105
545
|
}
|
|
@@ -152,10 +592,26 @@ export class NativeBicepProvider extends NativeProviderBase {
|
|
|
152
592
|
}
|
|
153
593
|
}
|
|
154
594
|
async validate() {
|
|
155
|
-
await this
|
|
595
|
+
await this.#compiledTemplate();
|
|
156
596
|
return [];
|
|
157
597
|
}
|
|
598
|
+
async validateSource(request) {
|
|
599
|
+
return await this.validateBoundSource(request, this.track, () => this.#target.cwd, this.#target.templateFile);
|
|
600
|
+
}
|
|
158
601
|
async previewApply(request) {
|
|
602
|
+
if (request.policyValidation !== undefined) {
|
|
603
|
+
try {
|
|
604
|
+
calculatePolicyValidationDigest(request.policyValidation);
|
|
605
|
+
request = { ...request, policyValidation: structuredClone(request.policyValidation) };
|
|
606
|
+
}
|
|
607
|
+
catch {
|
|
608
|
+
throw new IacProviderError("PREVIEW_HASH_MISMATCH", "Bicep policy validation inputs are invalid");
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
const source = await bindGeneratedSource(request, this.#target.cwd, this.#target.templateFile, this.#target.parametersFile);
|
|
612
|
+
const policyInputs = request.policyValidation?.policyMap.mappings.length
|
|
613
|
+
? await this.#inputHashes(source)
|
|
614
|
+
: undefined;
|
|
159
615
|
const [stdout, stackResources] = await Promise.all([
|
|
160
616
|
this.run(this.#commands.preview(this.#target)),
|
|
161
617
|
this.#currentStackResources(),
|
|
@@ -166,14 +622,26 @@ export class NativeBicepProvider extends NativeProviderBase {
|
|
|
166
622
|
if (unrepresented.length > 0) {
|
|
167
623
|
normalized.blockers.push(`Group what-if cannot prove action-on-unmanage behavior for current stack resources: ${unrepresented.map(({ resourceId }) => resourceId).join(", ")}`);
|
|
168
624
|
}
|
|
169
|
-
|
|
625
|
+
let policy = {};
|
|
626
|
+
if (policyInputs !== undefined) {
|
|
627
|
+
let compiled = "";
|
|
628
|
+
try {
|
|
629
|
+
compiled = await this.#compiledTemplate();
|
|
630
|
+
}
|
|
631
|
+
catch {
|
|
632
|
+
normalized.blockers.push("Policy validation compiler output is unavailable");
|
|
633
|
+
}
|
|
634
|
+
policy = this.evaluatePolicy(this.track, request, compiled, normalized.blockers);
|
|
635
|
+
}
|
|
636
|
+
return await this.#bind(request, "apply", normalized, stackResources, policy, policyInputs, source);
|
|
170
637
|
}
|
|
171
638
|
async previewDestroy(request) {
|
|
639
|
+
const source = await bindGeneratedSource(request, this.#target.cwd, this.#target.templateFile, this.#target.parametersFile);
|
|
172
640
|
const resources = await this.#currentStackResources();
|
|
173
641
|
return await this.#bind(request, "destroy", {
|
|
174
642
|
changes: resources.map((resource) => ({ resourceId: resource.resourceId, action: "delete", material: true })),
|
|
175
643
|
blockers: [],
|
|
176
|
-
}, resources);
|
|
644
|
+
}, resources, {}, undefined, source);
|
|
177
645
|
}
|
|
178
646
|
async apply(preview, approval, authority) {
|
|
179
647
|
return await this.#execute("apply", preview, approval, authority);
|
|
@@ -192,21 +660,16 @@ export class NativeBicepProvider extends NativeProviderBase {
|
|
|
192
660
|
resources: [...resources],
|
|
193
661
|
};
|
|
194
662
|
}
|
|
195
|
-
async #bind(request, operation, normalized, stackResources) {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
663
|
+
async #bind(request, operation, normalized, stackResources, policy = {}, policyInputs, source) {
|
|
664
|
+
if (source !== undefined)
|
|
665
|
+
await readGeneratedSource(source, source);
|
|
666
|
+
const { templateHash, parametersHash } = await this.#inputHashes(source);
|
|
667
|
+
if (policyInputs !== undefined &&
|
|
668
|
+
(policyInputs.templateHash !== templateHash || policyInputs.parametersHash !== parametersHash)) {
|
|
669
|
+
normalized.blockers.push("Bicep inputs changed during policy validation preview");
|
|
670
|
+
}
|
|
200
671
|
const stackStateHash = sha256(stackResources);
|
|
201
|
-
const providerBindingHash =
|
|
202
|
-
resourceGroup: this.#target.resourceGroup,
|
|
203
|
-
stackName: this.#target.stackName,
|
|
204
|
-
actionOnUnmanage: this.#target.actionOnUnmanage,
|
|
205
|
-
denySettingsMode: this.#target.denySettingsMode,
|
|
206
|
-
templateHash,
|
|
207
|
-
parametersHash,
|
|
208
|
-
stackStateHash,
|
|
209
|
-
});
|
|
672
|
+
const providerBindingHash = this.#providerBindingHash(templateHash, parametersHash, stackStateHash, policy);
|
|
210
673
|
const preview = this.createPreview(this.track, operation, request, normalized, {
|
|
211
674
|
artifactHash: providerBindingHash,
|
|
212
675
|
});
|
|
@@ -217,11 +680,49 @@ export class NativeBicepProvider extends NativeProviderBase {
|
|
|
217
680
|
templateHash,
|
|
218
681
|
parametersHash,
|
|
219
682
|
stackStateHash,
|
|
683
|
+
...policy,
|
|
220
684
|
};
|
|
221
685
|
this.#bindings.set(this.previewKey(request, operation), binding);
|
|
222
686
|
await this.bindingStore?.save(preview.previewHash, binding);
|
|
687
|
+
this.rememberPolicy(preview, binding);
|
|
223
688
|
return preview;
|
|
224
689
|
}
|
|
690
|
+
#providerBindingHash(templateHash, parametersHash, stackStateHash, policy) {
|
|
691
|
+
return sha256({
|
|
692
|
+
resourceGroup: this.#target.resourceGroup,
|
|
693
|
+
stackName: this.#target.stackName,
|
|
694
|
+
actionOnUnmanage: this.#target.actionOnUnmanage,
|
|
695
|
+
denySettingsMode: this.#target.denySettingsMode,
|
|
696
|
+
templateHash,
|
|
697
|
+
parametersHash,
|
|
698
|
+
stackStateHash,
|
|
699
|
+
...(policy.policyValidationBinding === undefined ? {} : { policyValidation: policy.policyValidationBinding }),
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
async #inputHashes(source) {
|
|
703
|
+
const fileHash = async (file) => {
|
|
704
|
+
if (source === undefined)
|
|
705
|
+
return await this.#rawFileHash(file);
|
|
706
|
+
const path = relative(source.rootPath, resolve(source.rootPath, file)).split(sep).join("/");
|
|
707
|
+
const hash = source.fileHashes.get(path);
|
|
708
|
+
if (hash === undefined)
|
|
709
|
+
throw sourceBindingError();
|
|
710
|
+
return hash;
|
|
711
|
+
};
|
|
712
|
+
return {
|
|
713
|
+
templateHash: await fileHash(this.#target.templateFile),
|
|
714
|
+
parametersHash: this.#target.parametersFile === undefined
|
|
715
|
+
? createHash("sha256").update(Buffer.alloc(0)).digest("hex")
|
|
716
|
+
: await fileHash(this.#target.parametersFile),
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
async #compiledTemplate() {
|
|
720
|
+
return await this.run({
|
|
721
|
+
executable: "bicep",
|
|
722
|
+
args: ["build", this.#target.templateFile, "--stdout"],
|
|
723
|
+
...(this.#target.cwd === undefined ? {} : { cwd: this.#target.cwd }),
|
|
724
|
+
});
|
|
725
|
+
}
|
|
225
726
|
async #rawFileHash(path) {
|
|
226
727
|
const filePath = isAbsolute(path) ? path : resolve(this.#target.cwd ?? process.cwd(), path);
|
|
227
728
|
return createHash("sha256")
|
|
@@ -247,17 +748,23 @@ export class NativeBicepProvider extends NativeProviderBase {
|
|
|
247
748
|
if (binding === undefined || binding.preview.previewHash !== preview.previewHash) {
|
|
248
749
|
throw new IacProviderError("PREVIEW_HASH_MISMATCH", "No exact Bicep stack binding is available for this preview");
|
|
249
750
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
751
|
+
this.verifyPolicy(preview, binding);
|
|
752
|
+
if (binding.policyValidationBinding !== undefined &&
|
|
753
|
+
createHash("sha256")
|
|
754
|
+
.update(await this.#compiledTemplate())
|
|
755
|
+
.digest("hex") !== binding.policyValidationBinding.inputHash) {
|
|
756
|
+
throw new IacProviderError("PREVIEW_HASH_MISMATCH", "Bicep compiler output changed after policy validation");
|
|
757
|
+
}
|
|
758
|
+
const { templateHash, parametersHash } = await this.#inputHashes();
|
|
254
759
|
const stackStateHash = sha256(await this.#currentStackResources());
|
|
255
760
|
if (templateHash !== binding.templateHash ||
|
|
256
761
|
parametersHash !== binding.parametersHash ||
|
|
257
762
|
stackStateHash !== binding.stackStateHash ||
|
|
258
|
-
preview.artifactHash !== binding.providerBindingHash
|
|
763
|
+
preview.artifactHash !== binding.providerBindingHash ||
|
|
764
|
+
preview.artifactHash !== this.#providerBindingHash(templateHash, parametersHash, stackStateHash, binding)) {
|
|
259
765
|
throw new IacProviderError("PREVIEW_HASH_MISMATCH", "Bicep stack inputs, configuration, or managed state changed after preview");
|
|
260
766
|
}
|
|
767
|
+
this.rememberPolicy(preview, binding);
|
|
261
768
|
const stdout = await this.run(operation === "apply" ? this.#commands.stackApply(this.#target) : this.#commands.stackDestroy(this.#target));
|
|
262
769
|
let providerOperationId;
|
|
263
770
|
if (stdout.trim().length > 0) {
|
|
@@ -289,6 +796,9 @@ export class NativeTerraformProvider extends NativeProviderBase {
|
|
|
289
796
|
}
|
|
290
797
|
return [];
|
|
291
798
|
}
|
|
799
|
+
async validateSource(request) {
|
|
800
|
+
return await this.validateBoundSource(request, this.track, () => this.#target.cwd);
|
|
801
|
+
}
|
|
292
802
|
async previewApply(request) {
|
|
293
803
|
return await this.#preview(request, "apply");
|
|
294
804
|
}
|
|
@@ -352,8 +862,39 @@ export class NativeTerraformProvider extends NativeProviderBase {
|
|
|
352
862
|
if (this.#keyProvider === undefined || this.#artifactStore === undefined || this.bindingStore === undefined) {
|
|
353
863
|
throw new IacProviderError("PREVIEW_HASH_MISMATCH", "Terraform local/reference plan transport requires injected key, artifact, and binding stores");
|
|
354
864
|
}
|
|
355
|
-
|
|
865
|
+
if (operation === "apply" && request.policyValidation !== undefined) {
|
|
866
|
+
try {
|
|
867
|
+
calculatePolicyValidationDigest(request.policyValidation);
|
|
868
|
+
request = { ...request, policyValidation: structuredClone(request.policyValidation) };
|
|
869
|
+
}
|
|
870
|
+
catch {
|
|
871
|
+
throw new IacProviderError("PREVIEW_HASH_MISMATCH", "Terraform policy validation inputs are invalid");
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
const source = await bindGeneratedSource(request, this.#target.cwd);
|
|
875
|
+
const requestedPlanPath = this.#target.planPath(request, operation);
|
|
876
|
+
const planPath = source === undefined ? requestedPlanPath : resolve(source.rootPath, requestedPlanPath);
|
|
877
|
+
const outputs = new Set();
|
|
878
|
+
if (source !== undefined) {
|
|
879
|
+
const relativePlanPath = relative(source.rootPath, planPath).split(sep).join("/");
|
|
880
|
+
if (source.paths.has(relativePlanPath) ||
|
|
881
|
+
planPath === source.rootPath ||
|
|
882
|
+
[...source.paths].some((path) => path.startsWith(`${relativePlanPath}/`)) ||
|
|
883
|
+
/\.(?:tf|tfvars)(?:\.json)?$/i.test(planPath) ||
|
|
884
|
+
relativePlanPath === ".terraform.lock.hcl" ||
|
|
885
|
+
relativePlanPath === ".terraform" ||
|
|
886
|
+
relativePlanPath.startsWith(".terraform/") ||
|
|
887
|
+
[...source.paths].some((path) => path.startsWith(".terraform/"))) {
|
|
888
|
+
throw sourceBindingError();
|
|
889
|
+
}
|
|
890
|
+
outputs.add(planPath);
|
|
891
|
+
outputs.add(resolve(source.rootPath, ".terraform"));
|
|
892
|
+
outputs.add(resolve(source.rootPath, ".terraform.lock.hcl"));
|
|
893
|
+
}
|
|
356
894
|
await this.run(this.#commands.init(this.#target.cwd, true));
|
|
895
|
+
const policyExpected = operation === "apply" && Boolean(request.policyValidation?.policyMap.mappings.length);
|
|
896
|
+
const initialConfigHash = policyExpected ? await this.#target.configHash() : undefined;
|
|
897
|
+
const initialLockfileHash = this.#target.lockfileHash;
|
|
357
898
|
const stateIdentity = terraformStateIdentityOutput(await this.run(this.#commands.statePull(this.#target.cwd)));
|
|
358
899
|
await this.run(this.#commands.preview(this.#target.cwd, planPath, operation === "destroy"));
|
|
359
900
|
try {
|
|
@@ -361,6 +902,13 @@ export class NativeTerraformProvider extends NativeProviderBase {
|
|
|
361
902
|
const planDigest = createHash("sha256").update(planBytes).digest("hex");
|
|
362
903
|
const showOutput = await this.run(this.#commands.showJson(this.#target.cwd, planPath));
|
|
363
904
|
const normalized = normalizeTerraformPlan(parseJsonProcessOutput("terraform-plan", showOutput));
|
|
905
|
+
const policy = policyExpected ? this.evaluatePolicy(this.track, request, showOutput, normalized.blockers) : {};
|
|
906
|
+
if (policyExpected &&
|
|
907
|
+
createHash("sha256")
|
|
908
|
+
.update(await readFile(planPath))
|
|
909
|
+
.digest("hex") !== planDigest) {
|
|
910
|
+
normalized.blockers.push("Terraform saved plan changed during policy validation preview");
|
|
911
|
+
}
|
|
364
912
|
const authority = await this.currentAuthority();
|
|
365
913
|
if (authority.ownerEpoch !== request.ownerEpoch ||
|
|
366
914
|
authority.head !== request.commit ||
|
|
@@ -373,14 +921,22 @@ export class NativeTerraformProvider extends NativeProviderBase {
|
|
|
373
921
|
ttlMs: request.ttlMs,
|
|
374
922
|
});
|
|
375
923
|
const artifactRef = `${request.projectId}/${request.runId}/${operation}/${planDigest}.tfplan.enc`;
|
|
376
|
-
await this.#artifactStore.put(artifactRef, encrypted);
|
|
377
924
|
const configHash = await this.#target.configHash();
|
|
925
|
+
if (source !== undefined) {
|
|
926
|
+
if (resolve(this.#target.cwd) !== source.rootPath)
|
|
927
|
+
throw sourceBindingError();
|
|
928
|
+
await readGeneratedSource(source, source, outputs);
|
|
929
|
+
}
|
|
930
|
+
if (policyExpected && (configHash !== initialConfigHash || this.#target.lockfileHash !== initialLockfileHash)) {
|
|
931
|
+
normalized.blockers.push("Terraform configuration changed during policy validation preview");
|
|
932
|
+
}
|
|
378
933
|
const artifactHash = sha256({
|
|
379
934
|
planDigest,
|
|
380
935
|
configHash,
|
|
381
936
|
lockfileHash: this.#target.lockfileHash,
|
|
382
937
|
recipient: executionRecipient,
|
|
383
938
|
artifactRef,
|
|
939
|
+
...(policy.policyValidationBinding === undefined ? {} : { policyValidation: policy.policyValidationBinding }),
|
|
384
940
|
});
|
|
385
941
|
const preview = this.createPreview(this.track, operation, request, normalized, {
|
|
386
942
|
artifactHash,
|
|
@@ -415,9 +971,11 @@ export class NativeTerraformProvider extends NativeProviderBase {
|
|
|
415
971
|
createdAt: createdAt.toISOString(),
|
|
416
972
|
expiresAt: preview.expiresAt,
|
|
417
973
|
};
|
|
418
|
-
const binding = { kind: "terraform", preview, attestation };
|
|
974
|
+
const binding = { kind: "terraform", preview, attestation, ...policy };
|
|
975
|
+
await this.#artifactStore.put(artifactRef, encrypted);
|
|
419
976
|
this.#bindings.set(this.previewKey(request, operation), binding);
|
|
420
977
|
await this.bindingStore.save(preview.previewHash, binding);
|
|
978
|
+
this.rememberPolicy(preview, binding);
|
|
421
979
|
return preview;
|
|
422
980
|
}
|
|
423
981
|
finally {
|
|
@@ -435,12 +993,16 @@ export class NativeTerraformProvider extends NativeProviderBase {
|
|
|
435
993
|
if (binding === undefined) {
|
|
436
994
|
throw new IacProviderError("PREVIEW_HASH_MISMATCH", "No exact saved plan is bound to this preview");
|
|
437
995
|
}
|
|
996
|
+
this.verifyPolicy(preview, binding);
|
|
438
997
|
if (binding.attestation.recipient !== authority.recipientIdentity) {
|
|
439
998
|
throw new IacProviderError("APPROVAL_RECIPIENT_MISMATCH", "Saved plan recipient is not the current authority");
|
|
440
999
|
}
|
|
441
1000
|
if (binding.attestation.configHash !== (await this.#target.configHash()) ||
|
|
442
1001
|
binding.attestation.lockfileHash !== this.#target.lockfileHash ||
|
|
443
1002
|
binding.attestation.inputHash !== preview.inputHash ||
|
|
1003
|
+
binding.attestation.iacHash !== preview.iacHash ||
|
|
1004
|
+
binding.attestation.policyHash !== preview.policyHash ||
|
|
1005
|
+
binding.attestation.previewHash !== preview.previewHash ||
|
|
444
1006
|
binding.attestation.stateLineage !== preview.stateLineage ||
|
|
445
1007
|
binding.attestation.stateSerial !== preview.stateSerial) {
|
|
446
1008
|
throw new IacProviderError("PREVIEW_HASH_MISMATCH", "Saved plan attestation does not match the preview");
|
|
@@ -456,10 +1018,12 @@ export class NativeTerraformProvider extends NativeProviderBase {
|
|
|
456
1018
|
lockfileHash: binding.attestation.lockfileHash,
|
|
457
1019
|
recipient: binding.attestation.recipient,
|
|
458
1020
|
artifactRef: binding.attestation.artifactRef,
|
|
1021
|
+
...(binding.policyValidationBinding === undefined ? {} : { policyValidation: binding.policyValidationBinding }),
|
|
459
1022
|
});
|
|
460
1023
|
if (preview.artifactHash !== providerArtifactHash) {
|
|
461
1024
|
throw new IacProviderError("PREVIEW_HASH_MISMATCH", "Terraform provider binding hash does not match the preview");
|
|
462
1025
|
}
|
|
1026
|
+
this.rememberPolicy(preview, binding);
|
|
463
1027
|
if (this.#keyProvider === undefined || this.#artifactStore === undefined) {
|
|
464
1028
|
throw new IacProviderError("PREVIEW_HASH_MISMATCH", "Terraform plan decryption dependencies are unavailable");
|
|
465
1029
|
}
|