Skip to content

SBOM: say where a package was fetched from, and qualify the purl only when it is not the public registry - #212

Merged
villelaitila merged 1 commit into
softagram:mainfrom
villelaitila:feature/sbom-package-source
Sep 28, 2026
Merged

villelaitila merged 1 commit into
softagram:mainfrom
villelaitila:feature/sbom-package-source

Conversation

@villelaitila

Copy link
Copy Markdown
Contributor

What

The CycloneDX generator can now say where a 3rd-party package was fetched from. An analyzer stamps the ecosystem-neutral element attribute package_source (same convention as package_name / version / ecosystem), and the component publishes it as:

Case purl softagram:packageSource
Private feed pkg:npm/x@1.0.0?repository_url=https:%2F%2Fnpm.internal.example%2Frepo the URL
Public registry pkg:npm/x@1.0.0 (unqualified) the URL
Unknown pkg:npm/x@1.0.0 (unqualified) absent

Why this shape

  • Qualifier only for non-default registries. An unqualified purl already means the type's default registry (purl spec). Stating it for public packages would be redundant and would change the purl string of nearly every component, breaking consumers that compare purls as strings.
  • The property separates "public" from "unknown", which purl alone cannot express.
  • bom-ref is never qualified. dedup_key folds on it and every dependency edge resolves through it, so one package restored from two feeds is still one component.
  • Merges don't pick a winner. If the occurrences folded into one row disagree, or some state no source, both the property and the qualifier are dropped. This works the same way as the existing name-repair provenance retraction.
  • Encoding follows ECMA-427 clause 5.4: everything except alphanumerics, .-_~ and : is percent-encoded, so / becomes %2F, as in the standard's repository_url examples. Round-tripped through packageurl-python.
  • Nothing secret leaves. Only scheme, host, port and path are published: user info, query and fragment are dropped. Non-http(s) sources (local feed directories) and values containing a backslash are treated as unknown. Not covered: a token embedded in the URL path. The docs state this.
  • Every emitted purl type is classified as either having a default registry or explicitly having none. A test derives the emitted types from the purl-building code itself, so a new type cannot silently qualify its own public registry.

Status

Dormant. No analyzer stamps package_source yet, so no existing document changes until one does.

Docs: new section "Where a package was fetched from" in docs/data-formats.md.

Verification

  • pytest: 630 passed. That's the 587 on main plus 43 new tests in tests/converters/test_package_source_provenance.py.
  • Every rule was mutation-checked: disabling each one (encoding, default omission, query and user-info stripping, merge retraction, backslash guard, # guard, default port, trailing dot, gem entry) makes at least one test fail.
  • An independent adversarial review exercised all three merge sites, including the transitive and closure folds, with 3-way merges in every order. Its findings (incomplete default-registry table, overclaimed credential docs, git-shaped versions containing #, backslash host confusion) are fixed in this PR.
  • flake8: the only finding is the E501 that is already on main (indirectExposurePaths line).

Known follow-ups (not in this PR)

  • The version is spliced into the purl unencoded. That is an existing problem this PR only works around (for ?/#). Fixing it changes bom-ref for such versions and deserves its own change.
  • Two spellings of the public npm registry (registry.npmjs.org and registry.yarnpkg.com) count as disagreeing sources on merge. This is conservative but loses information.

… when it is not the public registry

A consumer reading the document cannot tell a package restored from the public
registry from one that only the organisation's own feed serves. For a
vulnerability consumer that is the difference between a match it can trust and
a name collision, and it is the question dependency-confusion review starts
from.

An analyzer now has a place to put that fact: the ecosystem-neutral element
attribute 'package_source', the same convention package_name/version/ecosystem
already follow. The converter publishes it twice.

softagram:packageSource carries it whenever it is known, the public registry
included. purl cannot say "unknown" -- an absent repository_url already means
"the type's default registry" -- so without the property a package confirmed
to come from the public registry and one whose source nobody recorded would
read the same.

The repository_url qualifier carries it only when it is NOT the type's default
registry. Stating the default would not be false, but the spec already implies
it, and it would change the purl string of nearly every public component: a
consumer comparing purls as strings would stop matching the rows whose identity
is least in doubt. The defaults are the purl type definitions' own, plus the
other spellings of the same registry that the definitions or the clients use
(registry.yarnpkg.com, api.nuget.org, repo1.maven.org, ...). Every purl type the
module emits is either in that table or explicitly listed as having no default,
and a test derives the emitted types from the purl-building code itself, so a
type added later cannot silently qualify its own public registry.

The value is percent-encoded as ECMA-427 clause 5.4 requires of a qualifier
value -- everything but the alphanumerics, '.-_~' and ':', so '/' becomes %2F,
as in every repository_url example the standard gives.

Only scheme, host, port and path leave. User info and the query are where
registry credentials travel, and an SBOM leaves the organisation, so they are
dropped at the last point before it does. A token embedded in the path is not
detected -- nothing distinguishes it from a feed name -- and the docs say so. A
source that is not an http(s) URL, such as a local NuGet feed directory, is
treated as unknown rather than published as a path on the analysis host, and so
is one containing a backslash, which parsers disagree about the host of.

A purl that already contains '?' or '#' gets no qualifier: versions are spliced
in unencoded, so a git-shaped version like 'github:user/repo#abc123' already
opens a subpath, and a qualifier after it would be read as part of it. The
property still states the source.

Identity does not change. bom-ref stays unqualified, because dedup_key folds on
it and every dependency edge resolves through it: one package restored from two
feeds is still one component. When the occurrences folded into one row disagree
about their source, or some of them state none, both the property and the
qualifier are dropped rather than one checkout's answer being chosen for all --
the same retraction the name-repair provenance already makes.

Dormant today: no analyzer stamps 'package_source' yet, so no existing document
changes until one does.
@softagram-bot

Copy link
Copy Markdown

Softagram Impact Report for pull/212 (head commit: 8328e57)

TL;DR Arch. Impact: 📈 +12 | Changed code files: 2 | Directly impacted code files: 5

⭐ Change Overview

Showing the changed files, dependency changes and the impact - click for full size
(Open in Softagram Desktop for full details)

⭐ Details of Dependency Changes (diagram)

details of dependency changes - click for full size
(Open in Softagram Desktop for full details)

🤖 AGENTS - machine-readable impact data (2 files changed, 5 impacted, +64/-0 deps)

Change overview

Head 8328e577d1da vs base 54946b55710a. 2 code files changed. 5 unchanged files directly depend on the changed files (see Impacted files). Dependencies: 64 added, 0 removed. New external components: 3. Removed external components: 0.

Added dependencies (61, showing 50)

from to type roles signal
sgraph/src> sgraph/converters/sbom_cyclonedx_generator.py External/Python/urllib/parse import prod→external new external component
sgraph/tests/converters/test_package_source_provenance.py sgraph/src> sgraph/selement.py/SElement import test→prod expands test coverage
sgraph/tests/converters/test_package_source_provenance.py sgraph/src> sgraph/sgraph.py/SGraph import test→prod expands test coverage
sgraph/tests/converters/test_package_source_provenance.py sgraph/src> sgraph/selementassociation.py/SElementAssociation import test→prod expands test coverage
sgraph/tests/converters/test_package_source_provenance.py sgraph/src> sgraph/converters/sbom_cyclonedx_generator.py import test→prod expands test coverage
sgraph/tests/converters/test_package_source_provenance.py/lodash_from_two_checkouts sgraph/src> sgraph/sgraph.py/SElement import test→prod expands test coverage
sgraph/tests/converters/test_package_source_provenance.py/model_with sgraph/src> sgraph/sgraph.py/SElement import test→prod expands test coverage
sgraph/tests/converters/test_package_source_provenance.py/model_with sgraph/src> sgraph/sgraph.py/SGraph import test→prod expands test coverage
sgraph/tests/converters/test_package_source_provenance.py External/PythonLibs/ast import test→external regular
sgraph/tests/converters/test_package_source_provenance.py External/PythonLibs/Usual dependencies import test→external regular
sgraph/tests/converters/test_package_source_provenance.py External/Python/Usual dependencies import test→external regular
sgraph/tests/converters/test_package_source_provenance.py External/PythonLibs/textwrap import test→external regular
sgraph/tests/converters/test_package_source_provenance.py External/PythonLibs/inspect import test→external regular
sgraph/src> sgraph/converters/sbom_cyclonedx_generator.py/elem_as_bom_data sgraph/src> sgraph/converters/sbom_cyclonedx_generator.py/source_qualified_purl func_ref prod→prod regular
sgraph/src> sgraph/converters/sbom_cyclonedx_generator.py/elem_as_bom_data sgraph/src> sgraph/converters/sbom_cyclonedx_generator.py/package_source_of func_ref prod→prod regular
sgraph/src> sgraph/converters/sbom_cyclonedx_generator.py/merge_component_evidence sgraph/src> sgraph/converters/sbom_cyclonedx_generator.py/purl_without_qualifiers func_ref prod→prod regular
sgraph/src> sgraph/converters/sbom_cyclonedx_generator.py/package_source_of External/PythonLibs/urllib import_ref prod→external regular
sgraph/src> sgraph/converters/sbom_cyclonedx_generator.py/source_qualified_purl External/PythonLibs/urllib import_ref prod→external regular
sgraph/tests/converters/test_package_source_provenance.py/component_for sgraph/tests/converters/test_package_source_provenance.py/components_of func_ref test→test regular
sgraph/tests/converters/test_package_source_provenance.py/lodash_from_two_checkouts sgraph/tests/converters/test_package_source_provenance.py/model_with func_ref test→test regular
sgraph/tests/converters/test_package_source_provenance.py/TestAPrivateSourceIsQualified/test_a_package_from_a_private_npm_registry_says_so sgraph/tests/converters/test_package_source_provenance.py/source_property func_ref test→test regular
sgraph/tests/converters/test_package_source_provenance.py/TestAPrivateSourceIsQualified/test_a_package_from_a_private_npm_registry_says_so sgraph/tests/converters/test_package_source_provenance.py/component_for func_ref test→test regular
sgraph/tests/converters/test_package_source_provenance.py/TestAPrivateSourceIsQualified/test_a_package_from_a_private_nuget_feed_says_so sgraph/tests/converters/test_package_source_provenance.py/component_for func_ref test→test regular
sgraph/tests/converters/test_package_source_provenance.py/TestAPurlThatAlreadyOpensASubpath/test_a_git_shaped_version_gets_no_qualifier sgraph/tests/converters/test_package_source_provenance.py/source_property func_ref test→test regular
sgraph/tests/converters/test_package_source_provenance.py/TestAPurlThatAlreadyOpensASubpath/test_a_git_shaped_version_gets_no_qualifier sgraph/tests/converters/test_package_source_provenance.py/component_for func_ref test→test regular
sgraph/tests/converters/test_package_source_provenance.py/TestAVersionlessPurl/test_the_qualifier_follows_the_name_with_no_stray_at sgraph/tests/converters/test_package_source_provenance.py/component_for func_ref test→test regular
sgraph/tests/converters/test_package_source_provenance.py/TestAnUnknownSourceStatesNothing/test_a_source_that_is_not_an_http_url sgraph/tests/converters/test_package_source_provenance.py/source_property func_ref test→test regular
sgraph/tests/converters/test_package_source_provenance.py/TestAnUnknownSourceStatesNothing/test_a_source_that_is_not_an_http_url sgraph/tests/converters/test_package_source_provenance.py/component_for func_ref test→test regular
sgraph/tests/converters/test_package_source_provenance.py/TestAnUnknownSourceStatesNothing/test_an_empty_attribute sgraph/tests/converters/test_package_source_provenance.py/source_property func_ref test→test regular
sgraph/tests/converters/test_package_source_provenance.py/TestAnUnknownSourceStatesNothing/test_an_empty_attribute sgraph/tests/converters/test_package_source_provenance.py/component_for func_ref test→test regular
sgraph/tests/converters/test_package_source_provenance.py/TestAnUnknownSourceStatesNothing/test_no_attribute sgraph/tests/converters/test_package_source_provenance.py/source_property func_ref test→test regular
sgraph/tests/converters/test_package_source_provenance.py/TestAnUnknownSourceStatesNothing/test_no_attribute sgraph/tests/converters/test_package_source_provenance.py/component_for func_ref test→test regular
sgraph/tests/converters/test_package_source_provenance.py/TestEveryEmittedTypeIsClassified/test_each_type_is_in_exactly_one_table sgraph/tests/converters/test_package_source_provenance.py/emitted_purl_types func_ref test→test regular
sgraph/tests/converters/test_package_source_provenance.py/TestEveryEmittedTypeIsClassified/test_the_derivation_sees_every_branch sgraph/tests/converters/test_package_source_provenance.py/emitted_purl_types func_ref test→test regular
sgraph/tests/converters/test_package_source_provenance.py/TestIdentityIsNotAffected/test_the_bom_ref_keeps_the_unqualified_purl sgraph/tests/converters/test_package_source_provenance.py/component_for func_ref test→test regular
sgraph/tests/converters/test_package_source_provenance.py/TestIdentityIsNotAffected/test_the_component_type_is_still_read_from_the_purl_type sgraph/tests/converters/test_package_source_provenance.py/component_for func_ref test→test regular
sgraph/tests/converters/test_package_source_provenance.py/TestNothingSecretIsPublished/test_a_port_that_is_the_scheme_default_is_dropped sgraph/tests/converters/test_package_source_provenance.py/source_property func_ref test→test regular
sgraph/tests/converters/test_package_source_provenance.py/TestNothingSecretIsPublished/test_a_port_that_is_the_scheme_default_is_dropped sgraph/tests/converters/test_package_source_provenance.py/component_for func_ref test→test regular
sgraph/tests/converters/test_package_source_provenance.py/TestNothingSecretIsPublished/test_query_and_fragment_are_dropped sgraph/tests/converters/test_package_source_provenance.py/source_property func_ref test→test regular
sgraph/tests/converters/test_package_source_provenance.py/TestNothingSecretIsPublished/test_query_and_fragment_are_dropped sgraph/tests/converters/test_package_source_provenance.py/component_for func_ref test→test regular
sgraph/tests/converters/test_package_source_provenance.py/TestNothingSecretIsPublished/test_the_port_is_kept sgraph/tests/converters/test_package_source_provenance.py/source_property func_ref test→test regular
sgraph/tests/converters/test_package_source_provenance.py/TestNothingSecretIsPublished/test_the_port_is_kept sgraph/tests/converters/test_package_source_provenance.py/component_for func_ref test→test regular
sgraph/tests/converters/test_package_source_provenance.py/TestNothingSecretIsPublished/test_user_info_is_dropped sgraph/tests/converters/test_package_source_provenance.py/source_property func_ref test→test regular
sgraph/tests/converters/test_package_source_provenance.py/TestNothingSecretIsPublished/test_user_info_is_dropped sgraph/tests/converters/test_package_source_provenance.py/component_for func_ref test→test regular
sgraph/tests/converters/test_package_source_provenance.py/TestThePublicRegistryIsNotQualified/test_a_default_host_of_another_type_is_not_a_default_here sgraph/tests/converters/test_package_source_provenance.py/component_for func_ref test→test regular
sgraph/tests/converters/test_package_source_provenance.py/TestThePublicRegistryIsNotQualified/test_npm sgraph/tests/converters/test_package_source_provenance.py/component_for func_ref test→test regular
sgraph/tests/converters/test_package_source_provenance.py/TestThePublicRegistryIsNotQualified/test_nuget_as_a_restore_records_it sgraph/tests/converters/test_package_source_provenance.py/component_for func_ref test→test regular
sgraph/tests/converters/test_package_source_provenance.py/TestThePublicRegistryIsNotQualified/test_the_other_spellings_of_a_default_registry sgraph/tests/converters/test_package_source_provenance.py/source_property func_ref test→test regular
sgraph/tests/converters/test_package_source_provenance.py/TestThePublicRegistryIsNotQualified/test_the_other_spellings_of_a_default_registry sgraph/tests/converters/test_package_source_provenance.py/component_for func_ref test→test regular
sgraph/tests/converters/test_package_source_provenance.py/TestThePublicRegistryIsNotQualified/test_the_property_still_says_where_it_came_from sgraph/tests/converters/test_package_source_provenance.py/source_property func_ref test→test regular

11 more omitted. Complete data: https://opensource.softagram.com/cdn/impact/d20d45f2-ea4e-4ae4-a9ca-0f350bd8fac5_sgraph_212_impact_change_graph_b20KVHz6Ft8OPwPmxWeh9Ct2dL01KU.png_change_info.json

Removed dependencies (0)

None.

Impacted files (5)

Unchanged files that directly depend on files changed in this PR - check them for behavioral impact. Grouped by changed file; dependent paths starting with ./ are relative to the changed file's directory:

changed file directly impacted dependents
sgraph/src/sgraph/converters/sbom_cyclonedx_generator.py 5: ./external_identification.py, sgraph/src/sgraph/graphdataservice.py, sgraph/tests/converters/sbom_cyclonedx_generator_test.py, sgraph/tests/converters/test_external_identification.py, sgraph/tests/converters/test_external_root_semantics.py

Complete data

[]

📄 Full report

Impact Report explained. Give feedback on this report to support@softagram.com

@villelaitila
villelaitila merged commit bee9a73 into softagram:main Sep 28, 2026
1 check passed
@villelaitila
villelaitila deleted the feature/sbom-package-source branch October 1, 2026 15:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants