Skip to content

fix(sbom): stop sbom validation failing on duplicate component links - #326

Draft
reyreavman wants to merge 6 commits into
mainfrom
fix/sbom/external-ref-dup-and-checks
Draft

reyreavman wants to merge 6 commits into
mainfrom
fix/sbom/external-ref-dup-and-checks

Conversation

@reyreavman

@reyreavman reyreavman commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator

Summary

An image SBOM could carry two external references of the same type for one component, which the ISPRAS checker reports and werf turns into a validation failure. The enrichment step also accepted any CycloneDX reference type from the PURL resolver and dropped the archive digest the resolver reports for a source distribution, so a green build could produce an SBOM that fails validation afterwards. werf sbom validate now reaches the checker's source-distribution and leaf-only VCS checks, and refuses the one combination the checker does not honor.

What

Enrichment

  • A component that already has an external reference of the resolved kind keeps it, and the resolved one is dropped; every component ends up with at most one reference per type.
  • An image built on top of another no longer accumulates a reference per merge: re-enriching an already enriched BOM is a no-op for components that already carry that type.
  • os-pm packages keep the vcs link supplied by os-pm; the resolver no longer adds a second one next to it.
  • BREAKING: a build fails when the PURL resolver answers with a reference kind other than vcs or source-distribution, with enrich: external reference kind "<kind>" is not allowed, expected "vcs" or "source-distribution". Such a kind previously produced an SBOM that failed ISPRAS validation later; a resolver returning e.g. website now stops the build instead.
  • A source-distribution reference carries the STREEBOG digest the resolver reports in hashes; a vcs reference carries none.
  • BREAKING: a build fails when the resolver answers source-distribution without a usable digest — no hashes, an algorithm other than STREEBOG-256/STREEBOG-512, or content that is not 64/128 hex characters — with enrich: source distribution has no hashes … or the matching algorithm/content error. Previously such a component entered the SBOM as a leaf with a bare archive URL, which the ISPRAS oss schema rejects.
    • VERIFIED: the production resolver returns hashes: [{"alg":"STREEBOG-256",…}] for pkg:npm/commondir@1.0.1 and hashes: [] for vcs results.
  • A source distribution inherited from a base image's SBOM without a digest — hashes absent or hashes: [] — is replaced by the freshly resolved one instead of being kept; one that already carries a digest is kept.
  • The BOM-wide externalReferences list is derived from the final component references: a link replaced on one component stays listed while another component still carries it with a digest.

werf sbom validate

  • --check-source-distribution makes the checker verify that each source-distribution URL exists and points to an archive; default false. It also turns on VCS URL validation of every component, exactly as --check-vcs does — that is how the checker image behaves, and the flag help says so.
  • --check-vcs-leaf-only restricts VCS URL checking to leaf components; default false.
  • --check-vcs-leaf-only together with --check-source-distribution is rejected before the checker runs, with --check-vcs-leaf-only cannot be combined with --check-source-distribution: the checker would skip source distributions of non-leaf components; use --check-vcs instead. The checker applies the leaf filter before reading any reference, so the combination silently left non-leaf archives unchecked.
  • The validation log header lists the checks the checker really runs, not the flags passed: --check-source-distribution alone yields with VCS, source distribution check; --check-vcs --check-vcs-leaf-only yields with leaf-only VCS check.
  • VERIFIED: with --check-source-distribution a dead tarball URL yields WARNING: … не указывает на архив или не существует and Result: 0 passed, 1 failed; without the flag the same file passes that check silently.
  • UNVERIFIED: an enriched BOM whose leaf component carries only a source-distribution reference with hashes has not been run through the ISPRAS checker image; the schema (component_specexternalReferences with STREEBOG hashes) was read, not executed. A fixture run against the pinned checker image would settle it.

Why

Two independent producers write external references — os-pm for system packages and the PURL resolver — and the resolver appended unconditionally, so a component resolved to a kind it already had ended up with two links. Enrichment also runs on the merged BOM of every downstream image, and it runs after DedupBOM, so each merge appended another copy. The ISPRAS checker warns on a component with two distinct vcs links and werf treats any warning as a failure, which makes the published SBOM unvalidatable.

Deduplicating by URL and type would not have been enough: the checker collapses identical links and only reports distinct ones, which is exactly the os-pm plus resolver case.

The enrichment allow-list was the full CycloneDX enum while the ISPRAS schema requires vcs or source-distribution per component, so the two disagreed and the disagreement surfaced at validation time rather than at the build that caused it. The same schema requires a leaf whose only link is a source distribution to carry a STREEBOG digest of the archive; the resolver already computes it, but the response struct had no field for it.

The two validate flags leak the checker image's internals: its --check-vcs-leaf-only is a continue ahead of any reference parsing, and its VCS collection is not gated by --check-vcs. Fixing the image would be the right long-term answer, but the image is consumed by a floating tag, so a werf-side guard is the only thing that makes the CLI contract hold for whatever image is pulled. Running the checker twice — leaf-only VCS in one pass, source distribution in another — is not an alternative: the second pass would still run the full VCS check.

The ISPRAS checker can verify that a source-distribution url exists and
points to an archive, and can restrict vcs checks to leaf components, but
neither switch was reachable: only --check-vcs was passed to the container.
A dead source distribution link therefore passed validation silently.

Expose both as --check-source-distribution and --check-vcs-leaf-only.

Signed-off-by: Radmir Khurum <radmir.khurum@flant.com>
A component that already carries an external reference of the resolved kind
got a second one appended: os-pm supplies a vcs link of its own, and every
downstream image re-enriches an already enriched BOM. Two links of the same
type make the ISPRAS checker report the component, which werf treats as a
validation failure.

Keep the reference already present and skip the resolved one.

Signed-off-by: Radmir Khurum <radmir.khurum@flant.com>
…ccept

The resolver response was accepted for any CycloneDX reference type, while
the ISPRAS schema demands a vcs or a source-distribution link per component.
A website or issue-tracker answer therefore produced a green build and an
SBOM that fails validation later, away from the change that caused it.

Fail the build on any other kind instead.

Signed-off-by: Radmir Khurum <radmir.khurum@flant.com>
@reyreavman

reyreavman commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator Author

Verification

  • Ran the ISPRAS checker image directly on hand-built fixtures to establish what it actually reports: one vcs link, two identical vcs links and vcs + source-distribution all pass; two distinct vcs links produce содержит 2 ссылки типа vcs. That is what the dedup rule is shaped around.
  • Ran werf sbom validate --check-vcs --check-source-distribution against a BOM holding a dead npm tarball URL: the warning appears only with the flag, and the run exits 1.
  • Ran the built binary with --check-vcs-leaf-only --check-source-distribution: exits 1 with the "cannot be combined" error before any container starts.
  • Built a two-image project (yarn packages directive plus an import from the first image) against a local registry with the built binary and read both SBOMs back: one external reference per component, no duplicates.
  • Queried the production PURL resolver for pkg:npm/commondir@1.0.1: kind: source-distribution, hashes: [{"alg":"STREEBOG-256","content":"4559fe…"}]; vcs results return hashes: []. The resolver mock in helpers_test.go reproduces that payload verbatim.
  • Read the checker source (sbom-checker.py, schemas/schema.json) for the two flag claims: leaf filter is a continue before externalReferences is read; vcs collection runs under check_vcs or check_vcs_leaf_only or check_source_distribution.
  • Mutation: restored pkg/sbom/externalref/enricher.go from origin/main → 4 specs failed, including keeps a single reference of a type the component already has and does not duplicate references when an enriched BOM is enriched again.
  • Mutation: RunOptions.Validate condition replaced with false → 2 specs failed (leaf-only vcs and source distribution, every check).
  • Mutation: hasHashes reduced to a nil check → 1 spec failed (replaces a source distribution … hashes empty).
  • Mutation: BOM-wide list restored to incremental "delete old key on replace" → 2 specs failed (keeps a shared source distribution in the BOM list …, both component orders).
  • Not run: test/e2e/sbom — the suites need a trusted builder base image from an external registry. The resolver mocks in those suites answer kind: "vcs", so the narrowed allow-list should not affect them, but that is read from the fixtures, not observed.
  • Not run: the checker image against an enriched leaf carrying only source-distribution + STREEBOG hashes. The image is consumed by the floating :master tag, so a runtime regression test would follow the image, not the code.

Review focus

  • The tie-break in Enrich: the pre-existing reference wins and the resolved one is dropped, except a hash-less source distribution, which the resolved one replaces. For os-pm packages that keeps os-pm's git:// URL rather than the resolver's https:// one, which matters under --check-vcs if a host no longer serves the git protocol. Confirm this is the precedence we want.
  • enabledChecks now models the checker's semantics (source-distribution ⇒ VCS, leaf-only narrows) rather than echoing flags. If the image is fixed later, this mapping has to follow.
  • docs/_includes/reference/cli/werf_sbom_validate.md is generated by task doc:gen.

Follow-up

  • Fix --check-vcs-leaf-only / --check-source-distribution coupling in the checker image (3p-ispras-sbom-checker, sbom-checker.py), then drop the werf-side guard.
  • Pin checker.Image to a digest instead of :master; without it a runtime regression test for the flags is not reproducible.
  • Decide whether --check-source-distribution should be enabled by default in the pipelines that already pass --check-vcs.

…ut a digest

The PURL resolver reports a STREEBOG (GOST R 34.11-2012) digest of the
archive next to a source-distribution url, but the resolve response was
decoded into a struct without a hashes field, so the digest was dropped and
the external reference entered the SBOM with a url alone. The ISPRAS oss
schema requires a leaf component whose only link is a source distribution to
carry that digest, so every such component made the published SBOM
unvalidatable while the build stayed green.

Hashes now travel from the resolver into the external reference, and
enrichment rejects a source distribution it could not be validated with: no
digest at all, an algorithm other than STREEBOG-256 or STREEBOG-512, or
content that is not 64 respectively 128 hexadecimal characters. A vcs
reference stays free of hashes, as the schema expects.

A source distribution left without a digest by an earlier enrichment — the
merged BOM of a base image built before this change — is replaced by the
resolved one instead of being kept: that base image cannot be fixed from the
downstream build, and keeping its link would carry the unvalidatable
reference into this SBOM too.

Signed-off-by: Radmir Khurum <radmir.khurum@flant.com>

@Fral738 Fral738 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two interactions in the checker image make the newly exposed checks behave differently from their CLI contract:

  • Combining --check-source-distribution with --check-vcs-leaf-only silently skips source archives on non-leaf components. A dead parent archive changes from failure to success just by enabling the VCS modifier.
  • --check-source-distribution alone also enables VCS validation, despite --check-vcs=false and the log reporting only a source distribution check.

The inline comments contain reproductions and concrete corrections. They were reproduced through the built CLI against checker image digest sha256:d9a95347a28b4bbbd6163b0c0dc5215e0e5b5a60acc2d519a55cc5bca86e6c49.

There is also a description/test claim to correct: "a component carrying only that reference is not an error" is not true of the source-distribution reference produced by the enricher. The ISPRAS schema requires hashes, including a STREEBOG hash, when a leaf has no vcs reference. The new acceptance test only checks the reference type; the resolver outcome supplies no hashes. A minimal otherwise-valid OSS BOM with that reference fails werf sbom validate --ispras-format oss --path <bom> with ERROR: 'hashes' is a required property, exit 1, even without URL checks. Missing hashes predate this PR; please qualify the description and the test's claimed coverage rather than presenting source-only output as proven valid. If schema-valid source-only output is intended in this PR, supply the required hash and test the serialized BOM against the checker.

Verification: task build and the full task test:unit passed. Mutations disabling duplicate suppression, changing the allow-list, and omitting the new Docker arguments were detected by the corresponding tests; restored runs passed. Full image-build e2e/integration suites were not run.

}

if opts.CheckVCSLeafOnly {
args = append(args, "--check-vcs-leaf-only")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Forwarding --check-vcs-leaf-only together with --check-source-distribution skips source-distribution URLs on non-leaf components too. The checker executes if args.check_vcs_leaf_only and components_value: continue before collecting either reference type, so this VCS modifier suppresses an independently requested archive check.

For an otherwise-valid OSS BOM with a dead http://127.0.0.1:1/source.tar.gz on a parent component and a child marked GOST:provided_by, werf sbom validate --ispras-format oss --path <bom> --check-source-distribution returns the archive warning and exit 1; adding --check-vcs-leaf-only returns Result: 1 passed, 0 failed, exit 0.

Until the checker image is fixed to apply the leaf predicate only while collecting VCS references, reject this flag combination before running Docker (users can run source validation separately). Add a runtime regression covering a non-leaf source archive; the current argv-only test accepts the combination without testing what it checks.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed against sbom-checker.py:178 — the leaf filter is a continue before externalReferences is read. Fixed in b952906: RunOptions.Validate rejects the combination up front (--check-vcs-leaf-only cannot be combined with --check-source-distribution … use --check-vcs instead), called from the CLI flag validation and from checker.Run. Running two passes is not an option because of your second point — the source-distribution pass would still do the full VCS check. Image fix + digest pin tracked in Follow-up.

}

if opts.CheckSourceDistribution {
args = append(args, "--check-source-distribution")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--check-source-distribution also activates VCS URL validation in this checker image. Its shared URL-checking block is entered for any of the three flags and collects vcs references unconditionally, so --check-vcs=false does not prevent repository probes and failures. The header nevertheless reports only with source distribution check.

An otherwise-valid OSS BOM containing only vcs: http://127.0.0.1:1/repo.git passes without checks (exit 0). Adding only --check-source-distribution makes the same CLI invocation fail with the VCS warning and exit 1, although there are no source-distribution references to validate.

Fix the checker image so VCS references are collected only when args.check_vcs or args.check_vcs_leaf_only, then use that corrected image and add a runtime regression for the source-only flag. If that image change cannot ship here, require explicit VCS opt-in and document the coupling instead of exposing this as an independent source-only check.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed: vcs collection at sbom-checker.py:183 is unconditional inside the check_vcs or check_vcs_leaf_only or check_source_distribution block. Fixed in b952906: the flag help and the command docs state that --check-source-distribution also validates VCS URLs of every component, and enabledChecks now reports what the image really runs — --check-source-distribution alone logs with VCS, source distribution check. Description updated accordingly; the "component carrying only source-distribution is not an error" line is replaced by the hashes claims from e9e9c68 plus an explicit UNVERIFIED for the checker run.

…bution checks

The ISPRAS checker image applies --check-vcs-leaf-only before it reads a
component's external references, so combined with --check-source-distribution
the archives of every non-leaf component went unchecked while the run reported
success. Reject the combination up front with an error naming --check-vcs as
the way out.

The same image also turns on VCS URL validation whenever
--check-source-distribution is passed, so a run with that flag alone can fail
on an unreachable repository. Say so in the flag help and in the "with ... check"
header, which now lists the checks the image really runs instead of the flags
that were passed.

Signed-off-by: Radmir Khurum <radmir.khurum@flant.com>
@reyreavman
reyreavman force-pushed the fix/sbom/external-ref-dup-and-checks branch from 576f9b0 to b952906 Compare September 17, 2026 12:46

@Fral738 Fral738 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The previous flag findings are addressed by the early combination guard and the updated help/logging contract. The remaining findings are in the new source-distribution repair path:

  • An inherited hashes: [] is treated as an existing digest, so enrichment succeeds without repairing the hash-less reference despite a valid resolver result.
  • Replacing one component's hash-less reference deletes the same URL/type from the BOM-wide reference set even when another component retains that reference with a valid digest.

Both cases were reproduced with focused Go tests on this head; the inline comments include the scenarios and corrections. The first also qualifies the description's claim that an inherited source distribution without a digest is replaced: it currently only handles a nil hashes pointer.

Verification: task build and the full task test:unit passed. Mutations of hash validation/copying/replacement and checker guard/logging were detected; restored runs passed and the worktree is clean. The built CLI rejects the incompatible flags before file access or container startup. The source-only serialized BOM has not been revalidated against the checker in this pass: the local Docker socket is absent and SSH to the available Linux host timed out. Full image-build e2e/integration suites were not run.

Comment thread pkg/sbom/externalref/enricher.go Outdated
// rather than kept: the base image it came from cannot be fixed from here,
// and keeping it would carry the unvalidatable link into this SBOM too.
if existing, idx, ok := findRefType(*comp.ExternalReferences, outcome.ref.Type); ok {
if existing.Hashes != nil || outcome.ref.Hashes == nil {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An inherited source reference with "hashes": [] has a non-nil Hashes pointer and is kept here, although it contains no digest. The valid replacement returned by the resolver is discarded, leaving a source-only leaf without the required hash while Enrich returns nil.

Changing the existing replaces a source distribution an earlier enrichment left without a digest fixture to Hashes: &[]cdx.Hash{} makes task test:unit paths="./pkg/sbom/externalref/..." -- --focus='replaces a source distribution' fail: the result still has https://example.com/old.tgz rather than the resolved URL. Restoring the nil-hashes fixture passes.

Treat an empty slice as missing too, and cover both nil and empty hashes in the replacement test:

Suggested change
if existing.Hashes != nil || outcome.ref.Hashes == nil {
if (existing.Hashes != nil && len(*existing.Hashes) > 0) || outcome.ref.Hashes == nil {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 32c896c: hasHashes treats a nil pointer and an empty slice alike. The replacement spec is now a table over hashes absent / hashes: []; the nil-only check fails the empty case. Description claim qualified.

Comment thread pkg/sbom/externalref/enricher.go Outdated
if existing.Hashes != nil || outcome.ref.Hashes == nil {
continue
}
delete(seen, refKey(existing))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seen is shared by all components, so deleting the old URL/type while repairing one component also removes a reference still used by another component. The other component's keep-existing branch does not reinsert it, and the serialized BOM-wide externalReferences loses that source link.

A focused test with two different PURLs sharing old.tgz reproduces this: component A has a valid STREEBOG digest and keeps old.tgz; component B has no digest and resolves to new.tgz. Enrich succeeds, A still contains old.tgz, but bom.ExternalReferences contains only new.tgz; ContainElement(existing) fails.

Build the aggregate from the final component references after all replacements instead of deleting a shared key during one component's update. Before converting seen to BOM references, rebuild it from the resulting components:

seen = make(map[string]cdx.ExternalReference)
for _, comp := range components {
	if comp.ExternalReferences == nil {
		continue
	}
	for _, ref := range *comp.ExternalReferences {
		seen[refKey(ref)] = ref
	}
}

Remove the now-redundant incremental aggregation and add the shared-URL regression with both component orders.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 32c896c as you suggest: incremental seen bookkeeping removed, the BOM-wide list is rebuilt from the final component references after the loop. Added keeps a shared source distribution in the BOM list when another component's copy is replaced in both component orders; restoring the delete-on-replace behaviour fails both.

…d keep shared links

An inherited source-distribution reference with "hashes": [] passed the
"already has a digest" check because only a nil pointer counted as missing, so
the valid replacement from the resolver was discarded and the SBOM kept a
source-only leaf without the digest the ISPRAS schema requires. Treat an empty
hash list as missing too.

Replacing one component's hash-less reference also deleted its URL/type from
the BOM-wide externalReferences list while another component still carried
that link with a valid digest. Derive the BOM-wide list from the final
component references after all replacements instead of maintaining it
incrementally.

Signed-off-by: Radmir Khurum <radmir.khurum@flant.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants