From 8328e577d1da8d0965fc5ea13ca6eaee47e2b5d8 Mon Sep 17 00:00:00 2001 From: Ville Laitila Date: Mon, 28 Sep 2026 09:35:31 +0300 Subject: [PATCH] SBOM: say where a package was fetched from, and qualify the purl only 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. --- docs/data-formats.md | 41 +++ .../converters/sbom_cyclonedx_generator.py | 147 +++++++- .../test_package_source_provenance.py | 347 ++++++++++++++++++ 3 files changed, 534 insertions(+), 1 deletion(-) create mode 100644 tests/converters/test_package_source_provenance.py diff --git a/docs/data-formats.md b/docs/data-formats.md index dfe7247..71d00ee 100644 --- a/docs/data-formats.md +++ b/docs/data-formats.md @@ -442,6 +442,47 @@ document's own subject, and not a 3rd-party package. It appears as a component m - Components describing internal packages carry **no** `dependencyDepth`. That property counts package hops through the 3rd-party closure, and an element of the estate is not one. +### Where a package was fetched from + +When the analyzer records the registry a 3rd-party package was restored from, the component says +so. A package from a private feed carries it in the purl as the `repository_url` qualifier: + +```json +{ "bom-ref": "pkg:nuget/Example.Internal.Core@1.4.0", + "purl": "pkg:nuget/Example.Internal.Core@1.4.0?repository_url=https:%2F%2Fpkgs.example.org%2Ffeed%2Fnuget%2Fv3%2Findex.json", + "properties": [ + { "name": "softagram:packageSource", "value": "https://pkgs.example.org/feed/nuget/v3/index.json" } + ] } +``` + +| Field | Meaning | +|-------|---------| +| `purl` `repository_url` qualifier | The registry the package came from, **only when it is not the type's default registry**. An unqualified purl means the default registry, as the purl specification defines. | +| `properties[softagram:packageSource]` | The same registry, **whenever it is known**, the public one included. Absent when the source is unknown. | + +- **The property is what tells "public" from "unknown".** Both leave the purl unqualified, so a + consumer looking for packages from outside the public registry reads the qualifier, and one + that needs to know the source was actually recorded reads the property. +- **`bom-ref` never carries the qualifier.** One package restored from two feeds is still one + component, and every dependency edge names it by the same ref. +- **When the merged occurrences of a package disagree about their source, or some of them do not + state one, both the qualifier and the property are dropped.** The document does not pick one + checkout's answer for all of them. +- **The default registries** are the purl type definitions' own, recognised by host together + with the other spellings of the same registry: `registry.npmjs.org` and `registry.yarnpkg.com` + for npm; `www.nuget.org`, `nuget.org` and `api.nuget.org` for NuGet; `pypi.org` and + `pypi.python.org`; `repo.maven.apache.org` and `repo1.maven.org`; `hub.docker.com`; + `rubygems.org`. `deb`, `golang` and `generic` have no default registry, so any known source is + qualified. +- **Only the scheme, host, port and path are published.** User info, query and fragment are + removed, because that is where registry credentials usually travel. **A token embedded in the + path itself is not detected** — no rule can tell such a segment from a feed name — so keeping + it out of the model is the analyzer's responsibility. A source that is not an http(s) URL, + such as a local feed directory, or that contains a backslash, is treated as unknown. +- **A purl whose version already contains `?` or `#`** — a git-shaped version such as + `github:user/repo#abc123` — gets no qualifier, because it would land inside the subpath. The + property still states the source. + ### What the document does not cover A component list cannot say what is missing from it. An external the analyzer saw and the diff --git a/src/sgraph/converters/sbom_cyclonedx_generator.py b/src/sgraph/converters/sbom_cyclonedx_generator.py index 9062b54..1d68302 100644 --- a/src/sgraph/converters/sbom_cyclonedx_generator.py +++ b/src/sgraph/converters/sbom_cyclonedx_generator.py @@ -5,6 +5,7 @@ import json import sys from collections import Counter, defaultdict +from urllib.parse import quote, urlsplit, urlunsplit from sgraph import SGraph, SElement from sgraph.converters.external_root_semantics import (canonical_purl_name, @@ -584,6 +585,130 @@ def bom_ref(elem, v): return purl_for(elem, v)[0] +# The attribute an analyzer stamps with the registry a package was actually fetched from. Neutral +# across ecosystems on purpose, the same way 'package_name'/'version'/'ecosystem' are: NuGet reads +# it from the '.nupkg.metadata' the restore writes beside every package, npm from the lockfile's +# resolved URL reduced to the registry it names, and a third ecosystem needs no branch here to be +# served. The value is expected to be the registry, not the artifact: a tarball URL would give +# every version of a package a different source and make agreement across a merge accidental. +PACKAGE_SOURCE_ATTRIBUTE = 'package_source' + +# Where the source is stated 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 +# this property a package confirmed to come from the public registry and one whose source nobody +# recorded would be indistinguishable in the document. +PACKAGE_SOURCE_PROPERTY = 'softagram:packageSource' + +# purl defines this qualifier for exactly this fact: the repository a package came from when it is +# not the ecosystem's default one. Emitting it is what lets a consumer tell a package from the +# public registry from one that only an authenticated internal feed serves - which, for a +# vulnerability consumer, is the difference between a match it can trust and a name collision. +REPOSITORY_URL_QUALIFIER = 'repository_url' + +# The hosts of each purl type's default registry, for which the qualifier is omitted. The first +# host of each entry is the 'Default Repository URL' of the purl type definition; the others are +# spellings of that same public registry - the ones the type definition itself names (maven's +# repo1 mirror, pypi's previous host), and the ones the ecosystems' own clients write: Yarn v1 +# lockfiles resolve public packages through registry.yarnpkg.com, a NuGet restore records the +# api.nuget.org service index, and older nuget.config files name the bare nuget.org host. Matched +# on the host alone, because the public registry is the host whatever path the client recorded. +# +# Qualifying a package from the default registry would not be false, but it would be redundant by +# the spec's own definition, and it would change the purl string of nearly every public component +# in the document: a consumer comparing purls as strings would stop matching the very rows whose +# identity is least in doubt. +DEFAULT_REPOSITORY_HOSTS = { + 'npm': {'registry.npmjs.org', 'registry.yarnpkg.com'}, + 'nuget': {'www.nuget.org', 'nuget.org', 'api.nuget.org'}, + 'pypi': {'pypi.org', 'pypi.python.org'}, + 'maven': {'repo.maven.apache.org', 'repo1.maven.org'}, + 'docker': {'hub.docker.com'}, + 'gem': {'rubygems.org'}, +} + +# The purl types this module emits whose type definition names no default registry, so any known +# source is qualified. Every emitted type is in exactly one of these two tables; a test derives the +# emitted types from the code that builds purls and holds both tables to that, so a purl type +# added later cannot silently qualify its public registry. +NO_DEFAULT_REPOSITORY_TYPES = {'deb', 'golang', 'generic'} + + +DEFAULT_PORT_BY_SCHEME = {'http': 80, 'https': 443} + + +def package_source_of(elem): + """The element's package source as a URL fit to publish, or None when there is none. + + Absent or blank means unknown, and so does a value that is not an http(s) URL with a host. A + NuGet source can be a local directory, and a filesystem path is not a repository URL: it would + publish the layout of the analysis host, user names included, and identify nothing a consumer + could reach. Treated as unknown rather than guessed at. + + What is published is the scheme, host, port and path, and nothing else. User info and the + query are where registry credentials usually travel - 'https://user:token@host/feed', + '?token=...' - and an SBOM is a document that leaves the organisation, so they are dropped + here, at the last point before it does. The fragment names nothing on the server. What this + does NOT catch is a token embedded in the path itself, as some hosted feeds issue them: no + rule could tell such a segment from a feed name, so keeping it out of the model is the + analyzer's job. + + The host is normalised so that spellings of one registry agree when duplicates are merged + and when the default registry is recognised: its trailing dot, a port that is the scheme's + default, and the path's trailing slash are dropped. + + A backslash anywhere makes the value unknown. urlsplit reads 'https://a.example\\@b.example' + as host b.example where a WHATWG parser reads a.example, so the host this function would + publish, and judge public or private, is not necessarily the one the client fetched from. + """ + raw = (elem.attrs.get(PACKAGE_SOURCE_ATTRIBUTE) or '').strip() + if not raw or '\\' in raw: + return None + try: + parts = urlsplit(raw) + port = parts.port + except ValueError: + return None + scheme = parts.scheme.lower() + host = (parts.hostname or '').rstrip('.') + if scheme not in DEFAULT_PORT_BY_SCHEME or not host: + return None + if ':' in host: + host = f'[{host}]' + netloc = host if port in (None, DEFAULT_PORT_BY_SCHEME[scheme]) else f'{host}:{port}' + return urlunsplit((scheme, netloc, parts.path.rstrip('/'), '', '')) + + +def source_qualified_purl(purl, source): + """`purl` with a package source appended as a qualifier, unless it is the default registry. + + Applied to the emitted 'purl' and deliberately NOT to 'bom-ref'. dedup_key folds on the ref + and every dependency edge resolves through it, so a qualifier there would turn one package + fetched from two feeds into two components and leave each consumer pointing at a different + one. The identity stays the package; the qualifier states where these bytes came from. + + The value is percent-encoded as the purl standard requires of a qualifier value: everything + but the alphanumerics, '.-_~' and ':' - the slashes included, as in every repository_url + example the standard gives. Left raw, '&' or '=' would split the qualifier segment and + silently truncate the URL. + + A purl that already contains '?' or '#' gets no qualifier. Versions are spliced in unencoded, + so a git-shaped version such as 'github:user/repo#abc123' already opens a subpath, and a + qualifier appended after it would be read as part of that subpath rather than as a + qualifier. It also keeps purl_without_qualifiers exact: the first '?' is always the one + added here. The softagram:packageSource property still states the source for such a row. + """ + if source is None or '?' in purl or '#' in purl: + return purl + if urlsplit(source).hostname in DEFAULT_REPOSITORY_HOSTS.get(purl_type_of(purl), ()): + return purl + return f'{purl}?{REPOSITORY_URL_QUALIFIER}={quote(source, safe=":")}' + + +def purl_without_qualifiers(purl): + """The identity half of a purl: everything before the qualifier segment.""" + return purl.split('?', 1)[0] + + # The SPDX identifiers this converter is prepared to put in license.id, each with its canonical # url. Deliberately a short list of values someone has actually checked rather than a mapping # guessed from license strings: CycloneDX $refs the SPDX identifier list from license.id, so a @@ -763,6 +888,23 @@ def merge_component_evidence(surviving, duplicate): prop for prop in surviving['properties'] if prop['name'] != NAME_RESOLUTION_PROPERTY ] + # Same rule, same reason, for where the bytes came from: the source describes THIS row, and + # after a merge the row is about every element that folded into it. It survives only when all + # of them say the same thing. A disagreement is not resolved to whichever element document + # order happened to traverse first, and an element that says nothing does not corroborate one + # that does -- 'unknown' is not agreement. The property is compared rather than the qualifier: + # the qualifier is derived from it and omits the public registry, so two rows without one may + # still disagree. Dropped from 'purl' only, which is why the qualifier was kept out of + # 'bom-ref': identity cannot change here, because callers have already resolved edges through + # it. + surviving_source = _property(surviving, PACKAGE_SOURCE_PROPERTY) + duplicate_source = _property(duplicate, PACKAGE_SOURCE_PROPERTY) + if surviving_source != duplicate_source: + if surviving_source is not None: + surviving['properties'].remove(surviving_source) + if 'purl' in surviving: + surviving['purl'] = purl_without_qualifiers(surviving['purl']) + def finalize_components(components): """Re-render the evidence-derived properties and drop the collection-time key. @@ -851,6 +993,9 @@ def elem_as_bom_data(elem, other_externals_by_name, external_root, noisy=False): custom_properties = [] # noqa custom_properties.append({'name': 'sourceCodeReferences', 'value': direct_deps_paths}) custom_properties.extend(purl_properties) + source = package_source_of(elem) + if source is not None: + custom_properties.append({'name': PACKAGE_SOURCE_PROPERTY, 'value': source}) if indirect_deps: # str(): CycloneDX types properties[].value as a string, so a bare int here @@ -868,7 +1013,7 @@ def elem_as_bom_data(elem, other_externals_by_name, external_root, noisy=False): 'name': repaired_name if repaired_name is not None else clean_name(elem.name), 'version': v, 'bom-ref': ref, - 'purl': ref, + 'purl': source_qualified_purl(ref, source), 'type': cyclonedx_component_type(ref), 'licenses': licenses, 'scope': 'required', diff --git a/tests/converters/test_package_source_provenance.py b/tests/converters/test_package_source_provenance.py new file mode 100644 index 0000000..95557e7 --- /dev/null +++ b/tests/converters/test_package_source_provenance.py @@ -0,0 +1,347 @@ +"""Where a package was fetched from, carried into the document. + +A consumer reading this document cannot today tell a package that came from the public registry +from one that came from a feed only the organisation can reach. Both halves of that question -- +"where did this come from" and "is this ours" -- are answered by the same fact, and the analyzers +can state it: a NuGet restore writes the feed into `.nupkg.metadata` beside every package, and an +npm lockfile records the registry each package resolved from. + +This module is the export half. The analyzer stamps the ecosystem-neutral attribute +`package_source` on the package element -- the same convention `_package_identity_in_subtree` +documents for `package_name`/`version`/`ecosystem` -- and the converter publishes it twice: as the +`softagram:packageSource` property whenever it is known, and as the purl qualifier the spec +defines for exactly this whenever it is not the type's default registry. + +What it must not do is change what a component IS. The qualifier goes in `purl` only; `bom-ref` +keeps the unqualified spelling, because `dedup_key` folds on it and two checkouts of one package +that happen to name different feeds must still be one component. +""" +import ast +import inspect +import json +import re +import textwrap + +import pytest + +from sgraph import SElement, SElementAssociation, SGraph +from sgraph.converters import sbom_cyclonedx_generator + +NPM_PUBLIC = 'https://registry.npmjs.org' +NPM_PRIVATE = 'https://npm.internal.example/repository/npm-private' +NUGET_PUBLIC = 'https://api.nuget.org/v3/index.json' +NUGET_PRIVATE = ('https://pkgs.dev.azure.com/example-org/example-project' + '/_packaging/internal-feed/nuget/v3/index.json') + +SOURCE_PROPERTY = 'softagram:packageSource' + + +def model_with(packages, parent='NPM'): + """A model whose External/ holds these (name, version, attrs) packages.""" + model = SGraph(SElement(None, '')) + root = model.createOrGetElementFromPath(f'/Org/External/{parent}') + referrer = model.createOrGetElementFromPath('/Org/repoA/src/app.js') + for name, version, attrs in packages: + elem = SElement(root, name) + elem.attrs['version'] = version + for key, value in (attrs or {}).items(): + elem.attrs[key] = value + SElementAssociation(referrer, elem, 'use').initElems() + return model + + +def components_of(model): + sbom = sbom_cyclonedx_generator.generate_from_sgraph(model) + return {c['name']: c for c in sbom['components']} + + +def component_for(name, version, attrs, parent='NPM'): + return components_of(model_with([(name, version, attrs)], parent))[name] + + +def source_property(component): + values = [p['value'] for p in component.get('properties', []) if p['name'] == SOURCE_PROPERTY] + assert len(values) <= 1 + return values[0] if values else None + + +def lodash_from_two_checkouts(first_source, second_source): + """One package reached from two repositories, each checkout naming its own source.""" + first = {'package_source': first_source} if first_source is not None else {} + model = model_with([('lodash', '4.17.21', first)]) + other = model.createOrGetElementFromPath('/Org/External/NPM2') + elem = SElement(other, 'lodash') + elem.attrs['version'] = '4.17.21' + elem.attrs['repotype'] = 'NPM' + if second_source is not None: + elem.attrs['package_source'] = second_source + referrer = model.createOrGetElementFromPath('/Org/repoB/src/app.js') + SElementAssociation(referrer, elem, 'use').initElems() + + sbom = sbom_cyclonedx_generator.generate_from_sgraph(model) + return [c for c in sbom['components'] if c['name'] == 'lodash'] + + +class TestAPrivateSourceIsQualified: + def test_a_package_from_a_private_npm_registry_says_so(self): + component = component_for('lodash', '4.17.21', {'package_source': NPM_PRIVATE}) + + assert component['purl'] == ('pkg:npm/lodash@4.17.21?repository_url=' + 'https:%2F%2Fnpm.internal.example%2Frepository%2Fnpm-private') + assert source_property(component) == NPM_PRIVATE + + def test_a_package_from_a_private_nuget_feed_says_so(self): + component = component_for('Example.Internal.Core', '1.4.0', + {'package_source': NUGET_PRIVATE}, parent='Assemblies') + + assert component['purl'].startswith('pkg:nuget/Example.Internal.Core@1.4.0?') + assert component['purl'].endswith( + '?repository_url=https:%2F%2Fpkgs.dev.azure.com%2Fexample-org%2Fexample-project' + '%2F_packaging%2Finternal-feed%2Fnuget%2Fv3%2Findex.json') + + +class TestThePublicRegistryIsNotQualified: + """An absent repository_url already means the type's default registry. Stating it would be + redundant, and would change the purl string of nearly every public component.""" + + @pytest.mark.parametrize('source', [ + NPM_PUBLIC, + NPM_PUBLIC + '/', + 'https://registry.yarnpkg.com', + 'https://registry.npmjs.org:443/', + 'https://registry.npmjs.org./', + ]) + def test_npm(self, source): + component = component_for('lodash', '4.17.21', {'package_source': source}) + + assert component['purl'] == 'pkg:npm/lodash@4.17.21' + + def test_nuget_as_a_restore_records_it(self): + component = component_for('Newtonsoft.Json', '13.0.3', {'package_source': NUGET_PUBLIC}, + parent='Assemblies') + + assert component['purl'] == 'pkg:nuget/Newtonsoft.Json@13.0.3' + + @pytest.mark.parametrize('parent, name, source', [ + ('Assemblies', 'Newtonsoft.Json', 'https://www.nuget.org/api/v2'), + ('Assemblies', 'Newtonsoft.Json', 'https://nuget.org/api/v2'), + ('PIP', 'requests', 'https://pypi.python.org/simple'), + ]) + def test_the_other_spellings_of_a_default_registry(self, parent, name, source): + component = component_for(name, '1.0.0', {'package_source': source}, parent=parent) + + assert '?' not in component['purl'] + assert source_property(component) == source + + def test_the_property_still_says_where_it_came_from(self): + """Without it, 'confirmed public' and 'nobody recorded a source' would look the same.""" + component = component_for('lodash', '4.17.21', {'package_source': NPM_PUBLIC + '/'}) + + assert source_property(component) == NPM_PUBLIC + + def test_a_default_host_of_another_type_is_not_a_default_here(self): + component = component_for('lodash', '4.17.21', {'package_source': 'https://pypi.org'}) + + assert component['purl'] == 'pkg:npm/lodash@4.17.21?repository_url=https:%2F%2Fpypi.org' + + +def emitted_purl_types(): + """Every purl type the generator can emit, read from the code that builds purls.""" + generator = sbom_cyclonedx_generator + tree = ast.parse(textwrap.dedent(inspect.getsource(generator.resolved_purl))) + types = set() + for node in ast.walk(tree): + if isinstance(node, ast.Assign) and any( + isinstance(target, ast.Name) and target.id == 'pkgtype' + for target in node.targets): + if isinstance(node.value, ast.Constant): + types.add(node.value.value) + elif isinstance(node.value, ast.Name): + types.add(getattr(generator, node.value.id)) + types |= set(re.findall(r'pkg:([a-z]+)/', inspect.getsource(generator.maven_purl))) + types |= set(generator.PURL_TYPE_BY_REFERENCING_EXTENSION.values()) + return types + + +class TestEveryEmittedTypeIsClassified: + """A purl type missing from both tables would qualify its own public registry.""" + + def test_the_derivation_sees_every_branch(self): + assert {'npm', 'deb', 'pypi', 'golang', 'nuget', 'docker', 'maven', 'gem', + 'generic'} <= emitted_purl_types() + + def test_each_type_is_in_exactly_one_table(self): + with_default = set(sbom_cyclonedx_generator.DEFAULT_REPOSITORY_HOSTS) + without_default = sbom_cyclonedx_generator.NO_DEFAULT_REPOSITORY_TYPES + + assert not with_default & without_default + assert emitted_purl_types() <= with_default | without_default + + +class TestAnUnknownSourceStatesNothing: + def test_no_attribute(self): + """Absent is 'unknown'. A default would relabel every private package as public, which + is the exact failure this is meant to remove.""" + component = component_for('lodash', '4.17.21', {}) + + assert component['purl'] == 'pkg:npm/lodash@4.17.21' + assert source_property(component) is None + + @pytest.mark.parametrize('value', ['', ' ']) + def test_an_empty_attribute(self, value): + """purl gives an empty qualifier value the same meaning as no qualifier.""" + component = component_for('lodash', '4.17.21', {'package_source': value}) + + assert component['purl'] == 'pkg:npm/lodash@4.17.21' + assert source_property(component) is None + + @pytest.mark.parametrize('value', [ + '/home/alice/nuget-local', + 'C:\\Users\\alice\\nuget-local', + 'file:///home/alice/nuget-local', + 'registry.npmjs.org', + 'https://host:notaport/feed', + 'https://evil.example\\@registry.npmjs.org/', + ]) + def test_a_source_that_is_not_an_http_url(self, value): + """A local feed directory is not a repository URL, and publishing it would publish the + analysis host's paths and user names. A backslash is refused outright, because parsers + disagree about which host such a URL names.""" + component = component_for('lodash', '4.17.21', {'package_source': value}) + + assert component['purl'] == 'pkg:npm/lodash@4.17.21' + assert source_property(component) is None + + +class TestNothingSecretIsPublished: + def test_user_info_is_dropped(self): + component = component_for( + 'lodash', '4.17.21', + {'package_source': 'https://ci-user:s3cret-token@npm.internal.example/repo'}) + + assert source_property(component) == 'https://npm.internal.example/repo' + assert 's3cret' not in json.dumps(component) + assert 'ci-user' not in json.dumps(component) + + def test_query_and_fragment_are_dropped(self): + component = component_for( + 'lodash', '4.17.21', + {'package_source': 'https://npm.internal.example/repo?token=s3cret#frag'}) + + assert source_property(component) == 'https://npm.internal.example/repo' + assert 's3cret' not in json.dumps(component) + + def test_the_port_is_kept(self): + component = component_for('lodash', '4.17.21', + {'package_source': 'https://npm.internal.example:8443/repo'}) + + assert source_property(component) == 'https://npm.internal.example:8443/repo' + assert component['purl'].endswith('repository_url=https:%2F%2Fnpm.internal.example:8443' + '%2Frepo') + + def test_a_port_that_is_the_scheme_default_is_dropped(self): + """So 'host:443' and 'host' are one source when duplicates are merged.""" + component = component_for('lodash', '4.17.21', + {'package_source': 'https://npm.internal.example:443/repo'}) + + assert source_property(component) == 'https://npm.internal.example/repo' + + +class TestIdentityIsNotAffected: + def test_the_bom_ref_keeps_the_unqualified_purl(self): + """`dedup_key` folds on bom-ref and dependency edges resolve through it. A qualifier + there would split one package into one component per feed.""" + component = component_for('lodash', '4.17.21', {'package_source': NPM_PRIVATE}) + + assert component['bom-ref'] == 'pkg:npm/lodash@4.17.21' + assert component['purl'] != component['bom-ref'] + + def test_the_component_type_is_still_read_from_the_purl_type(self): + component = component_for('lodash', '4.17.21', {'package_source': NPM_PRIVATE}) + + assert component['type'] == 'library' + + +class TestTheValueIsEncodedAsTheStandardRequires: + def test_separators_in_the_path_are_encoded(self): + """'&' and '=' would split the qualifier segment. '/' is encoded as in every + repository_url example the standard gives, and '@' like every other separator character + outside its separator role. Only ':' stays raw.""" + component = component_for('lodash', '4.17.21', + {'package_source': 'https://host.example/a&b=c/@d e'}) + + qualifier = component['purl'].split('?', 1)[1] + assert qualifier == 'repository_url=https:%2F%2Fhost.example%2Fa%26b%3Dc%2F%40d%20e' + + def test_an_already_escaped_path_is_escaped_again(self): + """The qualifier value is the URL string itself, so its '%' is data and must be encoded; + decoding the qualifier gives back the URL unchanged.""" + component = component_for('lodash', '4.17.21', + {'package_source': 'https://host.example/feed%2Fx'}) + + assert component['purl'].endswith('repository_url=https:%2F%2Fhost.example%2Ffeed%252Fx') + + +class TestAVersionlessPurl: + def test_the_qualifier_follows_the_name_with_no_stray_at(self): + """Four of the five returns in resolved_purl emit a versionless purl. The qualifier has + to attach to all of them, and must not resurrect the trailing '@' those returns exist to + avoid.""" + component = component_for('lodash', 'https://github.com/x/y.git', + {'package_source': NPM_PRIVATE}) + + assert component['purl'].startswith('pkg:npm/lodash?repository_url=') + assert '@?' not in component['purl'] + # The raw value still reaches the consumer; only the purl declines to carry it. + assert component['version'] == 'https://github.com/x/y.git' + + +class TestAPurlThatAlreadyOpensASubpath: + def test_a_git_shaped_version_gets_no_qualifier(self): + """Versions are spliced in unencoded, so '#' already starts a subpath, and a qualifier + appended after it would be read as part of that subpath.""" + component = component_for('dep1', 'github:user/repo#abc123', + {'package_source': NPM_PRIVATE}) + + assert 'repository_url' not in component['purl'] + assert source_property(component) == NPM_PRIVATE + + +class TestTwoSourcesForOnePackage: + """One package, two checkouts, two sources. The document must not pick a winner.""" + + def test_the_package_is_still_one_component(self): + assert len(lodash_from_two_checkouts(NPM_PUBLIC, NPM_PRIVATE)) == 1 + + @pytest.mark.parametrize('first, second', [ + (NPM_PUBLIC, NPM_PRIVATE), + (NPM_PRIVATE, NPM_PUBLIC), + (NPM_PRIVATE, 'https://npm2.internal.example'), + ]) + def test_a_disagreement_drops_the_source_rather_than_choosing(self, first, second): + [lodash] = lodash_from_two_checkouts(first, second) + + assert lodash['purl'] == 'pkg:npm/lodash@4.17.21' + assert source_property(lodash) is None + + @pytest.mark.parametrize('source', [NPM_PUBLIC, NPM_PRIVATE]) + def test_agreement_survives_the_merge(self, source): + [lodash] = lodash_from_two_checkouts(source, source) + + assert source_property(lodash) == source + assert lodash['purl'] == sbom_cyclonedx_generator.source_qualified_purl( + 'pkg:npm/lodash@4.17.21', source) + + def test_two_spellings_of_one_registry_agree(self): + [lodash] = lodash_from_two_checkouts(NPM_PRIVATE, NPM_PRIVATE + '/') + + assert source_property(lodash) == NPM_PRIVATE + + @pytest.mark.parametrize('first, second', [(NPM_PRIVATE, None), (None, NPM_PRIVATE)]) + def test_silence_on_one_side_does_not_corroborate_the_other(self, first, second): + """'unknown' is not agreement. The same retraction the repair provenance already makes + when any merged element did not support the claim.""" + [lodash] = lodash_from_two_checkouts(first, second) + + assert lodash['purl'] == 'pkg:npm/lodash@4.17.21' + assert source_property(lodash) is None