diff --git a/modules/packages/helm/metadata.go b/modules/packages/helm/metadata.go index 723c3583ff..08231cf465 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 3de53929e9..7d6ceb275c 100644 --- a/routers/api/packages/api.go +++ b/routers/api/packages/api.go @@ -357,6 +357,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 c0fc032e15..8b732d8547 100644 --- a/routers/api/packages/helm/helm.go +++ b/routers/api/packages/helm/helm.go @@ -209,6 +209,76 @@ 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 { + 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.AddFileToExistingPackage( + ctx, + &packages_service.PackageInfo{ + Owner: ctx.Package.Owner, + PackageType: packages_model.TypeHelm, + Name: metadata.Name, + Version: metadata.Version, + }, + &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.ErrPackageNotExist: + apiError(ctx, http.StatusNotFound, 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 f66676b81d..add2cf5f90 100644 --- a/tests/integration/api_packages_helm_test.go +++ b/tests/integration/api_packages_helm_test.go @@ -60,8 +60,39 @@ dependencies: zw.Close() content := buf.Bytes() + // The signature is invalid, but the repository isn't verifying it. + provContent := `-----BEGIN PGP SIGNED MESSAGE----- +Hash: SHA512 + +` + chartContent + ` + +... +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("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)() @@ -95,6 +126,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) {