-
-
Notifications
You must be signed in to change notification settings - Fork 0
feat(sbom): detector-asserted package origin in SBOM export #397
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
b1868c7
130f38d
406e449
a37737d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -99,6 +99,58 @@ Both formats carry: | |||||||||||||||||
| knows fixed versions, each vulnerability carries a `recommendation` | ||||||||||||||||||
| ("Upgrade <package> to <version>"). No guidance is invented when no fix is | ||||||||||||||||||
| known. SPDX 2.3 has no equivalent field. | ||||||||||||||||||
| - Where each package came from, when its lockfile says: an exact download | ||||||||||||||||||
| location or a source repository. See "Where a package came from" below. | ||||||||||||||||||
|
|
||||||||||||||||||
| ### Where a package came from | ||||||||||||||||||
|
|
||||||||||||||||||
| Each detector reports the origin of the packages it resolves, reading the field | ||||||||||||||||||
| its own lockfile records it in. Bomly does not infer origin from the shape of a | ||||||||||||||||||
| URL, because the same string means different things in different ecosystems. | ||||||||||||||||||
|
|
||||||||||||||||||
| A detector reports one of two things, or nothing at all: | ||||||||||||||||||
|
|
||||||||||||||||||
| | What the lockfile records | SPDX 2.3 | CycloneDX | | ||||||||||||||||||
| |---|---|---| | ||||||||||||||||||
| | The exact file the package was fetched from | `downloadLocation` | `distribution` external reference | | ||||||||||||||||||
| | The repository it was resolved from, plus the commit | `downloadLocation` as `git+<url>@<revision>` | `vcs` external reference (URL only) | | ||||||||||||||||||
| | Neither | `NOASSERTION` | no reference | | ||||||||||||||||||
|
|
||||||||||||||||||
| CycloneDX external references have no field for a revision, so the commit a | ||||||||||||||||||
| detector resolved appears only in the SPDX form. | ||||||||||||||||||
|
Comment on lines
+111
to
+120
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
rg -n -C 8 \
'SetOriginVCS|MetadataKeyOriginVCSURL|MetadataKeyOriginVCSRevision|git\+|VCSURL|revision' \
internal/detectors/origin.go internal/sbom sdk --glob '*.go' || true
rg -n -C 6 \
'empty.*revision|invalid.*revision|revision.*origin|origin.*revision' \
internal/detectors internal/sbom --glob '*_test.go' || trueRepository: bomly-dev/bomly-cli Length of output: 27478 🏁 Script executed: #!/bin/bash
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
docs = Path("docs/SBOM.md").read_text()
origin = Path("internal/detectors/origin.go").read_text()
spdx = Path("internal/sbom/spdx23.go").read_text()
cdx = Path("internal/sbom/cyclonedx.go").read_text()
tests = Path("internal/sbom/origin_test.go").read_text()
checks = {
"docs repository row claims a commit": "The repository it was resolved from, plus the commit" in docs,
"setter retains a valid VCS URL": "setOriginValue(dep, MetadataKeyOriginVCSURL, normalized)" in origin,
"setter conditionally stores revision": "if pinned := strings.TrimSpace(revision); isValidOriginRevision(pinned)" in origin,
"SPDX appends revision only when present": 'if revision := strings.TrimSpace(component.VCSRevision); revision != ""' in spdx,
"CycloneDX emits repository URL": 'Type: cdx.ERTypeVCS, URL: repository' in cdx,
"tests cover unpinned repository origins": "TestUnpinnedRepositoryOriginOmitsTheRevisionSuffix" in tests,
"tests expect unpinned SPDX repository locator": 'want "git+"+repository' in tests,
}
for label, passed in checks.items():
print(f"{'PASS' if passed else 'FAIL'}: {label}")
start = docs.index("| What the lockfile records |")
end = docs.index("\n\nCycloneDX external references", start)
print("\nDocumented table:")
print(docs[start:end])
print("\nRelevant setter behavior:")
start = origin.index("// SetOriginVCS")
end = origin.index("\n}\n", start) + 2
print(origin[start:end])
print("\nRelevant SPDX behavior:")
start = spdx.index("func spdxVCSLocator")
end = spdx.index("\n}\n", start) + 2
print(spdx[start:end])
PYRepository: bomly-dev/bomly-cli Length of output: 1805 Document optional VCS revisions. A VCS origin can contain only a repository URL. Document SPDX as 🤖 Prompt for AI Agents |
||||||||||||||||||
|
|
||||||||||||||||||
| What each ecosystem yields: | ||||||||||||||||||
|
|
||||||||||||||||||
| - **npm, pnpm, yarn, bun** — the registry tarball recorded in the lockfile. | ||||||||||||||||||
| Yarn Classic appends the package checksum to that URL; it identifies | ||||||||||||||||||
| contents rather than a location, so it is dropped. pnpm v9 entries that | ||||||||||||||||||
| record only an integrity hash report nothing. | ||||||||||||||||||
| - **uv, poetry, pipenv, pip** — a repository plus the commit that was locked, | ||||||||||||||||||
| or a direct archive URL, depending on the recorded source type. | ||||||||||||||||||
| - **cargo, Bundler, SwiftPM, pub** — the repository and resolved commit for | ||||||||||||||||||
| git dependencies and source-control pins. | ||||||||||||||||||
| - **Go modules, Maven, Gradle, NuGet, and the other detectors** — nothing yet; | ||||||||||||||||||
| their manifests do not record a per-package location. | ||||||||||||||||||
| - Packages found by Syft, and packages read from an ingested SBOM, carry no | ||||||||||||||||||
| origin. | ||||||||||||||||||
|
|
||||||||||||||||||
| Four kinds of value are never published, in any ecosystem: | ||||||||||||||||||
|
|
||||||||||||||||||
| - **Registry and index roots** (`https://rubygems.org/`, `https://pub.dev`, the | ||||||||||||||||||
| crates.io index). They say where an ecosystem fetches from, not where a | ||||||||||||||||||
| package came from — and once out of context, a private server URL is | ||||||||||||||||||
| indistinguishable from a repository. | ||||||||||||||||||
| - **Local paths** — workspace members, editable installs, `file:` and `path:` | ||||||||||||||||||
| dependencies. These describe the machine that ran the scan. | ||||||||||||||||||
| - **Non-web locations** — `ssh://`, `git@host:org/repo`, and similar remotes | ||||||||||||||||||
| that name a transport rather than a fetchable address. | ||||||||||||||||||
| - **URLs carrying credentials.** A lockfile pointing at a private registry can | ||||||||||||||||||
| embed a token; publishing it in an SBOM would leak a live secret. | ||||||||||||||||||
|
|
||||||||||||||||||
| Every published location is an absolute `http`/`https` URL with a host and no | ||||||||||||||||||
| embedded credentials. Values are re-serialized from a parse rather than copied | ||||||||||||||||||
| from the lockfile, and the same check runs again at export, so origin supplied | ||||||||||||||||||
| by a plugin is held to the same rule as origin from a built-in detector. | ||||||||||||||||||
|
Comment on lines
+150
to
+153
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win Clarify the scope of the HTTP(S) validation rule. The text says every published location is an absolute HTTP(S) URL. This conflicts with the SPDX mapping on Line 116, which emits Proposed wording-Every published location is an absolute `http`/`https` URL with a host and no
-embedded credentials. Values are re-serialized from a parse rather than copied
-from the lockfile, and the same check runs again at export, so origin supplied
-by a plugin is held to the same rule as origin from a built-in detector.
+Every detector-origin URL is an absolute `http`/`https` URL with a host and no
+embedded credentials. Values are re-serialized from a parse before export, and
+export applies the same check to plugin-supplied origins. SPDX may then compose
+a validated repository URL as `git+<url>@<revision>`.📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||
|
|
||||||||||||||||||
| ### Document identity | ||||||||||||||||||
|
|
||||||||||||||||||
|
|
@@ -171,8 +223,9 @@ Reachability annotations and other Bomly-specific metadata are emitted in the JS | |||||||||||||||||
| ### Preservation and conversion limits | ||||||||||||||||||
|
|
||||||||||||||||||
| Bomly preserves component identity (including PURL), dependency edges, roots, | ||||||||||||||||||
| scope, package type, licenses, digests, CPEs, and the enrichment fields described | ||||||||||||||||||
| above when the destination format has an equivalent representation. Encoding is | ||||||||||||||||||
| scope, package type, licenses, digests, CPEs, package origin, and the enrichment | ||||||||||||||||||
| fields described above when the destination format has an equivalent | ||||||||||||||||||
| representation. Encoding is | ||||||||||||||||||
| deterministic when the scan timestamp and document identifiers are fixed. | ||||||||||||||||||
|
|
||||||||||||||||||
| Some information necessarily becomes less specific during conversion: | ||||||||||||||||||
|
|
@@ -184,6 +237,9 @@ Some information necessarily becomes less specific during conversion: | |||||||||||||||||
| round trip. | ||||||||||||||||||
| - Development scope maps to CycloneDX `excluded`; runtime scope maps to | ||||||||||||||||||
| `required`. SPDX stores Bomly's normalized scope in the package comment. | ||||||||||||||||||
| - A resolved commit survives an SPDX round trip (it is part of the | ||||||||||||||||||
| `git+<url>@<revision>` download location) but not a CycloneDX one, where an | ||||||||||||||||||
| external reference carries only the repository URL. | ||||||||||||||||||
|
Comment on lines
+240
to
+242
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a Bomly-generated SPDX document is ingested and exported again, the resolved commit does not survive: Useful? React with 👍 / 👎. |
||||||||||||||||||
| - Bomly relationship confidence (`direct`, `transitive`, or `unknown`), source | ||||||||||||||||||
| provenance, reachability analysis, policy findings, and run diagnostics are | ||||||||||||||||||
| report data rather than portable SBOM fields. Use JSON when those distinctions | ||||||||||||||||||
|
|
||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| package cargo | ||
|
|
||
| import ( | ||
| "net/url" | ||
| "strings" | ||
|
|
||
| "github.com/bomly-dev/bomly-cli/internal/detectors" | ||
| "github.com/bomly-dev/bomly-sdk" | ||
| ) | ||
|
|
||
| // setCargoOrigin records the repository a git-sourced crate was resolved from. | ||
| // Cargo writes one source string per package: "registry+"/"sparse+" name an | ||
| // index root rather than this crate's location, path and workspace members | ||
| // carry no source at all, and only "git+" identifies where the code came from. | ||
| func setCargoOrigin(node *sdk.Dependency, source string) { | ||
| trimmed := strings.TrimSpace(source) | ||
| if !strings.HasPrefix(trimmed, "git+") { | ||
| return | ||
| } | ||
| repository := strings.TrimPrefix(trimmed, "git+") | ||
| detectors.SetOriginVCS(node, repository, cargoSourceRevision(repository)) | ||
| } | ||
|
|
||
| // cargoSourceRevision returns the revision cargo locked. The URL fragment holds | ||
| // the resolved commit; the "rev", "tag", and "branch" query parameters hold | ||
| // what the manifest asked for, which is the weaker answer. | ||
| func cargoSourceRevision(repository string) string { | ||
| parsed, err := url.Parse(strings.TrimSpace(repository)) | ||
| if err != nil { | ||
| return "" | ||
| } | ||
| if parsed.Fragment != "" { | ||
| return parsed.Fragment | ||
| } | ||
| query := parsed.Query() | ||
| for _, key := range []string{"rev", "tag", "branch"} { | ||
| if value := strings.TrimSpace(query.Get(key)); value != "" { | ||
| return value | ||
| } | ||
| } | ||
| return "" | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,92 @@ | ||
| package cargo | ||
|
|
||
| import ( | ||
| "testing" | ||
|
|
||
| "github.com/bomly-dev/bomly-cli/internal/detectors" | ||
| "github.com/bomly-dev/bomly-sdk" | ||
| ) | ||
|
|
||
| // Cargo.lock records one source string per package. Only "git+" names where the | ||
| // code came from; the index prefixes name a registry, and path or workspace | ||
| // members carry no source at all. | ||
| func TestSetCargoOriginBySourcePrefix(t *testing.T) { | ||
| cases := []struct { | ||
| name string | ||
| source string | ||
| want detectors.Origin | ||
| }{ | ||
| { | ||
| name: "git dependency pins the resolved commit in the fragment", | ||
| source: "git+https://github.com/example/helper?rev=main#3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f", | ||
| want: detectors.Origin{VCSURL: "https://github.com/example/helper", VCSRevision: "3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f"}, | ||
| }, | ||
| { | ||
| name: "requested tag is used when no commit was recorded", | ||
| source: "git+https://github.com/example/helper?tag=v1.2.3", | ||
| want: detectors.Origin{VCSURL: "https://github.com/example/helper", VCSRevision: "v1.2.3"}, | ||
| }, | ||
| { | ||
| name: "branch dependency without a pin keeps the repository", | ||
| source: "git+https://github.com/example/helper", | ||
| want: detectors.Origin{VCSURL: "https://github.com/example/helper"}, | ||
| }, | ||
| {name: "crates.io index root", source: "registry+https://github.com/rust-lang/crates.io-index"}, | ||
| {name: "sparse index root", source: "sparse+https://index.crates.io/"}, | ||
| {name: "path or workspace member", source: ""}, | ||
| {name: "credentialed private git remote", source: "git+https://token:s3cret-value-here@git.corp/team/helper#4d5e6f70"}, | ||
| {name: "ssh git remote", source: "git+ssh://git@github.com/example/helper#5e6f7081"}, | ||
| } | ||
|
|
||
| for _, tc := range cases { | ||
| t.Run(tc.name, func(t *testing.T) { | ||
| node := sdk.NewDependency(sdk.Dependency{Coordinates: sdk.Coordinates{Name: "helper", Version: "1.0.0"}}) | ||
| setCargoOrigin(node, tc.source) | ||
| if got := detectors.OriginFrom(node.Metadata); got != tc.want { | ||
| t.Fatalf("origin = %+v, want %+v", got, tc.want) | ||
| } | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| // The lockfile path builds nodes through the same helper. | ||
| func TestCargoLockGraphCarriesOrigin(t *testing.T) { | ||
| lock := []byte(` | ||
| [[package]] | ||
| name = "demo" | ||
| version = "0.1.0" | ||
| dependencies = ["helper", "serde"] | ||
|
|
||
| [[package]] | ||
| name = "helper" | ||
| version = "1.0.0" | ||
| source = "git+https://github.com/example/helper?rev=main#6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192" | ||
|
|
||
| [[package]] | ||
| name = "serde" | ||
| version = "1.0.0" | ||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| `) | ||
| manifest := []byte("[package]\nname = \"demo\"\nversion = \"0.1.0\"\n[dependencies]\nhelper = { git = \"https://github.com/example/helper\" }\nserde = \"1\"\n") | ||
|
|
||
| graph, err := depGraphFromLock(lock, manifest) | ||
| if err != nil { | ||
| t.Fatalf("depGraphFromLock() error = %v", err) | ||
| } | ||
|
|
||
| helper, ok := graph.Node("helper@1.0.0") | ||
| if !ok { | ||
| t.Fatal("expected helper in graph") | ||
| } | ||
| want := detectors.Origin{VCSURL: "https://github.com/example/helper", VCSRevision: "6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192"} | ||
| if got := detectors.OriginFrom(helper.Metadata); got != want { | ||
| t.Fatalf("helper origin = %+v, want %+v", got, want) | ||
| } | ||
| serde, ok := graph.Node("serde@1.0.0") | ||
| if !ok { | ||
| t.Fatal("expected serde in graph") | ||
| } | ||
| if got := detectors.OriginFrom(serde.Metadata); !got.Empty() { | ||
| t.Fatalf("registry crate asserted an origin: %+v", got) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,6 +9,7 @@ import ( | |
| "sort" | ||
| "strings" | ||
|
|
||
| "github.com/bomly-dev/bomly-cli/internal/detectors" | ||
| "github.com/bomly-dev/bomly-cli/internal/detectors/node" | ||
| "github.com/bomly-dev/bomly-sdk" | ||
| "github.com/bomly-dev/bomly-sdk/system" | ||
|
|
@@ -247,6 +248,10 @@ func depGraphFromNPMLockfile(projectPath string) (npmLockfileGraphs, error) { | |
| pkg.Metadata = map[string]any{sdk.MetadataKeyNPM: meta} | ||
| } | ||
| pkgNode := sdk.NewDependency(pkg) | ||
| // npm records the registry tarball a package was installed from. | ||
| // Workspace members cleared ResolvedURL above (it names a local | ||
| // directory), and git or file specs are rejected by the invariant. | ||
| detectors.SetOriginArtifact(pkgNode, pkg.ResolvedURL) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For supported Useful? React with 👍 / 👎.
Comment on lines
+251
to
+254
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -e
file="internal/detectors/node/npm/npm_lockfile_parser.go"
printf '%s\n' '--- parser outline ---'
ast-grep outline "$file" --view compact || true
printf '%s\n' '--- relevant parser sections ---'
sed -n '110,285p' "$file"
printf '%s\n' '--- origin helper definitions and usages ---'
rg -n -C 3 'func SetOriginArtifact|SetOriginArtifact\(' internal
printf '%s\n' '--- flat graph helper references ---'
rg -n -C 5 'DepGraphFromNPMNode|NPMNode' .
printf '%s\n' '--- npm parser tests ---'
fd -i 'npm' internal | grep -E '(_test\.go$|test)' | head -80Repository: bomly-dev/bomly-cli Length of output: 19252 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- flat graph implementation ---'
sed -n '1,230p' internal/detectors/node/common.go
printf '%s\n' '--- npm node definitions ---'
rg -n -C 8 'type NPM(List)?Node|Resolved|Dependencies' internal/detectors/node
printf '%s\n' '--- npm v1 tests and fixture ---'
sed -n '1,280p' internal/detectors/node/npm/npm_lockfile_parser_test.go
find internal/detectors/node/testdata/lockfiles/npm-v1 -maxdepth 2 -type f -print -exec sed -n '1,180p' {} \;
printf '%s\n' '--- origin metadata assertions in npm tests ---'
rg -n -C 5 'Origin|origin|ResolvedURL|ArtifactURL|Metadata' internal/detectors/node/npm internal/detectors/node/*/*test.goRepository: bomly-dev/bomly-cli Length of output: 50376 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- npm lockfile types and v1 branch ---'
sed -n '1,185p' internal/detectors/node/npm/npm_lockfile_parser.go
printf '%s\n' '--- v1 parser tests ---'
rg -n -C 12 'v1|npm-v1|depGraphFromNPMLockfile|ResolvedURL|Origin|origin' internal/detectors/node/npm/npm_lockfile_parser_test.go internal/detectors/node/npm/npm_lockfile_workspaces_test.go
printf '%s\n' '--- v1 fixture files ---'
find internal/detectors/node/testdata/lockfiles/npm-v1 -type f -maxdepth 2 -print
while IFS= read -r f; do
printf '\n--- %s ---\n' "$f"
sed -n '1,220p' "$f"
done < <(find internal/detectors/node/testdata/lockfiles/npm-v1 -type f -maxdepth 2)
printf '%s\n' '--- SDK origin representation ---'
sed -n '1,155p' internal/detectors/origin.goRepository: bomly-dev/bomly-cli Length of output: 21754 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- npm v1 fixture ---'
cat internal/detectors/node/testdata/lockfiles/npm-v1/package-lock.json
printf '%s\n' '--- npm v1 test file ---'
cat -n internal/detectors/node/npm/npm_lockfile_parser_test.go | sed -n '1,115p'
printf '%s\n' '--- origin helper ---'
cat -n internal/detectors/origin.go | sed -n '95,145p'
printf '%s\n' '--- deterministic shape check ---'
python3 - <<'PY'
import json
from pathlib import Path
p = Path("internal/detectors/node/testdata/lockfiles/npm-v1/package-lock.json")
data = json.loads(p.read_text())
print("lockfileVersion:", data.get("lockfileVersion"))
print("has packages map:", bool(data.get("packages")))
for name, entry in data.get("dependencies", {}).items():
print(name, {
"version": entry.get("version"),
"resolved": entry.get("resolved"),
"integrity": entry.get("integrity"),
"dependency_keys": sorted(entry.get("dependencies", {}).keys()),
})
PYRepository: bomly-dev/bomly-cli Length of output: 9170 Preserve v1 npm The v1 path loses 🤖 Prompt for AI Agents |
||
| if entry.License != "" { | ||
| sdk.SetDetectionLicenses(pkgNode, []sdk.PackageLicense{{Value: entry.License, Type: "declared"}}) | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: bomly-dev/bomly-cli
Length of output: 50376
🏁 Script executed:
Repository: bomly-dev/bomly-cli
Length of output: 9607
🏁 Script executed:
Repository: bomly-dev/bomly-cli
Length of output: 43143
🏁 Script executed:
Repository: bomly-dev/bomly-cli
Length of output: 26514
🌐 Web query:
github.com/bomly-dev/bomly-sdk GraphContainer ConsolidatedGraph AddNode Dependency ID metadata ResolvedURL💡 Result:
The bomly-sdk provides a graph-based representation of software dependencies used by the Bomly CLI [1][2]. Based on the codebase, here is the synthesis of the components you mentioned: GraphContainer and ConsolidatedGraph In the Bomly architecture, detection-time dependency graphs are initially resolved for subprojects (e.g., individual directories containing manifest files) and then consolidated into a single, unified structure [3][2][4]. While the SDK provides the core Graph type (sdk.Graph) for representing dependency topology [1], the result of the scan pipeline is often referred to as a ConsolidatedGraph [1]. This consolidation step deduplicates package sets and merges per-subproject graphs into a single graph used for subsequent auditing and matching [3][2]. AddNode and Dependency The sdk.Graph object is node-centric, where each node is represented by an *sdk.Dependency [1]. - AddNode(dep *sdk.Dependency): This method is used to add a dependency to the graph [1]. It returns an error (typically ErrNodeAlreadyExist) if a collision occurs based on the node's stable ID [1]. - Dependency: This struct represents a node in the detection-time graph [3][2]. It carries specific metadata such as scope, location of discovery, and edges to other dependencies [3]. It points to a package by its PURL (Package URL) but does not itself contain enriched data like vulnerabilities or licenses [3]. ID, Metadata, and ResolvedURL - ID: Nodes in the graph are identified by a stable ID [1]. This ID is used for lookups (g.Node(id)) and to define the topology [1]. - Metadata: Dependency metadata is handled primarily through fields within the sdk.Dependency struct and associated helpers [1][5]. This includes coordinates (Name, Version, PURL), scopes, and detection-time facts [3][5]. Additional enrichment (e.g., license or vulnerability data) is typically held in the PackageRegistry, which is keyed by PURL, rather than stored directly within the graph nodes [1][5]. - ResolvedURL: While there is no single "ResolvedURL" field explicitly detailed as a primary SDK type in the documentation snippets, Bomly uses PURLs (Package URLs) as the canonical, stable identifier for matching artifacts within the PackageRegistry [1][3][5]. For detailed implementation, the canonical API is found in sdk/graph.go, and models are defined in dev-docs/MODELS.md within the bomly-cli repository [1][2].
Citations:
🏁 Script executed:
Repository: bomly-dev/bomly-cli
Length of output: 1332
🏁 Script executed:
Repository: bomly-dev/bomly-cli
Length of output: 19444
🏁 Script executed:
Repository: bomly-dev/bomly-cli
Length of output: 14384
Preserve conflicting origin metadata during node deduplication.
normalizeGraphPackageIdentityand SDK graph merging deduplicate by node ID, which excludesDependency.MetadataandResolvedURL. The merge keeps the first node and merges only relationships and locations, so duplicate nodes can losebomly.origin.*metadata. Define conflict handling or preserve origins per occurrence, and add a test for different origins with one node ID.🤖 Prompt for AI Agents