Skip to content

feat(sbom): add SHA-256 hash support to CycloneDX SBOM component model#503

Merged
a-oren merged 4 commits into
guacsec:mainfrom
a-oren:worktree-TC-4330
May 6, 2026
Merged

feat(sbom): add SHA-256 hash support to CycloneDX SBOM component model#503
a-oren merged 4 commits into
guacsec:mainfrom
a-oren:worktree-TC-4330

Conversation

@a-oren

@a-oren a-oren commented May 6, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Add optional hashes parameter to getComponent() and addDependency() in the CycloneDX SBOM model
  • Update Sbom wrapper, base_pyproject graph structure, python_controller DependencyEntry, and python_pip SBOM creation paths to forward hash data
  • Hashes are omitted when not provided, maintaining full backward compatibility
  • Add 6 unit tests covering hashes present, absent, empty array, serialization, and source vs target isolation

Implements TC-4330

Test plan

  • 6 new unit tests for hash support pass
  • All existing tests pass unchanged (0 regressions)
  • ESLint clean on all modified files

🤖 Generated with Claude Code

Summary by Sourcery

Add optional hash support to CycloneDX SBOM components and propagate hash data through Python dependency providers while keeping existing behavior unchanged when hashes are absent.

New Features:

  • Allow CycloneDX SBOM components to include optional hashes metadata on components and dependencies.

Enhancements:

  • Extend SBOM and Python provider models to carry hash information through graph and dependency structures.
  • Update SBOM construction logic so hash data is attached or updated on existing components without creating duplicates.

Tests:

  • Add unit tests validating hash propagation, omission when absent or empty, JSON serialization, and correct handling when components are first created without hashes.

Add optional hashes parameter to getComponent() and addDependency() in
the CycloneDX SBOM model, allowing downstream providers to attach
SHA-256 artifact hashes to components. Update Sbom wrapper,
base_pyproject graph structure, python_controller DependencyEntry, and
python_pip SBOM creation paths to forward hash data through the full
pipeline. Hashes are omitted when not provided, maintaining backward
compatibility.

Implements TC-4330

Assisted-by: Claude Code
@sourcery-ai

sourcery-ai Bot commented May 6, 2026

Copy link
Copy Markdown

Reviewer's Guide

Adds optional hash metadata (e.g., SHA-256) to CycloneDX SBOM components and threads it through the SBOM model, Sbom wrapper, and Python providers while preserving backward compatibility and verifying behavior with new unit tests.

Sequence diagram for hash-aware dependency addition

sequenceDiagram
    participant Provider as Base_pyproject_or_Python_pip
    participant Sbom as Sbom
    participant Model as CycloneDxSbom
    participant PURL as PackageURL

    Provider->>Sbom: addDependency(sourceRef, targetRef, scope, targetHashes)
    Sbom->>Model: addDependency(sourceRef, targetRef, scope, targetHashes)

    Model->>PURL: sourceRef.toString()
    Model->>PURL: targetRef.toString()

    loop ensure_components_exist
        Model->>Model: getComponentIndex(purl)
        alt component_not_found
            Model->>Model: getComponent(ref, type, scope, licenses, hashes)
            Model->>Model: push new Component into components
        else component_exists
            alt target_component_and_hashes_provided_and_missing
                Model->>Model: set components[existingIndex].hashes = targetHashes
            end
        end
    end

    Model-->>Sbom: CycloneDxSbom (for chaining)
    Sbom-->>Provider: Sbom (for chaining)
Loading

Class diagram for updated SBOM hash propagation model

classDiagram
    class Sbom {
      - any sbomModel
      + addDependency(sourceRef, targetRef, scope, targetHashes)
    }

    class CycloneDxSbom {
      - Array~Component~ components
      + addDependency(sourceRef, targetRef, scope, targetHashes) CycloneDxSbom
      + getComponentIndex(purl) number
    }

    class Component {
      string bom_ref
      string name
      string purl
      string type
      string version
      string scope
      Array~License~ licenses
      Array~Hash~ hashes
    }

    class Hash {
      string alg
      string content
    }

    class License {
    }

    class Base_pyproject {
      - Map~string, GraphEntry~ graph
      + buildSbom(sbom)
    }

    class GraphEntry {
      string name
      string version
      Array~string~ children
      Array~Hash~ hashes
    }

    class Python_pip_provider {
      + addAllDependencies(source, dep, sbom)
      + getSbomForComponentAnalysis(manifest, opts)
    }

    class DependencyEntry {
      string name
      string version
      Array~DependencyEntry~ dependencies
      Array~Hash~ hashes
    }

    class Python_controller {
      Array~DependencyEntry~ dependencies
    }

    class PackageURL {
      + toString() string
    }

    %% Relationships
    Sbom --> CycloneDxSbom : wraps
    CycloneDxSbom o--> Component : manages
    Component o--> Hash : uses
    GraphEntry o--> Hash : optional_hashes
    DependencyEntry o--> Hash : optional_hashes

    Base_pyproject --> Sbom : uses_addDependency
    Python_pip_provider --> Sbom : uses_addDependency
    Python_controller --> DependencyEntry : produces

    CycloneDxSbom --> PackageURL : component_keys
    Base_pyproject --> GraphEntry : uses
    Python_pip_provider --> DependencyEntry : uses
Loading

File-Level Changes

Change Details Files
Extend CycloneDX component model to accept and serialize optional hashes for components, and update dependency addition logic to set or backfill hashes on target components only.
  • Add optional hashes parameter to getComponent and include hashes array in component objects when provided and non-empty.
  • Extend addDependency signature to accept scope and targetHashes, passing hashes into getComponent when creating new target components.
  • When a target component already exists without hashes, update its hashes field only if hashes are newly provided and hashes are not yet set, ensuring source components never receive target hashes.
src/cyclone_dx_sbom.js
Propagate optional hash data from dependency graphs and Python dependency entries into SBOM dependencies through the Sbom wrapper and providers.
  • Update Sbom.addDependency wrapper to accept and forward optional targetHashes to the underlying CycloneDxSbom model.
  • Extend Base_pyproject GraphEntry typedef with optional hashes and pass entry.hashes into sbom.addDependency for both direct and transitive dependencies in all traversal modes.
  • Extend Python providers (python_pip and python_controller) DependencyEntry typedefs with optional hashes and forward dep.hashes into sbom.addDependency wherever dependencies are added, including recursive graph construction and component-analysis SBOM generation.
src/sbom.js
src/providers/base_pyproject.js
src/providers/python_pip.js
src/providers/python_controller.js
Add unit tests to validate hash handling on components, including presence, absence, updates, serialization, and empty-array behavior.
  • Create a new test suite covering addDependency with and without hashes, ensuring hashes are only attached to target components and never sources.
  • Verify that hashes are included in serialized SBOM JSON when present and omitted when absent.
  • Test that components first created without hashes are later updated with hashes when re-encountered as targets, without duplicating components, and that empty hash arrays are treated as no hashes.
test/cyclone_dx_sbom_hashes.test.js

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hey - I've found 3 issues, and left some high level feedback:

  • In CycloneDxSbom.addDependency, hashes are only applied when the component is first created (when getComponentIndex(purl) < 0); if a dependency is added earlier without hashes and later with hashes, the hash data will be silently dropped—if this is not intentional, consider merging or updating hashes on subsequent calls or documenting the requirement to pass hashes on first use.
  • In python_pip.js the DependencyEntry typedef is duplicated (near provideComponent and again near addAllDependencies); consider consolidating this to a single shared typedef to avoid divergence when the shape changes in the future (e.g., the newly added hashes field).
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `CycloneDxSbom.addDependency`, hashes are only applied when the component is first created (when `getComponentIndex(purl) < 0`); if a dependency is added earlier without hashes and later with hashes, the hash data will be silently dropped—if this is not intentional, consider merging or updating hashes on subsequent calls or documenting the requirement to pass hashes on first use.
- In `python_pip.js` the `DependencyEntry` typedef is duplicated (near `provideComponent` and again near `addAllDependencies`); consider consolidating this to a single shared typedef to avoid divergence when the shape changes in the future (e.g., the newly added `hashes` field).

## Individual Comments

### Comment 1
<location path="src/cyclone_dx_sbom.js" line_range="127-130" />
<code_context>
 		[sourceRef, targetRef].forEach((ref, index) => {
 			const purl = index === 0 ? sourcePurl : targetPurl;
 			if (this.getComponentIndex(purl) < 0) {
-				this.components.push(getComponent(ref, "library", scope));
+				const hashes = index === 1 ? targetHashes : undefined;
+				this.components.push(getComponent(ref, "library", scope, undefined, hashes));
 			}
</code_context>
<issue_to_address>
**issue:** Components never get their hashes updated if they are first seen without hashes and only later with hashes.

`targetHashes` are only applied when a component is first created (`getComponentIndex(purl) < 0`). If a component is first seen via an edge without hashes and later via one with hashes, the existing component will never be updated. If that discovery order is possible, consider updating existing components when hashes become available, or clearly document that only the first occurrence is used and later hashes are ignored.
</issue_to_address>

### Comment 2
<location path="src/providers/python_pip.js" line_range="19" />
<code_context>
 }

-/** @typedef {{name: string, version: string, dependencies: DependencyEntry[]}} DependencyEntry */
+/** @typedef {{name: string, version: string, dependencies: DependencyEntry[], hashes?: Array<{alg: string, content: string}>}} DependencyEntry */

 export default class Python_controller {
</code_context>
<issue_to_address>
**suggestion:** There are two `DependencyEntry` typedefs; consider consolidating to a single definition.

Defining `DependencyEntry` only once (e.g., at the top of the module) will prevent the shapes from drifting over time and make future maintenance simpler.
</issue_to_address>

### Comment 3
<location path="test/cyclone_dx_sbom_hashes.test.js" line_range="10-19" />
<code_context>
+
+suite('CycloneDX SBOM hash support', () => {
+
+	/** Verifies that addDependency with hashes produces a component containing the hashes array. */
+	test('getComponent with hashes includes hashes array in component', () => {
+		// Given an SBOM with a root and a dependency with hashes
+		const sbom = new CycloneDxSbom()
+		const root = new PackageURL('pypi', undefined, 'my-app', '1.0.0', undefined, undefined)
+		const purl = new PackageURL('pypi', undefined, 'requests', '2.33.1', undefined, undefined)
+		sbom.addRoot(root)
+
+		// When adding a dependency with hashes
+		sbom.addDependency(root, purl, undefined, sampleHashes)
+
+		// Then the target component should include the hashes
+		const targetComponent = sbom.components.find(c => c.name === 'requests')
+		expect(targetComponent).to.exist
+		expect(targetComponent.hashes).to.deep.equal(sampleHashes)
+	})
+
</code_context>
<issue_to_address>
**issue (testing):** Add a test for calling addDependency with hashes when the target component already exists

This only tests the case where addDependency creates the component. Please add a test for when the target component already exists (e.g., from addRoot or a prior dependency) and addDependency is later called with hashes, to document whether hashes are merged, ignored, or otherwise handled without creating a duplicate component.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread src/cyclone_dx_sbom.js
Comment thread src/providers/python_pip.js
Comment thread test/cyclone_dx_sbom_hashes.test.js
@a-oren
a-oren requested review from ruromero and removed request for ruromero May 6, 2026 10:07
- Update existing component hashes when first seen without them,
  handling cases where Map iteration order causes a package to appear
  as a source before it appears as a target with hashes
- Remove duplicate DependencyEntry typedef in python_pip.js
- Add tests for hash update on pre-existing components

Implements TC-4330

Assisted-by: Claude Code
@a-oren

a-oren commented May 6, 2026

Copy link
Copy Markdown
Contributor Author

@sourcery-ai review

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hey - I've found 2 issues, and left some high level feedback:

  • In python_pip.addAllDependencies, the recursive call to addAllDependencies does not forward dependency.hashes, so hashes for transitive dependencies are silently dropped; consider passing hashes through in the recursive call to preserve them across the full tree.
  • In CycloneDxSbom.addDependency, hashes are only applied to an existing target component if it has no hashes field; if the same component is later observed with different hash data this is ignored, so it may be worth explicitly deciding whether to overwrite, merge, or validate consistency and documenting that behavior.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `python_pip.addAllDependencies`, the recursive call to `addAllDependencies` does not forward `dependency.hashes`, so hashes for transitive dependencies are silently dropped; consider passing hashes through in the recursive call to preserve them across the full tree.
- In `CycloneDxSbom.addDependency`, hashes are only applied to an existing target component if it has no `hashes` field; if the same component is later observed with different hash data this is ignored, so it may be worth explicitly deciding whether to overwrite, merge, or validate consistency and documenting that behavior.

## Individual Comments

### Comment 1
<location path="src/cyclone_dx_sbom.js" line_range="58-61" />
<code_context>
 		});
 	}

+	// Add hashes if provided (CycloneDX 1.4 format).
+	if (hashes && hashes.length > 0) {
+		componentObject.hashes = hashes;
+	}
+
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Avoid storing the caller’s `hashes` array by reference to prevent accidental mutation.

Because `hashes` is assigned directly to `componentObject`, any later changes to `hashes` by the caller will also change the SBOM. To avoid this shared mutable state, clone the array (and ideally its elements), for example:

```ts
componentObject.hashes = hashes.map(h => ({ alg: h.alg, content: h.content }));
```

At minimum, shallow-copy the array:

```ts
componentObject.hashes = [...hashes];
```

```suggestion
	// Add hashes if provided (CycloneDX 1.4 format).
	if (hashes && hashes.length > 0) {
		// Clone the hashes array (and its elements) to avoid shared mutable state
		componentObject.hashes = hashes.map(h => ({
			alg: h.alg,
			content: h.content,
		}));
	}
```
</issue_to_address>

### Comment 2
<location path="src/providers/base_pyproject.js" line_range="18-19" />
<code_context>
 const DEFAULT_ROOT_VERSION = '0.0.0'

-/** @typedef {{name: string, version: string, children: string[]}} GraphEntry */
+/** @typedef {{name: string, version: string, children: string[], hashes?: Array<{alg: string, content: string}>}} GraphEntry */
 /** @typedef {{name: string, version: string, dependencies: DepTreeEntry[]}} DepTreeEntry */
 /** @typedef {{directDeps: string[], graph: Map<string, GraphEntry>}} DependencyData */
 /** @typedef {{ecosystem: string, content: string, contentType: string}} Provided */
</code_context>
<issue_to_address>
**suggestion:** The `hashes` shape typedef is duplicated across providers and could drift over time.

`hashes?: Array<{alg: string, content: string}>` is now repeated across `GraphEntry`, `DependencyEntry`, etc. Consider extracting a shared `HashEntry` typedef and referencing it in these types to keep them consistent as the structure evolves.

Suggested implementation:

```javascript
/** @typedef {{alg: string, content: string}} HashEntry */
/** @typedef {{name: string, version: string, children: string[], hashes?: HashEntry[]}} GraphEntry */
/** @typedef {{name: string, version: string, dependencies: DepTreeEntry[]}} DepTreeEntry */

```

1. Search this file (and related provider files, if they share typedefs) for other inline uses of `Array<{alg: string, content: string}>` or `{alg: string, content: string}` in typedefs such as `DependencyEntry`, `DependencyNode`, etc.
2. Replace those inline shapes with `HashEntry[]` or `HashEntry` as appropriate, reusing the new shared typedef to keep the structure consistent.
3. If there is a central types file for providers, consider moving `HashEntry` there and importing/reusing it instead of redefining it per file.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread src/cyclone_dx_sbom.js
Comment thread src/providers/base_pyproject.js
@a-oren
a-oren requested a review from ruromero May 6, 2026 12:04

@ruromero ruromero 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.

Looks good, I'm missing the updated expected python resulting sbom (stack/component) for pip

Comment thread src/providers/python_pip.js Outdated
@a-oren

a-oren commented May 6, 2026

Copy link
Copy Markdown
Contributor Author

Verification Report — TC-4330

Review Feedback

# Thread Author Classification Status
1 Components never get hashes updated sourcery-ai code change request ✅ Addressed (d726890)
2 Duplicate DependencyEntry typedef sourcery-ai suggestion ✅ Addressed (d726890)
3 Missing test for existing component sourcery-ai code change request ✅ Addressed (d726890)
4 Clone hashes array (shallow copy) sourcery-ai suggestion Skipped — no codebase convention
5 Shared HashEntry typedef sourcery-ai suggestion Skipped — no central types file
6 const NO_SCOPE = undefined ruromero code change request Pending implementation

Ruben's review body ("missing updated expected python resulting sbom"): This is a plumbing task — no provider populates hashes yet, so golden files remain unchanged. The existing pip golden file tests pass identically to clean main.

Deterministic Checks

Check Result
Scope containment ✅ PASS — 6 files changed, all within src/ and test/
Diff size ✅ PASS — +183 / -17 lines (small)
Commit traceability ✅ PASS — 2 commits, both reference TC-4330 via branch
Sensitive patterns ✅ PASS — no secrets, credentials, or .env files
CI status ✅ PASS — all checks green (lint+test Node 22/24, PR title, commit messages)

Acceptance Criteria

# Criterion Result
1 getComponent() accepts optional hashes parameter ✅ PASS
2 addDependency() accepts and forwards hash data ✅ PASS
3 Graph data structure in base_pyproject.js supports optional hashes ✅ PASS
4 DependencyEntry in python_controller.js supports optional hashes ✅ PASS
5 Without hashes, components generated without hashes field ✅ PASS
6 Existing tests continue to pass unchanged ✅ PASS

Test Requirements

# Requirement Result
1 Unit test: getComponent with hashes ✅ PASS
2 Unit test: getComponent without hashes ✅ PASS
3 Unit test: addDependency forwards hashes ✅ PASS
4 All existing SBOM golden file tests pass ✅ PASS

Overall Result: WARN

All acceptance criteria pass. One open code change request from @ruromero (NO_SCOPE constant) pending implementation. Bot suggestions (shallow copy, shared typedef) classified as non-actionable per codebase conventions.


This comment was AI-generated by sdlc-workflow/verify-pr v0.5.11.

…lity

Replace bare `undefined` scope arguments with a named `NO_SCOPE`
constant to make the intent explicit at call sites.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@a-oren
a-oren requested a review from ruromero May 6, 2026 14:20

@ruromero ruromero 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.

lgtm

@a-oren
a-oren merged commit 5348b9e into guacsec:main May 6, 2026
5 checks passed
@a-oren
a-oren deleted the worktree-TC-4330 branch May 6, 2026 15:14
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