Skip to content
Merged
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
64 changes: 64 additions & 0 deletions docs/sbom.md
Original file line number Diff line number Diff line change
Expand Up @@ -642,6 +642,70 @@ graph TD
D -->|GENERATED_FROM| C
```

##### Bundled dependencies

Some RPM spec files declare copylib or vendored components using `Provides: bundled(...)` or
`Provides: golang(...)`. These are recorded on binary subpackage RPM headers (for example
`vim-enhanced` may provide `bundled(libvterm)`), not on the SRPM itself. Collect them from
Koji `getRPMDeps` Provides (type `1`) across every RPM in the build.

Each bundled component should be represented as its own package object, linked to the SRPM with
a `DEPENDENCY_OF` relationship (the bundled package is a dependency of the SRPM build). Set
`primaryPackagePurpose` to `LIBRARY`, `downloadLocation` to `NOASSERTION`, and `filesAnalyzed`
to `false`.

Use a typed purl when the provide name indicates the ecosystem:

| Language prefix in provide | purl type | Example provide | Example purl |
|----------------------------|-----------|-----------------|--------------|
| *(none)* / generic bundled | `generic` | `bundled(libvterm)` | `pkg:generic/libvterm` |
| `golang(...)` | `golang` | `golang(github.com/foo/bar)` | `pkg:golang/github.com/foo/bar@1.2.3` |
| `bundled(python(...))` | `pypi` | `bundled(python(requests))` | `pkg:pypi/requests@2.31.0` |
| `bundled(nodejs(...))` | `npm` | `bundled(nodejs(lodash))` | `pkg:npm/lodash@4.17.21` |
| `bundled(ruby(...))` | `gem` | `bundled(ruby(rake))` | `pkg:gem/rake@13.0.6` |
| `bundled(crate(...))` | `cargo` | `bundled(crate(serde))` | `pkg:cargo/serde@1.0.0` |
| `bundled(mvn(...))` | `maven` | `bundled(mvn(org/foo))` | `pkg:maven/org/foo@1.0.0` |

=== "SPDX 2.3"

```json
{
"SPDXID": "SPDXRef-Bundled-11cdd6f19dc1",
"name": "libvterm (generic)",
"versionInfo": "NOASSERTION",
"downloadLocation": "NOASSERTION",
"filesAnalyzed": false,
"primaryPackagePurpose": "LIBRARY",
"externalRefs": [
{
"referenceCategory": "PACKAGE-MANAGER",
"referenceType": "purl",
"referenceLocator": "pkg:generic/libvterm"
}
]
}
```

=== "SPDX 2.3"

```json
{
"spdxElementId": "SPDXRef-Bundled-11cdd6f19dc1",
"relationshipType": "DEPENDENCY_OF",
"relatedSpdxElement": "SPDXRef-SRPM"
}
```

**Example:** the build-time SBOM for `vim-9.1.083-5.el10` includes `bundled(libvterm)` from
the `vim-enhanced` subpackage:
[build/vim-9.1.083-5.el10](https://github.com/RedHatProductSecurity/security-data-guidelines/blob/main/sbom/examples/rpm/build/vim-9.1.083-5.el10.spdx.json).

Bundled components declared in the spec are distinct from:

- **Source archives** (`Source0`, `Source1`, …), which are linked with `CONTAINS` from the SRPM.
- **Components discovered by scanning** an unpacked source tree (for example with Syft), which
are nested under the relevant source archive.

#### Product

Individual components such as packages and container images are almost always provided as part of a specific product.
Expand Down
8 changes: 8 additions & 0 deletions sbom/examples/rpm/build/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ When comparing two `pkg:rpm` purls, identical except that one has a
`repository_id` qualifier and the other does not, many systems may treat
these as distinct.

Bundled dependencies
--------------------

Build-time SBOMs may include packages for `bundled()` / `golang()` Provides
from the RPM spec (see [Understanding SBOMs — Bundled dependencies](../../../../docs/sbom.md#bundled-dependencies)).
The `vim-9.1.083-5.el10` example includes `bundled(libvterm)` linked to the
SRPM with `DEPENDENCY_OF`.

When a container image is built including RPMs, the SBOM for the container
image should refer to the RPMs using external references both with and without
the `repository_id` qualifier, to ensure purl matching to the original RPM
Expand Down
189 changes: 189 additions & 0 deletions sbom/examples/rpm/build/bundled_provides.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
"""
``bundled()`` / ``golang()`` from Koji Provides (Deptopia ``internal/sources/rpm.go``).

Lightweight SPDX 2 document fragments for manifests (packages + DEPENDENCY_OF).
"""

from __future__ import annotations

import hashlib
from dataclasses import dataclass
from typing import Any

LANG_TO_PURL_TYPE: dict[str, str] = {
"golang": "golang",
"python": "pypi",
"nodejs": "npm",
"rust": "cargo",
"ruby": "gem",
"java": "maven",
"generic": "generic",
}


@dataclass
class RpmDep:
"""Koji ``getRPMDeps`` row."""

name: str
version: str
dep_type: int # 0 requires, 1 provides


@dataclass
class BundledDep:
"""Single ``bundled()`` or ``golang()`` provide after parsing."""

path: str
version: str
lang: str # "generic", "golang", "python", ...


def _bundled_purl(dep: BundledDep) -> str:
purl_type = LANG_TO_PURL_TYPE.get(dep.lang, "generic")
ver = f"@{dep.version}" if dep.version else ""
return f"pkg:{purl_type}/{dep.path}{ver}"
Comment on lines +42 to +45

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="sbom/examples/rpm/build/bundled_provides.py"
echo "== File: $FILE =="
sed -n '1,120p' "$FILE" | nl -ba | sed -n '1,120p'

echo
echo "== Lines around _bundled_purl (35-80) =="
sed -n '35,80p' "$FILE" | nl -ba

echo
echo "== Find BundledDep and dep.path construction =="
rg -n "class BundledDep|def _bundled_purl|dep\.path|_dep_lang_from_inner|LANG_TO_PURL_TYPE" "$FILE" -n

echo
echo "== Show full definitions related to bundling/parsing (where dep.path comes from) =="
# print likely relevant sections
sed -n '1,200p' "$FILE" | nl -ba | sed -n '1,200p'

Repository: RedHatProductSecurity/security-data-guidelines

Length of output: 207


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="sbom/examples/rpm/build/bundled_provides.py"

echo "== File head (first 160 lines) =="
sed -n '1,160p' "$FILE" | awk '{printf "%6d\t%s\n", NR, $0}'

echo
echo "== Lines around _bundled_purl (lines 30-90 approx) =="
sed -n '30,90p' "$FILE" | awk '{printf "%6d\t%s\n", NR+29, $0}'

echo
echo "== Grep key symbols =="
rg -n "class BundledDep|def _bundled_purl|dep\.path|_dep_lang_from_inner|LANG_TO_PURL_TYPE" "$FILE"

echo
echo "== Dump section that defines BundledDep and helpers (look 1-140) =="
sed -n '1,140p' "$FILE" | awk '{printf "%6d\t%s\n", NR, $0}'

Repository: RedHatProductSecurity/security-data-guidelines

Length of output: 13993


🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n "bundled\\(nodejs|golang\\(|bundled\\(|golang\\(" sbom/examples/rpm/build -S || true
rg -n "nodejs\\(|nodejs-\\w+|`@scope`|`@angular`|`@react`" sbom/examples/rpm/build -S || true
rg -n "bundled\\(" -S sbom/examples | head -n 200 || true
ls -R sbom/examples/rpm/build | head -n 200 || true

Repository: RedHatProductSecurity/security-data-guidelines

Length of output: 2281


🌐 Web query:

Package URL (purl) npm scoped package @scope ambiguity version delimiter percent-encoding guidance for namespace/name

💡 Result:

In the Package URL (purl) specification, to represent an npm scoped package (which typically uses the format @scope/name), the '@' character in the scope must be percent-encoded as %40 [1][2][3]. This is required to avoid ambiguity with the '@' character used as the version delimiter, which appears later in the purl syntax [2][3]. According to the standard purl syntax—pkg:npm//@—the version is separated from the package identifier by an unencoded '@' [4][2][3]. If the '@' symbol at the start of a scope were not encoded, parsers might incorrectly identify it as the start of the version string [3][5]. Therefore, a scoped package such as @storybook/addon is represented as pkg:npm/%40storybook/addon [1][3]. Key guidance for encoding namespaces and names in purl: - Scopes (namespaces): The leading '@' must be encoded as %40 [1][3]. - Separators: The '/' that separates the namespace (scope) from the name does not need to be and must not be percent-encoded [2][5]. - Version Delimiter: The '@' used to separate the package identifier from the version string is a structural separator and should not be encoded in that specific position [2][5]. - Consistency: The general rule for purl is that structural characters are only encoded if they appear outside their defined functional positions, where they would otherwise cause parsing ambiguity [2]. By using %40 for the scope's '@', the resulting PURL remains unambiguous and conformant with the specification [2][3].

Citations:


🌐 Web query:

SPDX externalRef purl encoding rules pkg:type/namespace/name@version percent-encoding reserved characters

💡 Result:

When using a Package URL (PURL) in an SPDX ExternalRef (referenceType: purl), the PURL must be encoded as an ASCII string using UTF-8 and percent-encoding as defined by RFC 3986 [1][2]. The following rules apply to percent-encoding within PURL components [1][2]: General Encoding Rules - All non-ASCII characters must be UTF-8 encoded and then percent-encoded [1][2]. - It is permissible to percent-encode any component, with the exception of the type [1][2]. - Producers and consumers must always percent-encode and percent-decode components and segments [1][2]. Separator and Reserved Character Rules - Type: Must not be encoded and must not contain separators [1][2]. - Separators (#,?, @,:) and delimiters (/, =) have specific handling: - The ':' (scheme/type separator) and '/' (type/namespace/name/subpath segment separator) must not be percent-encoded, as they are unambiguous [1][2]. - The '=' (qualifiers key/value separator) must not be encoded [1][2]. - The '@' (version separator), '?' (qualifiers separator), and '#' (subpath separator) must be percent-encoded as '%40', '%3F', and '%23' respectively if they appear elsewhere (i.e., when they are not acting as the primary separator for those components) [1][2]. When implementing an SPDX ExternalRef, the PURL is typically stored in the referenceLocator field [3][4]. Adhering to these encoding rules ensures compatibility across different SBOM producers and consumers [1][4].

Citations:


🌐 Web query:

CycloneDX component purl field percent encoding rules

💡 Result:

The Package URL (PURL) specification, which CycloneDX uses for component identification, mandates strict percent-encoding rules to ensure unambiguous parsing [1][2][3]. A PURL is defined as an ASCII string [2]. Key Percent-Encoding Rules: 1. General Requirement: Components and segments within a PURL must be percent-encoded according to RFC 3986 [2][3]. Non-ASCII characters must first be UTF-8 encoded and then percent-encoded [2]. 2. Separator Characters: Specific reserved characters acting as separators must NOT be percent-encoded when used in their designated structural roles [2][3]: - The colon ':' (scheme/type separator) must NOT be encoded [2][3]. - The forward slash '/' (used to separate type/namespace/name segments) must NOT be encoded [2][3]. - The '@' version separator, '?' qualifiers separator, and '#' subpath separator must NOT be encoded when functioning as delimiters [2][3]. - The '=' character used within qualifiers to separate keys and values must NOT be encoded [2]. 3. Encoding Reserved Characters Elsewhere: When reserved characters (such as '@', '?', '#', or ':') appear within a component's data (rather than as its primary separator), they must be percent-encoded to maintain the PURL's integrity [2]. For instance, '@' must be encoded as %40, '?' as %3F, and '#' as %23 when they occur inside a namespace, name, version, or qualifier value [2]. 4. The Type Component: The package 'type' MUST NOT be encoded [2][3]. It must be composed only of ASCII letters, numbers, periods ('.'), and dashes ('-'), and it must start with an ASCII letter [3]. 5. Producers and Consumers: All producers and consumers of PURL data are required to consistently apply these percent-encoding and decoding rules to ensure interoperability across different ecosystems and SBOM formats [2].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n "bundled\\(nodejs|golang\\(|bundled\\(|golang\\(" sbom/examples/rpm/build -S || true
rg -n "nodejs\\(|nodejs-\\w+|`@scope`|`@angular`|`@react`" sbom/examples/rpm/build -S || true
rg -n "bundled\\(" -S sbom/examples | head -n 200 || true
ls -R sbom/examples/rpm/build | head -n 200 || true

Repository: RedHatProductSecurity/security-data-guidelines

Length of output: 2281


🌐 Web query:

Package URL (purl) npm scoped package @scope version delimiter percent-encoding guidance

💡 Result:

In the Package URL (purl) specification, scoped npm packages must be formatted by placing the scope in the namespace component and the package name in the name component [1][2]. Because the @ symbol is used as a delimiter for the version in a purl (e.g., name@version), the @ symbol at the beginning of an npm scope must be percent-encoded as %40 to avoid ambiguity and ensure correct parsing [3][4][5][6]. Guidance for construction: 1. Namespace: The scope (including the leading @) must be percent-encoded [1][7]. For example, the scope @angular becomes %40angular [5][8]. 2. Name: The package name itself should be placed in the name component [3][2]. 3. Version Delimiter: The @ symbol used to separate the package name from the version is a reserved separator and must not be percent-encoded in its role as a delimiter [1][9]. 4. Example: A scoped package named @angular/core with version 16.2.0 is represented as pkg:npm/%40angular/core@16.2.0 [8]. It is essential to follow this encoding rule because parsers use the @ character to identify the start of the version string; leaving the scope's @ unencoded can cause the parser to incorrectly identify the version or fail to parse the purl entirely [6]. All producers and consumers of purl data must adhere to these encoding rules to maintain interoperability across ecosystems [3][9].

Citations:


🌐 Web query:

Package URL purl encoding rules for pkg:type/namespace/name@version reserved characters percent-encoding

💡 Result:

The Package URL (purl) specification defines a standardized format to represent software package identity, using percent-encoding to ensure components can be parsed unambiguously [1][2][3]. A purl is strictly an ASCII string where non-ASCII characters must be UTF-8 encoded and then percent-encoded according to RFC 3986 [1][2]. The core encoding rules for purl components (namespace, name, version, qualifiers, and subpath) are as follows: 1. Mandatory Separators: Several characters are reserved as structural separators and must NOT be percent-encoded when used for their designated purpose [1][2]: - The colon ':' (after the 'pkg' scheme) [1][2]. - The forward slash '/' (separating type, namespace, name, and subpath segments) [1][2]. - The at sign '@' (version separator) [1][2]. - The question mark '?' (qualifiers separator) [1][2]. - The hash sign '#' (subpath separator) [1][2]. - The equal sign '=' (qualifiers key/value separator) [1][2]. 2. Encoding Reserved Characters: When these reserved characters appear within a component's data (rather than acting as a separator), they must be percent-encoded to prevent parsing ambiguity [1][4][2]. For example: - The '@' separator must be encoded as '%40' if it appears inside a component's value [1]. - The '?' separator must be encoded as '%3F' [1]. - The '#' separator must be encoded as '%23' [1]. - Any reserved character (e.g., '!', "'") that has no special meaning to the purl structure must still be percent-encoded [4]. 3. Type and Component-Specific Rules: - The package 'type' MUST NOT be percent-encoded and must not contain separators [1]. - Components like 'namespace', 'name', 'version', 'qualifiers', and 'subpath' must be percent-encoded [1][5][2]. - Producers and consumers must always perform percent-decoding when consuming data and percent-encoding when producing it [1]. - When percent-encoding is required, the string is first converted to UTF-8 bytes and then each byte is represented by a percent-encoded character triplet (e.g., '%XX') [1][2]. In summary, characters that do not function as designated separators must be percent-encoded if they are not in the 'unreserved' set (alphanumeric characters and '.-_~') [5][2][6]. Reserved characters acting as separators are left unencoded, while those same characters appearing as literal content within a component must be percent-encoded [1][4][2].

Citations:


🌐 Web query:

CycloneDX component purl percent encoding guidance

💡 Result:

CycloneDX guidance for percent-encoding in component purl/bom-ref is ultimately governed by the Package-URL (PURL) specification (ECMA-427), because CycloneDX’s purl field is a PURL string. 1) What percent-encoding must look like (PURL ECMA-427) - Percent-encoding is applied to specific PURL components (e.g., namespace, name, version, subpath) and is defined per component’s rules in ECMA-427: when percent-encoding is required, the component string is encoded as UTF-8 and then each “data octet” is replaced by a percent-encoded “character triplet” using RFC 3986 [1][2]. - In the percent-encoding rules, certain characters must not be percent-encoded (for example alphanumerics, punctuation, and separator characters when used as PURL separators), and the percent sign is not percent-encoded when it is already representing a percent-encoded character [1][2]. - Spaces must be percent-encoded as %20 [1][2]. 2) Canonical guidance on slashes (/) - In the PURL structure, the purl “namespace” and “name” components are separate segments; ECMA-427 specifies that namespace and name are “percent-encoded strings” (i.e., slashes that occur within those segment values are not “structural” separators and therefore can/should be encoded according to the component percent-encoding rules) [2]. 3) Practical CycloneDX ecosystem issue: encoded “/” (e.g., %2F) vs downstream tool expectations - CycloneDX tooling (example: cdxgen) has historically produced %2F in purl/bom-ref for Go modules, which caused interoperability friction with tools like Dependency-Track that couldn’t parse the URL-encoded purl as expected [3]. - A cdxgen issue describes “golang” PURLs containing URL-encoded module names (example: pkg:golang/github.com%2Fapache%2Fthrift@...), and notes that while this is technically defensible, it causes friction with downstream tools [4]. - CycloneDX community/tooling discussions include that this problem was fixed in a later cdxgen version (the reported fix was “Resolved in 10.3.0” for the Go module %2F encoding case) [4] and a separate issue notes a fix shipped in cdxgen version 8.3.3 for %2F appearing in purl/bom-ref [3]. 4) Bottom line guidance you can apply when generating CycloneDX purl strings - Do not “undo” percent-encoding inside purl just to make it look nicer (e.g., turning %2F back into /), because PURL’s grammar relies on correct percent-encoding for components that are treated as data rather than separators [1][2]. - If you are seeing %2F in the purl (common with Go module identifiers), treat it as a symptom of correctly encoding component data per ECMA-427, and handle downstream tool compatibility by using updated CycloneDX tooling or by ensuring your consumer/parsing tooling supports PURL percent-encoding (cdxgen explicitly discussed this interoperability issue and provided fixes in later versions) [3][4].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show how bundling README describes inputs
sed -n '1,120p' sbom/examples/rpm/build/README.md | awk '{printf "%5d\t%s\n", NR, $0}'

echo "== Search for npm purls in example outputs =="
rg -n "pkg:npm/" sbom/examples/rpm/build/*.cdx.json sbom/examples/rpm/build/*.spdx.json | head -n 100

echo
echo "== Search for raw '@' inside pkg:npm/ occurrences =="
# Match e.g. pkg:npm/@scope/name
rg -n "pkg:npm/[^[:space:]]*@[^[:space:]]*" sbom/examples/rpm/build/*.cdx.json sbom/examples/rpm/build/*.spdx.json || true

echo
echo "== Search for percent-encoded scope marker %40 in npm purls =="
rg -n "pkg:npm/%40" sbom/examples/rpm/build/*.cdx.json sbom/examples/rpm/build/*.spdx.json || true

echo
echo "== Inspect full purl strings around first match if any =="
python3 - <<'PY'
import glob, re
files = glob.glob("sbom/examples/rpm/build/*.cdx.json") + glob.glob("sbom/examples/rpm/build/*.spdx.json")
pat = re.compile(r'("referenceLocator"\s*:\s*")(?P<purl>pkg:[^"]+)(")')
for f in files:
    txt=open(f,'r',encoding='utf-8').read()
    m=None
    for m in pat.finditer(txt):
        p=m.group('purl')
        if p.startswith("pkg:npm/"):
            print("FILE", f)
            print(p)
            raise SystemExit
print("No pkg:npm purls found")
PY

Repository: RedHatProductSecurity/security-data-guidelines

Length of output: 2695


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Search for npm purls in example outputs (non-fatal if none) =="
rg -n "pkg:npm/" sbom/examples/rpm/build/*.cdx.json sbom/examples/rpm/build/*.spdx.json || true

echo
echo "== Search for pkg:npm/ then any '@' after =="
rg -n "pkg:npm/[^[:space:]]*@[^[:space:]]*" sbom/examples/rpm/build/*.cdx.json sbom/examples/rpm/build/*.spdx.json || true

echo
echo "== Search for percent-encoded npm scope marker %40 in npm purls =="
rg -n "pkg:npm/%40" sbom/examples/rpm/build/*.cdx.json sbom/examples/rpm/build/*.spdx.json || true

echo
echo "== Search for bundled(nodejs( pattern in source/docs =="
rg -n "bundled\\(nodejs\\(|golang\\(nodejs\\(" -S sbom/examples/rpm/build sbom/examples | head -n 200 || true

echo
echo "== Search for '@' inside dep.path construction inputs (nodejs- / npm) in this file =="
rg -n "nodejs-|npm\\b|nodejs\\b" sbom/examples/rpm/build/bundled_provides.py

Repository: RedHatProductSecurity/security-data-guidelines

Length of output: 612


🏁 Script executed:

#!/bin/bash
set -euo pipefail
python3 - <<'PY'
import glob, re, json

files = glob.glob("sbom/examples/rpm/build/*.cdx.json") + glob.glob("sbom/examples/rpm/build/*.spdx.json")
pat = re.compile(r'^pkg:npm/.*$', re.M)

found=[]
for f in files:
    txt=open(f,'r',encoding='utf-8').read()
    for m in re.finditer(r'"referenceLocator"\s*:\s*"([^"]+)"', txt):
        v=m.group(1)
        if v.startswith("pkg:npm/"):
            found.append((f,v))
            break

print("npm purl count:", len(found))
for f,v in found[:50]:
    print(f, "=>", v)
PY

Repository: RedHatProductSecurity/security-data-guidelines

Length of output: 108


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="sbom/examples/rpm/build/bundled_provides.py"
wc -l "$FILE"

echo "== Tail (last 160 lines) =="
tail -n 160 "$FILE" | awk '{printf "%6d\t%s\n", NR+('"$(wc -l <"$FILE")'-160), $0}' || true

echo
echo "== Show remaining portion starting at 120 =="
sed -n '120,260p' "$FILE" | awk '{printf "%6d\t%s\n", NR+119, $0}'

echo
echo "== Show remaining portion starting at 260 =="
sed -n '260,420p' "$FILE" | awk '{printf "%6d\t%s\n", NR+259, $0}'

echo
echo "== Find all _bundled_purl call sites in this file =="
rg -n "_bundled_purl\\(" "$FILE"

Repository: RedHatProductSecurity/security-data-guidelines

Length of output: 247


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="sbom/examples/rpm/build/bundled_provides.py"
wc -l "$FILE"

echo "== Tail 80 lines =="
tail -n 80 "$FILE" | sed -n '1,80p' | awk '{printf "%6d\t%s\n", NR, $0}'

echo
echo "== Show ranges 120-189 =="
sed -n '120,189p' "$FILE" | awk '{printf "%6d\t%s\n", NR+119, $0}'

echo
echo "== Call sites for _bundled_purl =="
rg -n "_bundled_purl\\(" "$FILE"

echo
echo "== CycloneDX conversion section (look for cyclonedx/lang) =="
rg -n "cyclonedx|bom-ref|component.*purl|referenceLocator|purl" "$FILE"

Repository: RedHatProductSecurity/security-data-guidelines

Length of output: 6569


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "bundled\\(nodejs\\(" -S . || true
rg -n "bundled\\(nodejs\\(.*@.*\\/" -S . || true
rg -n "@[a-zA-Z0-9._-]+/" sbom -S || true
rg -n "nodejs\\([^)]*@[^)]*\\)" -S . || true
rg -n "npm scoped|scoped npm|%40|pkg:npm/" -S sbom docs . || true

Repository: RedHatProductSecurity/security-data-guidelines

Length of output: 546


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "pkg:npm/%40" docs sbom -S || true
rg -n "bundled\\(nodejs\\([^)]*@[^)]*\\)" docs/sbom.md -S || true
rg -n "bundled\\(nodejs\\(.*@.*\\/.*\\)\\)" docs/sbom.md -S || true
rg -n "@[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+" docs/sbom.md -S || true
rg -n "nodejs\\-@|bundled\\(nodejs\\(\\@|%40" docs/sbom.md -S || true

Repository: RedHatProductSecurity/security-data-guidelines

Length of output: 336


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "pkg:npm/%40" docs sbom -S || true
rg -n "bundled\\(nodejs\\([^)]*@[^)]*\\)" docs/sbom.md -S || true
rg -n "bundled\\(nodejs\\(.*@.*\\/.*\\)\\)" docs/sbom.md -S || true
rg -n "@[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+" docs/sbom.md -S || true
rg -n "nodejs\\-@|bundled\\(nodejs\\(\\@|%40" docs/sbom.md -S || true

Repository: RedHatProductSecurity/security-data-guidelines

Length of output: 336


🏁 Script executed:

#!/bin/bash
set -euo pipefail

DOC="docs/sbom.md"

echo "== Search for %40 in docs/sbom.md =="
rg -n "%40" "$DOC" || true

echo
echo "== Search for common scoped-npm patterns in docs/sbom.md =="
rg -n "@[a-zA-Z0-9_.-]+/" "$DOC" || true
rg -n "scoped npm|npm scoped|%40|pkg:npm/" "$DOC" || true

echo
echo "== Show bundled dependencies section around line 630-700 =="
sed -n '620,690p' "$DOC" | awk '{printf "%5d\t%s\n", NR+619, $0}'

Repository: RedHatProductSecurity/security-data-guidelines

Length of output: 3502


Percent-encode bundled purl paths (npm scoped @... ambiguity)

_bundled_purl injects dep.path verbatim into pkg:{purl_type}/{dep.path}@{version}. For scoped npm provides like bundled(nodejs(@scope/name)), this yields pkg:npm/@scope/name@..., but npm scope @ must be percent-encoded (e.g., %40scope) to avoid ambiguity with the purl version delimiter.

Suggested fix
 import hashlib
 from dataclasses import dataclass
 from typing import Any
+from urllib.parse import quote
@@
 def _bundled_purl(dep: BundledDep) -> str:
     purl_type = LANG_TO_PURL_TYPE.get(dep.lang, "generic")
     ver = f"@{dep.version}" if dep.version else ""
-    return f"pkg:{purl_type}/{dep.path}{ver}"
+    encoded_path = "/".join(quote(part, safe="") for part in dep.path.split("/"))
+    return f"pkg:{purl_type}/{encoded_path}{ver}"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def _bundled_purl(dep: BundledDep) -> str:
purl_type = LANG_TO_PURL_TYPE.get(dep.lang, "generic")
ver = f"@{dep.version}" if dep.version else ""
return f"pkg:{purl_type}/{dep.path}{ver}"
def _bundled_purl(dep: BundledDep) -> str:
purl_type = LANG_TO_PURL_TYPE.get(dep.lang, "generic")
ver = f"@{dep.version}" if dep.version else ""
encoded_path = "/".join(quote(part, safe="") for part in dep.path.split("/"))
return f"pkg:{purl_type}/{encoded_path}{ver}"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@sbom/examples/rpm/build/bundled_provides.py` around lines 42 - 45, The
_bundled_purl function currently inserts dep.path verbatim causing ambiguity for
scoped npm names (e.g., `@scope/name`) — percent-encode the path segment before
building the purl (encode the leading @ as %40 and other reserved characters,
while preserving path separators) by using urllib.parse.quote with safe='/' (or
equivalent) and then return f"pkg:{purl_type}/{encoded_path}{ver}"; update the
function (_bundled_purl) and add the urllib.parse import (or your project's
URL-encoding helper) so scoped npm provides become pkg:npm/%40scope/name@...



def _dep_lang_from_inner(name_inner: str) -> tuple[str, str]:
"""Map inner provide name → (path, lang). Mirrors ``getDepListLangFromName``."""
if name_inner.startswith("golang)") and "(" in name_inner:
ss = name_inner[len("golang)") :].lstrip()
if ss.startswith("(") and ")" in ss:
path = ss[1 : ss.index(")")]
return path, "golang"

s = name_inner
if " with " in s:
s = s.strip().strip("()")
s = s.split(" ", 1)[0]

parts = s.split("(", 1)
if len(parts) > 1:
prefix, rest = parts[0], parts[1].rstrip(")")
lp = prefix.lower()
if "golang" in lp:
return rest, "golang"
if "python" in lp:
return rest, "python"
if "npm" in lp or "nodejs" in lp:
return rest, "nodejs"
if "ruby" in lp:
return rest, "ruby"
if "crate" in lp:
return rest, "rust"
if "mvn" in lp:
return rest, "java"

sl = name_inner.lower()
if sl.startswith("nodejs-"):
return name_inner.split("-", 1)[1], "nodejs"
if sl.startswith("python-") or sl.startswith("python3-") or sl.startswith("python2-"):
return name_inner.split("-", 1)[1], "python"
if sl.startswith("rubygem-"):
return name_inner.split("-", 1)[1], "ruby"

return name_inner, "generic"


def _parse_wrapped(
prefix: str, rpm_name: str, rpm_ver: str, default_lang: str
) -> BundledDep | None:
pfx = prefix + "("
if not rpm_name.startswith(pfx) or not rpm_name.endswith(")"):
return None
inner = rpm_name[len(pfx) : -1]
path, lang = _dep_lang_from_inner(inner)
if lang == "generic" and default_lang != "generic":
lang = default_lang
return BundledDep(path=path, version=rpm_ver, lang=lang)


def bundled_golang_from_provides(provides: list[RpmDep]) -> list[BundledDep]:
"""Extract ``bundled(...)`` and ``golang(...)`` Provides (type 1 rows)."""
out: list[BundledDep] = []
seen: set[tuple[str, str, str]] = set()
for d in provides:
b = _parse_wrapped("bundled", d.name, d.version, "generic")
if b:
key = (b.path, b.version, b.lang)
if key not in seen:
seen.add(key)
out.append(b)
continue
g = _parse_wrapped("golang", d.name, d.version, "golang")
if g:
key = (g.path, g.version, g.lang)
if key not in seen:
seen.add(key)
out.append(g)
return out


def _ref_id(s: str) -> str:
h = hashlib.sha256(s.encode("utf-8")).hexdigest()[:12]
return f"SPDXRef-Bundled-{h}"


def bundled_provides_to_spdx_fragments(
bundled: list[BundledDep],
*,
srpm_spdx_id: str,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""
Return ``(packages, relationships)`` SPDX JSON-LD-style dicts (subset).

Each bundled dep becomes a package; ``DEPENDENCY_OF`` links it to ``srpm_spdx_id``.
"""
packages: list[dict[str, Any]] = []
rels: list[dict[str, Any]] = []
for b in bundled:
pid = _ref_id(f"{b.path}\0{b.version}\0{b.lang}")
name = f"{b.path} ({b.lang})"
if b.version:
name = f"{name} {b.version}"
packages.append(
{
"SPDXID": pid,
"name": name,
"versionInfo": b.version or "NOASSERTION",
"downloadLocation": "NOASSERTION",
"filesAnalyzed": False,
"primaryPackagePurpose": "LIBRARY",
"externalRefs": [
{
"referenceCategory": "PACKAGE-MANAGER",
"referenceType": "purl",
"referenceLocator": _bundled_purl(b),
}
],
}
)
rels.append(
{
"spdxElementId": pid,
"relationshipType": "DEPENDENCY_OF",
"relatedSpdxElement": srpm_spdx_id,
}
)
return packages, rels


def bundled_provides_to_cdx_components(bundled: list[BundledDep]) -> list[dict[str, Any]]:
"""Return CycloneDX components for bundled deps (type ``library``)."""
components: list[dict[str, Any]] = []
for b in bundled:
purl = _bundled_purl(b)
name = f"{b.path} ({b.lang})"
if b.version:
name = f"{name} {b.version}"
components.append(
{
"bom-ref": purl,
"type": "library",
"name": name,
"version": b.version or None,
"purl": purl,
}
)
return components
62 changes: 57 additions & 5 deletions sbom/examples/rpm/build/from-koji.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,17 @@
import re
import subprocess
import sys
import koji
from copy import deepcopy
from tempfile import TemporaryDirectory

import koji
from bundled_provides import (
RpmDep,
bundled_golang_from_provides,
bundled_provides_to_cdx_components,
bundled_provides_to_spdx_fragments,
)

# Script requires these RPMs: brewkoji, rpmdevtools, rpm-build
# Run with: ./from-koji.py brew <NVR>

Expand Down Expand Up @@ -99,6 +106,39 @@ def get_sha256_checksum(filename):
h.update(chunk)
return h.hexdigest()

@staticmethod
def get_build_provides(build_id):
"""Collect Provides (type 1) from every RPM in a Koji build."""
build = SESSION.getBuild(build_id)
provides = []
for rpm in SESSION.listBuildRPMs(build["id"]):
rpm_id = rpm.get("id")
if not rpm_id:
continue
for dep in SESSION.getRPMDeps(rpm_id):
if dep.get("type") != 1:
continue
provides.append(
RpmDep(
name=str(dep.get("name") or ""),
version=str(dep.get("version") or ""),
dep_type=1,
)
)
return provides

def add_bundled_provides(self, build_id):
"""Add bundled()/golang() provides as DEPENDENCY_OF packages linked to the SRPM."""
bundled_deps = bundled_golang_from_provides(self.get_build_provides(build_id))
if not bundled_deps:
return []
packages, rels = bundled_provides_to_spdx_fragments(
bundled_deps, srpm_spdx_id="SPDXRef-SRPM"
)
self.spdx_packages.extend(packages)
self.spdx_relationships.extend(rels)
return bundled_provides_to_cdx_components(bundled_deps)

def run_syft_spdx(self, builddir):
syft = subprocess.run(
cwd=os.path.dirname(builddir),
Expand Down Expand Up @@ -515,6 +555,8 @@ def process_build(self, build_id, rpmmod):
self.spdx_packages.append(package)
self.cdx_components.append(create_cdx_from_spdx(package))

bundled_cdx_components = self.add_bundled_provides(build_id)

spdx = {
"spdxVersion": "SPDX-2.3",
"dataLicense": "CC0-1.0",
Expand Down Expand Up @@ -553,17 +595,27 @@ def process_build(self, build_id, rpmmod):

copy_of_cdx_root["pedigree"] = {"ancestors": cdx_pedigrees}
self.cdx_components.append(copy_of_cdx_root)
for bundled_component in bundled_cdx_components:
if bundled_component.get("version") is None:
bundled_component.pop("version", None)
self.cdx_components.append(bundled_component)
Comment on lines +598 to +601

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate and print the relevant section around the reported line numbers
FILE="sbom/examples/rpm/build/from-koji.py"
nl -ba "$FILE" | sed -n '560,680p'

# Find where bom-ref is set/normalized in this file
rg -n --hidden --no-ignore-vcs "bom-ref|bom_ref|dependsOn|cdx\\[\"dependencies\"\\]" "$FILE"

# Print helper functions / blocks that build component identity or bom-ref
rg -n --hidden --no-ignore-vcs "purl|bom.*ref|component.*ref|cyclonedx" "$FILE"

Repository: RedHatProductSecurity/security-data-guidelines

Length of output: 133


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Confirm file exists
ls -la "sbom/examples/rpm/build/from-koji.py"

# Show lines around 580-630 with line numbers (using awk since nl isn't available)
awk 'NR>=560 && NR<=650 {printf "%d\t%s\n", NR, $0}' "sbom/examples/rpm/build/from-koji.py"

# Search for bom-ref / dependency wiring
rg -n --hidden --no-ignore-vcs "bom-ref|bom_ref|dependsOn|cdx\\[\"dependencies\"\\]" "sbom/examples/rpm/build/from-koji.py"

# Search for where bom-ref/purl/component identity is constructed
rg -n --hidden --no-ignore-vcs "purl|bom.*ref|component.*ref|cyclonedx|cdx_components" "sbom/examples/rpm/build/from-koji.py"

Repository: RedHatProductSecurity/security-data-guidelines

Length of output: 6698


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="sbom/examples/rpm/build/from-koji.py"

# Show function where bundled provides are converted to CycloneDX components
rg -n "def add_bundled_provides|bundled_provides_to_cdx_components" "$FILE"
# Show the relevant implementations (around the matches)
awk 'NR>=1 && NR<=320 {printf "%d\t%s\n", NR, $0}' "$FILE" | head -n 320
awk 'NR>=320 && NR<=560 {printf "%d\t%s\n", NR, $0}' "$FILE" | head -n 240
awk 'NR>=520 && NR<=640 {printf "%d\t%s\n", NR, $0}' "$FILE" | head -n 200

# Also locate how bom-ref is constructed for syft/spdx components
rg -n "create_cdx_from_spdx\\(|syft_cdx_components|self\\.cdx_components\\.extend\\(syft_cdx_components|pkg:generic/|pkg:rpm/" "$FILE"

Repository: RedHatProductSecurity/security-data-guidelines

Length of output: 29053


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="sbom/examples/rpm/build/from-koji.py"

# Show create_cdx_from_spdx implementation
awk 'NR>=640 && NR<=740 {printf "%d\t%s\n", NR, $0}' "$FILE"

# Locate bundled_provides.py and inspect relevant function(s)
ls -la sbom/examples/rpm/build
fd -a "bundled_provides.py" -t f . | head -n 20

# If found, print around bundled_provides_to_cdx_components and bom-ref construction
BFILE="$(fd -a "bundled_provides.py" -t f sbom/examples/rpm/build sbom/examples/rpm -x echo 2>/dev/null | head -n 1 || true)"
if [ -z "${BFILE}" ]; then
  BFILE="$(fd -a "bundled_provides.py" -t f . | head -n 1)"
fi
echo "Using bundled providers file: $BFILE"

rg -n "def bundled_provides_to_cdx_components|bom-ref|bom_ref|dependsOn|purl" "$BFILE"
awk 'NR>=1 && NR<=260 {printf "%d\t%s\n", NR, $0}' "$BFILE" | head -n 260

# Print just the function body region more directly
rg -n "def bundled_provides_to_cdx_components" "$BFILE" | head -n 5
start=$(rg -n "def bundled_provides_to_cdx_components" "$BFILE" | head -n 1 | cut -d: -f1)
if [ -n "${start}" ]; then
  awk -v s="$start" 'NR>=s && NR<=s+220 {printf "%d\t%s\n", NR, $0}' "$BFILE"
fi

Repository: RedHatProductSecurity/security-data-guidelines

Length of output: 11693


Deduplicate bundled CycloneDX components by bom-ref before appending them.

  • bundled_provides_to_cdx_components() emits "bom-ref"/"purl" from _bundled_purl(b), and create_cdx_from_spdx() also uses the package-manager purl as "bom-ref". Since syft components are appended without any "bom-ref" dedupe, a bundled provide can collide with an existing syft/spdx component and produce duplicate CycloneDX component identifiers.
  • cdx["dependencies"][0]["dependsOn"] (lines 615-618) is then built from bundled "bom-ref" values, so duplicate ids make dependency references ambiguous/invalid for BOM consumers.
Suggested fix
         copy_of_cdx_root["pedigree"] = {"ancestors": cdx_pedigrees}
         self.cdx_components.append(copy_of_cdx_root)
+        existing_bom_refs = {component["bom-ref"] for component in self.cdx_components}
         for bundled_component in bundled_cdx_components:
             if bundled_component.get("version") is None:
                 bundled_component.pop("version", None)
-            self.cdx_components.append(bundled_component)
+            if bundled_component["bom-ref"] not in existing_bom_refs:
+                self.cdx_components.append(bundled_component)
+                existing_bom_refs.add(bundled_component["bom-ref"])
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for bundled_component in bundled_cdx_components:
if bundled_component.get("version") is None:
bundled_component.pop("version", None)
self.cdx_components.append(bundled_component)
copy_of_cdx_root["pedigree"] = {"ancestors": cdx_pedigrees}
self.cdx_components.append(copy_of_cdx_root)
existing_bom_refs = {component["bom-ref"] for component in self.cdx_components}
for bundled_component in bundled_cdx_components:
if bundled_component.get("version") is None:
bundled_component.pop("version", None)
if bundled_component["bom-ref"] not in existing_bom_refs:
self.cdx_components.append(bundled_component)
existing_bom_refs.add(bundled_component["bom-ref"])
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@sbom/examples/rpm/build/from-koji.py` around lines 598 - 601, Currently
bundled_cdx_components are appended without checking for existing bom-ref
collisions, which can duplicate CycloneDX component ids; before appending in the
loop inside the routine that handles bundled_cdx_components, compute an
existing_bom_refs set from self.cdx_components (use keys "bom-ref" and fallback
to "purl"), then filter bundled_cdx_components to skip any component whose
"bom-ref" (from _bundled_purl(b) / bundled_provides_to_cdx_components output) is
already present; keep the existing logic that pops version when None, but only
append components whose bom-ref is not in existing_bom_refs so
create_cdx_from_spdx() and the cdx["dependencies"][0]["dependsOn"] entries
remain unambiguous.

cdx["components"] = sorted(self.cdx_components, key=lambda c: c["purl"])

binary_rpm_purls = set()
for cdx_component in self.cdx_components:
if cdx_component["bom-ref"] == copy_of_cdx_root["bom-ref"]:
continue
binary_rpm_purls.add(cdx_component["purl"])
if cdx_component["bom-ref"].startswith("pkg:rpm/"):
binary_rpm_purls.add(cdx_component["purl"])

cdx["dependencies"] = [
{"ref": copy_of_cdx_root["bom-ref"], "provides": sorted(list(binary_rpm_purls))}
]
srpm_dep = {
"ref": copy_of_cdx_root["bom-ref"],
"provides": sorted(list(binary_rpm_purls)),
}
bundled_refs = sorted(c["bom-ref"] for c in bundled_cdx_components)
if bundled_refs:
srpm_dep["dependsOn"] = bundled_refs
cdx["dependencies"] = [srpm_dep]

with open(f"{build_id}.spdx.json", "w") as fp:
# Add an extra newline at the end since a lot of editors add one when you save a file,
Expand Down
9 changes: 9 additions & 0 deletions sbom/examples/rpm/build/vim-9.1.083-5.el10.cdx.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@
}
},
"components": [
{
"bom-ref": "pkg:generic/libvterm",
"type": "library",
"name": "libvterm (generic)",
"purl": "pkg:generic/libvterm"
},
{
"bom-ref": "pkg:rpm/redhat/vim-X11-debuginfo@9.1.083-5.el10?arch=aarch64&epoch=2",
"type": "library",
Expand Down Expand Up @@ -1192,6 +1198,9 @@
"pkg:rpm/redhat/xxd@9.1.083-5.el10?arch=ppc64le&epoch=2",
"pkg:rpm/redhat/xxd@9.1.083-5.el10?arch=s390x&epoch=2",
"pkg:rpm/redhat/xxd@9.1.083-5.el10?arch=x86_64&epoch=2"
],
"dependsOn": [
"pkg:generic/libvterm"
]
}
]
Expand Down
Loading
Loading