From f4f2844762f026c30ea4ba6b14feb3b1c9d3da27 Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Sat, 21 Feb 2026 10:40:10 +0100 Subject: [PATCH 1/2] add support for uploading provenance files --- modules/packages/helm/metadata.go | 42 ++++++++++++ routers/api/packages/api.go | 1 + routers/api/packages/helm/helm.go | 74 +++++++++++++++++++++ tests/integration/api_packages_helm_test.go | 55 +++++++++++++++ 4 files changed, 172 insertions(+) diff --git a/modules/packages/helm/metadata.go b/modules/packages/helm/metadata.go index 421fc5e725..44be8d4004 100644 --- a/modules/packages/helm/metadata.go +++ b/modules/packages/helm/metadata.go @@ -25,6 +25,8 @@ var ( ErrInvalidVersion = util.NewInvalidArgumentErrorf("package version is invalid") // ErrInvalidChart indicates an invalid chart ErrInvalidChart = util.NewInvalidArgumentErrorf("chart is invalid") + // ErrInvalidProvenance indicates an invalid provenance file + ErrInvalidProvenance = util.NewInvalidArgumentErrorf("provenance file is invalid") ) // Metadata for a Chart file. This models the structure of a Chart.yaml file. @@ -128,3 +130,43 @@ func ParseChartFile(r io.Reader) (*Metadata, error) { return metadata, nil } + +// ParseProvenanceFile parses a provenance file to retrieve the metadata of a Helm chart +func ParseProvenanceFile(r io.Reader) (*Metadata, error) { + data, err := io.ReadAll(io.LimitReader(r, 1<<20)) + if err != nil { + return nil, err + } + + content := string(data) + + // A provenance file is a PGP signed message. + // The content is between the header and the signature. + const ( + header = "-----BEGIN PGP SIGNED MESSAGE-----" + separator = "...\n" + ) + + i := strings.Index(content, header) + if i == -1 { + // Not a PGP signed message, so it's not a valid prov file + return nil, ErrInvalidProvenance + } + + content = content[i+len(header):] + // Skip Hash: ${hash} from header + // https://www.gnupg.org/gph/en/manual/x135.html + i = strings.Index(content, "\n\n") + if i != -1 { + content = content[i+2:] + } + i = strings.Index(content, separator) + if i == -1 { + // The file HAS to include the separator + // https://helm.sh/docs/topics/provenance/#the-provenance-file + return nil, ErrInvalidProvenance + } + content = content[:i] // We only need the chart.yaml part + + return ParseChartFile(strings.NewReader(content)) +} diff --git a/routers/api/packages/api.go b/routers/api/packages/api.go index 71fee23c92..f765d15ea9 100644 --- a/routers/api/packages/api.go +++ b/routers/api/packages/api.go @@ -351,6 +351,7 @@ func CommonRoutes() *web.Router { r.Get("/index.yaml", helm.Index) r.Get("/{filename}", helm.DownloadPackageFile) r.Post("/api/charts", reqPackageAccess(perm.AccessModeWrite), helm.UploadPackage) + r.Post("/api/prov", reqPackageAccess(perm.AccessModeWrite), helm.UploadProvenanceFile) }, reqPackageAccess(perm.AccessModeRead)) r.Group("/maven", func() { r.Put("/*", reqPackageAccess(perm.AccessModeWrite), maven.UploadPackageFile) diff --git a/routers/api/packages/helm/helm.go b/routers/api/packages/helm/helm.go index 4c1b72d5c0..75f7f1d2c5 100644 --- a/routers/api/packages/helm/helm.go +++ b/routers/api/packages/helm/helm.go @@ -209,6 +209,80 @@ func UploadPackage(ctx *context.Context) { ctx.Status(http.StatusCreated) } +func UploadProvenanceFile(ctx *context.Context) { + upload, needToClose, err := ctx.UploadStream() + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + if needToClose { + defer upload.Close() + } + + buf, err := packages_module.CreateHashedBufferFromReader(upload) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + defer buf.Close() + + metadata, err := helm_module.ParseProvenanceFile(buf) + if err != nil { + if errors.Is(err, util.ErrInvalidArgument) || errors.Is(err, io.EOF) { + apiError(ctx, http.StatusBadRequest, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + if _, err := buf.Seek(0, io.SeekStart); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + _, _, err = packages_service.CreatePackageOrAddFileToExisting( + ctx, + &packages_service.PackageCreationInfo{ + PackageInfo: packages_service.PackageInfo{ + Owner: ctx.Package.Owner, + PackageType: packages_model.TypeHelm, + Name: metadata.Name, + Version: metadata.Version, + }, + SemverCompatible: true, + Creator: ctx.Doer, + Metadata: metadata, + }, + &packages_service.PackageFileCreationInfo{ + PackageFileInfo: packages_service.PackageFileInfo{ + Filename: createProvenanceFilename(metadata), + }, + Creator: ctx.Doer, + Data: buf, + IsLead: false, + OverwriteExisting: true, + }, + ) + if err != nil { + switch err { + case packages_model.ErrDuplicatePackageVersion: + apiError(ctx, http.StatusConflict, err) + case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize: + apiError(ctx, http.StatusForbidden, err) + default: + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + ctx.Status(http.StatusCreated) +} + func createFilename(metadata *helm_module.Metadata) string { return strings.ToLower(fmt.Sprintf("%s-%s.tgz", metadata.Name, metadata.Version)) } + +func createProvenanceFilename(metadata *helm_module.Metadata) string { + return strings.ToLower(fmt.Sprintf("%s-%s.tgz.prov", metadata.Name, metadata.Version)) +} diff --git a/tests/integration/api_packages_helm_test.go b/tests/integration/api_packages_helm_test.go index 02df4ae906..a548e66856 100644 --- a/tests/integration/api_packages_helm_test.go +++ b/tests/integration/api_packages_helm_test.go @@ -60,6 +60,30 @@ dependencies: zw.Close() content := buf.Bytes() + // The signature is invalid, but the repository isn't verifying it. + provContent := `-----BEGIN PGP SIGNED MESSAGE----- +Hash: SHA512 + +apiVersion: v2 +description: ` + packageDescription + ` +name: ` + packageName + ` +type: application +version: ` + packageVersion + ` + +... +files: + ` + filename + `: sha256:d31d2f08b885ec696c37c7f7ef106709aaf5e8575b6d3dc5d52112ed29a9cb92 +-----BEGIN PGP SIGNATURE----- + +wsBcBAEBCgAQBQJdy0ReCRCEO7+YH8GHYgAAfhUIADx3pHHLLINv0MFkiEYpX/Kd +nvHFBNps7hXqSocsg0a9Fi1LRAc3OpVh3knjPfHNGOy8+xOdhbqpdnB+5ty8YopI +mYMWp6cP/Mwpkt7/gP1ecWFMevicbaFH5AmJCBihBaKJE4R1IX49/wTIaLKiWkv2 +cR64bmZruQPSW83UTNULtdD7kuTZXeAdTMjAK0NECsCz9/eK5AFggP4CDf7r2zNi +hZsNrzloIlBZlGGns6mUOTO42J/+JojnOLIhI3Psd0HBD2bTlsm/rSfty4yZUs7D +qtgooNdohoyGSzR5oapd7fEvauRQswJxOA0m0V+u9/eyLR0+JcYB8Udi1prnWf8= +=aHfz +-----END PGP SIGNATURE-----` + url := fmt.Sprintf("/api/packages/%s/helm", user.Name) t.Run("Upload", func(t *testing.T) { @@ -95,6 +119,37 @@ dependencies: req = NewRequestWithBody(t, "POST", uploadURL, bytes.NewReader(content)). AddBasicAuth(user.Name) MakeRequest(t, req, http.StatusCreated) + + provURL := url + "/api/prov" + + // Upload Provenance file + req = NewRequestWithBody(t, "POST", provURL, bytes.NewReader([]byte(provContent))). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusCreated) + + pvs, err = packages.GetVersionsByPackageType(t.Context(), user.ID, packages.TypeHelm) + assert.NoError(t, err) + assert.Len(t, pvs, 1) + + pfs, err = packages.GetFilesByVersionID(t.Context(), pvs[0].ID) + assert.NoError(t, err) + assert.Len(t, pfs, 2) + + var provFile *packages.PackageFile + for _, pf := range pfs { + if pf.Name == filename+".prov" { + provFile = pf + break + } + } + assert.NotNil(t, provFile) + assert.False(t, provFile.IsLead) + + req = NewRequest(t, "GET", fmt.Sprintf("%s/%s.prov", url, filename)). + AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, provContent, resp.Body.String()) }) t.Run("Download", func(t *testing.T) { From b81a3816cb14b497f3481edfa71b7e7b42bea627 Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Sun, 22 Feb 2026 13:52:54 +0100 Subject: [PATCH 2/2] refactor endpoint to disallow standalone prov files --- routers/api/packages/helm/helm.go | 22 +++++++++------------ tests/integration/api_packages_helm_test.go | 17 +++++++++++----- 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/routers/api/packages/helm/helm.go b/routers/api/packages/helm/helm.go index 75f7f1d2c5..ff888a7f59 100644 --- a/routers/api/packages/helm/helm.go +++ b/routers/api/packages/helm/helm.go @@ -209,6 +209,7 @@ func UploadPackage(ctx *context.Context) { ctx.Status(http.StatusCreated) } +// UploadProvenanceFile uploads and attaches the provenance file to existing helm chart func UploadProvenanceFile(ctx *context.Context) { upload, needToClose, err := ctx.UploadStream() if err != nil { @@ -241,18 +242,13 @@ func UploadProvenanceFile(ctx *context.Context) { return } - _, _, err = packages_service.CreatePackageOrAddFileToExisting( + _, err = packages_service.AddFileToExistingPackage( ctx, - &packages_service.PackageCreationInfo{ - PackageInfo: packages_service.PackageInfo{ - Owner: ctx.Package.Owner, - PackageType: packages_model.TypeHelm, - Name: metadata.Name, - Version: metadata.Version, - }, - SemverCompatible: true, - Creator: ctx.Doer, - Metadata: metadata, + &packages_service.PackageInfo{ + Owner: ctx.Package.Owner, + PackageType: packages_model.TypeHelm, + Name: metadata.Name, + Version: metadata.Version, }, &packages_service.PackageFileCreationInfo{ PackageFileInfo: packages_service.PackageFileInfo{ @@ -266,8 +262,8 @@ func UploadProvenanceFile(ctx *context.Context) { ) if err != nil { switch err { - case packages_model.ErrDuplicatePackageVersion: - apiError(ctx, http.StatusConflict, err) + case packages_model.ErrPackageNotExist: + apiError(ctx, http.StatusNotFound, err) case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize: apiError(ctx, http.StatusForbidden, err) default: diff --git a/tests/integration/api_packages_helm_test.go b/tests/integration/api_packages_helm_test.go index a548e66856..e08e126554 100644 --- a/tests/integration/api_packages_helm_test.go +++ b/tests/integration/api_packages_helm_test.go @@ -64,11 +64,7 @@ dependencies: provContent := `-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA512 -apiVersion: v2 -description: ` + packageDescription + ` -name: ` + packageName + ` -type: application -version: ` + packageVersion + ` +` + chartContent + ` ... files: @@ -86,6 +82,17 @@ qtgooNdohoyGSzR5oapd7fEvauRQswJxOA0m0V+u9/eyLR0+JcYB8Udi1prnWf8= url := fmt.Sprintf("/api/packages/%s/helm", user.Name) + t.Run("UploadProvFileWithoutChart", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + provURL := url + "/api/prov" + + // Attempt to upload provenance file without chart to back it. + req := NewRequestWithBody(t, "POST", provURL, bytes.NewReader([]byte(provContent))). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusNotFound) + }) + t.Run("Upload", func(t *testing.T) { defer tests.PrintCurrentTest(t)()