Repository: project-copacetic/copacetic (go)

Issue:
[REQ] Multi-Arch - Support update all features
### What kind of request is this?

None

### What is your request or suggestion?

If report-dir is not provided with multi arch, copa should patch the architectures that exist. Add a --platform flag to be able to differentiate.

### Are you willing to submit PRs to contribute to this feature request?

- [ ] Yes, I am willing to implement it.

Pull request:
feat: support update all for multi platform patching
<!--
Please include the type of change in the PR title as "<type>: <summary>"
Valid <type> include: build, ci, docs, feat, fix, perf, refactor, test
See https://github.com/project-copacetic/copacetic/blob/master/CONTRIBUTING.md#pull-requests) for guidelines.
-->

- Checks if manifest list or single arch image when no report is provided
- if no reportDir provided and is multi platform image, by default patches all platforms unless `--platforms` flag is set to filter which ones to patch

Describe the changes in this pull request using active verbs such as _Add_, _Remove_, _Replace_ ...

Closes #1071


Patch:
diff --git a/pkg/buildkit/buildkit.go b/pkg/buildkit/buildkit.go
index 8700dcb4..2e7c7602 100644
--- a/pkg/buildkit/buildkit.go
+++ b/pkg/buildkit/buildkit.go
@@ -133,7 +133,8 @@ func DiscoverPlatformsFromReport(reportDir, scanner string) ([]types.PatchPlatfo
 				Architecture: report.Metadata.Config.Arch,
 				Variant:      report.Metadata.Config.Variant,
 			},
-			ReportFile: filePath,
+			ReportFile:     filePath,
+			ShouldPreserve: false, // This platform has a report, so it should be patched
 		}
 
 		if platform.Architecture == arm64 && platform.Variant == "v8" {
@@ -198,6 +199,8 @@ func DiscoverPlatformsFromReference(manifestRef string) ([]types.PatchPlatform,
 					OSVersion:    m.Platform.OSVersion,
 					OSFeatures:   m.Platform.OSFeatures,
 				},
+				ReportFile:     "",    // No report file for platforms discovered from reference
+				ShouldPreserve: false, // Default to false, will be set appropriately later
 			}
 			if m.Platform.Architecture == arm64 && m.Platform.Variant == "v8" {
 				// some scanners may not add v8 to arm64 reports, so we
@@ -256,11 +259,13 @@ func DiscoverPlatforms(manifestRef, reportDir, scanner string) ([]types.PatchPla
 			if rp, ok := reportSet[PlatformKey(pl.Platform)]; ok {
 				// Platform has a report - will be patched
 				pl.ReportFile = rp
+				pl.ShouldPreserve = false
 				platforms = append(platforms, pl)
 			} else {
 				// Platform has no report - preserve original without patching
 				log.Debugf("No report found for platform %s, preserving original", PlatformKey(pl.Platform))
 				pl.ReportFile = ""
+				pl.ShouldPreserve = true
 				platforms = append(platforms, pl)
 			}
 		}
diff --git a/pkg/patch/cmd.go b/pkg/patch/cmd.go
index e3a3ee7e..ce7e1d1c 100644
--- a/pkg/patch/cmd.go
+++ b/pkg/patch/cmd.go
@@ -28,6 +28,7 @@ type patchArgs struct {
 	output        string
 	bkOpts        buildkit.Opts
 	push          bool
+	platform      []string
 	loader        string
 }
 
@@ -57,6 +58,7 @@ func NewPatchCmd() *cobra.Command {
 				ua.loader,
 				ua.ignoreError,
 				ua.push,
+				ua.platform,
 				bkopts)
 		},
 	}
@@ -76,6 +78,10 @@ func NewPatchCmd() *cobra.Command {
 	flags.StringVarP(&ua.format, "format", "f", "openvex", "Output format, defaults to 'openvex'")
 	flags.StringVarP(&ua.output, "output", "o", "", "Output file path")
 	flags.BoolVarP(&ua.push, "push", "p", false, "Push patched image to destination registry")
+	flags.StringSliceVar(&ua.platform, "platform", nil,
+		"Target platform(s) for multi-arch images when no report directory is provided (e.g., linux/amd64,linux/arm64). "+
+			"Valid platforms: linux/amd64, linux/arm64, linux/riscv64, linux/ppc64le, linux/s390x, linux/386, linux/arm/v7, linux/arm/v6. "+
+			"If platform flag is used, only specified platforms are patched and the rest are preserved. If not specified, all platforms present in the image are patched.")
 	flags.StringVarP(&ua.loader, "loader", "l", "", "Loader to use for loading images. Options: 'docker', 'podman', or empty for auto-detection based on buildkit address")
 
 	if err := patchCmd.MarkFlagRequired("image"); err != nil {
diff --git a/pkg/patch/patch.go b/pkg/patch/patch.go
index 577f3270..bbc7e6d1 100644
--- a/pkg/patch/patch.go
+++ b/pkg/patch/patch.go
@@ -247,6 +247,7 @@ func Patch(
 	ctx context.Context, timeout time.Duration,
 	image, reportPath, patchedTag, suffix, workingFolder, scanner, format, output, loader string,
 	ignoreError, push bool,
+	targetPlatforms []string,
 	bkOpts buildkit.Opts,
 ) error {
 	timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
@@ -254,7 +255,7 @@ func Patch(
 
 	ch := make(chan error)
 	go func() {
-		ch <- patchWithContext(timeoutCtx, ch, image, reportPath, patchedTag, suffix, workingFolder, scanner, format, output, loader, ignoreError, push, bkOpts)
+		ch <- patchWithContext(timeoutCtx, ch, image, reportPath, patchedTag, suffix, workingFolder, scanner, format, output, loader, ignoreError, push, targetPlatforms, bkOpts)
 	}()
 
 	select {
@@ -284,21 +285,62 @@ func patchWithContext(
 	ch chan error,
 	image, reportPath, patchedTag, suffix, workingFolder, scanner, format, output, loader string,
 	ignoreError, push bool,
+	targetPlatforms []string,
 	bkOpts buildkit.Opts,
 ) error {
-	// Handle empty report path - single-arch patching without report
+	// Handle empty report path - check if image is manifest list or single arch
 	if reportPath == "" {
-		platform := types.PatchPlatform{
-			Platform: platforms.Normalize(platforms.DefaultSpec()),
-		}
-		if platform.OS != LINUX {
-			platform.OS = LINUX
+		// Discover platforms from the image reference to determine if it's multi-arch
+		discoveredPlatforms, err := buildkit.DiscoverPlatformsFromReference(image)
+		if err != nil {
+			// Failed to discover platforms - treat as single-arch image
+			log.Warnf("Failed to discover platforms for image %s (treating as single-arch): %v", image, err)
+			if len(targetPlatforms) > 0 {
+				log.Info("Platform flag ignored when platform discovery fails")
+			}
+
+			// Fallback to default platform
+			platform := types.PatchPlatform{
+				Platform:       platforms.Normalize(platforms.DefaultSpec()),
+				ReportFile:     "",
+				ShouldPreserve: false,
+			}
+			if platform.OS != LINUX {
+				platform.OS = LINUX
+			}
+
+			result, err := patchSingleArchImage(ctx, ch, image, "", patchedTag, suffix, workingFolder, scanner, format, output, loader, platform, ignoreError, push, bkOpts, false)
+			if err == nil && result != nil && result.PatchedRef != nil {
+				log.Infof("Patched image (%s): %s\n", platform.OS+"/"+platform.Architecture, result.PatchedRef)
+			}
+			return err
 		}
-		result, err := patchSingleArchImage(ctx, ch, image, reportPath, patchedTag, suffix, workingFolder, scanner, format, output, loader, platform, ignoreError, push, bkOpts, false)
-		if err == nil && result != nil && result.PatchedRef != nil {
-			log.Infof("Patched image (%s): %s\n", platform.OS+"/"+platform.Architecture, result.PatchedRef)
+
+		if len(discoveredPlatforms) <= 1 {
+			// Single-arch image - ignore platform flag
+			log.Debugf("Detected single-arch image")
+			if len(targetPlatforms) > 0 {
+				log.Info("Platform flag ignored for single-arch image")
+			}
+
+			platform := types.PatchPlatform{
+				Platform:       platforms.Normalize(platforms.DefaultSpec()),
+				ReportFile:     "",
+				ShouldPreserve: false,
+			}
+			if platform.OS != LINUX {
+				platform.OS = LINUX
+			}
+
+			result, err := patchSingleArchImage(ctx, ch, image, "", patchedTag, suffix, workingFolder, scanner, format, output, loader, platform, ignoreError, push, bkOpts, false)
+			if err == nil && result != nil && result.PatchedRef != nil {
+				log.Infof("Patched image (%s): %s\n", platform.OS+"/"+platform.Architecture, result.PatchedRef)
+			}
+			return err
 		}
-		return err
+
+		log.Debugf("Detected multi-platform image with %d platforms", len(discoveredPlatforms))
+		return patchMultiPlatformImage(ctx, ch, image, "", patchedTag, suffix, workingFolder, scanner, format, output, loader, ignoreError, push, bkOpts, targetPlatforms, discoveredPlatforms)
 	}
 
 	// Check if reportPath exists
@@ -315,7 +357,10 @@ func patchWithContext(
 	if f.IsDir() {
 		// Handle directory - multi-platform patching
 		log.Debugf("Using report directory: %s", reportPath)
-		return patchMultiPlatformImage(ctx, ch, image, reportPath, patchedTag, suffix, workingFolder, scanner, format, output, loader, ignoreError, push, bkOpts)
+		if len(targetPlatforms) > 0 {
+			log.Info("Platform flag ignored when report directory is provided")
+		}
+		return patchMultiPlatformImage(ctx, ch, image, reportPath, patchedTag, suffix, workingFolder, scanner, format, output, loader, ignoreError, push, bkOpts, nil, nil)
 	}
 	// Handle file - single-arch patching
 	log.Debugf("Using report file: %s", reportPath)
@@ -844,20 +889,116 @@ func getRepoNameWithDigest(patchedImageName, imageDigest string) string {
 	return nameWithDigest
 }
 
+var validPlatforms = []string{
+	"linux/386",
+	"linux/amd64",
+	"linux/arm",
+	"linux/arm/v5",
+	"linux/arm/v6",
+	"linux/arm/v7",
+	"linux/arm64",
+	"linux/arm64/v8",
+	"linux/ppc64le",
+	"linux/s390x",
+}
+
+// filterPlatforms filters discovered platforms based on user-specified target platforms.
+func filterPlatforms(discoveredPlatforms []types.PatchPlatform, targetPlatforms []string) []types.PatchPlatform {
+	var filtered []types.PatchPlatform
+
+	for _, target := range targetPlatforms {
+		// Validate platform against allowed list
+		if !slices.Contains(validPlatforms, target) {
+			log.Warnf("Platform %s is not in the list of valid platforms: %v", target, validPlatforms)
+			continue
+		}
+
+		targetPlatform, err := platforms.Parse(target)
+		if err != nil {
+			log.Warnf("Invalid platform format %s: %v", target, err)
+			continue
+		}
+		targetPlatform = platforms.Normalize(targetPlatform)
+
+		for _, discovered := range discoveredPlatforms {
+			if platforms.Only(targetPlatform).Match(discovered.Platform) {
+				filtered = append(filtered, discovered)
+				break
+			}
+		}
+	}
+
+	return filtered
+}
+
 func patchMultiPlatformImage(
 	ctx context.Context,
 	ch chan error,
 	image, reportDir, patchedTag, suffix, workingFolder, scanner, format, output, loader string,
 	ignoreError, push bool,
 	bkOpts buildkit.Opts,
+	targetPlatforms []string,
+	discoveredPlatforms []types.PatchPlatform,
 ) error {
 	log.Debugf("Handling platform specific errors with ignore-errors=%t", ignoreError)
-	platforms, err := buildkit.DiscoverPlatforms(image, reportDir, scanner)
-	if err != nil {
-		return err
-	}
-	if len(platforms) == 0 {
-		return fmt.Errorf("no patchable platforms found for image %s", image)
+
+	var platforms []types.PatchPlatform
+	if reportDir != "" {
+		// Using report directory - discover platforms from reports
+		var err error
+		platforms, err = buildkit.DiscoverPlatforms(image, reportDir, scanner)
+		if err != nil {
+			return err
+		}
+		if len(platforms) == 0 {
+			return fmt.Errorf("no patchable platforms found for image %s", image)
+		}
+	} else {
+		// No report directory - use discovered platforms and filter
+		if len(discoveredPlatforms) == 0 {
+			return fmt.Errorf("no platforms provided for image %s", image)
+		}
+
+		if len(targetPlatforms) > 0 {
+			// Filter platforms based on user specification and validate
+			patchPlatforms := filterPlatforms(discoveredPlatforms, targetPlatforms)
+			if len(patchPlatforms) == 0 {
+				return fmt.Errorf("none of the specified platforms %v are available in the image", targetPlatforms)
+			}
+
+			// Create a map to track which platforms should be patched
+			shouldPatchMap := make(map[string]bool)
+			for _, p := range patchPlatforms {
+				key := buildkit.PlatformKey(p.Platform)
+				shouldPatchMap[key] = true
+			}
+
+			// Process all platforms, marking which should be patched vs preserved
+			for _, p := range discoveredPlatforms {
+				platformCopy := p
+				key := buildkit.PlatformKey(p.Platform)
+				if shouldPatchMap[key] {
+					// Platform should be patched
+					platformCopy.ReportFile = ""
+					platformCopy.ShouldPreserve = false
+				} else {
+					// Platform should be preserved
+					platformCopy.ShouldPreserve = true
+				}
+				platforms = append(platforms, platformCopy)
+			}
+
+			log.Infof("Patching specified platforms, preserving others")
+		} else {
+			// Patch all available platforms since no specific platforms were requested
+			for _, p := range discoveredPlatforms {
+				platformCopy := p
+				platformCopy.ReportFile = "" // No vulnerability report, just patch with latest packages
+				platformCopy.ShouldPreserve = false
+				platforms = append(platforms, platformCopy)
+			}
+			log.Infof("Patching all available platforms")
+		}
 	}
 
 	sem := make(chan struct{}, runtime.NumCPU())
@@ -880,9 +1021,9 @@ func patchMultiPlatformImage(
 			}
 			defer func() { <-sem }()
 
-			if p.ReportFile == "" {
-				// No report for this platform - preserve original
-				log.Infof("No report for platform %s, preserving original in manifest", p.OS+"/"+p.Architecture)
+			if p.ShouldPreserve {
+				// Platform marked for preservation - preserve original
+				log.Infof("Platform %s marked for preservation, preserving original in manifest", p.OS+"/"+p.Architecture)
 
 				// Parse the original image reference for the result
 				originalRef, err := reference.ParseNormalizedNamed(image)
@@ -955,7 +1096,13 @@ func patchMultiPlatformImage(
 				return nil
 			}
 
-			res, err := patchSingleArchImage(gctx, ch, image, p.ReportFile, patchedTag, suffix, workingFolder, scanner, format, output, loader, p, ignoreError, push, bkOpts, true)
+			// When no report directory is provided, patch with empty report file
+			reportFile := p.ReportFile
+			if reportDir == "" {
+				reportFile = ""
+			}
+
+			res, err := patchSingleArchImage(gctx, ch, image, reportFile, patchedTag, suffix, workingFolder, scanner, format, output, loader, p, ignoreError, push, bkOpts, true)
 			mu.Lock()
 			defer mu.Unlock()
 			if err != nil {
diff --git a/pkg/types/types.go b/pkg/types/types.go
index 2b0c7329..910c439c 100644
--- a/pkg/types/types.go
+++ b/pkg/types/types.go
@@ -24,7 +24,8 @@ type UpdateManifest struct {
 // PatchPlatform is an extension of ispec.Platform but with a reportFile.
 type PatchPlatform struct {
 	ispec.Platform
-	ReportFile string `json:"reportFile"`
+	ReportFile     string `json:"reportFile"`
+	ShouldPreserve bool   `json:"shouldPreserve"`
 }
 
 // String returns a string representation of the PatchPlatform.
diff --git a/website/docs/multiplatform-patching.md b/website/docs/multiplatform-patching.md
index 6dd2c43e..a22931a4 100644
--- a/website/docs/multiplatform-patching.md
+++ b/website/docs/multiplatform-patching.md
@@ -6,11 +6,19 @@ Copa also supports patching multi-platform container images, streamlining the pr
 
 ## Usage
 
-To patch a multi-platform image, you can use the `copa patch` command with the `--report` flag pointing to a directory (which tells Copa this will be a multi-platform patch) along with flags to specify your image, and desired output tag.
+Similar to patching single architecture images, Copa supports two ways to patch multi-platform container images:
 
-### Create vulnerability reports for multi-platform images
+1. **Report-based patching**: Use the `--report` flag pointing to a directory containing platform-specific vulnerability reports. This is the traditional approach that patches only platforms that exist and have respective reports.
 
-Before you can patch a multi-platform image, you need to generate vulnerability reports for each platform architecture. You can do this using `trivy` using `--platform` flag to specify the architecture. Below is an example of how to generate vulnerability reports for a multi-platform image like `nginx:1.25.0`.
+2. **Update all outdated packages**: When report flag is omitted, Copa detects if it is a manifest list and patches oudated packages across all platforms. Use the optional `--platform` flag to specify which platforms to patch.
+
+### Method 1: Report-based Multi-Platform Patching
+
+This method uses vulnerability reports to determine which platforms and vulnerabilities to patch.
+
+#### Create vulnerability reports for multi-platform images
+
+Before you can patch a multi-platform image, you need to generate vulnerability reports for each platform architecture you wish to patch. You can do this using `trivy` using `--platform` flag to specify the architecture. Below is an example of how to generate vulnerability reports for a multi-platform image like `nginx:1.25.0`.
 
 ```bash
 export IMAGE=docker.io/library/nginx:1.25.0 # Replace with your multi-platform image
@@ -27,9 +35,9 @@ trivy image --vuln-type os --scanners vuln --ignore-unfixed \
 
 This will create two JSON files in the `reports` directory, one for each architecture (`amd64` and `arm64`).
 
-### Patching Multi-Platform Images
+#### Patching with Reports
 
-To patch a multi-platform image, you can use the `copa patch` command with the `--image` flag to specify the multi-platform image, the `--report` flag to point to the directory containing your vulnerability reports, and optionally a `--tag` for the final patched image.
+To patch a multi-platform image using vulnerability reports, use the `copa patch` command with the `--image` flag to specify the multi-platform image, the `--report` flag to point to the directory containing your vulnerability reports, and optionally a `--tag` for the final patched image.
 
 ```bash
 copa patch \
@@ -37,21 +45,58 @@ copa patch \
   --report reports
 ```
 
-Key Flags for Multi-Platform Patching:
+### Method 2: Update all outdated packages
+
+To patch a multi-platform across all platforms and update all outdated packages to latest, use the `copa patch` command with the `--image` flag to specify the multi-platform image, and omit the `--report` flag. 
+
+```bash
+copa patch \
+  --image $IMAGE \
+```
+To filter only specific platforms to patch, provide them with the `--platform` flag.
+
+```bash
+copa patch \
+  --image $IMAGE \
+   --platform=linux/amd64,linux/arm64
+```
+
+#### Valid Platform Values
+
+The `--platform` flag accepts the following values:
+- `linux/amd64`
+- `linux/arm64`
+- `linux/riscv64`
+- `linux/ppc64le`
+- `linux/s390x`
+- `linux/386`
+- `linux/arm/v7`
+- `linux/arm/v6`
+
+You can specify multiple platforms by separating them with commas.
 
-- `--report <directory_path>`: Specifies the directory containing platform-specific vulnerability reports.
+## Key Flags for Multi-Platform Patching
+
+- `--image <image_name>`: The multi-platform container image to patch.
+- `--report <directory_path>` (optional): Specifies the directory containing platform-specific vulnerability reports. When provided, only platforms with reports are patched.
+- `--platform <platform_list>` (optional): Comma-separated list of platforms to patch (e.g., `linux/amd64,linux/arm64`). Only available when `--report` is not used. If not specified, all platforms are patched.
 - `--tag <final_tag>` (optional): The tag for the final, reassembled multi-platform manifest (e.g., `1.0-patched`).
 - `--push` (optional): If included, Copa pushes the final multi-platform manifest to the registry.
 - `--ignore-errors` (optional, default: `false`): When `false` (default), Copa warns about errors and fails if any platform encounters an error. When `true`, Copa warns about errors but continues processing other platforms.
 
 ### Things to Keep in Mind
 
-If you don't provide a `--report` flag pointing to a directory, Copa will not perform multi-platform patching and will instead only patch the image for the architecture of the host machine.
-
-If `--push` is not specified, the individual patched images will be saved locally, and you can push them to your registry later using `docker push` and then `docker manifest create/push` to create the multi-platform manifest.
+- **Automatic platform detection**: Copa automatically detects whether an image is multi-platform (manifest list) or single-platform and handles them accordingly.
+- **Report vs. platform flags**: The `--platform` flag is only available when not using `--report`. When using `--report`, platforms are determined by the reports available.
+- **Platform preservation**: When using `--platform`, only specified platforms are patched; others are preserved unchanged in the final manifest.
+- **No local storage for unspecified platforms**: If `--push` is not specified, the individual patched images will be saved locally, but preserved platforms will only exist in the registry.
+- **Single-platform fallback**: If you don't provide a `--report` directory and don't use `--platform`, Copa will detect if the image is single-platform and patch only that platform.
 
 :::note
-Copa copies over unpatched platforms as a passthrough - only platforms with vulnerability reports are patched, while other platforms remain unchanged in the final multi-platform image.
+**Report-based vs. Platform-based patching:**
+- When using `--report`, Copa copies over unpatched platforms as a passthrough - only platforms with vulnerability reports are patched, while other platforms remain unchanged in the final multi-platform image.
+- When using `--platform`, only the specified platforms are patched, and others are preserved unchanged.
+- When using neither flag, Copa patches all available platforms if the image is multi-platform.
 :::
 
 :::warning


Test patch:
diff --git a/integration/singlearch/fixtures/test-images.json b/integration/singlearch/fixtures/test-images.json
index 8f92aeb8..8ae69c33 100644
--- a/integration/singlearch/fixtures/test-images.json
+++ b/integration/singlearch/fixtures/test-images.json
@@ -5,7 +5,8 @@
         "digest": "sha256:42d3e6bc186572245aded5a0be381012adba6d89355fa9486dd81b0c634695b5",
         "distro": "Alpine",
         "description": "Valid apk/db, apk present",
-        "ignoreErrors": false
+        "ignoreErrors": false,
+        "isManifestList": true
     },
     {
         "image": "docker.io/grafana/grafana",
@@ -14,7 +15,8 @@
         "digest": "sha256:42d3e6bc186572245aded5a0be381012adba6d89355fa9486dd81b0c634695b5",
         "distro": "Alpine",
         "description": "Valid apk/db, apk present, locally tagged with fully-qualified name",
-        "ignoreErrors": false
+        "ignoreErrors": false,
+        "isManifestList": false
     },
     {
         "image": "docker.io/library/nginx",
@@ -22,7 +24,8 @@
         "digest": "sha256:2bcabc23b45489fb0885d69a06ba1d648aeda973fae7bb981bafbb884165e514",
         "distro": "Debian",
         "description": "Valid dpkg/status, apt present",
-        "ignoreErrors": false
+        "ignoreErrors": false,
+        "isManifestList": true
     },
     {
         "image": "docker.io/library/nginx",
@@ -31,7 +34,8 @@
         "localName": "local/image:tag",
         "distro": "Debian",
         "description": "Valid dpkg/status, apt present, locally tagged with repo and image name",
-        "ignoreErrors": false
+        "ignoreErrors": false,
+        "isManifestList": false
     },
     {
         "image": "registry.k8s.io/kube-proxy",
@@ -39,7 +43,8 @@
         "digest": "sha256:30116c7218264d95623d3918a50da703675755cae866cd4c324586611fcd50ea",
         "distro": "Debian",
         "description": "Valid dpkg/status, apt present, custom network config",
-        "ignoreErrors": false
+        "ignoreErrors": false,
+        "isManifestList": true
     },
     {
         "image": "registry.k8s.io/kube-proxy",
@@ -47,7 +52,8 @@
         "digest": "sha256:1e4f13f5f5c215813fb9c9c6f56da1c0354363f2a69bd12732658f79d585864f",
         "distro": "Custom Google Distroless",
         "description": "Custom dpkg/status.d with text names, no apt, libssl1.1",
-        "ignoreErrors": false
+        "ignoreErrors": false,
+        "isManifestList": true
     },
     {
         "image": "registry.k8s.io/kube-proxy",
@@ -55,7 +61,8 @@
         "digest": "sha256:1e4f13f5f5c215813fb9c9c6f56da1c0354363f2a69bd12732658f79d585864f",
         "distro": "Custom Google Distroless",
         "description": "Custom dpkg/status.d with text names, with both libssl1.1 and libssl1",
-        "ignoreErrors": false
+        "ignoreErrors": false,
+        "isManifestList": true
     },
     {
         "image": "docker.io/fluent/fluent-bit",
@@ -63,7 +70,8 @@
         "digest": "sha256:2d80c13c2e7e06aa6a2e54a1825c6adbb3829c8a133ff617a0a61790bd61c53d",
         "distro": "Google Distroless",
         "description": "Custom dpkg/status.d with base64 names",
-        "ignoreErrors": false
+        "ignoreErrors": false,
+        "isManifestList": true
     },
     {
         "image": "docker.io/fluent/fluent-bit",
@@ -72,7 +80,8 @@
         "localName": "localimage:tag",
         "distro": "Google Distroless",
         "description": "Custom dpkg/status.d with base64 names, locally tagged with image name only",
-        "ignoreErrors": false
+        "ignoreErrors": false,
+        "isManifestList": false
     },
     {
         "image": "docker.io/openpolicyagent/opa",
@@ -80,7 +89,8 @@
         "digest": "sha256:c4b11c9b86eaba41276ae682bb6875332316242010b7523efe30f365ad0c3cb8",
         "distro": "Google Distroless",
         "description": "Custom dpkg/status.d with text names, no apt, libssl1",
-        "ignoreErrors": false
+        "ignoreErrors": false,
+        "isManifestList": false
     },
     {
         "image": "quay.io/calico/cni",
@@ -88,7 +98,8 @@
         "digest": "sha256:a925b445c2688fc9c149b20ea04faabd40610d3304a6efda68e5dada7a41b813",
         "distro": "Redhat",
         "description": "Valid rpm DB, microdnf & rpm present",
-        "ignoreErrors": false
+        "ignoreErrors": false,
+        "isManifestList": false
     },
     {
         "image": "mcr.microsoft.com/cbl-mariner/base/core",
@@ -96,7 +107,8 @@
         "digest": "sha256:60323975ec3aabe1840920a65237950a54c5fef6ffc811a5d26bb6bd130f1cc3",
         "distro": "Mariner",
         "description": "Valid rpm DB, tdnf, yum & rpm present",
-        "ignoreErrors": false
+        "ignoreErrors": false,
+        "isManifestList": true
     },
     {
         "image": "mcr.microsoft.com/cbl-mariner/base/core",
@@ -104,7 +116,8 @@
         "digest": "sha256:c85680df0ddccfd5bf0cd60ff7d0c07b0ea783bcee9ce5dc748b68c0d36e280a",
         "distro": "Mariner",
         "description": "Valid rpm DB, tdnf, yum & rpm present, arm64 cross-arch",
-        "ignoreErrors": false
+        "ignoreErrors": false,
+        "isManifestList": false
     },
     {
         "image": "mcr.microsoft.com/cbl-mariner/distroless/base",
@@ -112,7 +125,8 @@
         "digest": "sha256:f550c5428df17b145851ad75983aca6d613ad4b51ca7983b2a83e67d0ac91a5d",
         "distro": "Mariner Distroless",
         "description": "Custom rpmmanifest files, no yum/tdnf/dnf/microdnf/rpm",
-        "ignoreErrors": false
+        "ignoreErrors": false,
+        "isManifestList": true
     },
     {
         "image": "mcr.microsoft.com/azurelinux/base/core",
@@ -120,7 +134,8 @@
         "digest": "sha256:02004412d6133fba772fd88dd45ea99b61258722bfc796c156937df4a5d75c6c",
         "distro": "Azure Linux",
         "description": "Valid rpm DB, tdnf & rpm present, no dnf or yum",
-        "ignoreErrors": false
+        "ignoreErrors": false,
+        "isManifestList": true
     },
     {
         "image": "mcr.microsoft.com/azurelinux/base/core",
@@ -128,7 +143,8 @@
         "digest": "sha256:5975d2ba45e7d256d4eb4e2b3df3aefbaddf25f14fa300fa126fb93b9f082d33",
         "distro": "Azure Linux",
         "description": "Valid rpm DB, tdnf & rpm present, no dnf or yum, arm64 cross-arch",
-        "ignoreErrors": false
+        "ignoreErrors": false,
+        "isManifestList": false
     },
     {
         "image": "mcr.microsoft.com/azurelinux/distroless/base",
@@ -136,7 +152,8 @@
         "digest": "sha256:50c24841324cdb36a268bb1288dd6f8bd5bcf19055c24f6aaa750a740a8be62d",
         "distro": "Azure Linux Distroless",
         "description": "Custom rpmmanifest files, no yum/tdnf/dnf/microdnf/rpm",
-        "ignoreErrors": false
+        "ignoreErrors": false,
+        "isManifestList": true
     },
     {
         "image": "docker.io/library/amazonlinux",
@@ -144,7 +161,8 @@
         "digest": "sha256:06380711d6a8ac0b6989f7e2a4419e560796791d9c7c843753a719c73552dc30",
         "distro": "Amazon Linux",
         "description": "Valid rpm DB, yum present",
-        "ignoreErrors": false
+        "ignoreErrors": false,
+        "isManifestList": true
     },
     {
         "image": "docker.io/library/oraclelinux",
@@ -152,7 +170,8 @@
         "digest": "sha256:ba39a0daabd2df95ed5f374d016e87513f8e579ecc5a1599d7cf94679a281a34",
         "distro": "Oracle Linux 7.9",
         "description": "Valid rpm DB, yum present",
-        "ignoreErrors": false
+        "ignoreErrors": false,
+        "isManifestList": false
     },
     {
         "image": "docker.io/library/oraclelinux",
@@ -160,7 +179,8 @@
         "digest": "sha256:67c889172b07b1f4067050abf4bcf7fce2febd280664df261fe17fa82501a498",
         "distro": "Oracle Linux 8.9",
         "description": "Valid rpm DB, yum present",
-        "ignoreErrors": true
+        "ignoreErrors": true,
+        "isManifestList": true
     },
     {
         "image": "docker.io/library/rockylinux",
@@ -168,14 +188,16 @@
         "digest": "sha256:69cecc7163282ad83e27b739fe8473b7c56e280e83827dcda60e5d37102457f1",
         "distro": "Rocky Linux",
         "description": "Valid rpm DB, yum present",
-        "ignoreErrors": false
+        "ignoreErrors": false,
+        "isManifestList": false
     },
     {
         "image": "docker.io/redhat/ubi9-minimal",
         "tag": "9.4-949",
         "digest": "sha256:9607229894026ebecade4623038fce35bb75bf9371aca7b08ca11b08d103d2ab",
         "distro": "Redhat",
-        "description": "Valid microdnf, no yum/dnf/rpm"
+        "description": "Valid microdnf, no yum/dnf/rpm",
+        "isManifestList": false
     },
     {
         "image": "docker.io/almalinux",
@@ -183,7 +205,8 @@
         "digest": "sha256:1c718a4cd7bab3bdb069ddbbd1eb593a390e6932d51d0048a2f6556303bafba7",
         "distro": "AlmaLinux",
         "description": "Valid rpm DB, microdnf & rpm present",
-        "ignoreErrors": false
+        "ignoreErrors": false,
+        "isManifestList": true
     },
     {
         "image": "ghcr.io/project-copacetic/copacetic/test/openssl",
@@ -191,7 +214,8 @@
         "digest": "sha256:a3ce5460b21cefef782ffd5f8b751c18d6b15b96e81da26248bfab032b4db742",
         "distro": "Mariner Distroless",
         "description": "Custom rpmmanifest files, no yum/tdnf/dnf/microdnf/rpm, custom openssl.cnf",
-        "ignoreErrors": false
+        "ignoreErrors": false,
+        "isManifestList": false
     },
     {
         "image": "ghcr.io/project-copacetic/copacetic/test/openssl",
@@ -199,6 +223,7 @@
         "digest": "sha256:453ce8b732afa551d2e8772d131d8b060b765a4532b33c1fc1e7a90569fb2fbb",
         "distro": "Google Distroless",
         "description": "Custom dpkg/status.d with text names, no apt, libssl1, custom openssl.cnf",
-        "ignoreErrors": false
+        "ignoreErrors": false,
+        "isManifestList": false
     }
 ]
diff --git a/integration/singlearch/patch_test.go b/integration/singlearch/patch_test.go
index 68aa95db..94d48118 100644
--- a/integration/singlearch/patch_test.go
+++ b/integration/singlearch/patch_test.go
@@ -24,13 +24,14 @@ import (
 var testImages []byte
 
 type testImage struct {
-	Image        string        `json:"image"`
-	Tag          string        `json:"tag"`
-	LocalName    string        `json:"localName,omitempty"`
-	Distro       string        `json:"distro"`
-	Digest       digest.Digest `json:"digest"`
-	Description  string        `json:"description"`
-	IgnoreErrors bool          `json:"ignoreErrors"`
+	Image          string        `json:"image"`
+	Tag            string        `json:"tag"`
+	LocalName      string        `json:"localName,omitempty"`
+	Distro         string        `json:"distro"`
+	Digest         digest.Digest `json:"digest"`
+	Description    string        `json:"description"`
+	IgnoreErrors   bool          `json:"ignoreErrors"`
+	IsManifestList bool          `json:"isManifestList"`
 }
 
 func TestPatch(t *testing.T) {
@@ -93,7 +94,6 @@ func TestPatch(t *testing.T) {
 			require.NoError(t, err, err)
 
 			tagPatched := img.Tag + "-patched"
-			patchedRef := fmt.Sprintf("%s:%s", r.Name(), tagPatched)
 
 			patchedMediaType, err := utils.GetMediaType(imageRef, imageloader.Docker)
 			require.NoError(t, err)
@@ -107,6 +107,14 @@ func TestPatch(t *testing.T) {
 			t.Log("patching image")
 			patch(t, ref, tagPatched, dir, img.IgnoreErrors, reportFile)
 
+			// For no-report tests with manifest images, Copa creates platform-specific tags like "-patched-amd64"
+			// The scanning should look for the tag that Copa actually created
+			scanTag := tagPatched
+			if !reportFile && img.IsManifestList {
+				scanTag += "-amd64"
+			}
+			patchedRef := fmt.Sprintf("%s:%s", r.Name(), scanTag)
+
 			switch {
 			case strings.Contains(img.Image, "oracle"):
 				t.Log("Oracle image detected. Skipping Trivy scan.")
@@ -176,6 +184,11 @@ func patch(t *testing.T, ref, patchedTag, path string, ignoreErrors bool, report
 		reportPath = "-r=" + path + "/scan.json"
 	}
 
+	var platformFlag string
+	if !reportFile {
+		platformFlag = "--platform=linux/amd64"
+	}
+
 	//#nosec G204
 	cmd := exec.Command(
 		copaPath,
@@ -186,6 +199,7 @@ func patch(t *testing.T, ref, patchedTag, path string, ignoreErrors bool, report
 		"-s="+scannerPlugin,
 		"--timeout=30m",
 		addrFl,
+		platformFlag,
 		"--ignore-errors="+strconv.FormatBool(ignoreErrors),
 		"--output="+path+"/vex.json",
 		"--debug",
diff --git a/pkg/patch/patch_test.go b/pkg/patch/patch_test.go
index 3ebb0e67..190bd0f6 100644
--- a/pkg/patch/patch_test.go
+++ b/pkg/patch/patch_test.go
@@ -511,11 +511,15 @@ func init() {
 }
 
 func TestPatch_BuildReturnsNilResponse(t *testing.T) {
+	// Use platforms that match the host to avoid emulation issues in test
+	targetPlatforms := []string{"linux/amd64"}
+
 	err := Patch(
 		context.Background(),
 		30*time.Second,
 		"alpine:3.19", "", "", "", "", "", "", "", "",
 		false, true,
+		targetPlatforms,
 		buildkit.Opts{},
 	)