@apexops/capabilities 0.10.0-next.5 → 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/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,12 +1,14 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import {
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { link, mkdir, mkdtemp, readFile, readdir, rm, stat, symlink, writeFile } from "node:fs/promises";
|
|
3
4
|
import { tmpdir } from "node:os";
|
|
4
5
|
import { join } from "node:path";
|
|
5
|
-
import test from "node:test";
|
|
6
|
+
import test, {} from "node:test";
|
|
7
|
+
import { sha256 } from "../iac.js";
|
|
6
8
|
import { Value } from "@sinclair/typebox/value";
|
|
7
9
|
import { FormatRegistry } from "@sinclair/typebox";
|
|
8
|
-
import { ExecutionPlanAttestationV1Schema, } from "@apexops/contracts";
|
|
9
|
-
import { IacOutputParseError, IacProviderError, LocalEncryptedPlanTransport, NativeBicepProvider, NativeTerraformProvider, normalizeAzureWhatIf, normalizeTerraformPlan, selectAzureDeploymentStack, } from "../index.js";
|
|
10
|
+
import { ExecutionPlanAttestationV1Schema, NativeValidationReceiptV1Schema, NATIVE_VALIDATION_COMMANDS, calculateNativeValidationCommandHash, calculateNativeValidationReceiptHash, hasValidNativeValidationReceipt, calculatePolicyValidationHash, calculatePolicyValidationDigest, hasValidPolicyValidation, } from "@apexops/contracts";
|
|
11
|
+
import { IacOutputParseError, IacProviderError, LocalEncryptedPlanTransport, NativeBicepProvider, NativeTerraformProvider, ProcessRunner, nativePolicyValidationBinding, normalizeAzureWhatIf, normalizeTerraformPlan, selectAzureDeploymentStack, } from "../index.js";
|
|
10
12
|
const hashes = {
|
|
11
13
|
head: "a".repeat(64),
|
|
12
14
|
input: "b".repeat(64),
|
|
@@ -24,6 +26,10 @@ const authority = {
|
|
|
24
26
|
ownerEpoch: 3,
|
|
25
27
|
recipientIdentity: "writer@example.com",
|
|
26
28
|
};
|
|
29
|
+
async function assertPosixMode(path, expectedMode) {
|
|
30
|
+
if (process.platform !== "win32")
|
|
31
|
+
assert.equal((await stat(path)).mode & 0o777, expectedMode);
|
|
32
|
+
}
|
|
27
33
|
function request(overrides = {}) {
|
|
28
34
|
return {
|
|
29
35
|
projectId: "project",
|
|
@@ -99,6 +105,1444 @@ class MemoryArtifactStore {
|
|
|
99
105
|
return this.values.get(reference);
|
|
100
106
|
}
|
|
101
107
|
}
|
|
108
|
+
async function nativePolicyFixture(context, track) {
|
|
109
|
+
const root = await mkdtemp(join(tmpdir(), `apex-native-policy-${track}-`));
|
|
110
|
+
context.after(async () => rm(root, { recursive: true, force: true }));
|
|
111
|
+
const sourceFile = track === "bicep" ? "main.bicep" : "main.tf";
|
|
112
|
+
const content = track === "bicep" ? "targetScope = 'resourceGroup'\n" : "terraform {}\n";
|
|
113
|
+
await writeFile(join(root, sourceFile), content);
|
|
114
|
+
const generatedSource = { rootPath: root, treeHash: sha256([{ path: sourceFile, content }]) };
|
|
115
|
+
const source = {
|
|
116
|
+
value: "native-value-must-not-leak",
|
|
117
|
+
invalid: false,
|
|
118
|
+
unknown: false,
|
|
119
|
+
compileFailed: false,
|
|
120
|
+
complete: true,
|
|
121
|
+
configHash: hashes.iac,
|
|
122
|
+
duringPreview: undefined,
|
|
123
|
+
duringShow: undefined,
|
|
124
|
+
duringInit: undefined,
|
|
125
|
+
duringCommand: undefined,
|
|
126
|
+
};
|
|
127
|
+
const json = () => source.invalid
|
|
128
|
+
? "{}"
|
|
129
|
+
: JSON.stringify(track === "bicep"
|
|
130
|
+
? { resources: { storage: { properties: { security: source.value } } } }
|
|
131
|
+
: {
|
|
132
|
+
complete: source.complete,
|
|
133
|
+
resource_changes: [
|
|
134
|
+
{
|
|
135
|
+
address: "azurerm_storage_account.main",
|
|
136
|
+
mode: "managed",
|
|
137
|
+
change: {
|
|
138
|
+
actions: ["create"],
|
|
139
|
+
before: null,
|
|
140
|
+
after: { properties: { security: source.value } },
|
|
141
|
+
after_unknown: { properties: { security: source.unknown } },
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
planned_values: {
|
|
146
|
+
root_module: {
|
|
147
|
+
resources: [
|
|
148
|
+
{
|
|
149
|
+
address: "azurerm_storage_account.main",
|
|
150
|
+
mode: "managed",
|
|
151
|
+
values: { properties: { security: source.value } },
|
|
152
|
+
},
|
|
153
|
+
],
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
const runner = new FakeRunner(async (process) => {
|
|
158
|
+
await source.duringCommand?.(process);
|
|
159
|
+
if (process.executable === "bicep") {
|
|
160
|
+
if (process.args[0] === "format" || process.args[0] === "lint") {
|
|
161
|
+
const command = NATIVE_VALIDATION_COMMANDS.bicep.find(({ args }) => args[0] === process.args[0]);
|
|
162
|
+
assert.deepEqual(process.args, command?.args);
|
|
163
|
+
return "";
|
|
164
|
+
}
|
|
165
|
+
assert.deepEqual(process.args, ["build", "main.bicep", "--stdout"]);
|
|
166
|
+
if (source.compileFailed)
|
|
167
|
+
throw new Error("compiler diagnostic containing private source");
|
|
168
|
+
return json();
|
|
169
|
+
}
|
|
170
|
+
if (process.args.includes("what-if")) {
|
|
171
|
+
await source.duringPreview?.();
|
|
172
|
+
return JSON.stringify({ changes: [{ resourceId: "/storage", changeType: "Create" }] });
|
|
173
|
+
}
|
|
174
|
+
if (process.args[2] === "list")
|
|
175
|
+
return "[]";
|
|
176
|
+
if (process.args[0] === "init")
|
|
177
|
+
await source.duringInit?.();
|
|
178
|
+
const outputArg = process.args.find((argument) => argument.startsWith("-out="));
|
|
179
|
+
if (outputArg !== undefined) {
|
|
180
|
+
await writeFile(outputArg.slice(5), "actual-saved-plan", { mode: 0o600 });
|
|
181
|
+
await source.duringPreview?.();
|
|
182
|
+
}
|
|
183
|
+
if (process.args[0] === "show") {
|
|
184
|
+
await source.duringShow?.();
|
|
185
|
+
return json();
|
|
186
|
+
}
|
|
187
|
+
if (process.args[0] === "state")
|
|
188
|
+
return JSON.stringify({ lineage: "policy-state", serial: 1 });
|
|
189
|
+
return "";
|
|
190
|
+
});
|
|
191
|
+
const bindings = new MemoryBindingStore();
|
|
192
|
+
const artifacts = new MemoryArtifactStore();
|
|
193
|
+
const runtime = {
|
|
194
|
+
runner,
|
|
195
|
+
bindingStore: bindings,
|
|
196
|
+
currentAuthority: async () => authority,
|
|
197
|
+
now: () => clock.value,
|
|
198
|
+
};
|
|
199
|
+
const makeProvider = (options = {}) => track === "bicep"
|
|
200
|
+
? new NativeBicepProvider({
|
|
201
|
+
...runtime,
|
|
202
|
+
target: {
|
|
203
|
+
cwd: options.cwd ?? root,
|
|
204
|
+
resourceGroup: "rg",
|
|
205
|
+
deploymentName: "preview",
|
|
206
|
+
stackName: "workload",
|
|
207
|
+
templateFile: options.templateFile ?? "main.bicep",
|
|
208
|
+
...(options.parametersFile === undefined ? {} : { parametersFile: options.parametersFile }),
|
|
209
|
+
denySettingsMode: "denyDelete",
|
|
210
|
+
},
|
|
211
|
+
})
|
|
212
|
+
: new NativeTerraformProvider({
|
|
213
|
+
...runtime,
|
|
214
|
+
artifactStore: artifacts,
|
|
215
|
+
keyProvider: async () => Buffer.alloc(32, 9),
|
|
216
|
+
target: {
|
|
217
|
+
cwd: options.cwd ?? root,
|
|
218
|
+
target: "dev",
|
|
219
|
+
planPath: () => options.planPath ?? join(root, "policy.tfplan"),
|
|
220
|
+
configHash: async () => source.configHash,
|
|
221
|
+
lockfileHash: hashes.lock,
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
const policyRequest = request({
|
|
225
|
+
policyValidation: {
|
|
226
|
+
policyMap: {
|
|
227
|
+
schemaVersion: "1.0.0",
|
|
228
|
+
projectId: "project",
|
|
229
|
+
runId: "run",
|
|
230
|
+
governanceHash: hashes.policy,
|
|
231
|
+
mappings: ["deny", "modify", "deployIfNotExists"].map((effect) => ({
|
|
232
|
+
policyAssignmentId: "/assignments/baseline",
|
|
233
|
+
effect,
|
|
234
|
+
logicalResourceId: "storage",
|
|
235
|
+
propertyPath: "properties.security",
|
|
236
|
+
expectedValue: "native-value-must-not-leak",
|
|
237
|
+
disposition: "planned",
|
|
238
|
+
})),
|
|
239
|
+
},
|
|
240
|
+
logicalResourceManifest: { storage: { codeSymbol: "storage", terraformAddress: "azurerm_storage_account.main" } },
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
const executed = () => runner.requests.some((entry) => entry.args[0] === "apply" || entry.args[2] === "create");
|
|
244
|
+
return {
|
|
245
|
+
root,
|
|
246
|
+
sourceFile,
|
|
247
|
+
generatedSource,
|
|
248
|
+
source,
|
|
249
|
+
json,
|
|
250
|
+
runner,
|
|
251
|
+
bindings,
|
|
252
|
+
artifacts,
|
|
253
|
+
makeProvider,
|
|
254
|
+
policyRequest,
|
|
255
|
+
executed,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
function rehashPolicy(receipt) {
|
|
259
|
+
const { receiptHash: previousHash, ...body } = receipt;
|
|
260
|
+
assert.ok(previousHash);
|
|
261
|
+
return { ...body, receiptHash: calculatePolicyValidationHash(body) };
|
|
262
|
+
}
|
|
263
|
+
for (const track of ["bicep", "terraform"]) {
|
|
264
|
+
test(`native ${track} preview snapshots policy inputs before awaited commands`, async (context) => {
|
|
265
|
+
for (const phase of ["init", "show"]) {
|
|
266
|
+
for (const mutation of ["value", "mappings", "binding"]) {
|
|
267
|
+
await context.test(`${phase}: ${mutation}`, async (child) => {
|
|
268
|
+
const fixture = await nativePolicyFixture(child, track);
|
|
269
|
+
const input = fixture.policyRequest.policyValidation;
|
|
270
|
+
const originalMapHash = sha256(input.policyMap);
|
|
271
|
+
const originalManifestHash = sha256(input.logicalResourceManifest);
|
|
272
|
+
fixture.source.value = "actual-private-mismatch";
|
|
273
|
+
const mutate = async () => {
|
|
274
|
+
if (mutation === "value") {
|
|
275
|
+
for (const mapping of input.policyMap.mappings)
|
|
276
|
+
mapping.expectedValue = fixture.source.value;
|
|
277
|
+
}
|
|
278
|
+
else if (mutation === "mappings") {
|
|
279
|
+
input.policyMap.mappings.length = 0;
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
delete input.logicalResourceManifest.storage;
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
if (track === "bicep")
|
|
286
|
+
fixture.source.duringCommand = async (command) => {
|
|
287
|
+
if (phase === "init" ? command.args.includes("what-if") : command.args[0] === "build")
|
|
288
|
+
await mutate();
|
|
289
|
+
};
|
|
290
|
+
else if (phase === "init")
|
|
291
|
+
fixture.source.duringInit = mutate;
|
|
292
|
+
else
|
|
293
|
+
fixture.source.duringShow = mutate;
|
|
294
|
+
const provider = fixture.makeProvider();
|
|
295
|
+
const preview = await provider.previewApply(fixture.policyRequest);
|
|
296
|
+
const receipt = provider.policyValidation(preview.previewHash);
|
|
297
|
+
assert.ok(receipt);
|
|
298
|
+
assert.equal(receipt.policyMapContentHash, originalMapHash);
|
|
299
|
+
assert.equal(receipt.logicalResourceManifestHash, originalManifestHash);
|
|
300
|
+
assert.equal(receipt.outcome, "fail");
|
|
301
|
+
assert.equal(receipt.results.length, 3);
|
|
302
|
+
assert.ok(receipt.results.every(({ reason }) => reason === "value-mismatch"));
|
|
303
|
+
assert.equal(preview.blockers.length, 3);
|
|
304
|
+
await assert.rejects(provider.apply(preview, approval(preview), authority), { code: "PREVIEW_BLOCKED" });
|
|
305
|
+
assert.equal(fixture.executed(), false);
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
test(`native ${track} preview bounds policy inputs before commands`, async (context) => {
|
|
311
|
+
const fixture = await nativePolicyFixture(context, track);
|
|
312
|
+
fixture.policyRequest.policyValidation.policyMap.mappings[0].expectedValue = Array(100_001).fill(null);
|
|
313
|
+
await assert.rejects(fixture.makeProvider().previewApply(fixture.policyRequest), {
|
|
314
|
+
code: "PREVIEW_HASH_MISMATCH",
|
|
315
|
+
message: `${track === "bicep" ? "Bicep" : "Terraform"} policy validation inputs are invalid`,
|
|
316
|
+
});
|
|
317
|
+
assert.equal(fixture.runner.requests.length, 0);
|
|
318
|
+
assert.equal(fixture.bindings.values.size, 0);
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
test("native terraform validateSource leaves accepted source pristine for fresh validation and preview", async (context) => {
|
|
322
|
+
const fixture = await nativePolicyFixture(context, "terraform");
|
|
323
|
+
const provider = fixture.makeProvider();
|
|
324
|
+
const input = {
|
|
325
|
+
projectId: "project",
|
|
326
|
+
runId: "run",
|
|
327
|
+
sourceHash: hashes.iac,
|
|
328
|
+
generatedSource: fixture.generatedSource,
|
|
329
|
+
policyHash: hashes.policy,
|
|
330
|
+
inputHash: hashes.input,
|
|
331
|
+
};
|
|
332
|
+
fixture.source.duringCommand = async (process) => {
|
|
333
|
+
if (process.args[0] !== "init")
|
|
334
|
+
return;
|
|
335
|
+
await mkdir(join(process.cwd, ".terraform"), { recursive: true });
|
|
336
|
+
await writeFile(join(process.cwd, ".terraform", "provider"), Buffer.from([0xff, 0xfe]));
|
|
337
|
+
await writeFile(join(process.cwd, ".terraform.lock.hcl"), "new native lock");
|
|
338
|
+
};
|
|
339
|
+
const receipt = await provider.validateSource(input);
|
|
340
|
+
assert.deepEqual(await provider.validateSource(input), receipt);
|
|
341
|
+
assert.equal(fixture.runner.requests.length, 6);
|
|
342
|
+
assert.deepEqual(await fixture.makeProvider().validateSource(input), receipt);
|
|
343
|
+
const scratchRoots = new Set(fixture.runner.requests.map(({ cwd }) => cwd));
|
|
344
|
+
assert.equal(scratchRoots.size, 3);
|
|
345
|
+
for (const root of scratchRoots) {
|
|
346
|
+
assert.notEqual(root, fixture.root);
|
|
347
|
+
await assert.rejects(stat(root), { code: "ENOENT" });
|
|
348
|
+
assert.equal(JSON.stringify(receipt).includes(root), false);
|
|
349
|
+
}
|
|
350
|
+
assert.deepEqual(await readdir(fixture.root), [fixture.sourceFile]);
|
|
351
|
+
assert.equal(await readFile(join(fixture.root, fixture.sourceFile), "utf8"), "terraform {}\n");
|
|
352
|
+
fixture.source.duringCommand = undefined;
|
|
353
|
+
await fixture.makeProvider().previewApply(request({ generatedSource: fixture.generatedSource }));
|
|
354
|
+
assert.deepEqual(await readdir(fixture.root), [fixture.sourceFile]);
|
|
355
|
+
const previous = fixture.runner.requests.length;
|
|
356
|
+
await writeFile(join(fixture.root, ".unexpected"), "hidden-source-change");
|
|
357
|
+
await assert.rejects(provider.validateSource(input), { code: "PREVIEW_HASH_MISMATCH" });
|
|
358
|
+
assert.equal(fixture.runner.requests.length, previous);
|
|
359
|
+
});
|
|
360
|
+
test("native terraform validateSource does not trust unknown, failed, or mistyped outputs", async (context) => {
|
|
361
|
+
for (const scenario of [
|
|
362
|
+
"preexisting-cache",
|
|
363
|
+
"preexisting-lock",
|
|
364
|
+
"later-cache",
|
|
365
|
+
"later-lock",
|
|
366
|
+
"failed-init",
|
|
367
|
+
"cache-file",
|
|
368
|
+
"lock-directory",
|
|
369
|
+
"cache-symlink",
|
|
370
|
+
]) {
|
|
371
|
+
await context.test(scenario, async (child) => {
|
|
372
|
+
const fixture = await nativePolicyFixture(child, "terraform");
|
|
373
|
+
const provider = fixture.makeProvider();
|
|
374
|
+
const input = {
|
|
375
|
+
projectId: "project",
|
|
376
|
+
runId: "run",
|
|
377
|
+
sourceHash: hashes.iac,
|
|
378
|
+
generatedSource: fixture.generatedSource,
|
|
379
|
+
policyHash: hashes.policy,
|
|
380
|
+
inputHash: hashes.input,
|
|
381
|
+
};
|
|
382
|
+
const cache = join(fixture.root, ".terraform");
|
|
383
|
+
const lock = join(fixture.root, ".terraform.lock.hcl");
|
|
384
|
+
if (scenario.startsWith("later-"))
|
|
385
|
+
await provider.validateSource(input);
|
|
386
|
+
if (scenario.endsWith("cache"))
|
|
387
|
+
await mkdir(cache);
|
|
388
|
+
if (scenario.endsWith("lock"))
|
|
389
|
+
await writeFile(lock, "untrusted native lock");
|
|
390
|
+
if (scenario === "failed-init") {
|
|
391
|
+
fixture.source.duringInit = async () => {
|
|
392
|
+
await mkdir(cache);
|
|
393
|
+
throw new Error("private-init-failure");
|
|
394
|
+
};
|
|
395
|
+
await assert.rejects(provider.validateSource(input), { code: "PREVIEW_HASH_MISMATCH" });
|
|
396
|
+
fixture.source.duringInit = undefined;
|
|
397
|
+
}
|
|
398
|
+
if (scenario === "cache-file")
|
|
399
|
+
fixture.source.duringInit = async () => {
|
|
400
|
+
await writeFile(cache, "not a cache directory");
|
|
401
|
+
};
|
|
402
|
+
if (scenario === "lock-directory")
|
|
403
|
+
fixture.source.duringInit = async () => {
|
|
404
|
+
await mkdir(lock);
|
|
405
|
+
};
|
|
406
|
+
if (scenario === "cache-symlink")
|
|
407
|
+
fixture.source.duringInit = async () => {
|
|
408
|
+
await symlink(fixture.root, cache);
|
|
409
|
+
};
|
|
410
|
+
const previous = fixture.runner.requests.length;
|
|
411
|
+
await assert.rejects(provider.validateSource(input), { code: "PREVIEW_HASH_MISMATCH" });
|
|
412
|
+
if (!["cache-file", "lock-directory", "cache-symlink"].includes(scenario))
|
|
413
|
+
assert.equal(fixture.runner.requests.length, previous);
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
test("native terraform validateSource rejects malformed scratch outputs and cleans failed init", async (context) => {
|
|
418
|
+
for (const scenario of [
|
|
419
|
+
"cache-file",
|
|
420
|
+
"lock-directory",
|
|
421
|
+
"cache-symlink",
|
|
422
|
+
"lock-symlink",
|
|
423
|
+
"lock-hardlink",
|
|
424
|
+
"failed-init",
|
|
425
|
+
]) {
|
|
426
|
+
await context.test(scenario, async (child) => {
|
|
427
|
+
const fixture = await nativePolicyFixture(child, "terraform");
|
|
428
|
+
fixture.source.duringCommand = async (process) => {
|
|
429
|
+
if (process.args[0] !== "init")
|
|
430
|
+
return;
|
|
431
|
+
const cache = join(process.cwd, ".terraform");
|
|
432
|
+
const lock = join(process.cwd, ".terraform.lock.hcl");
|
|
433
|
+
if (scenario === "cache-file")
|
|
434
|
+
await writeFile(cache, "not a cache directory");
|
|
435
|
+
if (scenario === "lock-directory")
|
|
436
|
+
await mkdir(lock);
|
|
437
|
+
if (scenario === "cache-symlink")
|
|
438
|
+
await symlink(fixture.root, cache);
|
|
439
|
+
if (scenario === "lock-symlink")
|
|
440
|
+
await symlink(join(fixture.root, fixture.sourceFile), lock);
|
|
441
|
+
if (scenario === "lock-hardlink")
|
|
442
|
+
await link(join(process.cwd, fixture.sourceFile), lock);
|
|
443
|
+
if (scenario === "failed-init") {
|
|
444
|
+
await mkdir(cache);
|
|
445
|
+
await writeFile(join(cache, "provider"), Buffer.from([0xff]));
|
|
446
|
+
await writeFile(lock, "new native lock");
|
|
447
|
+
throw new Error(`private-init-failure ${process.cwd}`);
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
await assert.rejects(fixture.makeProvider().validateSource({
|
|
451
|
+
projectId: "project",
|
|
452
|
+
runId: "run",
|
|
453
|
+
sourceHash: hashes.iac,
|
|
454
|
+
generatedSource: fixture.generatedSource,
|
|
455
|
+
policyHash: hashes.policy,
|
|
456
|
+
inputHash: hashes.input,
|
|
457
|
+
}), { code: scenario === "failed-init" ? "NATIVE_VALIDATION_FAILED" : "PREVIEW_HASH_MISMATCH" });
|
|
458
|
+
assert.equal(fixture.runner.requests.length, 1);
|
|
459
|
+
await assert.rejects(stat(fixture.runner.requests[0].cwd), { code: "ENOENT" });
|
|
460
|
+
assert.deepEqual(await readdir(fixture.root), [fixture.sourceFile]);
|
|
461
|
+
assert.equal(await readFile(join(fixture.root, fixture.sourceFile), "utf8"), "terraform {}\n");
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
for (const location of ["original", "scratch"]) {
|
|
466
|
+
test(`native terraform validateSource preserves accepted lockfiles in ${location}`, async (context) => {
|
|
467
|
+
for (const mutation of ["none", "init", "fmt", "validate"]) {
|
|
468
|
+
await context.test(mutation, async (child) => {
|
|
469
|
+
const fixture = await nativePolicyFixture(child, "terraform");
|
|
470
|
+
const lockContent = "accepted native lock";
|
|
471
|
+
await writeFile(join(fixture.root, ".terraform.lock.hcl"), lockContent);
|
|
472
|
+
const files = [
|
|
473
|
+
{ path: ".terraform.lock.hcl", content: lockContent },
|
|
474
|
+
{ path: fixture.sourceFile, content: await readFile(join(fixture.root, fixture.sourceFile), "utf8") },
|
|
475
|
+
].sort((left, right) => left.path.localeCompare(right.path));
|
|
476
|
+
const input = {
|
|
477
|
+
projectId: "project",
|
|
478
|
+
runId: "run",
|
|
479
|
+
sourceHash: hashes.iac,
|
|
480
|
+
generatedSource: { rootPath: fixture.root, treeHash: sha256(files) },
|
|
481
|
+
policyHash: hashes.policy,
|
|
482
|
+
inputHash: hashes.input,
|
|
483
|
+
};
|
|
484
|
+
fixture.source.duringCommand = async (process) => {
|
|
485
|
+
const root = location === "original" ? fixture.root : process.cwd;
|
|
486
|
+
assert.equal(await readFile(join(process.cwd, ".terraform.lock.hcl"), "utf8"), lockContent);
|
|
487
|
+
if (process.args[0] === mutation)
|
|
488
|
+
await writeFile(join(root, ".terraform.lock.hcl"), "changed lock");
|
|
489
|
+
};
|
|
490
|
+
const provider = fixture.makeProvider();
|
|
491
|
+
if (mutation === "none") {
|
|
492
|
+
const receipt = await provider.validateSource(input);
|
|
493
|
+
assert.deepEqual(await provider.validateSource(input), receipt);
|
|
494
|
+
}
|
|
495
|
+
else {
|
|
496
|
+
await assert.rejects(provider.validateSource(input), { code: "PREVIEW_HASH_MISMATCH" });
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
test("installed Bicep validates nested formatting and lint without changing accepted source", async (context) => {
|
|
503
|
+
const runner = new ProcessRunner();
|
|
504
|
+
try {
|
|
505
|
+
await runner.run({ executable: "bicep", args: ["--version"], timeoutMs: 60_000, maxOutputBytes: 4096 });
|
|
506
|
+
}
|
|
507
|
+
catch (error) {
|
|
508
|
+
if (error.code === "PROCESS_SPAWN_ERROR") {
|
|
509
|
+
context.skip("Bicep is not installed");
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
throw error;
|
|
513
|
+
}
|
|
514
|
+
for (const scenario of [
|
|
515
|
+
"pass",
|
|
516
|
+
"format-drift",
|
|
517
|
+
"lint-error",
|
|
518
|
+
"policy-pass",
|
|
519
|
+
"policy-mismatch",
|
|
520
|
+
"module-pass",
|
|
521
|
+
"module-mismatch",
|
|
522
|
+
"diagnostics-pass",
|
|
523
|
+
]) {
|
|
524
|
+
await context.test(scenario, async (child) => {
|
|
525
|
+
const root = await mkdtemp(join(tmpdir(), "apex-real-bicep-validation-"));
|
|
526
|
+
child.after(() => rm(root, { recursive: true, force: true }));
|
|
527
|
+
const checkModule = scenario.startsWith("module-");
|
|
528
|
+
const checkDiagnostics = scenario === "diagnostics-pass";
|
|
529
|
+
const checkPolicy = scenario.startsWith("policy-") || checkModule || checkDiagnostics;
|
|
530
|
+
const workspaceResourceId = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-fixture/providers/Microsoft.OperationalInsights/workspaces/log-fixture";
|
|
531
|
+
const files = [
|
|
532
|
+
{
|
|
533
|
+
path: "main.bicep",
|
|
534
|
+
content: checkPolicy
|
|
535
|
+
? "resource storage 'Microsoft.Storage/storageAccounts@2023-05-01' = {\n name: 'apexpolicytest'\n location: 'swedencentral'\n kind: 'StorageV2'\n sku: {\n name: 'Standard_LRS'\n }\n properties: {\n supportsHttpsTrafficOnly: true\n minimumTlsVersion: 'TLS1_2'\n allowBlobPublicAccess: false\n allowSharedKeyAccess: false\n }\n}\n"
|
|
536
|
+
: "output result string = 'ok'\n",
|
|
537
|
+
},
|
|
538
|
+
{
|
|
539
|
+
path: "modules/nested.bicep",
|
|
540
|
+
content: scenario === "format-drift"
|
|
541
|
+
? "output result string='ok'\n"
|
|
542
|
+
: scenario === "lint-error"
|
|
543
|
+
? "param unused string = 'value'\n"
|
|
544
|
+
: "output result string = 'nested'\n",
|
|
545
|
+
},
|
|
546
|
+
{
|
|
547
|
+
path: "bicepconfig.json",
|
|
548
|
+
content: JSON.stringify({
|
|
549
|
+
experimentalFeaturesEnabled: { symbolicNameCodegen: true },
|
|
550
|
+
analyzers: { core: { rules: { "no-unused-params": { level: "error" } } } },
|
|
551
|
+
}),
|
|
552
|
+
},
|
|
553
|
+
].sort((left, right) => left.path.localeCompare(right.path));
|
|
554
|
+
if (checkModule) {
|
|
555
|
+
const main = files.find(({ path }) => path === "main.bicep");
|
|
556
|
+
files.find(({ path }) => path === "modules/nested.bicep").content = main.content;
|
|
557
|
+
main.content = "module storageModule 'modules/nested.bicep' = {\n name: 'storage-module'\n}\n";
|
|
558
|
+
}
|
|
559
|
+
if (checkDiagnostics) {
|
|
560
|
+
const main = files.find(({ path }) => path === "main.bicep");
|
|
561
|
+
for (const service of ["blobServices", "fileServices", "queueServices", "tableServices"]) {
|
|
562
|
+
main.content += `\nresource ${service} 'Microsoft.Storage/storageAccounts/${service}@2023-05-01' = {\n parent: storage\n name: 'default'\n}\n\nresource ${service}Diagnostic 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = {\n scope: ${service}\n name: 'logs'\n properties: {\n workspaceId: '${workspaceResourceId}'\n logs: [\n {\n categoryGroup: 'allLogs'\n enabled: true\n }\n ]\n metrics: [\n {\n category: 'Transaction'\n enabled: true\n }\n ]\n }\n}\n`;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
await mkdir(join(root, "modules"));
|
|
566
|
+
for (const file of files)
|
|
567
|
+
await writeFile(join(root, file.path), file.content);
|
|
568
|
+
const calls = [];
|
|
569
|
+
const provider = new NativeBicepProvider({
|
|
570
|
+
runner: {
|
|
571
|
+
run: async (command) => {
|
|
572
|
+
calls.push(command);
|
|
573
|
+
return runner.run(command);
|
|
574
|
+
},
|
|
575
|
+
},
|
|
576
|
+
currentAuthority: async () => authority,
|
|
577
|
+
target: {
|
|
578
|
+
cwd: root,
|
|
579
|
+
templateFile: "main.bicep",
|
|
580
|
+
resourceGroup: "rg",
|
|
581
|
+
deploymentName: "validation",
|
|
582
|
+
stackName: "workload",
|
|
583
|
+
denySettingsMode: "denyDelete",
|
|
584
|
+
},
|
|
585
|
+
});
|
|
586
|
+
const policyValidation = checkPolicy
|
|
587
|
+
? {
|
|
588
|
+
policyMap: {
|
|
589
|
+
schemaVersion: "1.0.0",
|
|
590
|
+
projectId: "project",
|
|
591
|
+
runId: "run",
|
|
592
|
+
governanceHash: hashes.policy,
|
|
593
|
+
mappings: ["deny", "modify", "deployIfNotExists"].map((effect) => ({
|
|
594
|
+
policyAssignmentId: "/assignments/baseline",
|
|
595
|
+
effect,
|
|
596
|
+
logicalResourceId: "storage",
|
|
597
|
+
propertyPath: "properties.supportsHttpsTrafficOnly",
|
|
598
|
+
expectedValue: !scenario.endsWith("mismatch"),
|
|
599
|
+
disposition: "planned",
|
|
600
|
+
})),
|
|
601
|
+
},
|
|
602
|
+
logicalResourceManifest: { storage: { codeSymbol: checkModule ? "storageModule::storage" : "storage" } },
|
|
603
|
+
}
|
|
604
|
+
: undefined;
|
|
605
|
+
const input = {
|
|
606
|
+
projectId: "project",
|
|
607
|
+
runId: "run",
|
|
608
|
+
sourceHash: hashes.iac,
|
|
609
|
+
policyHash: policyValidation === undefined ? hashes.policy : sha256(policyValidation.policyMap),
|
|
610
|
+
inputHash: hashes.input,
|
|
611
|
+
generatedSource: { rootPath: root, treeHash: sha256(files) },
|
|
612
|
+
policyValidation,
|
|
613
|
+
...(checkDiagnostics
|
|
614
|
+
? { storageDiagnosticsTargets: { storage: { binding: { codeSymbol: "storage" }, workspaceResourceId } } }
|
|
615
|
+
: {}),
|
|
616
|
+
...(policyValidation === undefined
|
|
617
|
+
? {}
|
|
618
|
+
: {
|
|
619
|
+
storageSecurityBindings: policyValidation.logicalResourceManifest,
|
|
620
|
+
resourceParityManifest: {
|
|
621
|
+
schemaVersion: "1.0.0",
|
|
622
|
+
projectId: "project",
|
|
623
|
+
runId: "run",
|
|
624
|
+
track: "bicep",
|
|
625
|
+
resources: [
|
|
626
|
+
{
|
|
627
|
+
logicalId: "storage",
|
|
628
|
+
type: "Microsoft.Storage/storageAccounts",
|
|
629
|
+
implementationAddress: "native:Microsoft.Storage/storageAccounts@2023-05-01",
|
|
630
|
+
executionAddress: checkModule ? "storageModule::storage" : "storage",
|
|
631
|
+
implementationKind: "resource",
|
|
632
|
+
ownership: "managed",
|
|
633
|
+
dependsOn: [],
|
|
634
|
+
generatedDependencies: [],
|
|
635
|
+
sourcePath: "main.bicep",
|
|
636
|
+
},
|
|
637
|
+
],
|
|
638
|
+
},
|
|
639
|
+
}),
|
|
640
|
+
};
|
|
641
|
+
if (scenario === "pass" || scenario.endsWith("-pass")) {
|
|
642
|
+
const receipt = await provider.validateSource(input);
|
|
643
|
+
if (checkPolicy) {
|
|
644
|
+
assert.equal(receipt.policyValidation?.outcome, "pass");
|
|
645
|
+
assert.equal(receipt.resourceParity?.outcome, checkModule ? "unsupported" : checkDiagnostics ? "fail" : "pass");
|
|
646
|
+
if (checkDiagnostics) {
|
|
647
|
+
assert.equal(receipt.storageDiagnostics?.storage?.outcome, "pass");
|
|
648
|
+
assert.equal(receipt.storageDiagnostics?.storage?.fullBaselineEvaluated, false);
|
|
649
|
+
assert.equal(receipt.storageDiagnostics?.storage?.inputHash, receipt.policyValidation?.inputHash);
|
|
650
|
+
}
|
|
651
|
+
assert.equal(receipt.resourceParity?.manifestHash, calculatePolicyValidationDigest(input.resourceParityManifest));
|
|
652
|
+
assert.equal(receipt.resourceParity?.inputHash, receipt.policyValidation?.inputHash);
|
|
653
|
+
assert.equal(receipt.policyValidation?.results.length, 3);
|
|
654
|
+
assert.equal(receipt.storageSecurity?.storage?.outcome, "pass");
|
|
655
|
+
assert.equal(receipt.storageSecurity?.storage?.fullBaselineEvaluated, false);
|
|
656
|
+
assert.equal(receipt.storageSecurity?.storage?.sourceHash, input.sourceHash);
|
|
657
|
+
assert.equal(receipt.storageSecurity?.storage?.bindingHash, sha256(input.storageSecurityBindings.storage));
|
|
658
|
+
assert.equal(hasValidNativeValidationReceipt(receipt, {
|
|
659
|
+
...input,
|
|
660
|
+
track: "bicep",
|
|
661
|
+
treeHash: input.generatedSource.treeHash,
|
|
662
|
+
}), true);
|
|
663
|
+
for (const field of [
|
|
664
|
+
"sourceHash",
|
|
665
|
+
"inputHash",
|
|
666
|
+
"fullBaselineEvaluated",
|
|
667
|
+
"outcome",
|
|
668
|
+
"observedValueDigest",
|
|
669
|
+
]) {
|
|
670
|
+
const altered = structuredClone(receipt);
|
|
671
|
+
const diagnostic = altered.storageSecurity.storage;
|
|
672
|
+
if (field === "sourceHash")
|
|
673
|
+
diagnostic.sourceHash = "f".repeat(64);
|
|
674
|
+
else if (field === "inputHash")
|
|
675
|
+
diagnostic.inputHash = "f".repeat(64);
|
|
676
|
+
else if (field === "fullBaselineEvaluated")
|
|
677
|
+
Object.assign(diagnostic, { fullBaselineEvaluated: true });
|
|
678
|
+
else if (field === "outcome")
|
|
679
|
+
diagnostic.outcome = "fail";
|
|
680
|
+
else
|
|
681
|
+
diagnostic.results[0].observedValueDigest = "f".repeat(64);
|
|
682
|
+
const { receiptHash, ...body } = altered;
|
|
683
|
+
assert.ok(receiptHash);
|
|
684
|
+
altered.receiptHash = calculateNativeValidationReceiptHash(body);
|
|
685
|
+
assert.equal(hasValidNativeValidationReceipt(altered, {
|
|
686
|
+
...input,
|
|
687
|
+
track: "bicep",
|
|
688
|
+
treeHash: input.generatedSource.treeHash,
|
|
689
|
+
}), false);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
assert.deepEqual(receipt.commands.map(({ validatorId }) => validatorId), ["bicep:format", "bicep:build", "bicep:lint"]);
|
|
693
|
+
}
|
|
694
|
+
else {
|
|
695
|
+
await assert.rejects(provider.validateSource(input), {
|
|
696
|
+
code: scenario === "format-drift" ? "PREVIEW_HASH_MISMATCH" : "NATIVE_VALIDATION_FAILED",
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
assert.equal(calls.length, scenario === "format-drift" ? 1 : 3);
|
|
700
|
+
for (const file of files)
|
|
701
|
+
assert.equal(await readFile(join(root, file.path), "utf8"), file.content);
|
|
702
|
+
await assert.rejects(stat(calls[0].cwd), { code: "ENOENT" });
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
});
|
|
706
|
+
test("native parity snapshots the accepted binding before compiler commands", async (context) => {
|
|
707
|
+
const fixture = await nativePolicyFixture(context, "bicep");
|
|
708
|
+
const implementation = "native:Microsoft.Storage/storageAccounts@2023-05-01";
|
|
709
|
+
const binding = {
|
|
710
|
+
schemaVersion: "1.0.0",
|
|
711
|
+
projectId: "project",
|
|
712
|
+
runId: "run",
|
|
713
|
+
track: "bicep",
|
|
714
|
+
intentHash: hashes.input,
|
|
715
|
+
resourceBindings: { storage: { implementation, version: "2023-05-01", parameters: { name: "approved" } } },
|
|
716
|
+
};
|
|
717
|
+
const manifest = {
|
|
718
|
+
schemaVersion: "1.0.0",
|
|
719
|
+
projectId: "project",
|
|
720
|
+
runId: "run",
|
|
721
|
+
track: "bicep",
|
|
722
|
+
resources: [
|
|
723
|
+
{
|
|
724
|
+
logicalId: "storage",
|
|
725
|
+
type: "Microsoft.Storage/storageAccounts",
|
|
726
|
+
implementationAddress: implementation,
|
|
727
|
+
executionAddress: "storage",
|
|
728
|
+
implementationKind: "resource",
|
|
729
|
+
ownership: "managed",
|
|
730
|
+
dependsOn: [],
|
|
731
|
+
generatedDependencies: [],
|
|
732
|
+
sourcePath: "main.bicep",
|
|
733
|
+
},
|
|
734
|
+
],
|
|
735
|
+
};
|
|
736
|
+
const expected = calculatePolicyValidationDigest(binding);
|
|
737
|
+
fixture.source.duringCommand = async () => {
|
|
738
|
+
binding.resourceBindings.storage.parameters.name = "changed-after-await";
|
|
739
|
+
};
|
|
740
|
+
const receipt = await fixture.makeProvider().validateSource({
|
|
741
|
+
projectId: "project",
|
|
742
|
+
runId: "run",
|
|
743
|
+
sourceHash: hashes.iac,
|
|
744
|
+
generatedSource: fixture.generatedSource,
|
|
745
|
+
policyHash: hashes.policy,
|
|
746
|
+
inputHash: hashes.input,
|
|
747
|
+
resourceParityManifest: manifest,
|
|
748
|
+
resourceParityBinding: binding,
|
|
749
|
+
});
|
|
750
|
+
assert.equal(receipt.resourceParity?.bindingHash, expected);
|
|
751
|
+
assert.notEqual(receipt.resourceParity?.bindingHash, calculatePolicyValidationDigest(binding));
|
|
752
|
+
assert.equal(fixture.runner.requests.length, 3);
|
|
753
|
+
await assert.rejects(fixture.makeProvider().validateSource({
|
|
754
|
+
projectId: "project",
|
|
755
|
+
runId: "run",
|
|
756
|
+
sourceHash: hashes.iac,
|
|
757
|
+
generatedSource: fixture.generatedSource,
|
|
758
|
+
policyHash: hashes.policy,
|
|
759
|
+
inputHash: hashes.input,
|
|
760
|
+
resourceParityManifest: manifest,
|
|
761
|
+
resourceParityBinding: { ...binding, intentHash: "f".repeat(64) },
|
|
762
|
+
}), /inputs are invalid/);
|
|
763
|
+
assert.equal(fixture.runner.requests.length, 3);
|
|
764
|
+
});
|
|
765
|
+
test("native bicep validateSource requires the accepted main.bicep target", async (context) => {
|
|
766
|
+
const fixture = await nativePolicyFixture(context, "bicep");
|
|
767
|
+
await assert.rejects(fixture.makeProvider({ templateFile: "other.bicep" }).validateSource({
|
|
768
|
+
projectId: "project",
|
|
769
|
+
runId: "run",
|
|
770
|
+
sourceHash: hashes.iac,
|
|
771
|
+
generatedSource: fixture.generatedSource,
|
|
772
|
+
policyHash: hashes.policy,
|
|
773
|
+
inputHash: hashes.input,
|
|
774
|
+
}), { code: "PREVIEW_HASH_MISMATCH" });
|
|
775
|
+
assert.equal(fixture.runner.requests.length, 0);
|
|
776
|
+
});
|
|
777
|
+
test("native bicep validateSource evaluates mapped properties from its build output", async (context) => {
|
|
778
|
+
for (const scenario of ["pass", "mismatch", "expression", "invalid-output", "missing-binding"]) {
|
|
779
|
+
await context.test(scenario, async (context) => {
|
|
780
|
+
const fixture = await nativePolicyFixture(context, "bicep");
|
|
781
|
+
const policyValidation = structuredClone(fixture.policyRequest.policyValidation);
|
|
782
|
+
if (scenario === "mismatch")
|
|
783
|
+
fixture.source.value = "different";
|
|
784
|
+
if (scenario === "expression")
|
|
785
|
+
fixture.source.value = "[parameters('security')]";
|
|
786
|
+
if (scenario === "invalid-output")
|
|
787
|
+
fixture.source.invalid = true;
|
|
788
|
+
if (scenario === "missing-binding")
|
|
789
|
+
delete policyValidation.logicalResourceManifest.storage;
|
|
790
|
+
const input = {
|
|
791
|
+
projectId: "project",
|
|
792
|
+
runId: "run",
|
|
793
|
+
sourceHash: hashes.iac,
|
|
794
|
+
generatedSource: fixture.generatedSource,
|
|
795
|
+
policyHash: sha256(policyValidation.policyMap),
|
|
796
|
+
inputHash: hashes.input,
|
|
797
|
+
policyValidation,
|
|
798
|
+
};
|
|
799
|
+
const provider = fixture.makeProvider();
|
|
800
|
+
if (scenario === "pass") {
|
|
801
|
+
const receipt = await provider.validateSource(input);
|
|
802
|
+
const policy = Reflect.get(receipt, "policyValidation");
|
|
803
|
+
assert.ok(policy, "Native Bicep validation must return executed policy evidence");
|
|
804
|
+
assert.equal(policy.outcome, "pass");
|
|
805
|
+
assert.equal(policy.results.length, 3);
|
|
806
|
+
assert.equal(policy.sourceHash, input.sourceHash);
|
|
807
|
+
assert.equal(policy.policyMapHash, input.policyHash);
|
|
808
|
+
assert.equal(policy.inputHash, createHash("sha256").update(fixture.json()).digest("hex"));
|
|
809
|
+
assert.equal(hasValidNativeValidationReceipt(receipt, {
|
|
810
|
+
...input,
|
|
811
|
+
track: "bicep",
|
|
812
|
+
treeHash: input.generatedSource.treeHash,
|
|
813
|
+
}), true);
|
|
814
|
+
assert.equal(JSON.stringify(receipt).includes(fixture.source.value), false);
|
|
815
|
+
for (const key of [
|
|
816
|
+
"projectId",
|
|
817
|
+
"runId",
|
|
818
|
+
"track",
|
|
819
|
+
"sourceHash",
|
|
820
|
+
"policyMapHash",
|
|
821
|
+
"outcome",
|
|
822
|
+
"receiptHash",
|
|
823
|
+
]) {
|
|
824
|
+
const changed = structuredClone(receipt);
|
|
825
|
+
const nested = Reflect.get(changed, "policyValidation");
|
|
826
|
+
Reflect.set(nested, key, key.endsWith("Hash")
|
|
827
|
+
? "f".repeat(64)
|
|
828
|
+
: key === "track"
|
|
829
|
+
? "terraform"
|
|
830
|
+
: key === "outcome"
|
|
831
|
+
? "unsupported"
|
|
832
|
+
: "other");
|
|
833
|
+
if (key !== "receiptHash") {
|
|
834
|
+
const { receiptHash: oldPolicyHash, ...policyBody } = nested;
|
|
835
|
+
assert.ok(oldPolicyHash);
|
|
836
|
+
nested.receiptHash = calculatePolicyValidationHash(policyBody);
|
|
837
|
+
}
|
|
838
|
+
const { receiptHash: oldNativeHash, ...nativeBody } = changed;
|
|
839
|
+
assert.ok(oldNativeHash);
|
|
840
|
+
changed.receiptHash = calculateNativeValidationReceiptHash(nativeBody);
|
|
841
|
+
assert.equal(hasValidNativeValidationReceipt(changed, {
|
|
842
|
+
...input,
|
|
843
|
+
track: "bicep",
|
|
844
|
+
treeHash: input.generatedSource.treeHash,
|
|
845
|
+
}), false, key);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
else {
|
|
849
|
+
await assert.rejects(provider.validateSource(input), { code: "NATIVE_VALIDATION_FAILED" });
|
|
850
|
+
}
|
|
851
|
+
assert.equal(fixture.runner.requests.filter(({ args }) => args[0] === "build").length, 1);
|
|
852
|
+
assert.ok(fixture.runner.requests.every(({ executable }) => executable === "bicep"));
|
|
853
|
+
await assert.rejects(stat(fixture.runner.requests[0].cwd), { code: "ENOENT" });
|
|
854
|
+
assert.equal(fixture.executed(), false);
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
});
|
|
858
|
+
test("native Bicep policy inputs are bound before commands and snapshotted during execution", async (context) => {
|
|
859
|
+
const fixture = await nativePolicyFixture(context, "bicep");
|
|
860
|
+
const policyValidation = structuredClone(fixture.policyRequest.policyValidation);
|
|
861
|
+
const input = {
|
|
862
|
+
projectId: "project",
|
|
863
|
+
runId: "run",
|
|
864
|
+
sourceHash: hashes.iac,
|
|
865
|
+
generatedSource: fixture.generatedSource,
|
|
866
|
+
policyHash: sha256(policyValidation.policyMap),
|
|
867
|
+
inputHash: hashes.input,
|
|
868
|
+
policyValidation,
|
|
869
|
+
};
|
|
870
|
+
const provider = fixture.makeProvider();
|
|
871
|
+
for (const invalid of [
|
|
872
|
+
{ ...input, policyHash: hashes.policy },
|
|
873
|
+
{ ...input, runId: "other" },
|
|
874
|
+
]) {
|
|
875
|
+
await assert.rejects(provider.validateSource(invalid), { code: "NATIVE_VALIDATION_INPUT_INVALID" });
|
|
876
|
+
assert.equal(fixture.runner.requests.length, 0);
|
|
877
|
+
}
|
|
878
|
+
fixture.source.duringCommand = async () => {
|
|
879
|
+
policyValidation.policyMap.mappings[0].expectedValue = "mutated-after-validation-started";
|
|
880
|
+
};
|
|
881
|
+
const receipt = await provider.validateSource(input);
|
|
882
|
+
assert.equal(receipt.policyValidation?.outcome, "pass");
|
|
883
|
+
assert.equal(receipt.policyValidation?.policyMapContentHash, input.policyHash);
|
|
884
|
+
});
|
|
885
|
+
test("native command-only validation does not claim unexecuted policy checks", async (context) => {
|
|
886
|
+
for (const track of ["bicep", "terraform"]) {
|
|
887
|
+
const fixture = await nativePolicyFixture(context, track);
|
|
888
|
+
const policyValidation = structuredClone(fixture.policyRequest.policyValidation);
|
|
889
|
+
if (track === "bicep")
|
|
890
|
+
policyValidation.policyMap.mappings = [];
|
|
891
|
+
const receipt = await fixture.makeProvider().validateSource({
|
|
892
|
+
projectId: "project",
|
|
893
|
+
runId: "run",
|
|
894
|
+
sourceHash: hashes.iac,
|
|
895
|
+
generatedSource: fixture.generatedSource,
|
|
896
|
+
policyHash: sha256(policyValidation.policyMap),
|
|
897
|
+
inputHash: hashes.input,
|
|
898
|
+
policyValidation,
|
|
899
|
+
});
|
|
900
|
+
assert.equal(receipt.policyValidation, undefined);
|
|
901
|
+
assert.deepEqual(fixture.runner.requests.map(({ args }) => args[0]), NATIVE_VALIDATION_COMMANDS[track].map(({ args }) => args[0]));
|
|
902
|
+
}
|
|
903
|
+
});
|
|
904
|
+
for (const track of ["bicep", "terraform"]) {
|
|
905
|
+
test(`native ${track} validateSource returns a source-bound receipt`, async (context) => {
|
|
906
|
+
const fixture = await nativePolicyFixture(context, track);
|
|
907
|
+
const nestedPath = `modules/nested/${fixture.sourceFile}`;
|
|
908
|
+
const nestedContent = "\uFEFFnested source\r\n";
|
|
909
|
+
await mkdir(join(fixture.root, "modules", "nested"), { recursive: true });
|
|
910
|
+
await writeFile(join(fixture.root, nestedPath), nestedContent);
|
|
911
|
+
fixture.generatedSource.treeHash = sha256([
|
|
912
|
+
{ path: fixture.sourceFile, content: await readFile(join(fixture.root, fixture.sourceFile), "utf8") },
|
|
913
|
+
{ path: nestedPath, content: nestedContent },
|
|
914
|
+
].sort((left, right) => left.path.localeCompare(right.path)));
|
|
915
|
+
fixture.source.duringCommand = async (process) => {
|
|
916
|
+
assert.notEqual(process.cwd, fixture.root);
|
|
917
|
+
assert.equal(await readFile(join(process.cwd, nestedPath), "utf8"), nestedContent);
|
|
918
|
+
assert.deepEqual((await readdir(process.cwd)).sort(), [fixture.sourceFile, "modules"].sort());
|
|
919
|
+
await assertPosixMode(process.cwd, 0o700);
|
|
920
|
+
assert.equal((await stat(join(process.cwd, nestedPath))).nlink, 1);
|
|
921
|
+
};
|
|
922
|
+
const provider = fixture.makeProvider();
|
|
923
|
+
const input = {
|
|
924
|
+
projectId: "project",
|
|
925
|
+
runId: "run",
|
|
926
|
+
sourceHash: hashes.iac,
|
|
927
|
+
generatedSource: fixture.generatedSource,
|
|
928
|
+
policyHash: hashes.policy,
|
|
929
|
+
inputHash: hashes.input,
|
|
930
|
+
};
|
|
931
|
+
const receipt = await provider.validateSource(input);
|
|
932
|
+
assert.equal(Value.Check(NativeValidationReceiptV1Schema, receipt), true);
|
|
933
|
+
assert.equal(hasValidNativeValidationReceipt(receipt, { ...input, track, treeHash: input.generatedSource.treeHash }), true);
|
|
934
|
+
const { receiptHash, ...body } = receipt;
|
|
935
|
+
assert.equal(receiptHash, calculateNativeValidationReceiptHash(body));
|
|
936
|
+
assert.equal(receipt.outcome, "pass");
|
|
937
|
+
const commands = NATIVE_VALIDATION_COMMANDS[track];
|
|
938
|
+
assert.deepEqual(commands, track === "bicep"
|
|
939
|
+
? [
|
|
940
|
+
{ validatorId: "bicep:format", executable: "bicep", args: ["format", "--pattern", "**/*.bicep"] },
|
|
941
|
+
{ validatorId: "bicep:build", executable: "bicep", args: ["build", "main.bicep", "--stdout"] },
|
|
942
|
+
{
|
|
943
|
+
validatorId: "bicep:lint",
|
|
944
|
+
executable: "bicep",
|
|
945
|
+
args: ["lint", "--pattern", "**/*.bicep", "--no-restore"],
|
|
946
|
+
},
|
|
947
|
+
]
|
|
948
|
+
: [
|
|
949
|
+
{
|
|
950
|
+
validatorId: "terraform:init-backend-false",
|
|
951
|
+
executable: "terraform",
|
|
952
|
+
args: ["init", "-backend=false", "-input=false"],
|
|
953
|
+
},
|
|
954
|
+
{ validatorId: "terraform:format", executable: "terraform", args: ["fmt", "-check"] },
|
|
955
|
+
{ validatorId: "terraform:validate", executable: "terraform", args: ["validate"] },
|
|
956
|
+
]);
|
|
957
|
+
assert.deepEqual(receipt.commands.map(({ validatorId, commandHash }) => ({ validatorId, commandHash })), commands.map((command) => ({
|
|
958
|
+
validatorId: command.validatorId,
|
|
959
|
+
commandHash: calculateNativeValidationCommandHash(command),
|
|
960
|
+
})));
|
|
961
|
+
assert.deepEqual(fixture.runner.requests.map(({ executable, args, cwd }) => ({ executable, args, cwd })), commands.map(({ executable, args }) => ({ executable, args: [...args], cwd: fixture.runner.requests[0].cwd })));
|
|
962
|
+
const scratchRoot = fixture.runner.requests[0].cwd;
|
|
963
|
+
assert.notEqual(scratchRoot, fixture.root);
|
|
964
|
+
await assert.rejects(stat(scratchRoot), { code: "ENOENT" });
|
|
965
|
+
assert.equal(JSON.stringify(receipt).includes(scratchRoot), false);
|
|
966
|
+
assert.equal(JSON.stringify(receipt).includes(fixture.root), false);
|
|
967
|
+
assert.equal(JSON.stringify(receipt).includes(fixture.source.value), false);
|
|
968
|
+
assert.equal(JSON.stringify(receipt).includes("stdout"), false);
|
|
969
|
+
assert.equal(JSON.stringify(receipt).includes("stderr"), false);
|
|
970
|
+
assert.deepEqual(await provider.validateSource(input), receipt);
|
|
971
|
+
});
|
|
972
|
+
test(`native ${track} records empty policy applicability without planning or claiming property evaluation`, async (context) => {
|
|
973
|
+
const fixture = await nativePolicyFixture(context, track);
|
|
974
|
+
const policyMap = {
|
|
975
|
+
schemaVersion: "1.0.0",
|
|
976
|
+
projectId: "project",
|
|
977
|
+
runId: "run",
|
|
978
|
+
governanceHash: hashes.policy,
|
|
979
|
+
mappings: [],
|
|
980
|
+
};
|
|
981
|
+
const input = {
|
|
982
|
+
projectId: "project",
|
|
983
|
+
runId: "run",
|
|
984
|
+
sourceHash: hashes.iac,
|
|
985
|
+
generatedSource: fixture.generatedSource,
|
|
986
|
+
policyHash: calculatePolicyValidationDigest(policyMap),
|
|
987
|
+
inputHash: hashes.input,
|
|
988
|
+
policyValidation: { policyMap, logicalResourceManifest: {} },
|
|
989
|
+
};
|
|
990
|
+
const receipt = await fixture.makeProvider().validateSource(input);
|
|
991
|
+
assert.deepEqual(receipt.policyApplicability, {
|
|
992
|
+
status: "no-actionable-mappings",
|
|
993
|
+
policyMapContentHash: input.policyHash,
|
|
994
|
+
});
|
|
995
|
+
assert.equal(receipt.policyValidation, undefined);
|
|
996
|
+
assert.equal(hasValidNativeValidationReceipt(receipt, { ...input, track, treeHash: input.generatedSource.treeHash }), true);
|
|
997
|
+
assert.deepEqual(fixture.runner.requests.map(({ executable, args }) => ({ executable, args })), NATIVE_VALIDATION_COMMANDS[track].map(({ executable, args }) => ({ executable, args: [...args] })));
|
|
998
|
+
await assert.rejects(fixture.makeProvider().validateSource({ ...input, policyHash: "f".repeat(64) }), /inputs are invalid/);
|
|
999
|
+
const malformed = { ...policyMap, mappings: "" };
|
|
1000
|
+
const before = fixture.runner.requests.length;
|
|
1001
|
+
await assert.rejects(fixture.makeProvider().validateSource({
|
|
1002
|
+
...input,
|
|
1003
|
+
policyHash: calculatePolicyValidationDigest(malformed),
|
|
1004
|
+
policyValidation: { policyMap: malformed, logicalResourceManifest: {} },
|
|
1005
|
+
}), /inputs are invalid/);
|
|
1006
|
+
assert.equal(fixture.runner.requests.length, before);
|
|
1007
|
+
});
|
|
1008
|
+
test(`native ${track} validateSource rejects missing, stale, or misbound sources before commands`, async (context) => {
|
|
1009
|
+
for (const failure of [
|
|
1010
|
+
"missing",
|
|
1011
|
+
"missing-file",
|
|
1012
|
+
"stale",
|
|
1013
|
+
"cwd",
|
|
1014
|
+
"relative-root",
|
|
1015
|
+
"symlink",
|
|
1016
|
+
"hardlink",
|
|
1017
|
+
"hidden",
|
|
1018
|
+
"bad-hash",
|
|
1019
|
+
]) {
|
|
1020
|
+
await context.test(failure, async (child) => {
|
|
1021
|
+
const fixture = await nativePolicyFixture(child, track);
|
|
1022
|
+
const input = {
|
|
1023
|
+
projectId: "project",
|
|
1024
|
+
runId: "run",
|
|
1025
|
+
sourceHash: hashes.iac,
|
|
1026
|
+
generatedSource: fixture.generatedSource,
|
|
1027
|
+
policyHash: hashes.policy,
|
|
1028
|
+
inputHash: hashes.input,
|
|
1029
|
+
};
|
|
1030
|
+
if (failure === "missing-file")
|
|
1031
|
+
await rm(join(fixture.root, fixture.sourceFile));
|
|
1032
|
+
if (failure === "stale")
|
|
1033
|
+
await writeFile(join(fixture.root, fixture.sourceFile), "private-source-changed");
|
|
1034
|
+
if (failure === "hidden")
|
|
1035
|
+
await writeFile(join(fixture.root, ".unexpected"), "private-hidden-content");
|
|
1036
|
+
if (failure === "symlink")
|
|
1037
|
+
await symlink(join(fixture.root, fixture.sourceFile), join(fixture.root, "alias"));
|
|
1038
|
+
if (failure === "hardlink") {
|
|
1039
|
+
const other = await nativePolicyFixture(child, track);
|
|
1040
|
+
await rm(join(fixture.root, fixture.sourceFile));
|
|
1041
|
+
await link(join(other.root, other.sourceFile), join(fixture.root, fixture.sourceFile));
|
|
1042
|
+
}
|
|
1043
|
+
const malformed = failure === "missing"
|
|
1044
|
+
? { ...input, generatedSource: undefined }
|
|
1045
|
+
: failure === "relative-root"
|
|
1046
|
+
? { ...input, generatedSource: { ...input.generatedSource, rootPath: "." } }
|
|
1047
|
+
: failure === "bad-hash"
|
|
1048
|
+
? { ...input, inputHash: "private-invalid-hash" }
|
|
1049
|
+
: input;
|
|
1050
|
+
await assert.rejects(fixture
|
|
1051
|
+
.makeProvider(failure === "cwd" ? { cwd: tmpdir() } : {})
|
|
1052
|
+
.validateSource(malformed), (error) => {
|
|
1053
|
+
assert.ok(error instanceof IacProviderError);
|
|
1054
|
+
assert.equal(error.code, failure === "bad-hash" ? "NATIVE_VALIDATION_INPUT_INVALID" : "PREVIEW_HASH_MISMATCH");
|
|
1055
|
+
assert.equal(JSON.stringify(error).includes("private-"), false);
|
|
1056
|
+
assert.equal(error.message.includes(fixture.root), false);
|
|
1057
|
+
return true;
|
|
1058
|
+
});
|
|
1059
|
+
assert.equal(fixture.runner.requests.length, 0);
|
|
1060
|
+
});
|
|
1061
|
+
}
|
|
1062
|
+
});
|
|
1063
|
+
test(`native ${track} validateSource rejects every unsuccessful process result without output leakage`, async (context) => {
|
|
1064
|
+
const failures = [
|
|
1065
|
+
{ exitCode: 1 },
|
|
1066
|
+
{ exitCode: null },
|
|
1067
|
+
{ signal: "SIGTERM" },
|
|
1068
|
+
{ timedOut: true },
|
|
1069
|
+
{ outputTruncated: true },
|
|
1070
|
+
"throw",
|
|
1071
|
+
];
|
|
1072
|
+
for (const failure of failures) {
|
|
1073
|
+
for (const command of NATIVE_VALIDATION_COMMANDS[track]) {
|
|
1074
|
+
await context.test(`${command.validatorId} ${JSON.stringify(failure)}`, async (child) => {
|
|
1075
|
+
const fixture = await nativePolicyFixture(child, track);
|
|
1076
|
+
const run = fixture.runner.run.bind(fixture.runner);
|
|
1077
|
+
fixture.runner.run = async (process) => {
|
|
1078
|
+
const result = await run(process);
|
|
1079
|
+
if (process.args[0] !== command.args[0])
|
|
1080
|
+
return result;
|
|
1081
|
+
if (failure === "throw")
|
|
1082
|
+
throw new Error(`private-source ${fixture.root}`);
|
|
1083
|
+
return {
|
|
1084
|
+
...result,
|
|
1085
|
+
stdout: `private-source ${fixture.root}`,
|
|
1086
|
+
stderr: "private-diagnostic-secret",
|
|
1087
|
+
...failure,
|
|
1088
|
+
};
|
|
1089
|
+
};
|
|
1090
|
+
await assert.rejects(fixture.makeProvider().validateSource({
|
|
1091
|
+
projectId: "project",
|
|
1092
|
+
runId: "run",
|
|
1093
|
+
sourceHash: hashes.iac,
|
|
1094
|
+
generatedSource: fixture.generatedSource,
|
|
1095
|
+
policyHash: hashes.policy,
|
|
1096
|
+
inputHash: hashes.input,
|
|
1097
|
+
}), (error) => {
|
|
1098
|
+
assert.ok(error instanceof IacProviderError);
|
|
1099
|
+
assert.equal(error.code, "NATIVE_VALIDATION_FAILED");
|
|
1100
|
+
assert.equal(error.message, "Native validation command failed");
|
|
1101
|
+
assert.deepEqual(Object.keys(error).sort(), ["code", "name"]);
|
|
1102
|
+
assert.equal(JSON.stringify(error).includes("private-"), false);
|
|
1103
|
+
return true;
|
|
1104
|
+
});
|
|
1105
|
+
assert.equal(fixture.runner.requests.length, NATIVE_VALIDATION_COMMANDS[track].findIndex((entry) => entry.validatorId === command.validatorId) + 1);
|
|
1106
|
+
await assert.rejects(stat(fixture.runner.requests[0].cwd), { code: "ENOENT" });
|
|
1107
|
+
});
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
});
|
|
1111
|
+
for (const location of ["original", "scratch"]) {
|
|
1112
|
+
test(`native ${track} validateSource checks ${location} source after each command`, async (context) => {
|
|
1113
|
+
for (const command of NATIVE_VALIDATION_COMMANDS[track]) {
|
|
1114
|
+
for (const mutation of ["edit", "remove", "extra", "hidden", "symlink", "hardlink"]) {
|
|
1115
|
+
await context.test(`${command.validatorId} ${mutation}`, async (child) => {
|
|
1116
|
+
const fixture = await nativePolicyFixture(child, track);
|
|
1117
|
+
fixture.source.duringCommand = async (process) => {
|
|
1118
|
+
if (process.args[0] !== command.args[0])
|
|
1119
|
+
return;
|
|
1120
|
+
const root = location === "original" ? fixture.root : process.cwd;
|
|
1121
|
+
const path = join(root, fixture.sourceFile);
|
|
1122
|
+
if (mutation === "edit")
|
|
1123
|
+
await writeFile(path, "private-source-tamper");
|
|
1124
|
+
if (mutation === "remove")
|
|
1125
|
+
await rm(path);
|
|
1126
|
+
if (mutation === "extra")
|
|
1127
|
+
await writeFile(join(root, "extra.tf"), "private-source-extra");
|
|
1128
|
+
if (mutation === "hidden")
|
|
1129
|
+
await writeFile(join(root, ".unexpected"), "private-source-hidden");
|
|
1130
|
+
if (mutation === "symlink") {
|
|
1131
|
+
await rm(path);
|
|
1132
|
+
await symlink(join(fixture.root, "missing"), path);
|
|
1133
|
+
}
|
|
1134
|
+
if (mutation === "hardlink") {
|
|
1135
|
+
const other = await nativePolicyFixture(child, track);
|
|
1136
|
+
await rm(path);
|
|
1137
|
+
await link(join(other.root, other.sourceFile), path);
|
|
1138
|
+
}
|
|
1139
|
+
};
|
|
1140
|
+
await assert.rejects(fixture.makeProvider().validateSource({
|
|
1141
|
+
projectId: "project",
|
|
1142
|
+
runId: "run",
|
|
1143
|
+
sourceHash: hashes.iac,
|
|
1144
|
+
generatedSource: fixture.generatedSource,
|
|
1145
|
+
policyHash: hashes.policy,
|
|
1146
|
+
inputHash: hashes.input,
|
|
1147
|
+
}), { code: "PREVIEW_HASH_MISMATCH" });
|
|
1148
|
+
assert.equal(fixture.runner.requests.length, NATIVE_VALIDATION_COMMANDS[track].findIndex((entry) => entry.validatorId === command.validatorId) + 1);
|
|
1149
|
+
await assert.rejects(stat(fixture.runner.requests[0].cwd), { code: "ENOENT" });
|
|
1150
|
+
});
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
});
|
|
1154
|
+
}
|
|
1155
|
+
test(`native ${track} binds generated source before commands and after evaluation`, async (context) => {
|
|
1156
|
+
for (const mutation of ["none", "before", "during", "extra", "extra-during", "removed-during"]) {
|
|
1157
|
+
await context.test(mutation, async (child) => {
|
|
1158
|
+
const fixture = await nativePolicyFixture(child, track);
|
|
1159
|
+
const { generatedSource } = fixture;
|
|
1160
|
+
if (mutation === "before")
|
|
1161
|
+
await writeFile(join(fixture.root, fixture.sourceFile), "changed");
|
|
1162
|
+
if (mutation === "during") {
|
|
1163
|
+
fixture.source.duringPreview = async () => writeFile(join(fixture.root, fixture.sourceFile), "changed");
|
|
1164
|
+
}
|
|
1165
|
+
if (mutation === "extra")
|
|
1166
|
+
await writeFile(join(fixture.root, "extra.tf"), "unbound");
|
|
1167
|
+
if (mutation === "extra-during") {
|
|
1168
|
+
fixture.source.duringPreview = async () => writeFile(join(fixture.root, "extra.tf"), "unbound");
|
|
1169
|
+
}
|
|
1170
|
+
if (mutation === "removed-during") {
|
|
1171
|
+
fixture.source.duringPreview = async () => rm(join(fixture.root, fixture.sourceFile));
|
|
1172
|
+
}
|
|
1173
|
+
const provider = fixture.makeProvider();
|
|
1174
|
+
const preview = provider.previewApply({ ...fixture.policyRequest, generatedSource });
|
|
1175
|
+
if (mutation === "none") {
|
|
1176
|
+
const result = await preview;
|
|
1177
|
+
assert.deepEqual(result.blockers, []);
|
|
1178
|
+
assert.equal(provider.policyValidation(result.previewHash).sourceHash, hashes.iac);
|
|
1179
|
+
}
|
|
1180
|
+
else {
|
|
1181
|
+
await assert.rejects(preview, (error) => error instanceof IacProviderError && error.code === "PREVIEW_HASH_MISMATCH");
|
|
1182
|
+
assert.equal(fixture.bindings.values.size, 0);
|
|
1183
|
+
assert.equal(fixture.artifacts.values.size, 0);
|
|
1184
|
+
if (mutation === "before" || mutation === "extra")
|
|
1185
|
+
assert.equal(fixture.runner.requests.length, 0);
|
|
1186
|
+
}
|
|
1187
|
+
});
|
|
1188
|
+
}
|
|
1189
|
+
});
|
|
1190
|
+
test(`native ${track} generated source rejects unrelated roots and symlinks`, async (context) => {
|
|
1191
|
+
for (const mismatch of ["cwd", "root-link", "file-link", "directory-link", "relative-root"]) {
|
|
1192
|
+
await context.test(mismatch, async (child) => {
|
|
1193
|
+
const fixture = await nativePolicyFixture(child, track);
|
|
1194
|
+
const other = await nativePolicyFixture(child, track);
|
|
1195
|
+
let rootPath = fixture.root;
|
|
1196
|
+
if (mismatch === "root-link") {
|
|
1197
|
+
rootPath = join(other.root, "alias");
|
|
1198
|
+
await symlink(fixture.root, rootPath, "dir");
|
|
1199
|
+
}
|
|
1200
|
+
if (mismatch === "file-link") {
|
|
1201
|
+
await rm(join(fixture.root, fixture.sourceFile));
|
|
1202
|
+
await symlink(join(other.root, other.sourceFile), join(fixture.root, fixture.sourceFile));
|
|
1203
|
+
}
|
|
1204
|
+
if (mismatch === "directory-link")
|
|
1205
|
+
await symlink(other.root, join(fixture.root, "modules"), "dir");
|
|
1206
|
+
if (mismatch === "relative-root")
|
|
1207
|
+
rootPath = ".";
|
|
1208
|
+
const provider = fixture.makeProvider({ cwd: mismatch === "cwd" ? other.root : rootPath });
|
|
1209
|
+
await assert.rejects(provider.previewApply({
|
|
1210
|
+
...fixture.policyRequest,
|
|
1211
|
+
generatedSource: { ...fixture.generatedSource, rootPath },
|
|
1212
|
+
}), (error) => error instanceof IacProviderError && error.code === "PREVIEW_HASH_MISMATCH");
|
|
1213
|
+
assert.equal(fixture.runner.requests.length, 0);
|
|
1214
|
+
});
|
|
1215
|
+
}
|
|
1216
|
+
});
|
|
1217
|
+
test(`native ${track} checks sorted nested sources with and without policy`, async (context) => {
|
|
1218
|
+
for (const withPolicy of [true, false]) {
|
|
1219
|
+
for (const mutate of [false, true]) {
|
|
1220
|
+
const fixture = await nativePolicyFixture(context, track);
|
|
1221
|
+
const path = `modules/${fixture.sourceFile}`;
|
|
1222
|
+
await mkdir(join(fixture.root, "modules"));
|
|
1223
|
+
const content = "nested source\n";
|
|
1224
|
+
await writeFile(join(fixture.root, path), content);
|
|
1225
|
+
const generatedSource = {
|
|
1226
|
+
rootPath: fixture.root,
|
|
1227
|
+
treeHash: sha256([
|
|
1228
|
+
{ path, content },
|
|
1229
|
+
{ path: fixture.sourceFile, content: await readFile(join(fixture.root, fixture.sourceFile), "utf8") },
|
|
1230
|
+
].sort((left, right) => left.path.localeCompare(right.path))),
|
|
1231
|
+
};
|
|
1232
|
+
if (mutate)
|
|
1233
|
+
fixture.source.duringPreview = async () => writeFile(join(fixture.root, path), "changed module");
|
|
1234
|
+
const previewRequest = withPolicy ? fixture.policyRequest : request();
|
|
1235
|
+
const provider = fixture.makeProvider();
|
|
1236
|
+
if (mutate) {
|
|
1237
|
+
await assert.rejects(provider.previewApply({ ...previewRequest, generatedSource }), (error) => error instanceof IacProviderError && error.code === "PREVIEW_HASH_MISMATCH");
|
|
1238
|
+
}
|
|
1239
|
+
else {
|
|
1240
|
+
const preview = await provider.previewApply({ ...previewRequest, generatedSource });
|
|
1241
|
+
assert.deepEqual(preview.blockers, []);
|
|
1242
|
+
assert.equal(provider.policyValidation(preview.previewHash) !== undefined, withPolicy);
|
|
1243
|
+
assert.deepEqual((await provider.previewDestroy({ ...previewRequest, generatedSource })).blockers, []);
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
});
|
|
1248
|
+
test(`native ${track} bounds generated tree bytes and rejects invalid UTF-8`, async (context) => {
|
|
1249
|
+
for (const oversized of [true, false]) {
|
|
1250
|
+
const fixture = await nativePolicyFixture(context, track);
|
|
1251
|
+
const content = oversized ? Buffer.alloc(16 * 1024 * 1024 + 1, "x") : Buffer.from([0xff]);
|
|
1252
|
+
await writeFile(join(fixture.root, fixture.sourceFile), content);
|
|
1253
|
+
const generatedSource = {
|
|
1254
|
+
rootPath: fixture.root,
|
|
1255
|
+
treeHash: sha256([{ path: fixture.sourceFile, content: content.toString("utf8") }]),
|
|
1256
|
+
};
|
|
1257
|
+
await assert.rejects(fixture.makeProvider().previewApply({ ...fixture.policyRequest, generatedSource }), (error) => error instanceof IacProviderError && error.code === "PREVIEW_HASH_MISMATCH");
|
|
1258
|
+
assert.equal(fixture.runner.requests.length, 0);
|
|
1259
|
+
}
|
|
1260
|
+
});
|
|
1261
|
+
test(`native ${track} policy receipts bind actual output and survive restart without replacing approval`, async (context) => {
|
|
1262
|
+
const fixture = await nativePolicyFixture(context, track);
|
|
1263
|
+
const provider = fixture.makeProvider();
|
|
1264
|
+
const preview = await provider.previewApply(fixture.policyRequest);
|
|
1265
|
+
assert.deepEqual(preview.blockers, []);
|
|
1266
|
+
const receipt = provider.policyValidation(preview.previewHash);
|
|
1267
|
+
const binding = fixture.bindings.values.get(preview.previewHash);
|
|
1268
|
+
assert.deepEqual(nativePolicyValidationBinding(receipt), {
|
|
1269
|
+
track,
|
|
1270
|
+
sourceHash: receipt.sourceHash,
|
|
1271
|
+
policyMapHash: receipt.policyMapHash,
|
|
1272
|
+
policyMapContentHash: receipt.policyMapContentHash,
|
|
1273
|
+
logicalResourceManifestHash: receipt.logicalResourceManifestHash,
|
|
1274
|
+
inputHash: receipt.inputHash,
|
|
1275
|
+
receiptHash: receipt.receiptHash,
|
|
1276
|
+
});
|
|
1277
|
+
assert.deepEqual(binding.policyValidationBinding, nativePolicyValidationBinding(receipt));
|
|
1278
|
+
assert.equal(receipt.outcome, "pass");
|
|
1279
|
+
assert.equal(receipt.results.length, 3);
|
|
1280
|
+
assert.equal(receipt.sourceHash, fixture.policyRequest.iacHash);
|
|
1281
|
+
assert.equal(receipt.policyMapHash, fixture.policyRequest.policyHash);
|
|
1282
|
+
assert.equal(receipt.inputHash, createHash("sha256").update(fixture.json()).digest("hex"));
|
|
1283
|
+
assert.equal(hasValidPolicyValidation(receipt, binding.policyValidationBinding), true);
|
|
1284
|
+
assert.equal(JSON.stringify(binding).includes("native-value-must-not-leak"), false);
|
|
1285
|
+
assert.equal(JSON.stringify(receipt).includes('expectedValue"'), false);
|
|
1286
|
+
assert.equal(JSON.stringify(receipt).includes('observedValue"'), false);
|
|
1287
|
+
assert.notEqual(receipt.sourceHash, receipt.inputHash);
|
|
1288
|
+
assert.equal(fixture.executed(), false);
|
|
1289
|
+
const restarted = fixture.makeProvider();
|
|
1290
|
+
await assert.rejects(restarted.apply(preview, { ...approval(preview), decision: "rejected" }, authority), (error) => error instanceof IacProviderError && error.code === "APPROVAL_REJECTED");
|
|
1291
|
+
assert.equal(fixture.executed(), false);
|
|
1292
|
+
await fixture.bindings.save(preview.previewHash, JSON.parse(JSON.stringify(binding)));
|
|
1293
|
+
assert.equal((await restarted.apply(preview, approval(preview), authority)).state, "succeeded");
|
|
1294
|
+
assert.deepEqual(restarted.policyValidation(preview.previewHash), receipt);
|
|
1295
|
+
receipt.results.length = 0;
|
|
1296
|
+
assert.equal(provider.policyValidation(preview.previewHash).results.length, 3);
|
|
1297
|
+
if (track === "terraform") {
|
|
1298
|
+
assert.equal(fixture.runner.requests.filter((entry) => entry.args[0] === "show").length, 1);
|
|
1299
|
+
}
|
|
1300
|
+
});
|
|
1301
|
+
test(`native ${track} policy mismatches and unsupported expressions block with diagnostic receipts`, async (context) => {
|
|
1302
|
+
for (const [value, outcome, reason] of [
|
|
1303
|
+
["actual-private-mismatch", "fail", "value-mismatch"],
|
|
1304
|
+
[track === "bicep" ? "[parameters('security')]" : "${var.security}", "unsupported", "unsupported-expression"],
|
|
1305
|
+
]) {
|
|
1306
|
+
await context.test(outcome, async (child) => {
|
|
1307
|
+
const fixture = await nativePolicyFixture(child, track);
|
|
1308
|
+
fixture.source.value = value;
|
|
1309
|
+
const provider = fixture.makeProvider();
|
|
1310
|
+
const preview = await provider.previewApply(fixture.policyRequest);
|
|
1311
|
+
const receipt = provider.policyValidation(preview.previewHash);
|
|
1312
|
+
assert.equal(receipt.outcome, outcome);
|
|
1313
|
+
assert.equal(receipt.results[0].reason, reason);
|
|
1314
|
+
assert.equal(preview.blockers.length, 3);
|
|
1315
|
+
assert.equal(JSON.stringify(receipt).includes(value), false);
|
|
1316
|
+
await assert.rejects(fixture.makeProvider().apply(preview, approval(preview), authority), (error) => error instanceof IacProviderError && error.code === "PREVIEW_BLOCKED");
|
|
1317
|
+
assert.equal(fixture.executed(), false);
|
|
1318
|
+
});
|
|
1319
|
+
}
|
|
1320
|
+
});
|
|
1321
|
+
test(`native ${track} policy exemptions block, while empty baselines and destroy skip`, async (context) => {
|
|
1322
|
+
const fixture = await nativePolicyFixture(context, track);
|
|
1323
|
+
fixture.policyRequest.policyValidation.policyMap.mappings[0].disposition = "exempt";
|
|
1324
|
+
const provider = fixture.makeProvider();
|
|
1325
|
+
const exempt = await provider.previewApply(fixture.policyRequest);
|
|
1326
|
+
assert.equal(provider.policyValidation(exempt.previewHash).results[0].reason, "unverified-exemption");
|
|
1327
|
+
assert.equal(exempt.blockers.length, 1);
|
|
1328
|
+
const destroy = await provider.previewDestroy(fixture.policyRequest);
|
|
1329
|
+
assert.equal(provider.policyValidation(destroy.previewHash), undefined);
|
|
1330
|
+
fixture.policyRequest.policyValidation.policyMap.mappings.length = 0;
|
|
1331
|
+
const empty = await provider.previewApply(fixture.policyRequest);
|
|
1332
|
+
assert.equal(provider.policyValidation(empty.previewHash), undefined);
|
|
1333
|
+
assert.equal(fixture.bindings.values.get(empty.previewHash).policyValidationBinding, undefined);
|
|
1334
|
+
assert.deepEqual(empty.blockers, []);
|
|
1335
|
+
await fixture.makeProvider().apply(empty, approval(empty), authority);
|
|
1336
|
+
if (track === "bicep") {
|
|
1337
|
+
assert.equal(fixture.runner.requests.filter((entry) => entry.executable === "bicep").length, 1);
|
|
1338
|
+
}
|
|
1339
|
+
});
|
|
1340
|
+
test(`native ${track} rejects restarted policy receipt omission and tampering`, async (context) => {
|
|
1341
|
+
const fixture = await nativePolicyFixture(context, track);
|
|
1342
|
+
const preview = await fixture.makeProvider().previewApply(fixture.policyRequest);
|
|
1343
|
+
const original = fixture.bindings.values.get(preview.previewHash);
|
|
1344
|
+
const { policyValidation: receipt, policyValidationBinding: expectation, ...native } = original;
|
|
1345
|
+
assert.ok(receipt);
|
|
1346
|
+
assert.ok(expectation);
|
|
1347
|
+
const mutations = {
|
|
1348
|
+
"omit receipt": { ...native, policyValidationBinding: expectation },
|
|
1349
|
+
"omit expectation": { ...native, policyValidation: receipt },
|
|
1350
|
+
"omit both": native,
|
|
1351
|
+
"receipt hash": { ...original, policyValidation: { ...receipt, receiptHash: "f".repeat(64) } },
|
|
1352
|
+
results: { ...original, policyValidation: { ...receipt, results: [] } },
|
|
1353
|
+
};
|
|
1354
|
+
for (const field of [
|
|
1355
|
+
"sourceHash",
|
|
1356
|
+
"policyMapHash",
|
|
1357
|
+
"policyMapContentHash",
|
|
1358
|
+
"logicalResourceManifestHash",
|
|
1359
|
+
"inputHash",
|
|
1360
|
+
]) {
|
|
1361
|
+
const changed = rehashPolicy({ ...receipt, [field]: "f".repeat(64) });
|
|
1362
|
+
mutations[field] = {
|
|
1363
|
+
...original,
|
|
1364
|
+
policyValidation: changed,
|
|
1365
|
+
policyValidationBinding: { ...expectation, [field]: changed[field], receiptHash: changed.receiptHash },
|
|
1366
|
+
};
|
|
1367
|
+
}
|
|
1368
|
+
const changedResults = rehashPolicy({
|
|
1369
|
+
...receipt,
|
|
1370
|
+
results: receipt.results.map((result) => ({ ...result, mappingHash: "f".repeat(64) })),
|
|
1371
|
+
});
|
|
1372
|
+
mutations["rehashed results"] = {
|
|
1373
|
+
...original,
|
|
1374
|
+
policyValidation: changedResults,
|
|
1375
|
+
policyValidationBinding: { ...expectation, receiptHash: changedResults.receiptHash },
|
|
1376
|
+
};
|
|
1377
|
+
for (const [name, binding] of Object.entries(mutations)) {
|
|
1378
|
+
await context.test(name, async () => {
|
|
1379
|
+
await fixture.bindings.save(preview.previewHash, JSON.parse(JSON.stringify(binding)));
|
|
1380
|
+
await assert.rejects(fixture.makeProvider().apply(preview, approval(preview), authority), (error) => error instanceof IacProviderError && error.code === "PREVIEW_HASH_MISMATCH");
|
|
1381
|
+
assert.equal(fixture.executed(), false);
|
|
1382
|
+
});
|
|
1383
|
+
}
|
|
1384
|
+
});
|
|
1385
|
+
test(`native ${track} source drift during and after preview cannot bypass native binding`, async (context) => {
|
|
1386
|
+
const fixture = await nativePolicyFixture(context, track);
|
|
1387
|
+
const provider = fixture.makeProvider();
|
|
1388
|
+
const preview = await provider.previewApply(fixture.policyRequest);
|
|
1389
|
+
if (track === "bicep")
|
|
1390
|
+
fixture.source.value = "changed-compiler-output";
|
|
1391
|
+
else
|
|
1392
|
+
fixture.source.configHash = "f".repeat(64);
|
|
1393
|
+
await assert.rejects(fixture.makeProvider().apply(preview, approval(preview), authority), (error) => error instanceof IacProviderError && error.code === "PREVIEW_HASH_MISMATCH");
|
|
1394
|
+
fixture.source.value = "native-value-must-not-leak";
|
|
1395
|
+
fixture.source.configHash = hashes.iac;
|
|
1396
|
+
fixture.source.duringPreview = async () => {
|
|
1397
|
+
if (track === "bicep")
|
|
1398
|
+
await writeFile(join(fixture.root, "main.bicep"), "changed-source");
|
|
1399
|
+
else
|
|
1400
|
+
fixture.source.configHash = "f".repeat(64);
|
|
1401
|
+
};
|
|
1402
|
+
const drifted = await provider.previewApply(fixture.policyRequest);
|
|
1403
|
+
assert.match(drifted.blockers.join("\n"), /changed during policy validation preview/);
|
|
1404
|
+
assert.ok(provider.policyValidation(drifted.previewHash));
|
|
1405
|
+
await assert.rejects(fixture.makeProvider().apply(drifted, approval(drifted), authority), (error) => error instanceof IacProviderError && error.code === "PREVIEW_BLOCKED");
|
|
1406
|
+
assert.equal(fixture.executed(), false);
|
|
1407
|
+
});
|
|
1408
|
+
}
|
|
1409
|
+
test("native Bicep generated binding requires main.bicep and accepted parameters", async (context) => {
|
|
1410
|
+
for (const mismatch of [
|
|
1411
|
+
"compiled",
|
|
1412
|
+
"other-entrypoint",
|
|
1413
|
+
"outside-entrypoint",
|
|
1414
|
+
"outside-parameters",
|
|
1415
|
+
"none",
|
|
1416
|
+
]) {
|
|
1417
|
+
await context.test(mismatch, async (child) => {
|
|
1418
|
+
const fixture = await nativePolicyFixture(child, "bicep");
|
|
1419
|
+
const files = [
|
|
1420
|
+
{ path: "main.bicep", content: await readFile(join(fixture.root, "main.bicep"), "utf8") },
|
|
1421
|
+
{ path: "main.json", content: "{}" },
|
|
1422
|
+
{ path: "other.bicep", content: "targetScope = 'resourceGroup'\n" },
|
|
1423
|
+
{ path: "parameters.json", content: "{}" },
|
|
1424
|
+
].sort((left, right) => left.path.localeCompare(right.path));
|
|
1425
|
+
for (const file of files)
|
|
1426
|
+
await writeFile(join(fixture.root, file.path), file.content);
|
|
1427
|
+
const generatedSource = { rootPath: fixture.root, treeHash: sha256(files) };
|
|
1428
|
+
const provider = fixture.makeProvider({
|
|
1429
|
+
templateFile: mismatch === "compiled"
|
|
1430
|
+
? "main.json"
|
|
1431
|
+
: mismatch === "other-entrypoint"
|
|
1432
|
+
? "other.bicep"
|
|
1433
|
+
: mismatch === "outside-entrypoint"
|
|
1434
|
+
? "../main.bicep"
|
|
1435
|
+
: "main.bicep",
|
|
1436
|
+
parametersFile: mismatch === "outside-parameters" ? "../parameters.json" : "parameters.json",
|
|
1437
|
+
});
|
|
1438
|
+
if (mismatch === "none") {
|
|
1439
|
+
assert.deepEqual((await provider.previewApply({ ...fixture.policyRequest, generatedSource })).blockers, []);
|
|
1440
|
+
await provider.validate();
|
|
1441
|
+
assert.ok(fixture.runner.requests
|
|
1442
|
+
.filter((entry) => entry.executable === "bicep")
|
|
1443
|
+
.every((entry) => entry.args.includes("--stdout")));
|
|
1444
|
+
}
|
|
1445
|
+
else {
|
|
1446
|
+
await assert.rejects(provider.previewApply({ ...fixture.policyRequest, generatedSource }), (error) => error instanceof IacProviderError && error.code === "PREVIEW_HASH_MISMATCH");
|
|
1447
|
+
assert.equal(fixture.runner.requests.length, 0);
|
|
1448
|
+
}
|
|
1449
|
+
});
|
|
1450
|
+
}
|
|
1451
|
+
});
|
|
1452
|
+
test("native Terraform generated binding allows only newly created native outputs", async (context) => {
|
|
1453
|
+
for (const extra of ["none", "extra.tf", "extra.tf.json", "modules/main.tf", "other.tfplan", "main.json"]) {
|
|
1454
|
+
await context.test(extra, async (child) => {
|
|
1455
|
+
const fixture = await nativePolicyFixture(child, "terraform");
|
|
1456
|
+
fixture.source.duringInit = async () => {
|
|
1457
|
+
await mkdir(join(fixture.root, ".terraform", "modules"), { recursive: true });
|
|
1458
|
+
await writeFile(join(fixture.root, ".terraform", "modules", "modules.json"), "{}");
|
|
1459
|
+
await writeFile(join(fixture.root, ".terraform.lock.hcl"), "provider lock");
|
|
1460
|
+
};
|
|
1461
|
+
if (extra !== "none") {
|
|
1462
|
+
fixture.source.duringShow = async () => {
|
|
1463
|
+
await mkdir(join(fixture.root, "modules"), { recursive: true });
|
|
1464
|
+
await writeFile(join(fixture.root, extra), "unbound source");
|
|
1465
|
+
};
|
|
1466
|
+
}
|
|
1467
|
+
const preview = fixture
|
|
1468
|
+
.makeProvider()
|
|
1469
|
+
.previewApply({ ...fixture.policyRequest, generatedSource: fixture.generatedSource });
|
|
1470
|
+
if (extra === "none")
|
|
1471
|
+
assert.deepEqual((await preview).blockers, []);
|
|
1472
|
+
else {
|
|
1473
|
+
await assert.rejects(preview, (error) => error instanceof IacProviderError && error.code === "PREVIEW_HASH_MISMATCH");
|
|
1474
|
+
assert.equal(fixture.bindings.values.size, 0);
|
|
1475
|
+
assert.equal(fixture.artifacts.values.size, 0);
|
|
1476
|
+
}
|
|
1477
|
+
await assert.rejects(stat(join(fixture.root, "policy.tfplan")), /ENOENT/);
|
|
1478
|
+
});
|
|
1479
|
+
}
|
|
1480
|
+
});
|
|
1481
|
+
test("native Terraform generated binding never exempts accepted lockfiles or source plan paths", async (context) => {
|
|
1482
|
+
for (const mutation of ["lockfile", "entrypoint", "unbound-source", "during-init"]) {
|
|
1483
|
+
const fixture = await nativePolicyFixture(context, "terraform");
|
|
1484
|
+
const files = [
|
|
1485
|
+
{ path: ".terraform.lock.hcl", content: "accepted lock" },
|
|
1486
|
+
{ path: "main.tf", content: await readFile(join(fixture.root, "main.tf"), "utf8") },
|
|
1487
|
+
].sort((left, right) => left.path.localeCompare(right.path));
|
|
1488
|
+
await writeFile(join(fixture.root, ".terraform.lock.hcl"), files.find(({ path }) => path === ".terraform.lock.hcl").content);
|
|
1489
|
+
const generatedSource = { rootPath: fixture.root, treeHash: sha256(files) };
|
|
1490
|
+
if (mutation === "lockfile")
|
|
1491
|
+
fixture.source.duringInit = async () => writeFile(join(fixture.root, ".terraform.lock.hcl"), "changed lock");
|
|
1492
|
+
if (mutation === "during-init")
|
|
1493
|
+
fixture.source.duringInit = async () => writeFile(join(fixture.root, "main.tf"), "changed source");
|
|
1494
|
+
const provider = fixture.makeProvider({
|
|
1495
|
+
...(mutation === "entrypoint" ? { planPath: join(fixture.root, "main.tf") } : {}),
|
|
1496
|
+
...(mutation === "unbound-source" ? { planPath: join(fixture.root, "extra.tf") } : {}),
|
|
1497
|
+
});
|
|
1498
|
+
await assert.rejects(provider.previewApply({ ...fixture.policyRequest, generatedSource }), (error) => error instanceof IacProviderError && error.code === "PREVIEW_HASH_MISMATCH");
|
|
1499
|
+
assert.equal(fixture.bindings.values.size, 0);
|
|
1500
|
+
if (mutation === "entrypoint" || mutation === "unbound-source")
|
|
1501
|
+
assert.equal(fixture.runner.requests.length, 0);
|
|
1502
|
+
}
|
|
1503
|
+
});
|
|
1504
|
+
test("native generated binding does not ignore artifacts present before preview", async (context) => {
|
|
1505
|
+
for (const track of ["bicep", "terraform"]) {
|
|
1506
|
+
const fixture = await nativePolicyFixture(context, track);
|
|
1507
|
+
await writeFile(join(fixture.root, track === "bicep" ? "main.json" : ".terraform.lock.hcl"), "unaccepted output");
|
|
1508
|
+
await assert.rejects(fixture.makeProvider().previewApply({ ...fixture.policyRequest, generatedSource: fixture.generatedSource }), (error) => error instanceof IacProviderError && error.code === "PREVIEW_HASH_MISMATCH");
|
|
1509
|
+
assert.equal(fixture.runner.requests.length, 0);
|
|
1510
|
+
}
|
|
1511
|
+
});
|
|
1512
|
+
test("native Bicep unavailable or invalid compiler JSON preserves unsupported receipts", async (context) => {
|
|
1513
|
+
for (const failure of ["invalid", "compileFailed"]) {
|
|
1514
|
+
const fixture = await nativePolicyFixture(context, "bicep");
|
|
1515
|
+
fixture.source[failure] = true;
|
|
1516
|
+
const provider = fixture.makeProvider();
|
|
1517
|
+
const preview = await provider.previewApply(fixture.policyRequest);
|
|
1518
|
+
assert.equal(provider.policyValidation(preview.previewHash).outcome, "unsupported");
|
|
1519
|
+
assert.equal(provider.policyValidation(preview.previewHash).results[0].reason, "invalid-source");
|
|
1520
|
+
assert.ok(preview.blockers.length >= 3);
|
|
1521
|
+
assert.equal(JSON.stringify(preview).includes("private source"), false);
|
|
1522
|
+
assert.equal(fixture.executed(), false);
|
|
1523
|
+
}
|
|
1524
|
+
});
|
|
1525
|
+
test("native Terraform unknown saved-plan values remain unsupported", async (context) => {
|
|
1526
|
+
const fixture = await nativePolicyFixture(context, "terraform");
|
|
1527
|
+
fixture.source.unknown = true;
|
|
1528
|
+
const provider = fixture.makeProvider();
|
|
1529
|
+
const preview = await provider.previewApply(fixture.policyRequest);
|
|
1530
|
+
const receipt = provider.policyValidation(preview.previewHash);
|
|
1531
|
+
assert.equal(receipt.outcome, "unsupported");
|
|
1532
|
+
assert.equal(receipt.results[0].reason, "unsupported-expression");
|
|
1533
|
+
await assert.rejects(fixture.makeProvider().apply(preview, approval(preview), authority), (error) => error instanceof IacProviderError && error.code === "PREVIEW_BLOCKED");
|
|
1534
|
+
assert.equal(fixture.executed(), false);
|
|
1535
|
+
});
|
|
1536
|
+
test("native Terraform detects saved-plan byte mutation during show-json", async (context) => {
|
|
1537
|
+
const fixture = await nativePolicyFixture(context, "terraform");
|
|
1538
|
+
fixture.source.duringShow = async () => writeFile(join(fixture.root, "policy.tfplan"), "replaced-plan");
|
|
1539
|
+
const provider = fixture.makeProvider();
|
|
1540
|
+
const preview = await provider.previewApply(fixture.policyRequest);
|
|
1541
|
+
assert.match(preview.blockers.join("\n"), /saved plan changed during policy validation/);
|
|
1542
|
+
assert.ok(provider.policyValidation(preview.previewHash));
|
|
1543
|
+
await assert.rejects(fixture.makeProvider().apply(preview, approval(preview), authority), (error) => error instanceof IacProviderError && error.code === "PREVIEW_BLOCKED");
|
|
1544
|
+
assert.equal(fixture.executed(), false);
|
|
1545
|
+
});
|
|
102
1546
|
test("Azure and Terraform normalizers block unknown or unevaluated changes", () => {
|
|
103
1547
|
const azure = normalizeAzureWhatIf({
|
|
104
1548
|
properties: {
|
|
@@ -143,6 +1587,39 @@ test("normalizers block missing, duplicate, and malformed material change identi
|
|
|
143
1587
|
});
|
|
144
1588
|
assert.match(terraform.blockers.join("\n"), /no stable resource address|duplicate material|malformed/);
|
|
145
1589
|
});
|
|
1590
|
+
test("Terraform normalization cannot turn incomplete or malformed plan evidence into safe changes", () => {
|
|
1591
|
+
for (const plan of [
|
|
1592
|
+
{ resource_changes: [], complete: false },
|
|
1593
|
+
{ resource_changes: "invalid" },
|
|
1594
|
+
{ resource_changes: [], deferred_changes: {} },
|
|
1595
|
+
{ resource_changes: [], complete: "true" },
|
|
1596
|
+
{ resource_changes: [], errored: "false" },
|
|
1597
|
+
{ resource_changes: [{ address: "azapi_resource.main", change: { actions: ["no-op", 42] } }] },
|
|
1598
|
+
{ resource_changes: [{ address: "azapi_resource.main", change: { actions: ["create", null] } }] },
|
|
1599
|
+
]) {
|
|
1600
|
+
const normalized = normalizeTerraformPlan(plan);
|
|
1601
|
+
assert.ok(normalized.blockers.length > 0, JSON.stringify(plan));
|
|
1602
|
+
}
|
|
1603
|
+
const noOp = normalizeTerraformPlan({ resource_changes: [], complete: true, errored: false });
|
|
1604
|
+
assert.deepEqual(noOp, { changes: [], blockers: [] });
|
|
1605
|
+
assert.deepEqual(normalizeTerraformPlan({ terraform_version: "1.7.5", format_version: "1.2" }), {
|
|
1606
|
+
changes: [],
|
|
1607
|
+
blockers: [],
|
|
1608
|
+
});
|
|
1609
|
+
});
|
|
1610
|
+
test("native Terraform incomplete plans block apply even without policy mappings", async (context) => {
|
|
1611
|
+
for (const withPolicy of [false, true]) {
|
|
1612
|
+
await context.test(`policy=${withPolicy}`, async (child) => {
|
|
1613
|
+
const fixture = await nativePolicyFixture(child, "terraform");
|
|
1614
|
+
fixture.source.complete = false;
|
|
1615
|
+
const provider = fixture.makeProvider();
|
|
1616
|
+
const preview = await provider.previewApply(withPolicy ? fixture.policyRequest : request());
|
|
1617
|
+
assert.match(preview.blockers.join("\n"), /plan is incomplete/);
|
|
1618
|
+
await assert.rejects(provider.apply(preview, approval(preview), authority), (error) => error instanceof IacProviderError && error.code === "PREVIEW_BLOCKED");
|
|
1619
|
+
assert.equal(fixture.executed(), false);
|
|
1620
|
+
});
|
|
1621
|
+
}
|
|
1622
|
+
});
|
|
146
1623
|
function stack(name, resourceGroup = "rg", resources = []) {
|
|
147
1624
|
return {
|
|
148
1625
|
id: `/subscriptions/sub/resourceGroups/${resourceGroup}/providers/Microsoft.Resources/deploymentStacks/${name}`,
|
|
@@ -584,7 +2061,7 @@ test("local reference plan transport authenticates metadata, recipient, expiry,
|
|
|
584
2061
|
assert.throws(() => transport.decrypt({ ...encrypted, ciphertext: `${encrypted.ciphertext.slice(0, -2)}AA` }, key, authority.recipientIdentity));
|
|
585
2062
|
const handle = await transport.decryptToRestrictiveTemp(encrypted, key, authority.recipientIdentity);
|
|
586
2063
|
context.after(async () => handle.dispose());
|
|
587
|
-
|
|
2064
|
+
await assertPosixMode(handle.path, 0o600);
|
|
588
2065
|
assert.equal((await readFile(handle.path)).toString(), "saved-plan");
|
|
589
2066
|
await handle.dispose();
|
|
590
2067
|
clock.value = new Date("2026-07-13T01:02:00.000Z");
|