Repository: ossf/package-analysis
Pull request #739 (2023-06-02 14:17:23)

Save analyzed package
Closes https://github.com/ossf/package-analysis/issues/227

Same as #734, but conflicts are resolved.

This PR:
- adds `getArchiveURL` method for `crates.io`, `Packagist` and `RubyGems`
- changes the filename for archives downloaded from `crates.io` and `Packagist`, since their filenames don't contain info about the packages
- appends hash of file to the end of the filename
- uploads those analyzed packages

Examples:
```
chalk-5.2.0.tgz-9687d5d17a063e1b2aceaae5b83f8765eaeaf1c4bae8d74a21c1e9aeba1e4862
pipx-1.2.0.tar.gz-d1908041d24d525cafebeb177efb686133d719499cb55c54f596c95add579286
rand-0.8.5.tar.gz-34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404
ruby-macho-3.0.0.gem-a5b971f72ad0d9015afe5a49c46cc048a2ff581b2e56bf2fada9379af19fe069
```


maxfisher-g: Thank you for the PR! We're taking a look now
maxfisher-g: There are two larger issues which we might need to think about more, and may not solve in this PR:

1. When dynamic analysis and static analysis are run together, it seems like the package will be downloaded twice and possibly saved twice to the bucket. Is there any way you can think of to avoid this?
2. [Definitely out of scope for this PR] The downloaded archive should be the same one actually used for analysis, instead of it being downloaded separately after the analysis ends. This means we have to download it first, then copy it into the sandbox(es), and then run the analysis, and then upload it to the bucket. We don't have to do that in this PR, but when we merge this PR, I will create an issue for it. You are most welcome to help with that issue too if you like! It would be much appreciated :) 
dukecat0: Thanks for your reviews!

> When dynamic analysis and static analysis are run together, it seems like the package will be downloaded twice and possibly saved twice to the bucket. Is there any way you can think of to avoid this?

This is solved by introducing a new variable to indicate whether the package was saved already.

> We don't have to do that in this PR, but when we merge this PR, I will create an issue for it. You are most welcome to help with that issue too if you like! It would be much appreciated :)

I will be glad to work on this! :D
maxfisher-g: Nice work, almost there!

Please address the comment above, and then there is one more change I'd like to ask of you: 

https://github.com/ossf/package-analysis/pull/763 introduced a way to globally toggle features on and off. Could you please add an entry to internal/featureflags/featureflags.go for saving the analyzed package, and then add code to prevent saving if the feature is disabled? You can check that PR to see how it was done for the file writes case (in internal/strace/strace.go and internal/worker/save_data.go)
maxfisher-g: Hi @dukecat0, I recognise that there are a lot of things being merged to main at the moment and it could be annoying to have to keep up and fix merges, etc.

If you would like me to finish the remaining steps of this PR and merge it, I'm very happy to do so. But no worries if you would like to do it yourself too! Just let me know :) 
dukecat0: Hi @maxfisher-g, thanks! Please feel free to finish the remaining steps of this PR! :D 
It should only consist of a few lines of change to the code.

maxfisher-g: Sure, no worries :) 
dukecat0: Hi @maxfisher-g, I have some time now and can work on this again if you don't have time at the moment :)
maxfisher-g: > Hi @maxfisher-g, I have some time now and can work on this again if you don't have time at the moment :)

Sure, if you would like to it's all yours :) There's no rush on this btw, I was just offering in case you were tired of merging all the conflicts haha
dukecat0: Some new changes I've made:
- Moved `analyzedPackageSaved` to `ResultStores`
- Reset  `analyzedPackageSaved` to false in `handleMessage`
- Pass resultStores as a pointer to several functions
- Add an entry `AnalyzedPackage` to `featureflags`
- Remove the check for `p.archiveURL` since this method is now implemented for every ecosystem.
maxfisher-g: @dukecat0 thank you so much, this is looking great!
maxfisher-g: Oops, I clicked the approve button a bit early, but once the nits are resolved I think we're ready to merge! :D 
dukecat0: Should be ok now! :D
dukecat0: Btw, I think commands in `test/e2e/README.md` should be updated since some of them don't work.
maxfisher-g: Wonderful, thank you! Let's merge it!

> Btw, I think commands in `test/e2e/README.md` should be updated since some of them don't work.

Oh, which ones? Could you make an issue please?

Patch:
diff --git a/cmd/analyze/main.go b/cmd/analyze/main.go
index 4d0bd5e..58c0380 100644
--- a/cmd/analyze/main.go
+++ b/cmd/analyze/main.go
@@ -30,6 +30,7 @@ var (
 	dynamicUpload       = flag.String("upload", "", "bucket path for uploading dynamic analysis results")
 	staticUpload        = flag.String("upload-static", "", "bucket path for uploading static analysis results")
 	uploadFileWriteInfo = flag.String("upload-file-write-info", "", "bucket path for uploading information from file writes")
+	uploadAnalyzedPkg   = flag.String("upload-analyzed-pkg", "", "bucket path for uploading analyzed packages")
 	offline             = flag.Bool("offline", false, "disables sandbox network access")
 	customSandbox       = flag.String("sandbox-image", "", "override default dynamic analysis sandbox with custom image")
 	customAnalysisCmd   = flag.String("analysis-command", "", "override default dynamic analysis script path (use with custom sandbox image)")
@@ -66,6 +67,11 @@ func makeResultStores() worker.ResultStores {
 		rs.FileWrites = resultstore.New(bucket, resultstore.BasePath(path))
 	}
 
+	if *uploadAnalyzedPkg != "" {
+		bucket, path := parseBucketPath(*uploadAnalyzedPkg)
+		rs.AnalyzedPackage = resultstore.New(bucket, resultstore.BasePath(path))
+	}
+
 	return rs
 }
 
@@ -100,7 +106,7 @@ func makeSandboxOptions() []sandbox.Option {
 	return sbOpts
 }
 
-func dynamicAnalysis(pkg *pkgmanager.Pkg, resultStores worker.ResultStores) {
+func dynamicAnalysis(pkg *pkgmanager.Pkg, resultStores *worker.ResultStores) {
 	if !*offline {
 		sandbox.InitNetwork()
 	}
@@ -130,7 +136,7 @@ func dynamicAnalysis(pkg *pkgmanager.Pkg, resultStores worker.ResultStores) {
 	}
 }
 
-func staticAnalysis(pkg *pkgmanager.Pkg, resultStores worker.ResultStores) {
+func staticAnalysis(pkg *pkgmanager.Pkg, resultStores *worker.ResultStores) {
 	if !*offline {
 		sandbox.InitNetwork()
 	}
@@ -215,12 +221,12 @@ func main() {
 
 	if runMode[analysis.Static] {
 		log.Info("Starting static analysis")
-		staticAnalysis(pkg, resultStores)
+		staticAnalysis(pkg, &resultStores)
 	}
 
 	// dynamicAnalysis() currently panics on error, so it's last
 	if runMode[analysis.Dynamic] {
 		log.Info("Starting dynamic analysis")
-		dynamicAnalysis(pkg, resultStores)
+		dynamicAnalysis(pkg, &resultStores)
 	}
 }
diff --git a/cmd/worker/main.go b/cmd/worker/main.go
index 4db5cfb..0630111 100644
--- a/cmd/worker/main.go
+++ b/cmd/worker/main.go
@@ -40,6 +40,7 @@ type resultBucketPaths struct {
 	dynamicAnalysis string
 	staticAnalysis  string
 	fileWrites      string
+	analyzedPkg     string
 }
 
 type sandboxImageSpec struct {
@@ -91,7 +92,7 @@ func makeResultStores(dest resultBucketPaths) worker.ResultStores {
 	return resultStores
 }
 
-func handleMessage(ctx context.Context, msg *pubsub.Message, packagesBucket *blob.Bucket, resultStores worker.ResultStores, imageSpec sandboxImageSpec, notificationTopic *pubsub.Topic) error {
+func handleMessage(ctx context.Context, msg *pubsub.Message, packagesBucket *blob.Bucket, resultStores *worker.ResultStores, imageSpec sandboxImageSpec, notificationTopic *pubsub.Topic) error {
 	name := msg.Metadata["name"]
 	if name == "" {
 		log.Warn("name is empty")
@@ -177,6 +178,8 @@ func handleMessage(ctx context.Context, msg *pubsub.Message, packagesBucket *blo
 		return err
 	}
 
+	resultStores.AnalyzedPackageSaved = false
+
 	if notificationTopic != nil {
 		err := notification.PublishAnalysisCompletion(ctx, notificationTopic, name, version, ecosystem)
 		if err != nil {
@@ -188,7 +191,7 @@ func handleMessage(ctx context.Context, msg *pubsub.Message, packagesBucket *blo
 	return nil
 }
 
-func messageLoop(ctx context.Context, subURL, packagesBucket, notificationTopicURL string, imageSpec sandboxImageSpec, resultsBuckets worker.ResultStores) error {
+func messageLoop(ctx context.Context, subURL, packagesBucket, notificationTopicURL string, imageSpec sandboxImageSpec, resultsBuckets *worker.ResultStores) error {
 	sub, err := pubsub.OpenSubscription(ctx, subURL)
 	if err != nil {
 		return err
@@ -249,6 +252,7 @@ func main() {
 		dynamicAnalysis: os.Getenv("OSSF_MALWARE_ANALYSIS_RESULTS"),
 		staticAnalysis:  os.Getenv("OSSF_MALWARE_STATIC_ANALYSIS_RESULTS"),
 		fileWrites:      os.Getenv("OSSF_MALWARE_ANALYSIS_FILE_WRITE_RESULTS"),
+		analyzedPkg:     os.Getenv("OSSF_MALWARE_ANALYZED_PACKAGES"),
 	}
 	resultStores := makeResultStores(resultsBuckets)
 
@@ -275,13 +279,14 @@ func main() {
 		zap.String("results_bucket", resultsBuckets.dynamicAnalysis),
 		zap.String("static_results_bucket", resultsBuckets.staticAnalysis),
 		zap.String("file_write_results_bucket", resultsBuckets.fileWrites),
+		zap.String("analyzed_packages_bucket", resultsBuckets.analyzedPkg),
 		zap.String("image_tag", imageSpec.tag),
 		zap.Bool("image_nopull", imageSpec.noPull),
 		zap.String("topic_notification", notificationTopicURL),
 		zap.Reflect("feature_flags", featureflags.State()),
 	).Info("Starting worker")
 
-	err := messageLoop(ctx, subURL, packagesBucket, notificationTopicURL, imageSpec, resultStores)
+	err := messageLoop(ctx, subURL, packagesBucket, notificationTopicURL, imageSpec, &resultStores)
 	if err != nil {
 		log.Error("Error encountered", "error", err)
 	}
diff --git a/infra/worker/workers-set.yaml b/infra/worker/workers-set.yaml
index fb8dc59..1c70ece 100644
--- a/infra/worker/workers-set.yaml
+++ b/infra/worker/workers-set.yaml
@@ -28,6 +28,8 @@ spec:
           value: gs://ossf-malware-static-analysis-results
         - name: OSSF_MALWARE_ANALYSIS_FILE_WRITE_RESULTS
           value: gs://ossf-malware-analysis-file-write-results
+        # - name: OSSF_MALWARE_ANALYZED_PACKAGES
+          # value: gs://ossf-malware-analysis-analyzed-packages
         - name: LOGGER_ENV
           value: prod
         - name: OSSF_MALWARE_ANALYSIS_PACKAGES
diff --git a/internal/featureflags/features.go b/internal/featureflags/features.go
index 0480a42..8e93b8b 100644
--- a/internal/featureflags/features.go
+++ b/internal/featureflags/features.go
@@ -4,4 +4,6 @@ var (
 	// WriteFileContents will store the contents of write observed from strace
 	// data during dynamic analysis.
 	WriteFileContents = new("WriteFileContents", true)
+	// SaveAnalyzedPackages will save analyzed package.
+	SaveAnalyzedPackages = new("SaveAnalyzedPackages", false)
 )
diff --git a/internal/pkgmanager/crates.io.go b/internal/pkgmanager/crates.io.go
index f0a2179..8431728 100644
--- a/internal/pkgmanager/crates.io.go
+++ b/internal/pkgmanager/crates.io.go
@@ -4,6 +4,7 @@ import (
 	"encoding/json"
 	"fmt"
 	"net/http"
+	"strings"
 
 	"github.com/ossf/package-analysis/pkg/api/pkgecosystem"
 )
@@ -31,7 +32,24 @@ func getCratesLatest(pkg string) (string, error) {
 	return details.Versions[0].Num, nil
 }
 
+func getCratesArchiveURL(pkgName, version string) (string, error) {
+	pkgURL := fmt.Sprintf("https://crates.io/api/v1/crates/%s/%s/download", pkgName, version)
+	resp, err := http.Get(pkgURL)
+	if err != nil {
+		return "", err
+	}
+	defer resp.Body.Close()
+
+	return pkgURL, nil
+}
+
+func getCratesArchiveFilename(pkgName, version, _ string) string {
+	return strings.Join([]string{pkgName, "-", version, ".tar.gz"}, "")
+}
+
 var cratesPkgManager = PkgManager{
-	ecosystem:     pkgecosystem.CratesIO,
-	latestVersion: getCratesLatest,
+	ecosystem:       pkgecosystem.CratesIO,
+	latestVersion:   getCratesLatest,
+	archiveURL:      getCratesArchiveURL,
+	archiveFilename: getCratesArchiveFilename,
 }
diff --git a/internal/pkgmanager/download.go b/internal/pkgmanager/download.go
index 5ded562..255cea8 100644
--- a/internal/pkgmanager/download.go
+++ b/internal/pkgmanager/download.go
@@ -1,32 +1,47 @@
 package pkgmanager
 
 import (
-	"errors"
 	"fmt"
 	"io"
 	"net/http"
 	"os"
-	"path"
 	"path/filepath"
+	"strings"
+
+	"github.com/ossf/package-analysis/internal/utils"
 )
 
 /*
-downloadToDirectory downloads a file from the given URL to the given directory. The name of the
-local file is obtained from the last element of the URL path when split on the '/' character.
+downloadToDirectory downloads a file from the given URL to the given directory.
 On successful download, the full path to the downloaded file is returned.
-*/
-func downloadToDirectory(dir string, url string) (string, error) {
-	if url == "" {
-		return "", errors.New("url is empty")
-	}
 
-	fileName := path.Base(url)
+fileName argument is required with no default value, and the hash of the file
+is appended to the given filename. If an error occurs during hashing, then the
+original filename is used.
+*/
+func downloadToDirectory(dir string, url string, fileName string) (string, error) {
 	filePath := filepath.Join(dir, fileName)
 
 	if err := downloadToPath(filePath, url); err != nil {
 		return "", err
 	}
-	return filePath, nil
+
+	hash, err := utils.HashFile(filePath)
+	if err != nil {
+		// Error is ignored and the path with the original filename is returned
+		// We treat hashing as 'best-effort' rather than a strictly necessary part of the function
+		return filePath, nil
+	}
+
+	hashedFilePath := strings.Join([]string{filePath, hash[7:]}, "-")
+
+	err = os.Rename(filePath, hashedFilePath)
+	if err != nil {
+		// See above comment
+		return filePath, nil
+	}
+
+	return hashedFilePath, nil
 }
 
 /*
diff --git a/internal/pkgmanager/ecosystem.go b/internal/pkgmanager/ecosystem.go
index 8291994..4d110c3 100644
--- a/internal/pkgmanager/ecosystem.go
+++ b/internal/pkgmanager/ecosystem.go
@@ -2,6 +2,7 @@ package pkgmanager
 
 import (
 	"fmt"
+	"path"
 	"strings"
 
 	"github.com/ossf/package-analysis/pkg/api/pkgecosystem"
@@ -9,10 +10,11 @@ import (
 
 // PkgManager represents how packages from a common ecosystem are accessed.
 type PkgManager struct {
-	ecosystem      pkgecosystem.Ecosystem
-	latestVersion  func(string) (string, error)
-	archiveURL     func(string, string) (string, error)
-	extractArchive func(string, string) error
+	ecosystem       pkgecosystem.Ecosystem
+	latestVersion   func(string) (string, error)
+	archiveURL      func(string, string) (string, error)
+	archiveFilename func(string, string, string) string
+	extractArchive  func(string, string) error
 }
 
 var (
@@ -25,6 +27,10 @@ var (
 	}
 )
 
+func DefaultArchiveFilename(pkgName, version, downloadURL string) string {
+	return path.Base(downloadURL)
+}
+
 func Manager(e pkgecosystem.Ecosystem) *PkgManager {
 	return supportedPkgManagers[e]
 }
@@ -73,10 +79,6 @@ func (p *PkgManager) DownloadArchive(name, version, directory string) (string, e
 		return "", fmt.Errorf("no directory specified")
 	}
 
-	if p.archiveURL == nil {
-		return "", fmt.Errorf("not yet implemented for %s", p.Ecosystem())
-	}
-
 	downloadURL, err := p.archiveURL(name, version)
 	if err != nil {
 		return "", err
@@ -85,7 +87,10 @@ func (p *PkgManager) DownloadArchive(name, version, directory string) (string, e
 		return "", fmt.Errorf("no url found for package %s, version %s", name, version)
 	}
 
-	archivePath, err := downloadToDirectory(directory, downloadURL)
+	fileName := p.archiveFilename(name, version, downloadURL)
+
+	archivePath, err := downloadToDirectory(directory, downloadURL, fileName)
+
 	if err != nil {
 		return "", err
 	}
diff --git a/internal/pkgmanager/npm.go b/internal/pkgmanager/npm.go
index 6d0e39b..495c88e 100644
--- a/internal/pkgmanager/npm.go
+++ b/internal/pkgmanager/npm.go
@@ -71,8 +71,9 @@ func getNPMArchiveURL(pkgName, version string) (string, error) {
 }
 
 var npmPkgManager = PkgManager{
-	ecosystem:      pkgecosystem.NPM,
-	latestVersion:  getNPMLatest,
-	archiveURL:     getNPMArchiveURL,
-	extractArchive: utils.ExtractTarGzFile,
+	ecosystem:       pkgecosystem.NPM,
+	latestVersion:   getNPMLatest,
+	archiveURL:      getNPMArchiveURL,
+	archiveFilename: DefaultArchiveFilename,
+	extractArchive:  utils.ExtractTarGzFile,
 }
diff --git a/internal/pkgmanager/packagist.go b/internal/pkgmanager/packagist.go
index 08941cb..62c83c7 100644
--- a/internal/pkgmanager/packagist.go
+++ b/internal/pkgmanager/packagist.go
@@ -4,6 +4,7 @@ import (
 	"encoding/json"
 	"fmt"
 	"net/http"
+	"strings"
 	"time"
 
 	"github.com/ossf/package-analysis/pkg/api/pkgecosystem"
@@ -16,6 +17,12 @@ type packagistJSON struct {
 		License           []string  `json:"license,omitempty"`
 		Time              time.Time `json:"time"`
 		Name              string    `json:"name,omitempty"`
+		Dist              struct {
+			URL       string `json:"url"`
+			Type      string `json:"type"`
+			Shasum    string `json:"shasum,omitempty"`
+			Reference string `json:"reference"`
+		} `json:"dist"`
 	} `json:"packages"`
 }
 
@@ -48,7 +55,39 @@ func getPackagistLatest(pkg string) (string, error) {
 	return latestVersion, nil
 }
 
+func getPackagistArchiveURL(pkgName, version string) (string, error) {
+	resp, err := http.Get(fmt.Sprintf("https://repo.packagist.org/p2/%s.json", pkgName))
+	if err != nil {
+		return "", err
+	}
+	defer resp.Body.Close()
+
+	decoder := json.NewDecoder(resp.Body)
+	var details packagistJSON
+	err = decoder.Decode(&details)
+	if err != nil {
+		return "", err
+	}
+
+	for _, versions := range details.Packages {
+		for _, v := range versions {
+			if v.Version == version {
+				return v.Dist.URL, nil
+			}
+		}
+	}
+
+	return "", nil
+}
+
+func getPackagistArchiveFilename(pkgName, version, _ string) string {
+	pkg := strings.Split(pkgName, "/")
+	return strings.Join([]string{pkg[0], "-", pkg[1], "-", version, ".zip"}, "")
+}
+
 var packagistPkgManager = PkgManager{
-	ecosystem:     pkgecosystem.Packagist,
-	latestVersion: getPackagistLatest,
+	ecosystem:       pkgecosystem.Packagist,
+	latestVersion:   getPackagistLatest,
+	archiveURL:      getPackagistArchiveURL,
+	archiveFilename: getPackagistArchiveFilename,
 }
diff --git a/internal/pkgmanager/pypi.go b/internal/pkgmanager/pypi.go
index 8a20f51..b77c37a 100644
--- a/internal/pkgmanager/pypi.go
+++ b/internal/pkgmanager/pypi.go
@@ -78,8 +78,9 @@ func getPyPIArchiveURL(pkgName, version string) (string, error) {
 }
 
 var pypiPkgManager = PkgManager{
-	ecosystem:      pkgecosystem.PyPI,
-	latestVersion:  getPyPILatest,
-	archiveURL:     getPyPIArchiveURL,
-	extractArchive: utils.ExtractTarGzFile,
+	ecosystem:       pkgecosystem.PyPI,
+	latestVersion:   getPyPILatest,
+	archiveURL:      getPyPIArchiveURL,
+	archiveFilename: DefaultArchiveFilename,
+	extractArchive:  utils.ExtractTarGzFile,
 }
diff --git a/internal/pkgmanager/rubygems.go b/internal/pkgmanager/rubygems.go
index b158eda..daec609 100644
--- a/internal/pkgmanager/rubygems.go
+++ b/internal/pkgmanager/rubygems.go
@@ -29,7 +29,20 @@ func getRubyGemsLatest(pkg string) (string, error) {
 	return details.Version, nil
 }
 
+func getRubyGemsArchiveURL(pkgName, version string) (string, error) {
+	pkgURL := fmt.Sprintf("https://rubygems.org/gems/%v-%v.gem", pkgName, version)
+	resp, err := http.Get(pkgURL)
+	if err != nil {
+		return "", err
+	}
+	defer resp.Body.Close()
+
+	return pkgURL, nil
+}
+
 var rubygemsPkgManager = PkgManager{
-	ecosystem:     pkgecosystem.RubyGems,
-	latestVersion: getRubyGemsLatest,
+	ecosystem:       pkgecosystem.RubyGems,
+	latestVersion:   getRubyGemsLatest,
+	archiveURL:      getRubyGemsArchiveURL,
+	archiveFilename: DefaultArchiveFilename,
 }
diff --git a/internal/resultstore/resultstore.go b/internal/resultstore/resultstore.go
index 3f87f13..06ea6b5 100644
--- a/internal/resultstore/resultstore.go
+++ b/internal/resultstore/resultstore.go
@@ -16,6 +16,7 @@ import (
 
 	"github.com/ossf/package-analysis/internal/log"
 	"github.com/ossf/package-analysis/internal/utils"
+	"github.com/ossf/package-analysis/internal/pkgmanager"
 )
 
 type ResultStore struct {
@@ -111,6 +112,33 @@ func (rs *ResultStore) SaveTempFilesToZip(ctx context.Context, p Pkg, fileName s
 	return nil
 }
 
+func (rs *ResultStore) SaveAnalyzedPackage(ctx context.Context, manager *pkgmanager.PkgManager, pkg Pkg) error {
+	bkt, err := blob.OpenBucket(ctx, rs.bucket)
+	if err != nil {
+		return err
+	}
+	defer bkt.Close()
+
+	uploadPath := rs.generatePath(pkg)
+	log.Info("Uploading analyzed package",
+		"bucket", rs.bucket,
+		"path", uploadPath)
+
+	w, err := bkt.NewWriter(ctx, uploadPath, nil)
+	if err != nil {
+		return err
+	}
+	defer w.Close()
+
+	_, err = manager.DownloadArchive(pkg.Name(), pkg.Version(), uploadPath)
+
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
 // SaveWithFilename saves results to the bucket with the given filename
 func (rs *ResultStore) SaveWithFilename(ctx context.Context, p Pkg, filename string, analysis any) error {
 	if filename == "" {
diff --git a/internal/worker/save_data.go b/internal/worker/save_data.go
index e711e61..ee29fe2 100644
--- a/internal/worker/save_data.go
+++ b/internal/worker/save_data.go
@@ -15,15 +15,17 @@ import (
 // ResultStores holds ResultStore instances for saving each kind of analysis data.
 // They can be nil, in which case calling the associated Upload function here is a no-op
 type ResultStores struct {
-	DynamicAnalysis *resultstore.ResultStore
-	StaticAnalysis  *resultstore.ResultStore
-	FileWrites      *resultstore.ResultStore
+	DynamicAnalysis      *resultstore.ResultStore
+	StaticAnalysis       *resultstore.ResultStore
+	FileWrites           *resultstore.ResultStore
+	AnalyzedPackage      *resultstore.ResultStore
+	AnalyzedPackageSaved bool
 }
 
 // SaveDynamicAnalysisData saves the data from dynamic analysis to the corresponding bucket in the ResultStores.
 // This includes strace data, execution log, and file writes (in that order).
 // If any operation fails, the rest are aborted
-func SaveDynamicAnalysisData(ctx context.Context, pkg *pkgmanager.Pkg, dest ResultStores, data analysisrun.DynamicAnalysisResults) error {
+func SaveDynamicAnalysisData(ctx context.Context, pkg *pkgmanager.Pkg, dest *ResultStores, data analysisrun.DynamicAnalysisResults) error {
 	if dest.DynamicAnalysis == nil {
 		// nothing to do
 		return nil
@@ -42,12 +44,23 @@ func SaveDynamicAnalysisData(ctx context.Context, pkg *pkgmanager.Pkg, dest Resu
 	if err := SaveFileWritesData(ctx, pkg, dest, data); err != nil {
 		return err
 	}
+	if !featureflags.SaveAnalyzedPackages.Enabled() {
+		// Abort saving analyzed packages when feature is disabled.
+		return nil
+	}
+	if !dest.AnalyzedPackageSaved {
+		if err := SaveAnalyzedPackage(ctx, pkg, dest); err != nil {
+			return err
+		} else {
+			dest.AnalyzedPackageSaved = true
+		}
+	}
 
 	return nil
 }
 
 // saveExecutionLog saves the execution log to the dynamic analysis resultstore, only if it is nonempty
-func saveExecutionLog(ctx context.Context, pkg *pkgmanager.Pkg, dest ResultStores, data analysisrun.DynamicAnalysisResults) error {
+func saveExecutionLog(ctx context.Context, pkg *pkgmanager.Pkg, dest *ResultStores, data analysisrun.DynamicAnalysisResults) error {
 	if dest.DynamicAnalysis == nil || len(data.ExecutionLog) == 0 {
 		// nothing to do
 		return nil
@@ -62,7 +75,7 @@ func saveExecutionLog(ctx context.Context, pkg *pkgmanager.Pkg, dest ResultStore
 }
 
 // SaveStaticAnalysisData saves the data from static analysis to the corresponding bucket in the ResultStores
-func SaveStaticAnalysisData(ctx context.Context, pkg *pkgmanager.Pkg, dest ResultStores, data analysisrun.StaticAnalysisResults) error {
+func SaveStaticAnalysisData(ctx context.Context, pkg *pkgmanager.Pkg, dest *ResultStores, data analysisrun.StaticAnalysisResults) error {
 	if dest.StaticAnalysis == nil {
 		return nil
 	}
@@ -71,11 +84,24 @@ func SaveStaticAnalysisData(ctx context.Context, pkg *pkgmanager.Pkg, dest Resul
 		return fmt.Errorf("failed to save static analysis results to %s: %w", dest.StaticAnalysis, err)
 	}
 
+	if !featureflags.SaveAnalyzedPackages.Enabled() {
+		// Abort saving analyzed packages when feature is disabled.
+		return nil
+	}
+
+	if !dest.AnalyzedPackageSaved {
+		if err := SaveAnalyzedPackage(ctx, pkg, dest); err != nil {
+			return err
+		} else {
+			dest.AnalyzedPackageSaved = true
+		}
+	}
+
 	return nil
 }
 
 // SaveFileWritesData saves file writes data from dynamic analysis to the file writes bucket in the ResultStores
-func SaveFileWritesData(ctx context.Context, pkg *pkgmanager.Pkg, dest ResultStores, data analysisrun.DynamicAnalysisResults) error {
+func SaveFileWritesData(ctx context.Context, pkg *pkgmanager.Pkg, dest *ResultStores, data analysisrun.DynamicAnalysisResults) error {
 	if dest.FileWrites == nil {
 		return nil
 	}
@@ -95,3 +121,16 @@ func SaveFileWritesData(ctx context.Context, pkg *pkgmanager.Pkg, dest ResultSto
 
 	return nil
 }
+
+// SaveAnalyzedPackage saves the analyzed package from static and dynamic analysis to the analyzed packages bucket in the ResultStores
+func SaveAnalyzedPackage(ctx context.Context, pkg *pkgmanager.Pkg, dest *ResultStores) error {
+	if pkg.IsLocal() {
+		return nil
+	}
+
+	if err := dest.AnalyzedPackage.SaveAnalyzedPackage(ctx, pkg.Manager(), pkg); err != nil {
+		return fmt.Errorf("failed to upload analyzed package to %s: %w", dest.AnalyzedPackage, err)
+	}
+
+	return nil
+}
diff --git a/scripts/run_analysis.sh b/scripts/run_analysis.sh
index 23d3803..bda9aa1 100755
--- a/scripts/run_analysis.sh
+++ b/scripts/run_analysis.sh
@@ -3,6 +3,7 @@
 RESULTS_DIR=${RESULTS_DIR:-"/tmp/results"}
 STATIC_RESULTS_DIR=${STATIC_RESULTS_DIR:-"/tmp/staticResults"}
 FILE_WRITE_RESULTS_DIR=${FILE_WRITE_RESULTS_DIR:-"/tmp/writeResults"}
+ANALYZED_PACKAGES_DIR=${ANALYZED_PACKAGES_DIR:-"/tmp/analyzedPackages"}
 LOGS_DIR=${LOGS_DIR:-"/tmp/dockertmp"}
 
 # for pretty printing
@@ -42,6 +43,7 @@ function print_results_dirs {
 	echo "Dynamic analysis results: $RESULTS_DIR"
 	echo "Static analysis results:  $STATIC_RESULTS_DIR"
 	echo "File write results:       $FILE_WRITE_RESULTS_DIR"
+	echo "Analyzed package saved:   $ANALYZED_PACKAGES_DIR"
 	echo "Debug logs:               $LOGS_DIR"
 }
 
@@ -148,11 +150,11 @@ elif is_mount_type overlay /var/lib; then
 fi
 
 
-DOCKER_MOUNTS=("-v" "$CONTAINER_MOUNT_DIR:/var/lib/containers" "-v" "$RESULTS_DIR:/results" "-v" "$STATIC_RESULTS_DIR:/staticResults" "-v" "$FILE_WRITE_RESULTS_DIR:/writeResults" "-v" "$LOGS_DIR:/tmp")
+DOCKER_MOUNTS=("-v" "$CONTAINER_MOUNT_DIR:/var/lib/containers" "-v" "$RESULTS_DIR:/results" "-v" "$STATIC_RESULTS_DIR:/staticResults" "-v" "$FILE_WRITE_RESULTS_DIR:/writeResults" "-v" "$LOGS_DIR:/tmp" "-v" "$ANALYZED_PACKAGES_DIR:/analyzedPackages")
 
 ANALYSIS_IMAGE=gcr.io/ossf-malware-analysis/analysis
 
-ANALYSIS_ARGS=("analyze" "-upload" "file:///results/" "-upload-file-write-info" "file:///writeResults/" "-upload-static" "file:///staticResults/")
+ANALYSIS_ARGS=("analyze" "-upload" "file:///results/" "-upload-file-write-info" "file:///writeResults/" "-upload-static" "file:///staticResults/" "-upload-analyzed-pkg" "file:///analyzedPackages/")
 
 # Add the remaining command line arguments
 ANALYSIS_ARGS=("${ANALYSIS_ARGS[@]}" "${args[@]}")
@@ -218,6 +220,7 @@ sleep 1 # Allow time to read info above before executing
 mkdir -p "$RESULTS_DIR"
 mkdir -p "$STATIC_RESULTS_DIR"
 mkdir -p "$FILE_WRITE_RESULTS_DIR"
+mkdir -p "$ANALYZED_PACKAGES_DIR"
 mkdir -p "$LOGS_DIR"
 
 docker "${DOCKER_OPTS[@]}" "${DOCKER_MOUNTS[@]}" "$ANALYSIS_IMAGE" "${ANALYSIS_ARGS[@]}"
@@ -241,6 +244,7 @@ echo $LINE
 		rmdir --ignore-fail-on-non-empty "$RESULTS_DIR"
 		rmdir --ignore-fail-on-non-empty "$STATIC_RESULTS_DIR"
 		rmdir --ignore-fail-on-non-empty "$FILE_WRITE_RESULTS_DIR"
+		rmdir --ignore-fail-on-non-empty "$ANALYZED_PACKAGES_DIR"
 		rmdir --ignore-fail-on-non-empty "$LOGS_DIR"
 	fi
 


Test patch:
diff --git a/configs/e2e/docker-compose.yml b/configs/e2e/docker-compose.yml
index 8e8d3ac..6fa328e 100644
--- a/configs/e2e/docker-compose.yml
+++ b/configs/e2e/docker-compose.yml
@@ -47,7 +47,7 @@ services:
       MINIO_ROOT_PASSWORD: minio123
       MINIO_REGION_NAME: dummy_region
     entrypoint: sh
-    command: -c 'mkdir -p /data/package-analysis{,-static,-file-writes} && /usr/bin/minio server /data'
+    command: -c 'mkdir -p /data/package-analysis{,-static,-file-writes,-analyzed-packages} && /usr/bin/minio server /data'
     healthcheck:
       test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
       interval: 30s
@@ -67,6 +67,7 @@ services:
       OSSF_MALWARE_ANALYSIS_RESULTS: s3://package-analysis?endpoint=minio:9000&disableSSL=true&s3ForcePathStyle=true
       OSSF_MALWARE_STATIC_ANALYSIS_RESULTS: s3://package-analysis-static?endpoint=minio:9000&disableSSL=true&s3ForcePathStyle=true
       OSSF_MALWARE_ANALYSIS_FILE_WRITE_RESULTS: s3://package-analysis-file-writes?endpoint=minio:9000&disableSSL=true&s3ForcePathStyle=true
+      OSSF_MALWARE_ANALYZED_PACKAGES: s3://package-analysis-analyzed-packages?endpoint=minio:9000&disableSSL=true&s3ForcePathStyle=true
       OSSF_MALWARE_ANALYSIS_ENABLE_PROFILER: "true"
       KAFKA_BROKERS: kafka:9092
       AWS_ACCESS_KEY_ID: minio
diff --git a/internal/pkgmanager/download_test.go b/internal/pkgmanager/download_test.go
index fa6aafb..cb70aa5 100644
--- a/internal/pkgmanager/download_test.go
+++ b/internal/pkgmanager/download_test.go
@@ -1,6 +1,7 @@
 package pkgmanager
 
 import (
+	"fmt"
 	"os"
 	"testing"
 
@@ -73,13 +74,31 @@ func TestDownloadPyPIArchive(t *testing.T) {
 	testDownload(t, tests, Manager(pkgecosystem.PyPI))
 }
 
+func TestDownloadCratesArchive(t *testing.T) {
+	tests := []downloadTestSpec{
+		{"pkgname=rand version=(valid)", "rand", "0.8.5", false},
+		{"pkgname=rand version=(invalid)", "rand", "0.8.55", true},
+		{"pkgname=(invalid)", "fr(2t5j923)", "123", true},
+	}
+
+	testDownload(t, tests, Manager(pkgecosystem.CratesIO))
+}
+
 func TestDownloadToDirectory(t *testing.T) {
 	tmpDir, err := os.MkdirTemp("", "package-analysis-test-dl")
 	if err != nil {
 		t.Fatalf("Could not create temp dir for downloads: %v", err)
 	}
 
-	testURL := "https://google.com/robots.txt"
+	testPkg := "black"
+	version := "23.3.0"
+	checksum := "1c7b8d606e728a41ea1ccbd7264677e494e87cf630e399262ced92d4a8dac940"
+	fileName := fmt.Sprintf("%s-%s.tar.gz-%s", testPkg, version, checksum)
+
+	testURL, err := getPyPIArchiveURL(testPkg, version)
+	if err != nil {
+		t.Fatalf("Could not get PyPI archive url")
+	}
 
 	tests := []struct {
 		name     string
@@ -88,13 +107,13 @@ func TestDownloadToDirectory(t *testing.T) {
 		filename string
 		wantErr  bool
 	}{
-		{testURL + " (plain)", testURL, tmpDir, "robots.txt", false},
+		{testURL + " (plain)", testURL, tmpDir, fileName, false},
 		{testURL + "123 (invalid URL)", testURL + "123", tmpDir, testURL + "123", true},
-		{testURL + " (invalid dir)", testURL, "/tmp/does/not/exist", "robots.txt", true},
+		{testURL + " (invalid dir)", testURL, "/tmp/does/not/exist", fileName, true},
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			downloadedFile, err := downloadToDirectory(tt.dir, tt.url)
+			downloadedFile, err := downloadToDirectory(tt.dir, tt.url, DefaultArchiveFilename(testPkg, version, testURL))
 			if (err != nil) != tt.wantErr {
 				t.Errorf("Want error: %v; got error: %v", tt.wantErr, err)
 				return