ReviewOS

also looking at this

craft-native/craft

fix(sbom): preserve the full dependency inventory when merging

#297
Merged glennmichael123 wants to merge codex/sbom-inventory into main
4 files +106 -120
.github/workflows/ci.ymlmodified+3-0
Changes to .github/workflows/ci.yml
@@ -277,6 +277,9 @@ jobs:
277277 - name: Verify API documentation links
278278 run: bun run verify:docs
279279
280 - name: Test merged SBOM inventory
281 run: bun test scripts/merge-sbom.test.ts
282
280283 - name: Run pickier (lint + format check)
281284 run: bun run lint
282285
.github/workflows/sbom.ymlmodified+2-120
Changes to .github/workflows/sbom.yml
@@ -43,126 +43,8 @@ jobs:
4343 syft . -o cyclonedx-json=sbom/full-sbom.cyclonedx.json
4444 syft . -o spdx-json=sbom/full-sbom.spdx.json
4545
46 - name: Generate Zig dependencies SBOM
47 run: |
48 # Create a custom SBOM for Zig dependencies
49 cat > sbom/zig-dependencies.json << 'EOF'
50 {
51 "bomFormat": "CycloneDX",
52 "specVersion": "1.5",
53 "version": 1,
54 "metadata": {
55 "timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
56 "component": {
57 "type": "library",
58 "name": "craft-zig-core",
59 "version": "0.0.1",
60 "description": "Craft Zig Core - Native application framework"
61 }
62 },
63 "components": [
64 {
65 "type": "library",
66 "name": "zig-std",
67 "version": "stable",
68 "description": "Zig Standard Library",
69 "purl": "pkg:zig/std@stable",
70 "licenses": [{"license": {"id": "MIT"}}]
71 }
72 ],
73 "externalReferences": [
74 {
75 "type": "website",
76 "url": "https://github.com/craft-native/craft"
77 },
78 {
79 "type": "vcs",
80 "url": "https://github.com/craft-native/craft.git"
81 }
82 ]
83 }
84 EOF
85
86 # Add platform-specific dependencies
87 cat > sbom/platform-dependencies.json << 'EOF'
88 {
89 "bomFormat": "CycloneDX",
90 "specVersion": "1.5",
91 "version": 1,
92 "metadata": {
93 "component": {
94 "type": "application",
95 "name": "craft-platform-deps",
96 "version": "0.0.1"
97 }
98 },
99 "components": [
100 {
101 "type": "library",
102 "name": "WebKit",
103 "description": "macOS WebKit framework",
104 "purl": "pkg:apple/webkit",
105 "licenses": [{"license": {"id": "LGPL-2.1-or-later"}}],
106 "properties": [{"name": "platform", "value": "macos"}]
107 },
108 {
109 "type": "library",
110 "name": "GTK3",
111 "description": "GTK 3 toolkit",
112 "purl": "pkg:generic/gtk@3",
113 "licenses": [{"license": {"id": "LGPL-2.1-or-later"}}],
114 "properties": [{"name": "platform", "value": "linux"}]
115 },
116 {
117 "type": "library",
118 "name": "WebKit2GTK",
119 "version": "4.1",
120 "description": "WebKit2 GTK port",
121 "purl": "pkg:generic/webkit2gtk@4.1",
122 "licenses": [{"license": {"id": "LGPL-2.1-or-later"}}],
123 "properties": [{"name": "platform", "value": "linux"}]
124 },
125 {
126 "type": "library",
127 "name": "WebView2",
128 "description": "Microsoft Edge WebView2",
129 "purl": "pkg:nuget/Microsoft.Web.WebView2",
130 "licenses": [{"license": {"id": "BSD-3-Clause"}}],
131 "properties": [{"name": "platform", "value": "windows"}]
132 }
133 ]
134 }
135 EOF
136
137 - name: Merge SBOMs
138 run: |
139 # Create a merged SBOM with all components
140 cat > sbom/craft-sbom.json << EOF
141 {
142 "bomFormat": "CycloneDX",
143 "specVersion": "1.5",
144 "version": 1,
145 "serialNumber": "urn:uuid:$(uuidgen || cat /proc/sys/kernel/random/uuid)",
146 "metadata": {
147 "timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
148 "tools": [
149 {"name": "syft"}
150 ],
151 "component": {
152 "type": "application",
153 "name": "craft",
154 "version": "${GITHUB_REF_NAME:-0.0.1}",
155 "description": "Build desktop apps with web languages, powered by Zig",
156 "licenses": [{"license": {"id": "MIT"}}],
157 "externalReferences": [
158 {"type": "website", "url": "https://github.com/craft-native/craft"},
159 {"type": "vcs", "url": "https://github.com/craft-native/craft.git"},
160 {"type": "issue-tracker", "url": "https://github.com/craft-native/craft/issues"}
161 ]
162 }
163 }
164 }
165 EOF
46 - name: Merge scanned and declared runtime dependencies
47 run: bun scripts/merge-sbom.ts sbom
16648
16749 - name: Validate SBOMs
16850 run: |
scripts/merge-sbom.test.tsadded+32-0
Changes to scripts/merge-sbom.test.ts
@@ -0,0 +1,32 @@
1import { expect, test } from 'bun:test'
2import { mergeSbom } from './merge-sbom'
3
4const source = {
5 bomFormat: 'CycloneDX', specVersion: '1.7',
6 metadata: { component: { name: '.', 'bom-ref': 'source-root' }, tools: { components: [{ name: 'syft', version: '1.52.0' }] } },
7 components: [{ name: 'first', 'bom-ref': 'one' }, { name: 'second', 'bom-ref': 'two' }],
8 dependencies: [{ ref: 'source-root', dependsOn: ['one'] }, { ref: 'one', dependsOn: ['two'] }],
9}
10
11test('preserves the scanned inventory and dependency graph when adding runtime supplements', () => {
12 const original = structuredClone(source)
13 const { combined, zig, platform } = mergeSbom(source, '0.0.93', '0.17.0-dev.1963')
14 expect(combined.components).toHaveLength(7)
15 expect(combined.components.slice(0, 2)).toEqual(source.components)
16 expect(combined.dependencies?.find(dependency => dependency.ref === 'one')).toEqual(source.dependencies[1])
17 expect(combined.dependencies?.find(dependency => dependency.ref === 'source-root')?.dependsOn).toContain('one')
18 expect(combined.dependencies?.find(dependency => dependency.ref === 'source-root')?.dependsOn).toHaveLength(6)
19 expect(combined.metadata?.tools).toEqual(source.metadata.tools)
20 expect(combined.metadata?.component).toMatchObject({ name: 'craft', version: 'v0.0.93', 'bom-ref': 'source-root' })
21 expect(zig.metadata?.component?.version).toBe('0.0.93')
22 expect(platform.metadata?.component?.version).toBe('0.0.93')
23 expect(zig.components[0].version).toBe('0.17.0-dev.1963')
24 expect(Number.isNaN(Date.parse(String(zig.metadata?.timestamp)))).toBe(false)
25 expect(source).toEqual(original)
26})
27
28test('refuses missing inventory and colliding component references', () => {
29 expect(() => mergeSbom({ ...source, components: [] }, '1.0.0', 'dev')).toThrow('populated')
30 expect(() => mergeSbom({ ...source, bomFormat: 'SPDX' }, '1.0.0', 'dev')).toThrow('CycloneDX')
31 expect(() => mergeSbom({ ...source, components: [{ name: 'collision', 'bom-ref': 'urn:craft:zig-std' }] }, '1.0.0', 'dev')).toThrow('collides')
32})
scripts/merge-sbom.tsadded+69-0
Changes to scripts/merge-sbom.ts
@@ -0,0 +1,69 @@
1import { randomUUID } from 'node:crypto'
2import { readFileSync, writeFileSync } from 'node:fs'
3import { join, resolve } from 'node:path'
4
5type Component = { name: string, 'bom-ref'?: string, [key: string]: unknown }
6type Bom = {
7 bomFormat: string
8 specVersion: string
9 metadata?: { component?: Component, [key: string]: unknown }
10 components: Component[]
11 dependencies?: { ref: string, dependsOn?: string[] }[]
12 [key: string]: unknown
13}
14
15export function mergeSbom(source: Bom, version: string, zigVersion: string): { combined: Bom, zig: Bom, platform: Bom } {
16 if (source.bomFormat !== 'CycloneDX' || !Array.isArray(source.components) || source.components.length === 0)
17 throw new Error('A populated CycloneDX inventory is required before merging')
18 const timestamp = new Date().toISOString()
19 const supplement = (name: string, components: Component[]): Bom => ({
20 bomFormat: 'CycloneDX', specVersion: source.specVersion, version: 1,
21 metadata: { timestamp, component: { type: 'application', name, version } },
22 components,
23 })
24 const zig = supplement('craft-zig-core', [{
25 'bom-ref': 'urn:craft:zig-std', type: 'library', name: 'zig-std', version: zigVersion,
26 description: 'Standard library for the repository-declared Zig toolchain',
27 properties: [{ name: 'craft:version-source', value: 'pantry.jsonc dependencies.ziglang.org' }],
28 licenses: [{ license: { id: 'MIT' } }],
29 }])
30 const platform = supplement('craft-platform-deps', [
31 { 'bom-ref': 'urn:craft:webkit', type: 'library', name: 'WebKit', description: 'macOS WebKit system framework', properties: [{ name: 'platform', value: 'macos' }] },
32 { 'bom-ref': 'urn:craft:gtk3', type: 'library', name: 'GTK3', description: 'GTK 3 system toolkit', properties: [{ name: 'platform', value: 'linux' }] },
33 { 'bom-ref': 'urn:craft:webkit2gtk', type: 'library', name: 'WebKit2GTK', description: 'WebKit2GTK 4.1 API system dependency', properties: [{ name: 'platform', value: 'linux' }] },
34 { 'bom-ref': 'urn:craft:webview2', type: 'library', name: 'WebView2', description: 'Microsoft Edge WebView2 system runtime', properties: [{ name: 'platform', value: 'windows' }] },
35 ])
36 const combined = structuredClone(source)
37 const extra = [...zig.components, ...platform.components]
38 const existing = new Set(combined.components.map(component => component['bom-ref']).filter(Boolean))
39 if (extra.some(component => existing.has(component['bom-ref']!)))
40 throw new Error('Supplement component reference collides with the source inventory')
41 const rootRef = source.metadata?.component?.['bom-ref'] ?? 'urn:craft:root'
42 combined.serialNumber = `urn:uuid:${randomUUID()}`
43 combined.metadata = {
44 ...combined.metadata,
45 timestamp,
46 component: { 'bom-ref': rootRef, type: 'application', name: 'craft', version: `v${version}`, licenses: [{ license: { id: 'MIT' } }] },
47 }
48 combined.components.push(...extra)
49 combined.dependencies ??= []
50 let rootDependency = combined.dependencies.find(dependency => dependency.ref === rootRef)
51 if (!rootDependency) {
52 rootDependency = { ref: rootRef, dependsOn: [] }
53 combined.dependencies.push(rootDependency)
54 }
55 rootDependency.dependsOn = [...new Set([...(rootDependency.dependsOn ?? []), ...extra.map(component => component['bom-ref']!)])]
56 return { combined, zig, platform }
57}
58
59if (import.meta.main) {
60 const directory = resolve(Bun.argv[2] ?? 'sbom')
61 const root = resolve(import.meta.dir, '..')
62 const { version } = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
63 const toolchain = Bun.JSONC.parse(readFileSync(join(root, 'pantry.jsonc'), 'utf8')).dependencies['ziglang.org']
64 const source = JSON.parse(readFileSync(join(directory, 'full-sbom.cyclonedx.json'), 'utf8'))
65 const { combined, zig, platform } = mergeSbom(source, version, toolchain)
66 for (const [name, document] of Object.entries({ 'craft-sbom.json': combined, 'zig-dependencies.json': zig, 'platform-dependencies.json': platform }))
67 writeFileSync(join(directory, name), `${JSON.stringify(document, null, 2)}\n`)
68 console.log(`Merged ${source.components.length} scanned and ${zig.components.length + platform.components.length} declared runtime components`)
69}