PURL (Package URL) Generation
Package URLs follow the purl-spec to identify software packages across ecosystems. PURLs are generated dynamically at API response time from CVEAffected fields and persisted for caching.
Architecture
50+ mappings"] URLPattern["Registry URL Pattern
20+ regex patterns"] HashFallback["Hash Present → github"] GenericFallback["Default → generic"] end subgraph NamespaceDerivation["Namespace Derivation"] Maven["maven: vendor or group:artifact split"] Golang["golang: path segments (all but last)"] Docker["docker: org from image path"] GitHub["github/bitbucket: owner from repo URL"] Composer["composer: vendor/package split"] DistPkg["deb/rpm/apk: vendor"] NoNS["npm/pypi/cargo/etc: no namespace"] end subgraph Build["PURL Assembly"] Scheme["pkg:{type}"] NS["/{namespace} (optional)"] Name["/{name}"] Version["@{version} (optional)"] Quals["?{qualifiers} (optional)"] Subpath["#{subpath} (optional)"] end subgraph Validate["Version Validation"] Wildcards["Reject wildcards: x, X, *"] Ranges["Reject ranges: <, >, =, ^, ~, !"] Boolean["Reject boolean: ||, &&, -"] Multi["Reject multi-constraint"] end PkgName --> Build Product -->|"fallback if packageName empty"| Build CollURL --> URLPattern Eco --> EcoMap EcoMap -->|"matched"| TypeDerivation URLPattern -->|"matched"| TypeDerivation HashFallback -->|"matched"| TypeDerivation GenericFallback -->|"no match"| TypeDerivation TypeDerivation --> NamespaceDerivation NamespaceDerivation --> Build Validate --> Build Build --> Scheme --> NS --> Name --> Version --> Quals --> Subpath
PURL Format
pkg:<type>[/<namespace>]/<name>[@<version>][?<qualifiers>][#<subpath>]
| Component | Required | Example |
|---|---|---|
pkg: | yes | Fixed scheme prefix |
type | yes | npm, pypi, maven, cargo, etc. |
namespace | no | @angular (npm), org.apache (maven) |
name | yes | express, django, log4j-core |
version | no | 4.17.21, 3.2.1 |
qualifiers | no | arch=x86_64, repository_id=rhel-8-baseos |
subpath | no | lib/net45 |
Type Derivation
Type is derived through a priority chain:
non-empty?"} EcoLookup["ecosystemToPurlType map
normalise lowercase → lookup"] ValidType{"Valid PURL type?
[a-z][a-z0-9+.-]*"} UseEcoType["Use mapped/raw type"] CheckURL{"config.RepoURL
non-empty?"} URLMatch["registryURLPatterns
20+ regex matchers"] UseURLType["Use pattern-matched type"] CheckHash{"config.Hash
non-empty?"} UseGithub["type = 'github'"] UseGeneric["type = 'generic'"] Start --> CheckEco CheckEco -->|"yes"| EcoLookup EcoLookup -->|"found"| UseEcoType EcoLookup -->|"not found"| ValidType ValidType -->|"valid"| UseEcoType ValidType -->|"invalid"| CheckURL CheckEco -->|"no"| CheckURL CheckURL -->|"yes"| URLMatch URLMatch -->|"matched"| UseURLType URLMatch -->|"no match"| CheckHash CheckURL -->|"no"| CheckHash CheckHash -->|"yes"| UseGithub CheckHash -->|"no"| UseGeneric
Ecosystem-to-Type Mappings (50+ entries)
| Ecosystem(s) | PURL Type | Example PURL |
|---|---|---|
npm | npm | pkg:npm/express@4.18.2 |
pypi, pip | pypi | pkg:pypi/django@4.2 |
maven | maven | pkg:maven/org.apache.commons/commons-lang3@3.12 |
cargo, rust | cargo | pkg:cargo/tokio@1.28 |
rubygems, gem, ruby | gem | pkg:gem/rails@7.0 |
nuget, dotnet | nuget | pkg:nuget/Newtonsoft.Json@13.0 |
composer, php | composer | pkg:composer/symfony/http-foundation@6.3 |
go, golang | golang | pkg:golang/github.com/gin-gonic/gin@1.9 |
hex, elixir, erlang | hex | pkg:hex/phoenix@1.7 |
pub, dart, flutter | pub | pkg:pub/flutter_bloc@8.1 |
hackage, haskell | hackage | pkg:hackage/aeson@2.1 |
cran, r | cran | pkg:cran/ggplot2@3.4 |
github | github | pkg:github/vuetifyjs/vuetify@3.3 |
docker, oci | docker | pkg:docker/nginx@1.24 |
deb, debian, ubuntu | deb | pkg:deb/debian/openssl@3.0 |
rpm, fedora, rhel, centos, suse, sles | rpm | pkg:rpm/redhat/openssl@1.1.1k |
apk, alpine | apk | pkg:apk/alpine/curl@8.1 |
cocoapods, ios | cocoapods | pkg:cocoapods/Alamofire@5.7 |
swift, swiftpm | swift | pkg:swift/apple/swift-nio@2.55 |
conda, anaconda | conda | pkg:conda/numpy@1.25 |
conan | conan | pkg:conan/boost@1.82 |
huggingface | huggingface | pkg:huggingface/meta-llama/Llama-2-7b |
mlflow | mlflow | pkg:mlflow/my-model@1.0 |
julia | julia | pkg:julia/JSON@0.21 |
luarocks, lua | luarocks | pkg:luarocks/luasocket@3.1 |
opam, ocaml | opam | pkg:opam/core@0.16 |
cpan, perl | cpan | pkg:cpan/Mojolicious@9.33 |
yocto | yocto | pkg:yocto/busybox@1.36 |
bitnami | bitnami | pkg:bitnami/redis@7.2 |
bazel | bazel | pkg:bazel/rules_go@0.41 |
qpkg | qpkg | pkg:qpkg/Entware@1.0 |
vscode | vscode-extension | pkg:vscode-extension/ms-python/python@2023.14 |
alpm, archlinux | alpm | pkg:alpm/core/linux@6.4 |
bitbucket | bitbucket | pkg:bitbucket/atlassian/aui@9.3 |
generic, unknown | generic | pkg:generic/my-package@1.0 |
Registry URL Pattern Matching
When ecosystem is not provided, the collectionURL or RepoURL is matched against these regex patterns:
| Pattern | PURL Type |
|---|---|
npmjs.(org|com), registry.npmjs | npm |
pypi.(org|python.org) | pypi |
maven.(apache.org|central), mvnrepository.com | maven |
crates.io | cargo |
rubygems.org | gem |
nuget.org | nuget |
packagist.org | composer |
pkg.go.dev, golang.org | golang |
hex.pm | hex |
pub.dev | pub |
hackage.haskell.org | hackage |
cran.r-project.org | cran |
github.com | github |
hub.docker.com, gcr.io, ghcr.io | docker |
bitbucket.(org|com) | bitbucket |
conda-forge.org, anaconda.org | conda |
conan.io | conan |
huggingface.co | huggingface |
luarocks.org | luarocks |
opam.ocaml.org | opam |
metacpan.org | cpan |
marketplace.visualstudio.com | vscode-extension |
Namespace Derivation
group:artifact → namespace = group
else namespace = vendor"] Golang{"golang?"} GolangNS["Package contains '/'?
github.com/org/pkg → ns = github.com/org
else no namespace"] Docker{"docker?"} DockerNS["Package contains '/'?
org/image → namespace = org
else no namespace"] GH{"github/bitbucket?"} GHNS["RepoURL available?
parse path → namespace = owner
else namespace = vendor"] Composer{"composer?"} ComposerNS["Package contains '/'?
vendor/package → namespace = vendor
else namespace = vendor"] Dist{"deb/rpm/apk?"} DistNS["namespace = vendor
(redhat, debian, alpine, etc.)"] Other{"npm/pypi/cargo/gem/
nuget/hex/pub/hackage/
cran/cocoapods/swift/
conda/conan/etc."} NoNS["No namespace"] Type --> Maven Type --> Golang Type --> Docker Type --> GH Type --> Composer Type --> Dist Type --> Other Maven -->|"yes"| MavenNS Golang -->|"yes"| GolangNS Docker -->|"yes"| DockerNS GH -->|"yes"| GHNS Composer -->|"yes"| ComposerNS Dist -->|"yes"| DistNS Other -->|"yes"| NoNS
NPM Scoped Package Handling
NPM packages starting with @ use percent-encoded scope:
Input: @angular/core
Output: pkg:npm/%40angular/core
Input: @babel/parser@7.22.5
Output: pkg:npm/%40babel/parser@7.22.5
The @ is encoded as %40 per PURL spec, and the scope becomes the namespace with the / separator.
Version Validation
Versions must be concrete — ranges, wildcards, and operators are rejected:
(fallback from VersionWithOperator)"] Valid["Valid PURL version"] Invalid["Version omitted from PURL"] Raw --> Unknown Unknown -->|"yes"| Invalid Unknown -->|"no"| Wild Wild -->|"yes"| Invalid Wild -->|"no"| Range Range -->|"yes"| Strip Strip --> Valid Range -->|"no"| Bool Bool -->|"yes"| Invalid Bool -->|"no"| Multi Multi -->|"yes"| Invalid Multi -->|"no"| Valid
| Input | Result | Reason |
|---|---|---|
4.17.21 | @4.17.21 | Valid concrete version |
>=2.0.0 | stripped → 2.0.0 | Operator stripped via VersionWithOperator path |
2.x | omitted | Wildcard |
^1.0.0 | omitted | Range operator |
>=1.0 <2.0 | omitted | Multi-constraint |
1.0 || 2.0 | omitted | Boolean range |
unknown | omitted | Literal “unknown” |
Red Hat PURL Specifics
Per Red Hat Security Data Guidelines:
- RPM packages use
redhatnamespace:pkg:rpm/redhat/openssl@1.1.1k-7.el8_6 - Add
repository_idqualifier:?repository_id=rhel-8-for-x86_64-baseos-rpms - Add
archqualifier from platforms data:?arch=x86_64 - OCI images use unique tags (not “latest”):
pkg:oci/ubi8@sha256:abc... - RPM modules use
rpmmodqualifier:?rpmmod=Stream:Version:Context
Cloud Service PURLs
When cloud services are detected from titles/descriptions and no package ecosystem exists:
pkg:generic/AWS/CloudWatch
pkg:generic/AWS/EC2
pkg:generic/Azure/Key%20Vault
pkg:generic/GCP/Cloud%20SQL
These use the generic type with the cloud provider as namespace and service name as package name.
Generation Contexts
| Context | Handler | Input Fields |
|---|---|---|
/v2/vuln/{id}/affected | v2_affected.go | packageName, vendor, collectionURL |
/v1/vuln/{id} | vuln.go | packageName, vendor, product, ecosystem, collectionURL |
/v1/packages/search | package_search.go | packageName, vendor, product |
/v2/cloud-locators | v2_cloud_locators.go | vendor, product (from query params) |
| Synthesized entries | v2_affected.go | service name from title extraction |
Fallback Chain
non-empty?"} UsePkg["name = PackageName"] Product{"config.Product
non-empty?"} UseProd["name = Product"] Empty["return '' (empty)"] GenericFallback["fmt.Sprintf('pkg:generic/%s',
url.PathEscape(pkgId))"] Start --> PkgName PkgName -->|"yes"| UsePkg PkgName -->|"no"| Product Product -->|"yes"| UseProd Product -->|"no"| Empty Empty -->|"in handler"| GenericFallback
When BuildPurl() returns an empty string, the handler falls back to a pkg:generic/{name} PURL to ensure every affected entry has some form of package identifier.
Key Files
| File | Project | Purpose |
|---|---|---|
internal/shared/purl.go | vdb-api | BuildPurl, ecosystemToPurlType, registryURLPatterns, namespace derivation |
internal/shared/purl_parser.go | vdb-api | PURL string parsing |
internal/handler/v2_affected.go | vdb-api | PURL generation per affected entry |
internal/handler/vuln.go | vdb-api | x_purls array in VVD ADP container |
internal/purl/purl.go | cli | CLI-side PURL parser with 37 ecosystem mappings |
src/composables/useVdbApi.ts | website | Client-side PURL parsing for dispatch |
src/components/vdb-console/PurlBadge.vue | website | PURL display component |
src/shared/packages.ts | vdb-manager | Ecosystem definitions (28 languages, 50+ package managers) |
src/shared/ecosystem-helpers.ts | vdb-manager | Ecosystem detection from CVE data, collectionURL, references |