-
Notifications
You must be signed in to change notification settings - Fork 6
add bundled provides guidance for srpm #92
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
Changes from all commits
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 |
|---|---|---|
| @@ -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}" | ||
|
|
||
|
|
||
| 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 | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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> | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
|
|
@@ -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), | ||||||||||||||||||||||||||||
|
|
@@ -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", | ||||||||||||||||||||||||||||
|
|
@@ -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
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. 🧩 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"
fiRepository: RedHatProductSecurity/security-data-guidelines Length of output: 11693 Deduplicate bundled CycloneDX components by
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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||
| 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, | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
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.
🧩 Analysis chain
🏁 Script executed:
Repository: RedHatProductSecurity/security-data-guidelines
Length of output: 207
🏁 Script executed:
Repository: RedHatProductSecurity/security-data-guidelines
Length of output: 13993
🏁 Script executed:
Repository: RedHatProductSecurity/security-data-guidelines
Length of output: 2281
🌐 Web query:
Package URL (purl) npm scoped package@scopeambiguity 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/addonis 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:
Repository: RedHatProductSecurity/security-data-guidelines
Length of output: 2281
🌐 Web query:
Package URL (purl) npm scoped package@scopeversion 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%40to 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@angularbecomes%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/corewith version16.2.0is represented aspkg: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:
golangPURLs contain URL-encoded Go module names cdxgen/cdxgen#897🏁 Script executed:
Repository: RedHatProductSecurity/security-data-guidelines
Length of output: 2695
🏁 Script executed:
Repository: RedHatProductSecurity/security-data-guidelines
Length of output: 612
🏁 Script executed:
Repository: RedHatProductSecurity/security-data-guidelines
Length of output: 108
🏁 Script executed:
Repository: RedHatProductSecurity/security-data-guidelines
Length of output: 247
🏁 Script executed:
Repository: RedHatProductSecurity/security-data-guidelines
Length of output: 6569
🏁 Script executed:
Repository: RedHatProductSecurity/security-data-guidelines
Length of output: 546
🏁 Script executed:
Repository: RedHatProductSecurity/security-data-guidelines
Length of output: 336
🏁 Script executed:
Repository: RedHatProductSecurity/security-data-guidelines
Length of output: 336
🏁 Script executed:
Repository: RedHatProductSecurity/security-data-guidelines
Length of output: 3502
Percent-encode bundled purl paths (npm scoped
@...ambiguity)_bundled_purlinjectsdep.pathverbatim intopkg:{purl_type}/{dep.path}@{version}. For scoped npm provides likebundled(nodejs(@scope/name)), this yieldspkg:npm/@scope/name@..., but npm scope@must be percent-encoded (e.g.,%40scope) to avoid ambiguity with the purl version delimiter.Suggested fix
📝 Committable suggestion
🤖 Prompt for AI Agents