Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -969,11 +969,37 @@ denylist:
- "pkg:npm/%40scope/example@4.5.6"
```

Entries must be versioned PURLs without qualifiers or subpaths. Package names
are canonicalized (including PyPI case/separator normalization and npm scopes);
Entries must be versioned PURLs without subpaths or qualifiers (except a conda
`channel`, below). Package names are canonicalized (including npm scopes);
versions match exactly, not by range or glob. Invalid entries prevent startup.
An empty list disables the policy. Restart the proxy after changing the list.

Names and versions are compared the way each ecosystem treats them:

- PyPI names ignore case, and runs of `-`, `_` and `.` are equivalent (PEP 503),
so `pkg:pypi/zope.interface@6.0` also covers `zope_interface-6.0-*.whl`.
- NuGet ids and versions ignore case.
- A RubyGems entry also covers that version's platform gems:
`pkg:gem/nokogiri@1.16.0` denies `nokogiri-1.16.0-x86_64-linux.gem`.
- Composer versions ignore a leading `v`, which Packagist keeps from the tag:
`pkg:composer/symfony/console@6.1.7` also covers `v6.1.7`.
- Go versions are matched as written, `pkg:golang/example.com/mod@v1.0.0-RC1`,
though download URLs escape uppercase letters (`v1.0.0-!r!c1`).
- A Swift entry names the registry scope and package, ignoring case:
`pkg:swift/apple/swift-nio@2.62.0`. The stored form,
`pkg:generic/swift-registry/apple.swift-nio@2.62.0`, works too.
- An OCI entry names the repository as it is pulled through the proxy and a
digest or tag: `pkg:oci/library/alpine@sha256:<digest>` refuses the blob or
manifest with that digest, including a manifest a tag resolves to, and
`pkg:oci/library/alpine@3.19` refuses pulling that tag. Repositories on a
named upstream keep their prefix, as in `pkg:oci/upstream/ghcr/org/image@...`.
An entry covers only the digest it names: denying an image index does not
deny the platform manifests and layers it lists to a client that requests
them by digest directly.
- A conda entry covers the package in every channel. Add a `channel` qualifier,
such as `pkg:conda/numpy@1.26.4?channel=conda-forge`, to deny it in one
channel only.

Denied versions are removed from npm packuments, Cargo sparse indexes, and PyPI
simple HTML/JSON and release metadata, including responses served from cached
metadata during an upstream outage. No publication timestamp or enabled cooldown
Expand Down
133 changes: 123 additions & 10 deletions internal/denylist/denylist.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package denylist

import (
"fmt"
"regexp"
"strings"

"github.com/git-pkgs/purl"
Expand All @@ -14,7 +15,8 @@ type Policy struct {
}

// New validates and canonicalizes versioned PURLs. Qualifiers and subpaths are
// rejected because the proxy cannot reliably distinguish them at every endpoint.
// rejected because the proxy cannot reliably distinguish them at every endpoint,
// except a conda channel, which download URLs always name.
func New(packages []string) (*Policy, error) {
if len(packages) == 0 {
return nil, nil
Expand All @@ -25,19 +27,32 @@ func New(packages []string) (*Policy, error) {
if err != nil {
return nil, fmt.Errorf("invalid denylist package %q: %w", value, err)
}
if pkg.Version == "" || len(pkg.Qualifiers) != 0 || pkg.Subpath != "" ||
if pkg.Version == "" || !allowedQualifiers(pkg) || pkg.Subpath != "" ||
strings.ContainsAny(pkg.Namespace+pkg.Name+pkg.Version, "*?[]<>=| \t\r\n") {
return nil, fmt.Errorf("invalid denylist package %q: expected an exact versioned PURL without qualifiers or subpath", value)
}
key := pkg.WithoutVersion().String()
id := identify(pkg)
key := id.key
if id.channel != "" {
key = id.channelKey()
}
if p.packages[key] == nil {
p.packages[key] = make(map[string]bool)
}
p.packages[key][pkg.Version] = true
p.packages[key][id.version] = true
}
return p, nil
}

func allowedQualifiers(pkg *purl.PURL) bool {
for _, q := range pkg.Qualifiers {
if pkg.Type != "conda" || q.Key != "channel" {
return false
}
}
return true
}

// Denied reports whether a versioned PURL is denied.
func (p *Policy) Denied(value string) bool {
if p == nil || len(p.packages) == 0 {
Expand All @@ -47,21 +62,119 @@ func (p *Policy) Denied(value string) bool {
if err != nil {
return false
}
return p.packages[pkg.WithoutVersion().String()][pkg.Version]
id := identify(pkg)
versions := []string{id.version}
// A platform gem's version carries the platform ("1.16.0-x86_64-linux"),
// so also match the plain version.
if pkg.Type == "gem" {
if base, _, found := strings.Cut(id.version, "-"); found && base != "" {
versions = append(versions, base)
}
}
for _, version := range versions {
if p.packages[id.key][version] || (id.channel != "" && p.packages[id.channelKey()][version]) {
return true
}
}
return false
}

// Versions returns a caller-owned set of denied versions for a package PURL.
func (p *Policy) Versions(packagePURL string) map[string]bool {
if p == nil {
if p == nil || len(p.packages) == 0 {
return nil
}
versions := p.packages[packagePURL]
if len(versions) == 0 {
pkg, err := purl.Parse(packagePURL)
if err != nil {
return nil
}
result := make(map[string]bool, len(versions))
for version := range versions {
id := identify(pkg)
result := make(map[string]bool)
for version := range p.packages[id.key] {
result[version] = true
}
if id.channel != "" {
for version := range p.packages[id.channelKey()] {
result[version] = true
}
}
if len(result) == 0 {
return nil
}
return result
}

// identity is the form a PURL is compared in: the same for denylist entries
// and for the PURLs handlers build, however each spells the package.
type identity struct {
key string // versionless PURL without channel
channel string // conda channel, if any
version string
}

func (id identity) channelKey() string {
return id.key + "?channel=" + id.channel
}

var pypiSeparators = regexp.MustCompile(`[-_.]+`)

// swiftRegistryIdentity returns the registry scope and name of a Swift
// package. A source coordinate such as pkg:swift/github.com/apple/swift-nio
// names no registry identity.
func swiftRegistryIdentity(pkg *purl.PURL) (scope, name string, ok bool) {
switch {
case pkg.Type == "swift" && pkg.Namespace != "" && !strings.ContainsAny(pkg.Namespace, "./"):
return pkg.Namespace, pkg.Name, true
case pkg.Type == "generic" && strings.EqualFold(pkg.Namespace, "swift-registry"):
scope, name, found := strings.Cut(pkg.Name, ".")
return scope, name, found && scope != "" && name != ""
}
return "", "", false
}

func identify(pkg *purl.PURL) identity {
namespace, name, version := pkg.Namespace, pkg.Name, pkg.Version
var channel string
switch pkg.Type {
case "pypi":
// PEP 503: case-insensitive, runs of -, _ and . are equivalent.
name = pypiSeparators.ReplaceAllString(strings.ToLower(name), "-")
case "nuget":
// NuGet ids and versions are case-insensitive.
name, version = strings.ToLower(name), strings.ToLower(version)
case "conda":
// The conda handler names packages "channel/name"; entries may give the
// channel as a qualifier or namespace. An entry without one matches
// every channel.
channel = pkg.Qualifier("channel")
if namespace != "" {
channel, namespace = namespace, ""
}
if i := strings.LastIndex(name, "/"); i >= 0 {
channel, name = name[:i], name[i+1:]
}
case "composer":
// Packagist keeps a tag's leading v ("v6.1.7"); Composer treats it
// as the same version.
if len(version) > 1 && (version[0] == 'v' || version[0] == 'V') && version[1] >= '0' && version[1] <= '9' {
version = version[1:]
}
case "oci":
// Handlers name the repository as one name, library%2Falpine;
// entries write it as a path, library/alpine.
if namespace != "" {
name, namespace = namespace+"/"+name, ""
}
case "swift", "generic":
// Swift registry archives are stored as
// pkg:generic/swift-registry/{scope}.{name}; entries may write
// pkg:swift/{scope}/{name}. Registry identities ignore case.
if scope, pkgName, ok := swiftRegistryIdentity(pkg); ok {
namespace, name = strings.ToLower(scope), strings.ToLower(pkgName)
key := purl.New("swift", namespace, name, "", nil).String()
return identity{key: key, version: version}
}
}
key := purl.New(pkg.Type, namespace, name, "", nil).String()
return identity{key: key, channel: channel, version: version}
}
55 changes: 54 additions & 1 deletion internal/denylist/denylist_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,64 @@ func TestPolicy(t *testing.T) {
}

func TestInvalidPolicy(t *testing.T) {
for _, value := range []string{"", "not-a-purl", "pkg:npm/foo", "pkg:npm/foo@*", "pkg:npm/foo@>=1", "pkg:npm/foo@1?arch=x86", "pkg:npm/foo@1#src"} {
for _, value := range []string{"", "not-a-purl", "pkg:npm/foo", "pkg:npm/foo@*", "pkg:npm/foo@>=1", "pkg:npm/foo@1?arch=x86", "pkg:npm/foo@1#src", "pkg:conda/numpy@1?subdir=linux-64", "pkg:pypi/foo@1?channel=x"} {
t.Run(value, func(t *testing.T) {
if _, err := New([]string{value}); err == nil {
t.Fatal("invalid denylist accepted")
}
})
}
}

func TestEcosystemSpellings(t *testing.T) {
p, err := New([]string{
"pkg:pypi/zope.interface@6.0",
"pkg:nuget/Newtonsoft.Json@13.0.1-Beta",
"pkg:gem/nokogiri@1.16.0",
"pkg:gem/sorbet-static@0.5.1-universal-darwin-22",
"pkg:conda/numpy@1.26.4",
"pkg:conda/scipy@1.0?channel=conda-forge",
"pkg:conda/bioconda/samtools@1.0",
"pkg:composer/symfony/console@6.1.7",
"pkg:composer/monolog/monolog@v3.5.0",
"pkg:oci/library/alpine@sha256:abc",
"pkg:oci/upstream%2Fghcr%2Forg%2Fimage@sha256:def",
"pkg:swift/Apple/Swift-NIO@2.0.0",
"pkg:generic/swift-registry/apple.example@1.0.0",
})
if err != nil {
t.Fatal(err)
}
for _, value := range []string{
"pkg:pypi/zope-interface@6.0", "pkg:pypi/Zope__Interface@6.0",
"pkg:nuget/newtonsoft.json@13.0.1-beta",
"pkg:gem/nokogiri@1.16.0", "pkg:gem/nokogiri@1.16.0-x86_64-linux",
"pkg:gem/sorbet-static@0.5.1-universal-darwin-22",
"pkg:conda/numpy@1.26.4", "pkg:conda/conda-forge%2Fnumpy@1.26.4",
"pkg:conda/conda-forge%2Fscipy@1.0", "pkg:conda/bioconda%2Fsamtools@1.0",
"pkg:composer/symfony/console@v6.1.7", "pkg:composer/symfony/console@V6.1.7",
"pkg:composer/monolog/monolog@3.5.0",
"pkg:oci/library%2Falpine@sha256:abc", "pkg:oci/library/alpine@sha256:abc",
"pkg:oci/upstream/ghcr/org/image@sha256:def",
"pkg:generic/swift-registry/apple.swift-nio@2.0.0", "pkg:swift/apple/swift-nio@2.0.0",
"pkg:swift/apple/example@1.0.0",
} {
if !p.Denied(value) {
t.Errorf("not denied: %s", value)
}
}
for _, value := range []string{
"pkg:pypi/zopeinterface@6.0", "pkg:gem/nokogiri@1.16.1-x86_64-linux", "pkg:gem/Nokogiri@1.16.0",
"pkg:conda/bioconda%2Fscipy@1.0", "pkg:conda/scipy@1.0", "pkg:conda/conda-forge%2Fsamtools@1.0",
"pkg:composer/symfony/console@6.1.70", "pkg:composer/symfony/console@vv6.1.7", "pkg:npm/demo@v6.1.7",
"pkg:oci/alpine@sha256:abc", "pkg:oci/library%2Falpine@sha256:abd",
"pkg:generic/other/apple.swift-nio@2.0.0", "pkg:swift/github.com/apple/swift-nio@2.0.0",
} {
if p.Denied(value) {
t.Errorf("unexpected denial: %s", value)
}
}
if !p.Versions("pkg:pypi/zope_interface")["6.0"] {
t.Error("pypi versions not found under another spelling")
}
}
11 changes: 9 additions & 2 deletions internal/handler/container.go
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,10 @@ func (h *ContainerHandler) handleBlobDownload(w http.ResponseWriter, r *http.Req

filename := digest
cached, err := h.proxy.GetCachedArtifact(r.Context(), "oci", cacheName, digest, filename)
if errors.Is(err, ErrVersionDenied) {
h.containerError(w, http.StatusForbidden, "DENIED", err.Error())
return
}
if err != nil {
h.proxy.Logger.Error("failed to check blob cache", "error", err)
h.containerError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to check blob cache")
Expand Down Expand Up @@ -471,14 +475,17 @@ func (h *ContainerHandler) handleManifest(w http.ResponseWriter, r *http.Request
return
}

registryURL, upstreamName, _, ok := h.registryForRequest(r, name)
registryURL, upstreamName, cacheName, ok := h.registryForRequest(r, name)
if !ok {
h.containerError(w, http.StatusNotFound, "NAME_UNKNOWN", "unknown upstream registry")
return
}

h.proxy.Logger.Info("container manifest request", "name", upstreamName, "reference", reference)
h.serveManifest(w, r, registryURL, upstreamName, reference)
if h.manifestDenied(w, cacheName, reference) {
return
}
h.serveManifest(w, r, registryURL, upstreamName, cacheName, reference)
}

// handleTagsList caches tag list responses for offline OCI pulls.
Expand Down
Loading
Loading