@intentius/chant-lexicon-aws 0.14.0 → 0.15.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/components/__tests__/mock-cloud-executor.d.ts +106 -0
- package/dist/components/__tests__/mock-cloud-executor.d.ts.map +1 -0
- package/dist/components/apply.d.ts +242 -0
- package/dist/components/apply.d.ts.map +1 -0
- package/dist/components/builders.d.ts +37 -0
- package/dist/components/builders.d.ts.map +1 -0
- package/dist/components/capability-plugin.d.ts +23 -0
- package/dist/components/capability-plugin.d.ts.map +1 -0
- package/dist/components/cloud-executor.d.ts +350 -0
- package/dist/components/cloud-executor.d.ts.map +1 -0
- package/dist/components/config-bom.d.ts +135 -0
- package/dist/components/config-bom.d.ts.map +1 -0
- package/dist/components/host-delivery.d.ts +110 -0
- package/dist/components/host-delivery.d.ts.map +1 -0
- package/dist/components/index.d.ts +19 -0
- package/dist/components/index.d.ts.map +1 -0
- package/dist/components/job-submission.d.ts +89 -0
- package/dist/components/job-submission.d.ts.map +1 -0
- package/dist/components/publish.d.ts +194 -0
- package/dist/components/publish.d.ts.map +1 -0
- package/dist/components/safety.d.ts +49 -0
- package/dist/components/safety.d.ts.map +1 -0
- package/dist/components/wait-aws.d.ts +65 -0
- package/dist/components/wait-aws.d.ts.map +1 -0
- package/dist/generated/index.d.ts +7 -4
- package/dist/generated/index.d.ts.map +1 -1
- package/dist/import/live-export.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +4 -4
- package/dist/lint/audit-catalog.d.ts +10 -0
- package/dist/lint/audit-catalog.d.ts.map +1 -0
- package/dist/manifest.json +1 -1
- package/dist/meta.json +141 -30
- package/dist/op/activities/floci.d.ts +54 -0
- package/dist/op/activities/floci.d.ts.map +1 -0
- package/dist/op/activities/index.d.ts +8 -0
- package/dist/op/activities/index.d.ts.map +1 -0
- package/dist/ownership.d.ts +10 -0
- package/dist/ownership.d.ts.map +1 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/serializer.d.ts.map +1 -1
- package/dist/types/index.d.ts +145 -38
- package/package.json +12 -2
- package/src/components/__fixtures__/schemas/cyclonedx-1.5.schema.json +3800 -0
- package/src/components/__fixtures__/schemas/cyclonedx-spdx-license.schema.json +621 -0
- package/src/components/__fixtures__/schemas/jsf-0.82.schema.json +240 -0
- package/src/components/__fixtures__/schemas/spdx-2.3.schema.json +740 -0
- package/src/components/__fixtures__/synthesized-template.json +53 -0
- package/src/components/__tests__/mock-cloud-executor.ts +425 -0
- package/src/components/apply.test.ts +382 -0
- package/src/components/apply.ts +499 -0
- package/src/components/builders.test.ts +49 -0
- package/src/components/builders.ts +59 -0
- package/src/components/capability-plugin.ts +73 -0
- package/src/components/cloud-executor.test.ts +25 -0
- package/src/components/cloud-executor.ts +814 -0
- package/src/components/config-bom.test.ts +273 -0
- package/src/components/config-bom.ts +310 -0
- package/src/components/host-delivery.test.ts +121 -0
- package/src/components/host-delivery.ts +193 -0
- package/src/components/index.ts +19 -0
- package/src/components/job-submission.test.ts +97 -0
- package/src/components/job-submission.ts +138 -0
- package/src/components/pilots-e2e.test.ts +460 -0
- package/src/components/presets-e2e.test.ts +166 -0
- package/src/components/publish.test.ts +318 -0
- package/src/components/publish.ts +356 -0
- package/src/components/safety.test.ts +47 -0
- package/src/components/safety.ts +78 -0
- package/src/components/wait-aws.test.ts +79 -0
- package/src/components/wait-aws.ts +132 -0
- package/src/generated/index.d.ts +145 -38
- package/src/generated/index.ts +12 -9
- package/src/generated/lexicon-aws.json +141 -30
- package/src/import/live-export.ts +2 -1
- package/src/index.ts +5 -0
- package/src/lint/audit-catalog.ts +45 -0
- package/src/op/activities/floci.test.ts +67 -0
- package/src/op/activities/floci.ts +149 -0
- package/src/op/activities/index.ts +16 -0
- package/src/ownership.test.ts +18 -0
- package/src/ownership.ts +15 -0
- package/src/plugin.ts +3 -0
- package/src/serializer.ts +2 -1
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests `extract-config-bom` (#613) against a fixture synthesized template
|
|
3
|
+
* (./__fixtures__/synthesized-template.json) — a stack with a nested
|
|
4
|
+
* `AWS::CloudFormation::Stack`, an ECS task referencing a published image
|
|
5
|
+
* digest, an EC2 launch template referencing an AMI, and a custom resource
|
|
6
|
+
* carrying a lexicon version string. Purely structural/hermetic: no cloud
|
|
7
|
+
* calls, the template is read from a fixture file and passed as an in-memory
|
|
8
|
+
* string.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFileSync, mkdtempSync, rmSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
15
|
+
import Ajv2020 from "ajv/dist/2020";
|
|
16
|
+
import Ajv from "ajv";
|
|
17
|
+
import {
|
|
18
|
+
inventoryTemplate,
|
|
19
|
+
inventoryToBomPackages,
|
|
20
|
+
createExtractConfigBomCapability,
|
|
21
|
+
} from "./config-bom";
|
|
22
|
+
import { addArchiveTemplate } from "@intentius/chant/components/verbs/build";
|
|
23
|
+
import { findArchiveEntry, findConfigBomForSubject, templateEntries } from "@intentius/chant/components/verbs/build-archive";
|
|
24
|
+
import { DEFAULT_SBOM_FORMAT, SBOM_MEDIA_TYPES } from "@intentius/chant/components/verbs/sbom-generator";
|
|
25
|
+
|
|
26
|
+
const FIXTURES_DIR = join(import.meta.dirname, "__fixtures__");
|
|
27
|
+
const TEMPLATE = readFileSync(join(FIXTURES_DIR, "synthesized-template.json"), "utf-8");
|
|
28
|
+
const SCHEMAS_DIR = join(FIXTURES_DIR, "schemas");
|
|
29
|
+
|
|
30
|
+
function loadSchema(name: string): unknown {
|
|
31
|
+
return JSON.parse(readFileSync(join(SCHEMAS_DIR, name), "utf-8"));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const ctx = { env: "dev", component: "search-service" };
|
|
35
|
+
const FIXED_NOW = () => new Date("2026-01-01T00:00:00.000Z");
|
|
36
|
+
|
|
37
|
+
describe("inventoryTemplate (#613) — structural template walk", () => {
|
|
38
|
+
it("enumerates every declared resource with its CFN Type", () => {
|
|
39
|
+
const inventory = inventoryTemplate(TEMPLATE);
|
|
40
|
+
const names = inventory.resources.map((r) => r.name).sort();
|
|
41
|
+
expect(names).toEqual([
|
|
42
|
+
"SearchBucket",
|
|
43
|
+
"SearchLaunchTemplate",
|
|
44
|
+
"SearchServiceComponentMetadata",
|
|
45
|
+
"SearchTaskDefinition",
|
|
46
|
+
"network",
|
|
47
|
+
]);
|
|
48
|
+
expect(inventory.resources.find((r) => r.name === "SearchBucket")!.type).toBe("AWS::S3::Bucket");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("identifies the nested AWS::CloudFormation::Stack resource and its child template path", () => {
|
|
52
|
+
const inventory = inventoryTemplate(TEMPLATE);
|
|
53
|
+
expect(inventory.nestedStacks).toEqual([
|
|
54
|
+
{ resourceName: "network", templatePath: "network.template.json" },
|
|
55
|
+
]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("extracts the referenced image digest from the ECS task definition", () => {
|
|
59
|
+
const inventory = inventoryTemplate(TEMPLATE);
|
|
60
|
+
const imageRefs = inventory.externalReferences.filter((r) => r.kind === "image-digest");
|
|
61
|
+
expect(imageRefs).toHaveLength(1);
|
|
62
|
+
expect(imageRefs[0]!.value).toBe(
|
|
63
|
+
"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
64
|
+
);
|
|
65
|
+
expect(imageRefs[0]!.resourceName).toBe("SearchTaskDefinition");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("extracts the referenced AMI id from the launch template", () => {
|
|
69
|
+
const inventory = inventoryTemplate(TEMPLATE);
|
|
70
|
+
const amiRefs = inventory.externalReferences.filter((r) => r.kind === "ami");
|
|
71
|
+
expect(amiRefs).toEqual([
|
|
72
|
+
{ kind: "ami", value: "ami-0abcdef1234567890", resourceName: "SearchLaunchTemplate" },
|
|
73
|
+
]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("extracts a lexicon version string from a resource property named for it", () => {
|
|
77
|
+
const inventory = inventoryTemplate(TEMPLATE);
|
|
78
|
+
const versionRefs = inventory.externalReferences.filter((r) => r.kind === "lexicon-version");
|
|
79
|
+
expect(versionRefs).toEqual([
|
|
80
|
+
{ kind: "lexicon-version", value: "0.13.1", resourceName: "SearchServiceComponentMetadata" },
|
|
81
|
+
]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("handles a template with no resources gracefully", () => {
|
|
85
|
+
const inventory = inventoryTemplate(JSON.stringify({ Resources: {} }));
|
|
86
|
+
expect(inventory.resources).toEqual([]);
|
|
87
|
+
expect(inventory.nestedStacks).toEqual([]);
|
|
88
|
+
expect(inventory.externalReferences).toEqual([]);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("inventoryToBomPackages (#613)", () => {
|
|
93
|
+
it("projects every resource into a config-type BomPackage and every reference into an external-reference-type one", () => {
|
|
94
|
+
const inventory = inventoryTemplate(TEMPLATE);
|
|
95
|
+
const packages = inventoryToBomPackages(inventory);
|
|
96
|
+
const resourcePkgs = packages.filter((p) => p.type === "config");
|
|
97
|
+
const refPkgs = packages.filter((p) => p.type === "external-reference");
|
|
98
|
+
expect(resourcePkgs).toHaveLength(5);
|
|
99
|
+
expect(refPkgs).toHaveLength(3);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("tags the nested stack's config-package with a chant:nested-stack purl pointing at its child template", () => {
|
|
103
|
+
const inventory = inventoryTemplate(TEMPLATE);
|
|
104
|
+
const packages = inventoryToBomPackages(inventory);
|
|
105
|
+
const networkPkg = packages.find((p) => p.name === "network")!;
|
|
106
|
+
expect(networkPkg.purl).toBe("chant:nested-stack/network.template.json");
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("extract-config-bom capability (#613)", () => {
|
|
111
|
+
it("defaults to SPDX and emits a valid SPDX-2.3 document", async () => {
|
|
112
|
+
expect(DEFAULT_SBOM_FORMAT).toBe("spdx");
|
|
113
|
+
const capability = createExtractConfigBomCapability();
|
|
114
|
+
const output = await capability.run(ctx, { path: "search.template.json", content: TEMPLATE, now: FIXED_NOW });
|
|
115
|
+
expect(output.bom.format).toBe("spdx");
|
|
116
|
+
expect(output.bom.mediaType).toBe(SBOM_MEDIA_TYPES.spdx);
|
|
117
|
+
expect(JSON.parse(output.bom.bytes).spdxVersion).toBe("SPDX-2.3");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("emits a valid CycloneDX-1.5 document on request", async () => {
|
|
121
|
+
const capability = createExtractConfigBomCapability();
|
|
122
|
+
const output = await capability.run(ctx, {
|
|
123
|
+
path: "search.template.json",
|
|
124
|
+
content: TEMPLATE,
|
|
125
|
+
format: "cyclonedx",
|
|
126
|
+
now: FIXED_NOW,
|
|
127
|
+
});
|
|
128
|
+
expect(output.bom.format).toBe("cyclonedx");
|
|
129
|
+
expect(JSON.parse(output.bom.bytes).bomFormat).toBe("CycloneDX");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("validates the emitted SPDX config-BOM against the real SPDX-2.3 JSON Schema", async () => {
|
|
133
|
+
const schema = loadSchema("spdx-2.3.schema.json");
|
|
134
|
+
const ajv = new Ajv({ strict: false, allErrors: true });
|
|
135
|
+
const validate = ajv.compile(schema as object);
|
|
136
|
+
|
|
137
|
+
const capability = createExtractConfigBomCapability();
|
|
138
|
+
const output = await capability.run(ctx, { path: "search.template.json", content: TEMPLATE, now: FIXED_NOW });
|
|
139
|
+
const valid = validate(JSON.parse(output.bom.bytes));
|
|
140
|
+
if (!valid) throw new Error(ajv.errorsText(validate.errors));
|
|
141
|
+
expect(valid).toBe(true);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("validates the emitted CycloneDX config-BOM against the real CycloneDX-1.5 JSON Schema", async () => {
|
|
145
|
+
const cdxSchema = loadSchema("cyclonedx-1.5.schema.json");
|
|
146
|
+
const jsfSchema = loadSchema("jsf-0.82.schema.json");
|
|
147
|
+
const spdxLicenseSchema = loadSchema("cyclonedx-spdx-license.schema.json");
|
|
148
|
+
const ajv = new Ajv({ strict: false, allErrors: true });
|
|
149
|
+
// The CycloneDX-1.5 schema declares `iri-reference`/`idn-email` string
|
|
150
|
+
// formats; accept them as-is (this test validates document *structure*, not
|
|
151
|
+
// format semantics) so it passes regardless of the ambient ajv version.
|
|
152
|
+
ajv.addFormat("iri-reference", true);
|
|
153
|
+
ajv.addFormat("idn-email", true);
|
|
154
|
+
ajv.addSchema(jsfSchema as object);
|
|
155
|
+
ajv.addSchema(spdxLicenseSchema as object);
|
|
156
|
+
const validate = ajv.compile(cdxSchema as object);
|
|
157
|
+
|
|
158
|
+
const capability = createExtractConfigBomCapability();
|
|
159
|
+
const output = await capability.run(ctx, {
|
|
160
|
+
path: "search.template.json",
|
|
161
|
+
content: TEMPLATE,
|
|
162
|
+
format: "cyclonedx",
|
|
163
|
+
now: FIXED_NOW,
|
|
164
|
+
});
|
|
165
|
+
const valid = validate(JSON.parse(output.bom.bytes));
|
|
166
|
+
if (!valid) throw new Error(ajv.errorsText(validate.errors));
|
|
167
|
+
expect(valid).toBe(true);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("lists every declared resource and every external reference in the config-BOM's package count", async () => {
|
|
171
|
+
const capability = createExtractConfigBomCapability();
|
|
172
|
+
const output = await capability.run(ctx, { path: "search.template.json", content: TEMPLATE, now: FIXED_NOW });
|
|
173
|
+
// 5 resources + 3 external references (image digest, AMI, lexicon version)
|
|
174
|
+
expect(output.bom.packageCount).toBe(8);
|
|
175
|
+
expect(output.inventory.resources).toHaveLength(5);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("writes a sbom-kind, bomKind:config entry into the build-archive manifest, linked to the template's subject digest", async () => {
|
|
179
|
+
const capability = createExtractConfigBomCapability();
|
|
180
|
+
const { digest: templateDigest, manifest: templateManifest } = addArchiveTemplate({
|
|
181
|
+
path: "search.template.json",
|
|
182
|
+
content: TEMPLATE,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const output = await capability.run(ctx, {
|
|
186
|
+
path: "search.template.json",
|
|
187
|
+
content: TEMPLATE,
|
|
188
|
+
digest: templateDigest,
|
|
189
|
+
manifest: templateManifest,
|
|
190
|
+
now: FIXED_NOW,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// addArchiveTemplate (./build.ts) is a plain helper with no DeployContext,
|
|
194
|
+
// so a from-scratch manifest it seeds always starts as component:
|
|
195
|
+
// "unknown" — this capability threads that manifest through unchanged
|
|
196
|
+
// (accumulation, not ownership), matching generate-sbom's own
|
|
197
|
+
// accumulate-onto-a-template-manifest test in ./sbom.test.ts.
|
|
198
|
+
expect(output.manifest.component).toBe("unknown");
|
|
199
|
+
const entry = findArchiveEntry(output.manifest, output.archivePath);
|
|
200
|
+
expect(entry).toMatchObject({
|
|
201
|
+
kind: "sbom",
|
|
202
|
+
bomKind: "config",
|
|
203
|
+
digest: output.digest,
|
|
204
|
+
mediaType: SBOM_MEDIA_TYPES.spdx,
|
|
205
|
+
subjectDigest: templateDigest,
|
|
206
|
+
});
|
|
207
|
+
expect(findConfigBomForSubject(output.manifest, templateDigest)).toBe(entry);
|
|
208
|
+
expect(templateEntries(output.manifest)).toHaveLength(1);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("defaults the archive path to <template path>.config-bom.json", async () => {
|
|
212
|
+
const capability = createExtractConfigBomCapability();
|
|
213
|
+
const output = await capability.run(ctx, { path: "search.template.json", content: TEMPLATE, now: FIXED_NOW });
|
|
214
|
+
expect(output.archivePath).toBe("search.template.json.config-bom.json");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("accepts an explicit into path and an archive: wiring reference on the template path", async () => {
|
|
218
|
+
const capability = createExtractConfigBomCapability();
|
|
219
|
+
const output = await capability.run(ctx, {
|
|
220
|
+
path: "archive:search.template.json",
|
|
221
|
+
content: TEMPLATE,
|
|
222
|
+
into: "bom/search.config.spdx.json",
|
|
223
|
+
now: FIXED_NOW,
|
|
224
|
+
});
|
|
225
|
+
expect(output.archivePath).toBe("bom/search.config.spdx.json");
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("a config-only component (no software artifact) still yields a config-BOM from just its template", async () => {
|
|
229
|
+
// No image/jar/zip entry anywhere in this manifest — the config-BOM
|
|
230
|
+
// capability doesn't need one, closing #613's "config-only components
|
|
231
|
+
// don't get a BOM" gap.
|
|
232
|
+
const capability = createExtractConfigBomCapability();
|
|
233
|
+
const { manifest: templateManifest, digest: templateDigest } = addArchiveTemplate({
|
|
234
|
+
path: "infra.template.json",
|
|
235
|
+
content: TEMPLATE,
|
|
236
|
+
});
|
|
237
|
+
const output = await capability.run(
|
|
238
|
+
{ env: "dev", component: "dynamodb-infra" },
|
|
239
|
+
{ path: "infra.template.json", content: TEMPLATE, digest: templateDigest, manifest: templateManifest, now: FIXED_NOW },
|
|
240
|
+
);
|
|
241
|
+
expect(output.manifest.contents.map((e) => e.kind).sort()).toEqual(["sbom", "template"]);
|
|
242
|
+
expect(output.manifest.contents.find((e) => e.kind === "sbom")!.bomKind).toBe("config");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("declares no rollback — an already-generated, content-addressed config-BOM is not itself something to undo", () => {
|
|
246
|
+
const capability = createExtractConfigBomCapability();
|
|
247
|
+
expect(capability.rollback).toBeUndefined();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("two extractions from identical template content produce the same manifest digest (content-addressed)", async () => {
|
|
251
|
+
const capA = createExtractConfigBomCapability();
|
|
252
|
+
const capB = createExtractConfigBomCapability();
|
|
253
|
+
const outputA = await capA.run(ctx, { path: "search.template.json", content: TEMPLATE, now: FIXED_NOW });
|
|
254
|
+
const outputB = await capB.run(ctx, { path: "search.template.json", content: TEMPLATE, now: FIXED_NOW });
|
|
255
|
+
expect(outputA.digest).toBe(outputB.digest);
|
|
256
|
+
expect(outputA.manifest.manifestDigest).toBe(outputB.manifest.manifestDigest);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
describe("optional disk write (outDir)", () => {
|
|
260
|
+
let tmpDir: string;
|
|
261
|
+
afterEach(() => {
|
|
262
|
+
if (tmpDir) rmSync(tmpDir, { recursive: true, force: true });
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("writes config-bom.<format>.json to outDir when provided", async () => {
|
|
266
|
+
tmpDir = mkdtempSync(join(tmpdir(), "chant-config-bom-"));
|
|
267
|
+
const capability = createExtractConfigBomCapability();
|
|
268
|
+
await capability.run(ctx, { path: "search.template.json", content: TEMPLATE, outDir: tmpDir, now: FIXED_NOW });
|
|
269
|
+
const written = JSON.parse(readFileSync(join(tmpDir, "config-bom.spdx.json"), "utf-8"));
|
|
270
|
+
expect(written.spdxVersion).toBe("SPDX-2.3");
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
});
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `extract-config-bom` capability (#613, epic #551 follow-up to #606).
|
|
3
|
+
*
|
|
4
|
+
* chant synthesis is deterministic, so a synthesized IaC template (the
|
|
5
|
+
* CloudFormation JSON `chant build` already produces — see ../../build.ts
|
|
6
|
+
* and lexicons/aws/src/serializer.ts's `CFTemplate`) is itself a
|
|
7
|
+
* reproducible artifact with a legitimate bill-of-materials: it declares
|
|
8
|
+
* resources, may nest child stacks, and references external artifacts
|
|
9
|
+
* (container image digests, AMI ids, other lexicons' versions) that are not
|
|
10
|
+
* declared dependencies in any lockfile. This module is the config-BOM
|
|
11
|
+
* analogue of ./sbom.ts's `generate-sbom`: same archive/manifest wiring,
|
|
12
|
+
* same SPDX/CycloneDX writer (./bom-writer.ts), different subject (a
|
|
13
|
+
* template's structure instead of a lockfile's package list).
|
|
14
|
+
*
|
|
15
|
+
* **Scope.** The extractor is a pure, hermetic AST-ish walk over the
|
|
16
|
+
* template's already-parsed JSON — no cloud calls, no `cfn describe-stacks`,
|
|
17
|
+
* no network. It only reads chant's own synthesized output, so it works
|
|
18
|
+
* identically for any lexicon whose serializer emits a JSON document shaped
|
|
19
|
+
* like CloudFormation's `{ Resources: { name: { Type, Properties } } }` (the
|
|
20
|
+
* ../../serializer.ts `SerializerResult.primary`/`files` convention every
|
|
21
|
+
* lexicon serializer already produces). Non-JSON serializer output (a raw
|
|
22
|
+
* Kubernetes YAML manifest, say) is out of scope for this first cut — see
|
|
23
|
+
* `extractConfigBom`'s doc comment.
|
|
24
|
+
*
|
|
25
|
+
* **Why config-only/infra components get a BOM.** #606 shipped
|
|
26
|
+
* `generate-sbom` keyed to *artifact* types (image/jar/zip/dir); a
|
|
27
|
+
* config-only component (no `build` phase, e.g. a DynamoDB table or an EMR
|
|
28
|
+
* cluster definition — see epic #551's "Infra" archetype) has none of those
|
|
29
|
+
* and so was previously assumed to have nothing to attach a BOM to. That
|
|
30
|
+
* assumption undercounts what's true: the synthesized template *is* that
|
|
31
|
+
* component's build output. Treating it as a first-class archive artifact
|
|
32
|
+
* (a `template`-kind entry with its own content digest, peer to `image`/
|
|
33
|
+
* `asset` — see ./build-archive.ts's `addArchiveTemplate`) means a
|
|
34
|
+
* config-BOM can attach to it exactly the way a software SBOM attaches to
|
|
35
|
+
* an image digest, closing the gap.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import type { Capability } from "@intentius/chant/components/capability";
|
|
39
|
+
import {
|
|
40
|
+
addArchiveEntry,
|
|
41
|
+
archiveRelativePath,
|
|
42
|
+
contentDigest,
|
|
43
|
+
createBuildArchiveManifest,
|
|
44
|
+
type BuildArchiveManifest,
|
|
45
|
+
} from "@intentius/chant/components/verbs/build-archive";
|
|
46
|
+
import { writeBom, type BomPackage } from "@intentius/chant/components/verbs/bom-writer";
|
|
47
|
+
import { DEFAULT_SBOM_FORMAT, type SbomDocument, type SbomFormat } from "@intentius/chant/components/verbs/sbom-generator";
|
|
48
|
+
import { writeFileSync, mkdirSync } from "node:fs";
|
|
49
|
+
import { dirname, join } from "node:path";
|
|
50
|
+
|
|
51
|
+
// ── template model (subset of CFTemplate this extractor needs) ─────────────
|
|
52
|
+
|
|
53
|
+
interface TemplateResource {
|
|
54
|
+
Type?: string;
|
|
55
|
+
Properties?: Record<string, unknown>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface SynthesizedTemplate {
|
|
59
|
+
Resources?: Record<string, TemplateResource>;
|
|
60
|
+
Outputs?: Record<string, unknown>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** One external reference a template's resources point at — an image digest, an AMI id, or another lexicon's declared version. Enumerated separately from declared resources since these aren't things chant itself owns/synthesizes. */
|
|
64
|
+
export interface ExternalReference {
|
|
65
|
+
kind: "image-digest" | "ami" | "lexicon-version";
|
|
66
|
+
/** The reference value itself (a digest, an AMI id, a version string). */
|
|
67
|
+
value: string;
|
|
68
|
+
/** Logical resource name the reference was found on, when known. */
|
|
69
|
+
resourceName?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** One nested stack/module a template references (an `AWS::CloudFormation::Stack` resource, or the equivalent nesting concept in another lexicon). */
|
|
73
|
+
export interface NestedStackReference {
|
|
74
|
+
/** Logical name of the nesting resource in the parent template. */
|
|
75
|
+
resourceName: string;
|
|
76
|
+
/** Referenced child template's filename/path, when the parent's `Properties` names one (e.g. CFN's `TemplateURL`). */
|
|
77
|
+
templatePath?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Structural summary produced by walking a synthesized template — the data `extractConfigBom` projects into a `BomPackage[]` for the SPDX/CycloneDX writer. */
|
|
81
|
+
export interface ConfigBomInventory {
|
|
82
|
+
/** Every declared resource, keyed by logical name, with its CFN-style `Type` (e.g. `"AWS::S3::Bucket"`). */
|
|
83
|
+
resources: Array<{ name: string; type: string }>;
|
|
84
|
+
/** Nested stacks/modules this template declares. */
|
|
85
|
+
nestedStacks: NestedStackReference[];
|
|
86
|
+
/** External artifacts referenced by any resource (image digests, AMIs, lexicon versions). */
|
|
87
|
+
externalReferences: ExternalReference[];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** CloudFormation resource type used for nested stacks (see lexicons/aws/src/nested-stack.ts). Other lexicons' nesting resource types can be added here as they gain the same concept. */
|
|
91
|
+
const NESTED_STACK_TYPES = new Set(["AWS::CloudFormation::Stack"]);
|
|
92
|
+
|
|
93
|
+
/** Property names known to carry a nested stack's child template location, checked in order. */
|
|
94
|
+
function extractTemplatePath(properties: Record<string, unknown> | undefined): string | undefined {
|
|
95
|
+
const templateUrl = properties?.TemplateURL;
|
|
96
|
+
if (typeof templateUrl === "string") return templateUrl;
|
|
97
|
+
if (templateUrl && typeof templateUrl === "object") {
|
|
98
|
+
// { "Fn::Sub": "${TemplateBasePath}/network.template.json" } — pull the filename back out.
|
|
99
|
+
const sub = (templateUrl as Record<string, unknown>)["Fn::Sub"];
|
|
100
|
+
if (typeof sub === "string") {
|
|
101
|
+
const match = sub.match(/([^/{}$]+\.template\.json)$/);
|
|
102
|
+
if (match) return match[1];
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** AMI id pattern (`ami-` followed by 8 or 17 hex chars, matching both legacy and current EC2 id formats). */
|
|
109
|
+
const AMI_PATTERN = /^ami-[0-9a-f]{8}([0-9a-f]{9})?$/;
|
|
110
|
+
/** OCI/Docker content digest pattern (`sha256:<64 hex chars>`), the same form ./build-archive.ts's `contentDigest` produces. */
|
|
111
|
+
const DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/;
|
|
112
|
+
/** A bare `image:tag@sha256:digest` or `image@sha256:digest` reference. */
|
|
113
|
+
const IMAGE_DIGEST_REF_PATTERN = /@(sha256:[0-9a-f]{64})$/;
|
|
114
|
+
|
|
115
|
+
/** Property names likely to carry a lexicon version string, so a bare semver value there is attributed as `lexicon-version` rather than left unclassified. */
|
|
116
|
+
const LEXICON_VERSION_PROPERTY_NAMES = new Set(["LexiconVersion", "lexiconVersion", "ChantLexiconVersion"]);
|
|
117
|
+
|
|
118
|
+
/** Walk one resource's `Properties` tree (recursively, since a reference can be nested inside e.g. a container definition list) collecting external references. */
|
|
119
|
+
function scanPropertiesForReferences(
|
|
120
|
+
resourceName: string,
|
|
121
|
+
properties: unknown,
|
|
122
|
+
propertyKeyHint: string | undefined,
|
|
123
|
+
out: ExternalReference[],
|
|
124
|
+
): void {
|
|
125
|
+
if (properties === null || properties === undefined) return;
|
|
126
|
+
if (typeof properties === "string") {
|
|
127
|
+
if (AMI_PATTERN.test(properties)) {
|
|
128
|
+
out.push({ kind: "ami", value: properties, resourceName });
|
|
129
|
+
} else if (DIGEST_PATTERN.test(properties) || IMAGE_DIGEST_REF_PATTERN.test(properties)) {
|
|
130
|
+
const digestMatch = properties.match(IMAGE_DIGEST_REF_PATTERN);
|
|
131
|
+
out.push({ kind: "image-digest", value: digestMatch ? digestMatch[1]! : properties, resourceName });
|
|
132
|
+
} else if (propertyKeyHint && LEXICON_VERSION_PROPERTY_NAMES.has(propertyKeyHint) && /^\d+\.\d+\.\d+/.test(properties)) {
|
|
133
|
+
out.push({ kind: "lexicon-version", value: properties, resourceName });
|
|
134
|
+
}
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (Array.isArray(properties)) {
|
|
138
|
+
for (const item of properties) scanPropertiesForReferences(resourceName, item, propertyKeyHint, out);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (typeof properties === "object") {
|
|
142
|
+
for (const [key, value] of Object.entries(properties as Record<string, unknown>)) {
|
|
143
|
+
scanPropertiesForReferences(resourceName, value, key, out);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Walk a synthesized template's parsed JSON, enumerating declared resources,
|
|
150
|
+
* nested stack/module references, and external artifact references (image
|
|
151
|
+
* digests, AMI ids, lexicon versions found in resource properties).
|
|
152
|
+
*
|
|
153
|
+
* Deliberately structural, not semantic: this never talks to AWS/any cloud
|
|
154
|
+
* to resolve what an AMI id or image digest actually *is* — it only records
|
|
155
|
+
* that a resource's properties reference one, the same "surface the
|
|
156
|
+
* reference, don't interpret it" stance ./sbom-generator.ts's module doc
|
|
157
|
+
* takes for SBOM content generally.
|
|
158
|
+
*/
|
|
159
|
+
export function inventoryTemplate(templateJson: string): ConfigBomInventory {
|
|
160
|
+
const template: SynthesizedTemplate = JSON.parse(templateJson);
|
|
161
|
+
const resources: Array<{ name: string; type: string }> = [];
|
|
162
|
+
const nestedStacks: NestedStackReference[] = [];
|
|
163
|
+
const externalReferences: ExternalReference[] = [];
|
|
164
|
+
|
|
165
|
+
for (const [name, resource] of Object.entries(template.Resources ?? {})) {
|
|
166
|
+
const type = resource.Type ?? "Unknown";
|
|
167
|
+
resources.push({ name, type });
|
|
168
|
+
|
|
169
|
+
if (NESTED_STACK_TYPES.has(type)) {
|
|
170
|
+
nestedStacks.push({ resourceName: name, templatePath: extractTemplatePath(resource.Properties) });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
scanPropertiesForReferences(name, resource.Properties, undefined, externalReferences);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return { resources, nestedStacks, externalReferences };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Project a `ConfigBomInventory` into the standard-agnostic `BomPackage[]` shape ./bom-writer.ts's writers consume. Every declared resource becomes a `type: "config"` component; every external reference becomes a `type: "external-reference"` component, so a config-BOM reader sees "what chant declared" and "what chant points at but doesn't own" as distinguishable entries rather than a flat, ambiguous list. */
|
|
180
|
+
export function inventoryToBomPackages(inventory: ConfigBomInventory): BomPackage[] {
|
|
181
|
+
const resourcePackages: BomPackage[] = inventory.resources.map((r) => ({
|
|
182
|
+
name: r.name,
|
|
183
|
+
version: r.type,
|
|
184
|
+
type: "config",
|
|
185
|
+
}));
|
|
186
|
+
const nestedStackNames = new Set(inventory.nestedStacks.map((n) => n.resourceName));
|
|
187
|
+
for (const pkg of resourcePackages) {
|
|
188
|
+
if (nestedStackNames.has(pkg.name)) {
|
|
189
|
+
const nested = inventory.nestedStacks.find((n) => n.resourceName === pkg.name);
|
|
190
|
+
if (nested?.templatePath) pkg.purl = `chant:nested-stack/${nested.templatePath}`;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const referencePackages: BomPackage[] = inventory.externalReferences.map((ref, i) => ({
|
|
194
|
+
name: `${ref.kind}:${ref.resourceName ?? "unknown"}:${i}`,
|
|
195
|
+
version: ref.value,
|
|
196
|
+
type: "external-reference",
|
|
197
|
+
}));
|
|
198
|
+
return [...resourcePackages, ...referencePackages];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── extract-config-bom capability ───────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
export interface ExtractConfigBomInput {
|
|
204
|
+
/** Archive-relative (or local) path to the synthesized template being scanned — the `template` archive entry's `path`. An `archive:`-prefixed reference is accepted and stripped, matching `generate-sbom`'s `path` convention. */
|
|
205
|
+
path: string;
|
|
206
|
+
/** Serialized template content (the same bytes `addArchiveTemplate`, ./build.ts, was/will be given) — this capability never reads from disk itself, keeping it pure/hermetic and testable with an in-memory fixture. */
|
|
207
|
+
content: string;
|
|
208
|
+
/** Digest of the `template` archive entry this config-BOM describes (see ./build-archive.ts's `contentDigest`). Omitted when scanning a template ahead of it being archived. */
|
|
209
|
+
digest?: string;
|
|
210
|
+
/** BOM format to emit. Defaults to `DEFAULT_SBOM_FORMAT` (SPDX), same precedence story as `generate-sbom` (see ../../config.ts's `resolveSbomFormat`). */
|
|
211
|
+
format?: SbomFormat;
|
|
212
|
+
/** Where the config-BOM document is written inside the build archive. Defaults to `<path>.config-bom.json`. */
|
|
213
|
+
into?: string;
|
|
214
|
+
/** Manifest to extend, so a component's whole build phase (template + config-BOM, alongside any image + software SBOM) accumulates one manifest — same accumulation convention as `generate-sbom`'s `manifest` input. */
|
|
215
|
+
manifest?: BuildArchiveManifest;
|
|
216
|
+
/** Directory to also write the config-BOM document to on disk, mirroring the software SBOM's `sbom.<format>.json` convention (see ./lockfile-sbom-generator.ts's `sbomOutputPath`). Omitted: archive-only, no disk write (useful in tests). */
|
|
217
|
+
outDir?: string;
|
|
218
|
+
/** Clock override for deterministic tests. */
|
|
219
|
+
now?: () => Date;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export interface ExtractConfigBomOutput {
|
|
223
|
+
/** The generated config-BOM document (format, media type, bytes, package count, generator — reuses `SbomDocument`'s shape since a config-BOM is structurally the same kind of document as a software SBOM). */
|
|
224
|
+
bom: SbomDocument;
|
|
225
|
+
/** Structural inventory this config-BOM was derived from — resources, nested stacks, external references — surfaced directly so a caller/test can assert on template structure without re-parsing the BOM bytes. */
|
|
226
|
+
inventory: ConfigBomInventory;
|
|
227
|
+
/** Where the config-BOM was written inside the build archive. */
|
|
228
|
+
archivePath: string;
|
|
229
|
+
/** Content-addressed digest of the config-BOM document's own bytes. */
|
|
230
|
+
digest: string;
|
|
231
|
+
/** The build archive's manifest, now including this config-BOM's entry (`kind: "sbom"`, `bomKind: "config"`) alongside whatever `input.manifest` already held. */
|
|
232
|
+
manifest: BuildArchiveManifest;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Default archive path for a generated config-BOM when `into` is omitted: `<template path>.config-bom.json`. */
|
|
236
|
+
function defaultConfigBomPath(templatePath: string): string {
|
|
237
|
+
return `${archiveRelativePath(templatePath)}.config-bom.json`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Tool name recorded on the archive entry / BOM document — surfaced the same way `generate-sbom` records `sbom.generator` (e.g. "syft-1.2.3"). */
|
|
241
|
+
const CONFIG_BOM_GENERATOR = "chant-config-bom-extractor";
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Extract a config-BOM from a synthesized IaC template's declared resources,
|
|
245
|
+
* nested stacks, and external references, and fold the result into the
|
|
246
|
+
* build-archive manifest as an `sbom`-kind entry (`bomKind: "config"`) linked
|
|
247
|
+
* to the template artifact's digest — the config-BOM peer of `generate-sbom`
|
|
248
|
+
* (./sbom.ts). No rollback, for the same reason `generate-sbom` declares
|
|
249
|
+
* none: an already-generated, content-addressed BOM is evidence, not mutable
|
|
250
|
+
* state to compensate.
|
|
251
|
+
*/
|
|
252
|
+
export function createExtractConfigBomCapability(): Capability<ExtractConfigBomInput, ExtractConfigBomOutput> {
|
|
253
|
+
return {
|
|
254
|
+
kind: "extract-config-bom",
|
|
255
|
+
async run(ctx, input) {
|
|
256
|
+
const templatePath = archiveRelativePath(input.path);
|
|
257
|
+
const format = input.format ?? DEFAULT_SBOM_FORMAT;
|
|
258
|
+
const inventory = inventoryTemplate(input.content);
|
|
259
|
+
const packages = inventoryToBomPackages(inventory);
|
|
260
|
+
|
|
261
|
+
const doc = writeBom(
|
|
262
|
+
format,
|
|
263
|
+
{
|
|
264
|
+
subjectName: templatePath,
|
|
265
|
+
subjectId: input.digest ?? contentDigest(input.content),
|
|
266
|
+
packages,
|
|
267
|
+
generator: CONFIG_BOM_GENERATOR,
|
|
268
|
+
},
|
|
269
|
+
input.now,
|
|
270
|
+
);
|
|
271
|
+
const bom: SbomDocument = { ...doc, packageCount: packages.length, generator: CONFIG_BOM_GENERATOR };
|
|
272
|
+
|
|
273
|
+
if (input.outDir) {
|
|
274
|
+
mkdirSync(input.outDir, { recursive: true });
|
|
275
|
+
writeFileSync(join(input.outDir, `config-bom.${format}.json`), bom.bytes);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const into = input.into ?? defaultConfigBomPath(input.path);
|
|
279
|
+
const digest = contentDigest(bom.bytes);
|
|
280
|
+
const base = input.manifest ?? createBuildArchiveManifest(ctx.component);
|
|
281
|
+
const manifest = addArchiveEntry(base, {
|
|
282
|
+
kind: "sbom",
|
|
283
|
+
bomKind: "config",
|
|
284
|
+
path: into,
|
|
285
|
+
digest,
|
|
286
|
+
mediaType: bom.mediaType,
|
|
287
|
+
subjectDigest: input.digest,
|
|
288
|
+
packageCount: bom.packageCount,
|
|
289
|
+
generator: bom.generator,
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
return { bom, inventory, archivePath: into, digest, manifest };
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Default `extract-config-bom` capability. Pure/hermetic — no injectable
|
|
299
|
+
* backend needed since template inventory is a structural walk, not a scan
|
|
300
|
+
* requiring an external tool.
|
|
301
|
+
*
|
|
302
|
+
* Typically composed immediately after `addArchiveTemplate` (./build.ts) in
|
|
303
|
+
* a config-only/infra component's build phase — `addArchiveTemplate` folds
|
|
304
|
+
* the synthesized template into the archive as a first-class `template`-kind
|
|
305
|
+
* entry with its own content digest (#613 — IaC as a first-class build
|
|
306
|
+
* artifact, peer to `image`/`jar`), and this capability attaches the
|
|
307
|
+
* resulting config-BOM to that same digest.
|
|
308
|
+
*/
|
|
309
|
+
export const extractConfigBomCapability: Capability<ExtractConfigBomInput, ExtractConfigBomOutput> =
|
|
310
|
+
createExtractConfigBomCapability();
|