Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 56 additions & 1 deletion .github/workflows/sbom-interoperability.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ env:
CDX_CLI_URL: https://github.com/CycloneDX/cyclonedx-cli/releases/download/v0.32.0/cyclonedx-linux-x64
CDX_CLI_SHA256: 454879e6a4a405c8a13bff49b8982adcb0596f3019b26b0811c66e4d7f0783e1
FIXTURE_SBOM: test/smoke/testdata/sboms/go.spdx.json
MERGE_FIXTURE_A: test/smoke/testdata/sboms/go.spdx.json
MERGE_FIXTURE_B: test/smoke/testdata/sboms/js.spdx.json
OUTPUT_DIR: sbom-assurance-artifacts

jobs:
Expand Down Expand Up @@ -64,11 +66,56 @@ jobs:
sha256sum "${OUTPUT_DIR}/bomly.spdx.json" "${OUTPUT_DIR}/bomly.cdx.json" \
| tee "${OUTPUT_DIR}/checksums.txt"

- name: Generate a merged SBOM from two source documents
run: |
set -euo pipefail
# Two subprojects, one source document each, so the export has to
# reconcile two documents that each assert their own identity --
# the case ADR-0037 defines and the single-fixture run above never
# reaches.
mkdir -p "${OUTPUT_DIR}/merge-tree/a" "${OUTPUT_DIR}/merge-tree/b"
cp "${MERGE_FIXTURE_A}" "${OUTPUT_DIR}/merge-tree/a/"
cp "${MERGE_FIXTURE_B}" "${OUTPUT_DIR}/merge-tree/b/"
./bin/bomly scan --path "${OUTPUT_DIR}/merge-tree" --recursive --detectors sbom \
--format spdx > "${OUTPUT_DIR}/bomly-merged.spdx.json"
./bin/bomly scan --path "${OUTPUT_DIR}/merge-tree" --recursive --detectors sbom \
--format cyclonedx > "${OUTPUT_DIR}/bomly-merged.cdx.json"
sha256sum "${OUTPUT_DIR}/bomly-merged.spdx.json" "${OUTPUT_DIR}/bomly-merged.cdx.json" \
| tee -a "${OUTPUT_DIR}/checksums.txt"

- name: Check the merged document links its sources
run: |
set -euo pipefail
# A merged document states its own identity and names the documents
# it was built from, rather than adopting one source's identity
# (ADR-0037). Asserted here because only a real two-source run can
# produce it.
python3 - "${OUTPUT_DIR}/bomly-merged.cdx.json" "${MERGE_FIXTURE_A}" "${MERGE_FIXTURE_B}" <<'PYEOF'
import json, sys

merged = json.load(open(sys.argv[1]))
wanted = {json.load(open(path))["documentNamespace"] for path in sys.argv[2:]}
linked = {
ref["url"]
for ref in merged.get("externalReferences", [])
if ref.get("type") == "bom"
}
missing = wanted - linked
if missing:
raise SystemExit(f"merged document does not link its sources: {sorted(missing)}")
if merged.get("serialNumber") in wanted:
raise SystemExit("merged document adopted a source's identity")
print(f"merged document links {len(linked)} source(s): {sorted(linked)}")
PYEOF

- name: Validate SPDX 2.3 output (spdx/tools-java)
run: |
set -euo pipefail
java -jar "${OUTPUT_DIR}/validators/${SPDX_TOOLS_JAR}" Verify \
"${OUTPUT_DIR}/bomly.spdx.json" 2>&1 | tee "${OUTPUT_DIR}/spdx-validation.log"
java -jar "${OUTPUT_DIR}/validators/${SPDX_TOOLS_JAR}" Verify \
"${OUTPUT_DIR}/bomly-merged.spdx.json" 2>&1 \
| tee "${OUTPUT_DIR}/spdx-merged-validation.log"

- name: Validate CycloneDX 1.7 output (cyclonedx-cli)
run: |
Expand All @@ -77,14 +124,18 @@ jobs:
--input-file "${OUTPUT_DIR}/bomly.cdx.json" \
--input-format json --input-version v1_7 --fail-on-errors \
2>&1 | tee "${OUTPUT_DIR}/cyclonedx-validation.log"
"${OUTPUT_DIR}/validators/cyclonedx-cli" validate \
--input-file "${OUTPUT_DIR}/bomly-merged.cdx.json" \
--input-format json --input-version v1_7 --fail-on-errors \
2>&1 | tee "${OUTPUT_DIR}/cyclonedx-merged-validation.log"

- name: Summarize the result
if: always()
run: |
set -euo pipefail
{
printf '# SBOM interoperability assurance\n\n'
printf 'The built binary generated SPDX 2.3 and CycloneDX 1.7 SBOMs from a pinned fixture, and the official validators checked them.\n\n'
printf 'The built binary generated SPDX 2.3 and CycloneDX 1.7 SBOMs from a pinned fixture, and a merged pair from two source documents, and the official validators checked all four.\n\n'
printf -- '- **Revision:** `%s`\n' "${GITHUB_SHA}"
printf -- '- **Validators:** spdx/tools-java `%s`, cyclonedx-cli `%s` (both checksum-pinned)\n\n' "${SPDX_TOOLS_VERSION}" "${CDX_CLI_VERSION}"
if [[ -f "${OUTPUT_DIR}/checksums.txt" ]]; then
Expand All @@ -102,8 +153,12 @@ jobs:
path: |
sbom-assurance-artifacts/bomly.spdx.json
sbom-assurance-artifacts/bomly.cdx.json
sbom-assurance-artifacts/bomly-merged.spdx.json
sbom-assurance-artifacts/bomly-merged.cdx.json
sbom-assurance-artifacts/checksums.txt
sbom-assurance-artifacts/spdx-validation.log
sbom-assurance-artifacts/cyclonedx-validation.log
sbom-assurance-artifacts/spdx-merged-validation.log
sbom-assurance-artifacts/cyclonedx-merged-validation.log
if-no-files-found: warn
retention-days: 14
1 change: 1 addition & 0 deletions dev-docs/SECURITY_ASSURANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ start a subprocess, enable a plugin, or choose an output path.
| Repository manifests, lockfiles, and analyzer source | In-process file reads have a 64 MiB per-file limit that is checked before and during the read. Parsers never receive a partial over-limit document. Discovery is scoped to the selected target, does not follow directory symlinks, and has depth and exclusion controls. Pure parsers have registered fuzz targets and malformed-input tests. | `TestSDKContractReadLimitEnforcesBoundsAndGrowth`, `TestSDKContractReadRepositoryFileEnforces64MiBBound` (local contracts; full suite upstream in `bomly-dev/bomly-sdk` `system/read_test.go`), `test/assurance/REPOSITORY_INPUT_LIMITS.md`, `test/assurance/PARSER_FUZZING.md`, `scripts/run-fuzz.sh`, detector and analyzer package tests | A selected package manager's command output is not truncated because partial output could produce an incomplete graph. A local target may contain an unbounded number of individually bounded files. |
| Project discovery and baselines | Recursive discovery does not follow directory symlinks. Automatic baseline selection warns and ignores a symlinked `.bomly` directory or baseline file. Explicit baseline selection may use a symlink because the user chose that path. Baseline JSON is limited to 16 MiB and 10,000 entries, is strict, and validates duplicates in linear time. It can only supply policy-status decisions. | `TestPlanSubprojectsRecursiveDoesNotFollowSymlinkedDirs`, `TestResolversForTargetIgnoresAutomaticSymlinksAndAllowsExplicitSelection`, `TestResolversForTargetAllowsUserSelectedSymlinkAsProjectRoot`, `TestLoadRejectsMalformedAndUnsupportedDocuments`, `TestLoadRejectsOversizedBaseline`, `TestDocumentEntryLimit`, `TestDocumentRejectsIndexedAdvisoryOverlap`, `FuzzLoad` | An explicitly selected baseline path is trusted and may be a symlink. |
| SBOM and configuration input | Configuration reads are limited to 4 MiB and SBOM reads to 256 MiB before parsing. Strict configuration parsing rejects unknown keys. SBOM parsers are fuzzed and oversized documents fail clearly. | `TestSDKContractReadLimitEnforcesBoundsAndGrowth`, `TestLoadFileRejectsOversizedFile`, `TestDetectorResolveGraph_RejectsOversizedSBOM`, `FuzzLoadFile`, `FuzzUnmarshalAutoJSON` | A user-selected file can still consume work up to its limit. |
| SBOM assertions re-published on export | An ingested document's component and document-level assertions are re-emitted under Bomly's name, so each clears its SDK publication gate on ingest *and* again on export -- a `GraphEntry` is reachable by any detector or plugin, so a value written straight onto one never passed a decoder. Contacts drop email addresses; unpublishable identities, names, comments, references, CPEs and digests are dropped rather than written. Reference types `distribution` and `vcs` are deliberately not ingested, so a detector's origin assertion cannot re-enter as a document assertion (ADR-0033, ADR-0042). Gates are idempotent, so a value cannot be laundered by another hop. | `FuzzIngestedAssertions`, `FuzzDocumentAssertions`, `TestSourceClaimsAreRegatedOnExport`, `TestOriginIsNotReadBackFromAnIngestedDocument`, `TestSingleSourceExportIsAFixedPoint`, `TestIngestLeavesComponentsEligibleForEnrichment` | A published value is only as safe as the SDK gate that admitted it; a gate defect is an SDK defect (see bomly-dev/bomly-sdk#54). A merged SPDX export cannot yet name its sources (bomly-dev/bomly-sdk#55). |
| Plugin download and extraction | Direct URL packages require a checksum unless the user explicitly bypasses it. GitHub release metadata is limited to 4 MiB. ZIP and tar extraction reject traversal, links, and special files. Downloads are limited to 256 MiB; archives to 4,096 entries, 256 MiB per expanded file, and 512 MiB total. Partial files are removed. | `TestResolveGitHubReleaseRejectsOversizedMetadata`, `TestExtractZipArchiveRejectsEscapingAndSymlinkEntries`, `TestExtractTarGzArchiveRejectsEscapingLinksAndSpecialFiles`, `TestCopyDownloadWithLimit`, `TestInstallRemoteArchiveRejectsDeclaredDownloadOverLimit`, `TestArchiveExtractionLimitsAtBoundary`, `TestArchiveExtractionRejectsResourceLimits`, `TestWriteArchiveFileRemovesPartialFileAtLimit` | `--insecure-skip-checksum` is an explicit integrity bypass. |
| Plugin metadata and lifecycle | Manifests and runtime snapshots are limited to 1 MiB; the installed database is limited to 16 MiB. Plugins are installed disabled. Only enabled plugins register or run. The managed environment is allowlisted. | `TestReadFileWithLimitAcceptsExactBoundary`, `TestReadFileWithLimitRejectsOverBoundary`, `TestPluginJSONReadersRejectOversizedFiles`, `TestInstallDevBinaryVerifyEnableDisableAndUninstall`, `TestPrepareLoadsAndRunsExternalDetector`, `TestProtocolV1DetectorSnapshotDefaultsAbsentOptionalCapabilities`, `TestPluginEnvDoesNotForwardUnrelatedHostEnvironment` | Enabled plugins are trusted native processes with the user's privileges. The protocol is not an OS sandbox. |
| Package-manager and detector subprocesses | Debug logs contain executable, sanitized arguments, working directory, and raw subprocess stderr. Credential-like flag values and URL user information are redacted from command arguments. Raw stderr is hidden below debug verbosity and is not added to structured results. Build-tool commands have existing timeouts where their detector contract supplies one. | `TestSDKContractSanitizeArgsRedactsCredentials`, `TestSDKContractCommandStderrHiddenBelowDebug` (local contracts; full suites upstream in `bomly-dev/bomly-sdk` `logkit/command_test.go` and `logkit/stderr_test.go`), `TestNewConsole` (`internal/logging/logger_test.go`), `TestInstallLogsReproducibleCommandAndDebugStderr`, `TestRunGitLogsStderrAtDebug`, Java readiness command tests, representative detector command-log tests | The selected executable, its credential store, registry configuration, network traffic, and filesystem behavior are trusted host concerns. Bomly cannot reliably sanitize arbitrary tool output. Debug stderr may contain credentials or other sensitive values and users must handle debug logs as sensitive data. |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# ADR-0042: A conversion restates its source; a merge links its sources

- **Date:** 2026-09-05
- **Status:** Accepted

## Context

ADR-0037 gave document-level assertions a model home on `GraphEntry` and
stated the shape of the export projection: a merged document asserts its own
aggregate identity and links each source, while a single ingested document
re-exported "reproduces its own assertions". Implementing that (issue #396,
phase 2.5) turned two of its sentences into decisions that had to be made
concretely, because both formats give a document exactly one identity and the
model can hold several.

The first is what a *conversion* does — one source document in, one document
out. Minting a fresh identity is the safe-looking answer, and it is the wrong
one: the source identity then has to be carried as a link, and on the next
ingest that link has nowhere to live, because `DocumentAssertions` describes
what a document says about *itself* and has no field for the documents behind
it. Each hop would therefore differ from the last, and the fixed point #396
requires — Bomly's own export, re-ingested and re-exported, byte-identical —
would be unreachable by construction.

The second is what the export surface receives. The pipeline merges entries
into one `sdk.Graph` before formatting, and that merge is exactly the step
that discards which document each part came from.

## Decision

**The export surface takes the prepared entries alongside the graph.**
`FromGraphEntries(graph, entries, opts)` and `MarshalGraphEntriesJSON` are the
entry points; `FromDepGraph` and `MarshalDepGraphJSON` remain as the no-source
form. Both arguments are passed and neither is derived from the other: the
graph is the one already selected for output — consolidation renamed its
identities and the scope filter decided what stays — so rebuilding it from the
entries would export a different graph than the rest of the command reports.
The entries are there for the one thing only they carry.

**A conversion restates its source.** With exactly one source document, the
exported document adopts that document's identity rather than minting one, in
whichever identity slot the target format can hold it: an SPDX
`documentNamespace` takes any URI, a CycloneDX `serialNumber` takes only a
UUID URN, so a BOM-Link is parsed back to its serial and anything else is
linked instead. A caller-pinned identity always wins over both. The document
*name* is not adopted at the CLI, which names a document after the scanned
project.

**A merge links its sources.** With two or more, the document mints its own
identity and names each source through a reference of type `bom` carrying a
BOM-Link or the source's namespace URI. Creators and tools union in both
cases, which is the SDK's declared merge class for them.

**The claims are re-gated on export, not trusted from the entry.** A
`GraphEntry` is reachable by any detector or external plugin, so a value
written straight onto one never passed a decoder. Everything runs through
`DocumentAssertions.Normalized` on the way out as well as on the way in.

## Consequences

- `export → ingest → export` is a fixed point for both formats, asserted by
`TestSingleSourceExportIsAFixedPoint`. It found a real defect on its first
run: SPDX external references were emitted with the SDK's comparison-form
category (`other`) instead of the specification's spelling (`OTHER`), so an
ingested document changed case on its second export. `SPDXName()` is the
authority now.
- The fixed point holds within a format, not across one. A CycloneDX document
cannot hold a non-UUID identity, so an SPDX source converted to CycloneDX is
linked rather than adopted, and the chain back is a different document.
- A merged SPDX document links nothing. SPDX names another document through
`externalDocumentRefs`, and every entry requires a checksum over that
document's bytes — computable only at ingest, and `DocumentAssertions` has
nowhere to keep it. Filed as bomly-dev/bomly-sdk#55; the CycloneDX half
ships now, and the SPDX projection belongs beside it when the carrier grows
the field. Merged SPDX documents still preserve every component assertion.
- Consolidation carries `GraphEntry.Document` through, which it previously
dropped when rebuilding entries. That was silent: nothing downstream read
the field yet.
- `FuzzDocumentAssertions` covers the export projection with hostile claims
and asserts idempotence, the property whose failure in the component half
surfaced bomly-dev/bomly-sdk#54. The merged case is validated end to end by
the SBOM interoperability workflow, through both official validators.
1 change: 1 addition & 0 deletions dev-docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,4 @@ status to `Superseded by [ADR-NNNN](NNNN-slug.md)`; do not rewrite history
| ADR-0039 | 2026-08-26 | [Both modules build on Go 1.27; untrusted JSON parses strictly](0039-both-modules-build-on-go-1-27.md) | Accepted |
| ADR-0040 | 2026-08-26 | [The SDK is the default home for behavior](0040-the-sdk-is-the-default-home-for-behavior.md) | Accepted |
| ADR-0041 | 2026-08-29 | [Identity is the canonical PURL on typed graph nodes](0041-identity-is-the-canonical-purl-on-typed-nodes.md) | Accepted |
| ADR-0042 | 2026-09-05 | [A conversion restates its source; a merge links its sources](0042-a-conversion-restates-its-source-a-merge-links-its-sources.md) | Accepted |
Loading
Loading