also looking at this
fix(sbom): preserve the full dependency inventory when merging
#297
4 files
+106
-120
| @@ -277,6 +277,9 @@ jobs: | ||
| 277 | 277 | - name: Verify API documentation links |
| 278 | 278 | run: bun run verify:docs |
| 279 | 279 | |
| 280 | - name: Test merged SBOM inventory | |
| 281 | run: bun test scripts/merge-sbom.test.ts | |
| 282 | ||
| 280 | 283 | - name: Run pickier (lint + format check) |
| 281 | 284 | run: bun run lint |
| 282 | 285 | |
| @@ -43,126 +43,8 @@ jobs: | ||
| 43 | 43 | syft . -o cyclonedx-json=sbom/full-sbom.cyclonedx.json |
| 44 | 44 | syft . -o spdx-json=sbom/full-sbom.spdx.json |
| 45 | 45 | |
| 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 | |
| 166 | 48 | |
| 167 | 49 | - name: Validate SBOMs |
| 168 | 50 | run: | |
| @@ -0,0 +1,32 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { mergeSbom } from './merge-sbom' | |
| 3 | ||
| 4 | const 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 | ||
| 11 | test('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 | ||
| 28 | test('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 | }) | |
| @@ -0,0 +1,69 @@ | ||
| 1 | import { randomUUID } from 'node:crypto' | |
| 2 | import { readFileSync, writeFileSync } from 'node:fs' | |
| 3 | import { join, resolve } from 'node:path' | |
| 4 | ||
| 5 | type Component = { name: string, 'bom-ref'?: string, [key: string]: unknown } | |
| 6 | type 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 | ||
| 15 | export 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 | ||
| 59 | if (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 | } | |