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
3 changes: 3 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1119,6 +1119,8 @@ Durations support days (`7d`), hours (`48h`), and minutes (`30m`). Set to `0` to

Package PURL keys are normalized to canonical form before matching, so `pkg:npm/@babel/core` and `pkg:npm/%40babel/core` are equivalent, as are `pkg:pypi/Django` and `pkg:pypi/django`. If both forms configure the same package, the canonical entry wins.

Go module names in exact cooldown overrides are matched case-insensitively. If both lowercase and mixed-case keys are configured, the lowercase entry wins. Go package patterns match lowercase module names.

`package_patterns` matches canonical, versionless PURLs using `*` for zero or more characters and `?` for one character. Neither wildcard crosses `/` separators. Character classes (`[...]`) and backslash escapes are rejected at startup. For example, `"pkg:npm/@example/*"` matches packages under the `@example` npm scope. Patterns accept `@` as an alias for `%40`; other characters must use their canonical PURL form. Equivalent patterns with different durations are rejected at startup. Equal durations, such as `1d` and `24h`, are accepted.

Exact `packages` entries take precedence over patterns. When several patterns match, longer patterns win after excluding `*` and `?` from the length. Ties use lexical order of the normalized patterns.
Expand Down Expand Up @@ -1180,6 +1182,7 @@ Names and versions are compared the way each ecosystem treats them:
`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`).
Go module names in denylist entries are matched case-insensitively.
- 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.
Expand Down
6 changes: 3 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,12 @@ require (
github.com/git-pkgs/archives v0.10.0
github.com/git-pkgs/artifacts v0.2.1
github.com/git-pkgs/cooldown v0.3.0
github.com/git-pkgs/enrichment v0.7.4
github.com/git-pkgs/enrichment v0.7.5
github.com/git-pkgs/gcs v0.1.0
github.com/git-pkgs/integrity v0.2.0
github.com/git-pkgs/magic v0.6.0
github.com/git-pkgs/purl v0.1.24
github.com/git-pkgs/registries v0.12.0
github.com/git-pkgs/purl v0.2.1
github.com/git-pkgs/registries v0.13.0
github.com/git-pkgs/sbom v0.1.7
github.com/git-pkgs/spdx v0.3.3
github.com/go-chi/chi/v5 v5.5.0
Expand Down
12 changes: 6 additions & 6 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,8 @@ github.com/git-pkgs/artifacts v0.2.1 h1:VwdxR4yTDaqBZ34h0slxQBVyr2Xfa+QGOKCKviMx
github.com/git-pkgs/artifacts v0.2.1/go.mod h1:Otosgq52pXT5UNN7lh6s/lszpWKVDO8XrOjN1M70/IA=
github.com/git-pkgs/cooldown v0.3.0 h1:eAl2CQFwqXxluoVP9hHmropAf1VoV3gSxLMkN+NEu8A=
github.com/git-pkgs/cooldown v0.3.0/go.mod h1:FnTg8wufEALATC9Sm/9AHjLdA+u9eVn96e9BqXXjGB0=
github.com/git-pkgs/enrichment v0.7.4 h1:owiCKjITG8tNxBikcyUCDuDf1i3C/+Yoa5yJseuqIjA=
github.com/git-pkgs/enrichment v0.7.4/go.mod h1:ZN/cH4PnmzsapEVPvmAwREP7+ox+RX0yfR6KvoB8xKE=
github.com/git-pkgs/enrichment v0.7.5 h1:jbfFzf7+HSS7el8AuMri4vHXq2izIIZDVIU0uYI5LFM=
github.com/git-pkgs/enrichment v0.7.5/go.mod h1:TbxC/nGQN96YCbdoiDs8a9z3gk2zXmLVS1989/jvnsU=
github.com/git-pkgs/gcs v0.1.0 h1:E3awGtsO0xZyHT9FUfEwMHjMkRxw41Bh+c7lgTmPvBo=
github.com/git-pkgs/gcs v0.1.0/go.mod h1:bdkCFD66ryaWnU8MBhokVA3WkJfyAEchm9qec5woBpE=
github.com/git-pkgs/integrity v0.2.0 h1:sxpAk7EUllZnTYttOoPTW/Yfe5J2imglpuJiuBUZOxM=
Expand All @@ -133,10 +133,10 @@ github.com/git-pkgs/magic v0.6.0 h1:fINPe67FEIaFRVlZzhOIDClMkm+gftBroBENa8bHSxQ=
github.com/git-pkgs/magic v0.6.0/go.mod h1:SXOqcsNmbmpZjJZHEEWnwxprbsFvpwWgTAZrgXp4Jm4=
github.com/git-pkgs/pom v0.1.8 h1:mXCuWw6XkrKKZb6IurRZyIznYLPhV5AYg5ZO38Uj/l8=
github.com/git-pkgs/pom v0.1.8/go.mod h1:sQBeYRzjCUn77fcABPn6I/a1aBHFxm55jTmkNxYtGtw=
github.com/git-pkgs/purl v0.1.24 h1:JMEB1uNWI+baEpl6AeVx+Kfq1c+xkou+THxa5KJ3MrU=
github.com/git-pkgs/purl v0.1.24/go.mod h1:l8rCfvt5iXVrFIcoQ8+vCLOaHhrtEW2id0LENP+IzMw=
github.com/git-pkgs/registries v0.12.0 h1:fhxYaR5iR9Zlx6doLkjlXurVqPxkYcXmNFiIrFeV3/k=
github.com/git-pkgs/registries v0.12.0/go.mod h1:bKxgdy/PwnzQXL2FnOktmSQHTKk6B85MA4VtvvYItPc=
github.com/git-pkgs/purl v0.2.1 h1:b46hRlOMbrG6aXjjFJdYixGi5WTh0TLlAnH9iNeuOS0=
github.com/git-pkgs/purl v0.2.1/go.mod h1:l8rCfvt5iXVrFIcoQ8+vCLOaHhrtEW2id0LENP+IzMw=
github.com/git-pkgs/registries v0.13.0 h1:5i9dHnbiXseIbXLIZ61YnuFySJ9dQX+KEu1MrmwB2BU=
github.com/git-pkgs/registries v0.13.0/go.mod h1:1g/jXVi1M/3jWvW1Wom1NMnPrhglJ7UVEZ215l2Zh0M=
github.com/git-pkgs/sbom v0.1.7 h1:4eboy/Yed/SC9ZcC/xT50iywHPzWbmCU7azDb5T1RbA=
github.com/git-pkgs/sbom v0.1.7/go.mod h1:KIkZX0v31l62SeFsi2qsQXcZRVrAXvIdPQ4x4en+G2M=
github.com/git-pkgs/spdx v0.3.3 h1:xXlUoZzjDSkwxC6mWHfaW+kc8a0+LH6d+gGHuZWftqI=
Expand Down
3 changes: 2 additions & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ import (

"github.com/git-pkgs/cooldown"
"github.com/git-pkgs/proxy/internal/denylist"
"github.com/git-pkgs/proxy/internal/packageurl"
"github.com/git-pkgs/proxy/internal/retention"
"github.com/git-pkgs/purl"
"gopkg.in/yaml.v3"
Expand Down Expand Up @@ -261,7 +262,7 @@ func (c *CooldownConfig) NormalizedPackages() map[string]string {
for _, key := range keys {
canonical := key
if parsed, err := purl.Parse(key); err == nil {
canonical = parsed.String()
canonical = packageurl.PolicyPackageKey(parsed.String())
}
if _, exists := normalized[canonical]; exists && key != canonical {
continue
Expand Down
40 changes: 40 additions & 0 deletions internal/database/rekey_artifact.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package database

import (
"fmt"
"time"
)

// RekeyCachedArtifact moves one unchanged upstream record without replacing
// an existing destination. Access history and stored bytes are preserved.
func (db *DB) RekeyCachedArtifact(old *Artifact, pkg *Package, version *Version) (bool, error) {
tx, err := db.Beginx()
if err != nil {
return false, err
}
defer func() { _ = tx.Rollback() }()
now := time.Now()
_, err = tx.Exec(db.Rebind(`INSERT INTO packages (purl, ecosystem, name, created_at, updated_at)
VALUES (?, ?, ?, ?, ?) ON CONFLICT(purl) DO NOTHING`), pkg.PURL, pkg.Ecosystem, pkg.Name, now, now)
if err != nil {
return false, fmt.Errorf("creating artifact package: %w", err)
}
_, err = tx.Exec(db.Rebind(`INSERT INTO versions (purl, package_purl, created_at, updated_at)
VALUES (?, ?, ?, ?) ON CONFLICT(purl) DO NOTHING`), version.PURL, version.PackagePURL, now, now)
if err != nil {
return false, fmt.Errorf("creating artifact version: %w", err)
}
result, err := tx.Exec(db.Rebind(`UPDATE artifacts SET version_purl = ?, updated_at = ?
WHERE id = ? AND version_purl = ? AND storage_path = ? AND upstream_url = ? AND source = ?
AND NOT EXISTS (SELECT 1 FROM artifacts WHERE version_purl = ? AND filename = ?)`),
version.PURL, now, old.ID, old.VersionPURL, old.StoragePath, old.UpstreamURL, SourceUpstream,
version.PURL, old.Filename)
if err != nil {
return false, fmt.Errorf("moving cached artifact: %w", err)
}
count, err := result.RowsAffected()
if err != nil || count == 0 {
return false, err
}
return true, tx.Commit()
}
69 changes: 69 additions & 0 deletions internal/database/rekey_artifact_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package database

import (
"database/sql"
"testing"
)

func TestRekeyCachedArtifact(t *testing.T) {
runWithBothDatabases(t, func(t *testing.T, db *DB) {
for _, scenario := range []string{"move", "destination exists", "source changed", "local source"} {
t.Run(scenario, func(t *testing.T) {
oldPackage := &Package{PURL: "pkg:golang/example.com/old", Ecosystem: "golang", Name: "example.com/Old"}
oldVersion := &Version{PURL: oldPackage.PURL + "@v1.0.0", PackagePURL: oldPackage.PURL}
old := &Artifact{VersionPURL: oldVersion.PURL, Filename: scenario + ".zip",
UpstreamURL: "https://proxy.example/example.com/!old/@v/v1.0.0.zip",
StoragePath: sql.NullString{String: scenario + "/old.zip", Valid: true}}
if err := db.RecordCachedArtifact(oldPackage, oldVersion, old); err != nil {
t.Fatal(err)
}
old, err := db.GetArtifact(oldVersion.PURL, old.Filename)
if err != nil || old == nil {
t.Fatalf("source artifact = %+v, %v", old, err)
}
pkg := &Package{PURL: "pkg:golang/example.com/Old", Ecosystem: "golang", Name: "example.com/Old"}
version := &Version{PURL: pkg.PURL + "@v1.0.0", PackagePURL: pkg.PURL}
prepareRekeyConflict(t, db, scenario, old, pkg, version)
moved, err := db.RekeyCachedArtifact(old, pkg, version)
if err != nil || moved != (scenario == "move") {
t.Fatalf("RekeyCachedArtifact = %v, %v", moved, err)
}
checkRekeyResult(t, db, scenario, old, version)
})
}
})
}

func checkRekeyResult(t *testing.T, db *DB, scenario string, old *Artifact, version *Version) {
t.Helper()
current, err := db.GetArtifact(version.PURL, old.Filename)
if err != nil {
t.Fatal(err)
}
if scenario == "move" && (current == nil || current.ID != old.ID || current.StoragePath != old.StoragePath) {
t.Errorf("moved artifact = %+v, old = %+v", current, old)
}
if scenario == "destination exists" && (current == nil || current.StoragePath.String != "destination.zip") {
t.Errorf("destination replaced: %+v", current)
}
if paths := allPendingDeletes(t, db); len(paths) != 0 {
t.Errorf("rekey queued stored bytes for deletion: %v", paths)
}
}

func prepareRekeyConflict(t *testing.T, db *DB, scenario string, old *Artifact, pkg *Package, version *Version) {
t.Helper()
var err error
switch scenario {
case "destination exists":
err = db.RecordCachedArtifact(pkg, version, &Artifact{VersionPURL: version.PURL, Filename: old.Filename,
StoragePath: sql.NullString{String: "destination.zip", Valid: true}})
case "source changed":
_, err = db.Exec(db.Rebind(`UPDATE artifacts SET storage_path = ? WHERE id = ?`), "replacement.zip", old.ID)
case "local source":
_, err = db.Exec(db.Rebind(`UPDATE artifacts SET source = ? WHERE id = ?`), SourceLocal, old.ID)
}
if err != nil {
t.Fatal(err)
}
}
3 changes: 2 additions & 1 deletion internal/denylist/denylist.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"strings"

"github.com/git-pkgs/proxy/internal/packageurl"
"github.com/git-pkgs/purl"
)

Expand Down Expand Up @@ -138,5 +139,5 @@ func identify(pkg *purl.PURL) identity {
channel = c.Qualifier("channel")
}
key := purl.New(c.Type, c.Namespace, c.Name, "", nil).String()
return identity{key: key, channel: channel, version: c.Version}
return identity{key: packageurl.PolicyPackageKey(key), channel: channel, version: c.Version}
}
9 changes: 5 additions & 4 deletions internal/enrichment/enrichment.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,6 @@ func (s *Service) BulkEnrichPackages(ctx context.Context, packages []struct{ Eco

// CheckVulnerabilities queries for vulnerabilities affecting a package version.
func (s *Service) CheckVulnerabilities(ctx context.Context, ecosystem, name, version string) ([]VulnInfo, error) {
if packageurl.Make(ecosystem, name, version) == nil {
return nil, nil
}
return s.vulns.Check(ctx, ecosystem, name, version)
}

Expand Down Expand Up @@ -137,7 +134,8 @@ type EnrichmentResult struct {
}

// EnrichFull fetches package, version and vulnerability data for a package
// version concurrently. Failed lookups are logged and left empty.
// version concurrently. Unsupported vulnerability identities return an error;
// other failed lookups are logged and left empty.
func (s *Service) EnrichFull(ctx context.Context, ecosystem, name, version string) (*EnrichmentResult, error) {
result := &EnrichmentResult{}

Expand All @@ -147,6 +145,9 @@ func (s *Service) EnrichFull(ctx context.Context, ecosystem, name, version strin
wg.Go(func() { result.Version, verErr = s.EnrichVersion(ctx, ecosystem, name, version) })
wg.Go(func() { result.Vulnerabilities, vulnErr = s.CheckVulnerabilities(ctx, ecosystem, name, version) })
wg.Wait()
if errors.Is(vulnErr, shared.ErrUnsupportedVulnerabilityIdentity) {
return nil, vulnErr
}

if pkgErr != nil {
s.logger.Debug("failed to enrich package", "ecosystem", ecosystem, "name", name, "error", pkgErr)
Expand Down
11 changes: 8 additions & 3 deletions internal/enrichment/enrichment_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ package enrichment

import (
"context"
"errors"
"log/slog"
"os"
"testing"

shared "github.com/git-pkgs/enrichment"
"github.com/git-pkgs/registries"
)

Expand All @@ -26,7 +28,7 @@ func TestNew(t *testing.T) {
}
}

func TestSwiftRegistryIdentitySkipsPURLDependentLookups(t *testing.T) {
func TestSwiftRegistryIdentityLookups(t *testing.T) {
svc := New(slog.New(slog.NewTextHandler(os.Stdout, nil)))
ctx := context.Background()

Expand All @@ -41,8 +43,11 @@ func TestSwiftRegistryIdentitySkipsPURLDependentLookups(t *testing.T) {
}

vulnerabilities, err := svc.CheckVulnerabilities(ctx, "swift", "apple/example", "1.2.3")
if err != nil || vulnerabilities != nil {
t.Errorf("CheckVulnerabilities() = %#v, %v; want nil, nil", vulnerabilities, err)
if !errors.Is(err, shared.ErrUnsupportedVulnerabilityIdentity) || vulnerabilities != nil {
t.Errorf("CheckVulnerabilities() = %#v, %v; want unsupported identity", vulnerabilities, err)
}
if _, err := svc.EnrichFull(ctx, "swift", "apple/example", "1.2.3"); !errors.Is(err, shared.ErrUnsupportedVulnerabilityIdentity) {
t.Errorf("EnrichFull error = %v, want unsupported identity", err)
}

latest, err := svc.GetLatestVersion(ctx, "swift", "apple/example")
Expand Down
7 changes: 5 additions & 2 deletions internal/handler/cooldown_expiry.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"time"

"github.com/git-pkgs/cooldown"
"github.com/git-pkgs/proxy/internal/packageurl"
)

// A rewrite made with cooldown on stays right until a version it held back
Expand Down Expand Up @@ -127,6 +128,7 @@ type recordingCooldown struct {
}

func (r recordingCooldown) IsAllowed(ecosystem, packagePURL string, publishedAt time.Time) bool {
packagePURL = packageurl.PolicyPackageKey(packagePURL)
if r.p.Cooldown.IsAllowed(ecosystem, packagePURL, publishedAt) {
return true
}
Expand All @@ -135,6 +137,7 @@ func (r recordingCooldown) IsAllowed(ecosystem, packagePURL string, publishedAt
}

func (r recordingCooldown) Evaluate(ecosystem, packagePURL string, publishedAt, evaluatedAt time.Time) cooldown.Decision {
packagePURL = packageurl.PolicyPackageKey(packagePURL)
d := r.p.Cooldown.Evaluate(ecosystem, packagePURL, publishedAt, evaluatedAt)
if !d.Allowed {
r.p.withheld(d.AvailableAt)
Expand All @@ -143,7 +146,7 @@ func (r recordingCooldown) Evaluate(ecosystem, packagePURL string, publishedAt,
}

func (r recordingCooldown) For(ecosystem, packagePURL string) time.Duration {
return r.p.Cooldown.For(ecosystem, packagePURL)
return r.p.Cooldown.For(ecosystem, packageurl.PolicyPackageKey(packagePURL))
}

func (r recordingCooldown) Enabled() bool {
Expand All @@ -156,7 +159,7 @@ func (r recordingCooldown) availableAt(ecosystem, packagePURL string, publishedA
if publishedAt.IsZero() {
return time.Time{}
}
d := r.p.Cooldown.For(ecosystem, packagePURL)
d := r.For(ecosystem, packagePURL)
if d <= 0 {
return time.Time{}
}
Expand Down
2 changes: 1 addition & 1 deletion internal/handler/cooldown_fail_closed.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import "net/http"
// cooldownApplies reports whether cooldown withholds new versions of the
// package, taking package overrides and exemptions into account.
func (p *Proxy) cooldownApplies(ecosystem, name string) bool {
return p.cooldownEnabled() && p.Cooldown.For(ecosystem, canonicalPackagePURL(ecosystem, name)) > 0
return p.cooldownEnabled() && p.cooldown().For(ecosystem, canonicalPackagePURL(ecosystem, name)) > 0
}

// mustFilterMetadata reports whether the package's metadata may have
Expand Down
6 changes: 6 additions & 0 deletions internal/handler/denylist_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,12 @@ func TestDenylistMatchesEcosystemSpellings(t *testing.T) {
{"go case-escaped version", "pkg:golang/github.com/example/mod@v1.0.0-RC1", "/github.com/example/mod/@v/v1.0.0-!r!c1.zip",
"golang", "github.com/example/mod", "v1.0.0-!r!c1", "mod@v1.0.0-!r!c1.zip",
func(p *Proxy) http.Handler { return NewGoHandler(p, "http://proxy.test").Routes() }},
{"go lowercase rule", "pkg:golang/github.com/example/mod@v1.0.0", "/github.com/!example/mod/@v/v1.0.0.zip",
"golang", "github.com/Example/mod", "v1.0.0", goZipFilename("github.com/Example/mod", "v1.0.0"),
func(p *Proxy) http.Handler { return NewGoHandler(p, "http://proxy.test").Routes() }},
{"go mixed-case rule", "pkg:golang/github.com/Example/mod@v1.0.0", "/github.com/example/mod/@v/v1.0.0.zip",
"golang", "github.com/example/mod", "v1.0.0", "mod@v1.0.0.zip",
func(p *Proxy) http.Handler { return NewGoHandler(p, "http://proxy.test").Routes() }},
{"oci repository path", "pkg:oci/library/alpine@" + ociTestDigest, "/library/alpine/blobs/" + ociTestDigest,
"oci", "library/alpine", ociTestDigest, ociTestDigest,
func(p *Proxy) http.Handler {
Expand Down
Loading
Loading