Repository: aquasecurity/trivy (go)

Issue:
Out-of-graph OS packages are excluded when mixed with in-graph packages in SBOM
## Description

When scanning SBOM files with Trivy, many OS packages are missing from the output. This issue occurs when both in-graph OS packages (packages directly connected to the OS component in the dependency graph) and out-of-graph OS packages (orphaned packages not connected to the OS component) are present in the SBOM. Currently, only in-graph packages are included in the final output, while out-of-graph packages are excluded.

This is a general issue that affects all operating systems and SBOM formats.

## Environment

- Trivy version: Latest main branch
- SBOM formats: CycloneDX, SPDX (potentially affects both)
- Test case: Amazon Linux 2023 (but affects all OS types)

## Reproduction Steps

1. Use the SBOM file from the discussion
2. Run: `trivy sbom --list-all-pkgs --format json 9006.json`
3. Count packages: `jq '.Results[0].Packages | length'`

## Expected Behavior

Based on the original SBOM components:
```bash
jq '[.components[] | select(.type == "library")] | length' 9006.json
# Returns: 194
```

Both in-graph and out-of-graph OS packages should be included in the final output.

## Actual Behavior

Current Trivy output:
```bash
./trivy sbom --list-all-pkgs --format json 9006.json | jq '.Results[0].Packages | length'
# Returns: 27
```

Only in-graph OS packages are included, while out-of-graph packages are excluded.

## Root Cause

The issue occurs in the `addOrphanPkgs` function in `pkg/sbom/io/decode.go`. When both in-graph and out-of-graph OS packages exist:
1. In-graph packages are processed first and added to a PackageInfo entry
2. Out-of-graph packages are processed separately and put in a new PackageInfo entry instead of being merged with existing ones
3. Only the first PackageInfo entry is retained in the final output

## Impact

- Missing OS packages means vulnerability scanning may miss security issues in those packages
- Affects all OS types (Alpine, Debian, Ubuntu, RHEL, etc.) when scanning SBOM files
- Potentially affects both CycloneDX and SPDX formats
- Reduces accuracy of security scanning results

## Related Discussion

This issue is based on the discussion: https://github.com/aquasecurity/trivy/discussions/9006

Pull request:
fix(sbom): merge in-graph and out-of-graph OS packages in scan results
## Description

This PR fixes an issue where out-of-graph OS packages (packages not directly connected to the OS component in the dependency graph) were not being properly merged with existing PackageInfo entries containing in-graph OS packages during SBOM decoding. This resulted in missing OS packages in the final SBOM output.

When both in-graph and out-of-graph OS packages exist, only in-graph packages were included in the final output, while out-of-graph packages were excluded.

This fix applies to all operating systems and SBOM formats (CycloneDX, SPDX), not just the Amazon Linux 2023 CycloneDX example used for testing.

## Changes Made

- Modified `addOrphanPkgs` function in `pkg/sbom/io/decode.go` to use `lo.FindIndexOf` for finding existing PackageInfo entries with empty FilePath
- Out-of-graph OS packages are now merged into existing PackageInfo entries instead of creating separate entries
- Packages are sorted after merging for consistent ordering
- Added a test case to verify the fix works correctly for mixed in-graph and out-of-graph packages

## Test Results

### Verification Commands

Reference SBOM component count:
```bash
jq '[.components[] | select(.type == "library")] | length' 9006.json
194
```

Trivy output package count:
```bash
./trivy sbom --list-all-pkgs --format json 9006.json | jq '.Results[0].Packages | length'
194
```

### Results

- **Before fix (main branch)**: 27 packages (only in-graph packages)
- **After fix (this PR)**: 194 packages (both in-graph and out-of-graph packages)
- **Expected**: 194 packages (matches reference SBOM)

## Related issues
- Close #9193

## Checklist
- [x] I've read the [guidelines for contributing](https://trivy.dev/latest/community/contribute/pr/) to this repository.
- [x] I've followed the [conventions](https://trivy.dev/latest/community/contribute/pr/#title) in the PR title.
- [x] I've added tests that prove my fix is effective or that my feature works.
- [ ] I've updated the [documentation](https://github.com/aquasecurity/trivy/blob/main/docs) with the relevant information (if needed).
- [ ] I've added usage information (if the PR introduces new options)
- [ ] I've included a "before" and "after" example to the description (if the PR is a user interface change).

Patch:
diff --git a/pkg/commands/artifact/run.go b/pkg/commands/artifact/run.go
index 0d24e9a06..5998e491d 100644
--- a/pkg/commands/artifact/run.go
+++ b/pkg/commands/artifact/run.go
@@ -126,7 +126,7 @@ func NewRunner(ctx context.Context, cliOptions flag.Options, opts ...RunnerOptio
 		TraceHTTP: cliOptions.TraceHTTP,
 	}))
 	// get the sub command that is being used or fallback to "trivy"
-	commandName := lo.Ternary(len(os.Args) > 1, os.Args[1], "trivy")
+	commandName := lo.NthOr(os.Args, 1, "trivy")
 	r.versionChecker = notification.NewVersionChecker(commandName, &cliOptions)
 
 	// Update the vulnerability database if needed.
diff --git a/pkg/iac/adapters/arm/storage/adapt.go b/pkg/iac/adapters/arm/storage/adapt.go
index 871512cd2..cbc0b1553 100644
--- a/pkg/iac/adapters/arm/storage/adapt.go
+++ b/pkg/iac/adapters/arm/storage/adapt.go
@@ -6,6 +6,7 @@ import (
 	"github.com/aquasecurity/trivy/pkg/iac/providers/azure/storage"
 	"github.com/aquasecurity/trivy/pkg/iac/scanners/azure"
 	"github.com/aquasecurity/trivy/pkg/iac/types"
+	xslices "github.com/aquasecurity/trivy/pkg/x/slices"
 )
 
 func Adapt(deployment azure.Deployment) storage.Storage {
@@ -18,20 +19,6 @@ func adaptAccounts(deployment azure.Deployment) []storage.Account {
 	var accounts []storage.Account
 	for _, resource := range deployment.GetResourcesByType("Microsoft.Storage/storageAccounts") {
 
-		acl := resource.Properties.GetMapValue("networkAcls")
-
-		var bypasses []types.StringValue
-		bypassProp := acl.GetMapValue("bypass")
-		for bypass := range strings.SplitSeq(bypassProp.AsString(), ",") {
-			bypasses = append(bypasses, types.String(strings.TrimSpace(bypass), bypassProp.GetMetadata()))
-		}
-
-		networkRule := storage.NetworkRule{
-			Metadata:       acl.GetMetadata(),
-			Bypass:         bypasses,
-			AllowByDefault: types.Bool(acl.GetMapValue("defaultAction").EqualTo("Allow"), acl.GetMetadata()),
-		}
-
 		var queues []storage.Queue
 		for _, queueResource := range resource.GetResourcesByType("queueServices/queues") {
 			queues = append(queues, storage.Queue{
@@ -50,15 +37,18 @@ func adaptAccounts(deployment azure.Deployment) []storage.Account {
 
 		account := storage.Account{
 			Metadata:     resource.Metadata,
-			NetworkRules: []storage.NetworkRule{networkRule},
-			EnforceHTTPS: resource.Properties.GetMapValue("supportsHttpsTrafficOnly").AsBoolValue(false, resource.Properties.GetMetadata()),
+			NetworkRules: xslices.ZeroToNil(adaptNetworkRules(resource)),
+			// The default value is true since API version 2019-04-01.
+			EnforceHTTPS: resource.Properties.GetMapValue("supportsHttpsTrafficOnly").AsBoolValue(true, resource.Properties.GetMetadata()),
 			Containers:   containers,
 			QueueProperties: storage.QueueProperties{
 				Metadata:      resource.Properties.GetMetadata(),
 				EnableLogging: types.BoolDefault(false, resource.Properties.GetMetadata()),
 			},
-			MinimumTLSVersion: resource.Properties.GetMapValue("minimumTlsVersion").AsStringValue("", resource.Properties.GetMetadata()),
-			Queues:            queues,
+			// The default interpretation is TLS 1.0 for this property.
+			MinimumTLSVersion: resource.Properties.GetMapValue("minimumTlsVersion").
+				AsStringValue("TLS1_0", resource.Properties.GetMetadata()),
+			Queues: queues,
 		}
 
 		publicNetworkAccess := resource.Properties.GetMapValue("publicNetworkAccess")
@@ -70,3 +60,41 @@ func adaptAccounts(deployment azure.Deployment) []storage.Account {
 	}
 	return accounts
 }
+
+func adaptNetworkRules(resource azure.Resource) []storage.NetworkRule {
+	defaultBypasses := []types.StringValue{types.StringDefault("AzureServices", resource.Metadata)}
+	acl := resource.Properties.GetMapValue("networkAcls")
+	if acl.IsNull() {
+		// default network rule
+		return []storage.NetworkRule{{
+			Metadata:       resource.Metadata,
+			Bypass:         defaultBypasses,
+			AllowByDefault: types.BoolDefault(true, resource.Metadata),
+		}}
+	}
+
+	bypassProp := acl.GetMapValue("bypass")
+	bypassVal := bypassProp.AsString()
+
+	var bypasses []types.StringValue
+	if bypassVal != "" {
+		// Possible values are any combination of Logging|Metrics|AzureServices (For example, "Logging, Metrics")
+		// See https://github.com/Azure/azure-resource-manager-schemas/blob/0cb6180c9646c91ca212de0e59568c00ee3a47ec/schemas/2021-01-01/Microsoft.Storage.json#L2379
+		for bypass := range strings.SplitSeq(bypassVal, ",") {
+			bypasses = append(bypasses, types.String(strings.TrimSpace(bypass), bypassProp.GetMetadata()))
+		}
+	} else {
+		bypasses = defaultBypasses
+	}
+
+	allowByDefault := types.Bool(true, acl.GetMetadata())
+	if defaultAction := acl.GetMapValue("defaultAction"); !defaultAction.IsNull() {
+		allowByDefault = types.Bool(defaultAction.EqualTo("Allow"), defaultAction.GetMetadata())
+	}
+
+	return []storage.NetworkRule{{
+		Metadata:       acl.GetMetadata(),
+		Bypass:         bypasses,
+		AllowByDefault: allowByDefault,
+	}}
+}
diff --git a/pkg/iac/adapters/terraform/google/compute/networks.go b/pkg/iac/adapters/terraform/google/compute/networks.go
index 5ea8753d1..d8f269435 100644
--- a/pkg/iac/adapters/terraform/google/compute/networks.go
+++ b/pkg/iac/adapters/terraform/google/compute/networks.go
@@ -29,10 +29,11 @@ func adaptNetworks(modules terraform.Modules) (networks []compute.Network) {
 	for _, subnetworkBlock := range modules.GetResourcesByType("google_compute_subnetwork") {
 
 		subnetwork := compute.SubNetwork{
-			Metadata:       subnetworkBlock.GetMetadata(),
-			Name:           subnetworkBlock.GetAttribute("name").AsStringValueOrDefault("", subnetworkBlock),
-			Purpose:        subnetworkBlock.GetAttribute("purpose").AsStringValueOrDefault(defaultSubnetPurpose, subnetworkBlock),
-			EnableFlowLogs: iacTypes.BoolDefault(false, subnetworkBlock.GetMetadata()),
+			Metadata:              subnetworkBlock.GetMetadata(),
+			Name:                  subnetworkBlock.GetAttribute("name").AsStringValueOrDefault("", subnetworkBlock),
+			Purpose:               subnetworkBlock.GetAttribute("purpose").AsStringValueOrDefault(defaultSubnetPurpose, subnetworkBlock),
+			EnableFlowLogs:        iacTypes.BoolDefault(false, subnetworkBlock.GetMetadata()),
+			PrivateIPGoogleAccess: subnetworkBlock.GetAttribute("private_ip_google_access").AsBoolValueOrDefault(false, subnetworkBlock),
 		}
 
 		// logging
diff --git a/pkg/iac/providers/google/compute/subnetwork.go b/pkg/iac/providers/google/compute/subnetwork.go
index a0bd03d2a..4eac339c7 100755
--- a/pkg/iac/providers/google/compute/subnetwork.go
+++ b/pkg/iac/providers/google/compute/subnetwork.go
@@ -5,8 +5,9 @@ import (
 )
 
 type SubNetwork struct {
-	Metadata       iacTypes.Metadata
-	Name           iacTypes.StringValue
-	Purpose        iacTypes.StringValue
-	EnableFlowLogs iacTypes.BoolValue
+	Metadata              iacTypes.Metadata
+	Name                  iacTypes.StringValue
+	Purpose               iacTypes.StringValue
+	EnableFlowLogs        iacTypes.BoolValue
+	PrivateIPGoogleAccess iacTypes.BoolValue
 }
diff --git a/pkg/iac/rego/schemas/cloud.json b/pkg/iac/rego/schemas/cloud.json
index e05744cda..9ab7d12fb 100644
--- a/pkg/iac/rego/schemas/cloud.json
+++ b/pkg/iac/rego/schemas/cloud.json
@@ -6378,6 +6378,10 @@
           "type": "object",
           "$ref": "#/definitions/github.com.aquasecurity.trivy.pkg.iac.types.StringValue"
         },
+        "privateipgoogleaccess": {
+          "type": "object",
+          "$ref": "#/definitions/github.com.aquasecurity.trivy.pkg.iac.types.BoolValue"
+        },
         "purpose": {
           "type": "object",
           "$ref": "#/definitions/github.com.aquasecurity.trivy.pkg.iac.types.StringValue"
diff --git a/pkg/iac/scanners/azure/value.go b/pkg/iac/scanners/azure/value.go
index 381a663ac..1e216de9d 100644
--- a/pkg/iac/scanners/azure/value.go
+++ b/pkg/iac/scanners/azure/value.go
@@ -288,7 +288,11 @@ func (v Value) GetMapValue(key string) Value {
 	if v.Kind != KindObject {
 		return NullValue
 	}
-	return v.rMap[key]
+	v, exists := v.rMap[key]
+	if !exists {
+		return NullValue
+	}
+	return v
 }
 
 func (v Value) AsMap() map[string]Value {
@@ -370,3 +374,7 @@ func (v Value) AsStringValuesList(defaultValue string) (stringValues []types.Str
 
 	return stringValues
 }
+
+func (v Value) IsNull() bool {
+	return v.Kind == KindNull
+}
diff --git a/pkg/sbom/io/decode.go b/pkg/sbom/io/decode.go
index 0fe9c06d4..f45da396f 100644
--- a/pkg/sbom/io/decode.go
+++ b/pkg/sbom/io/decode.go
@@ -391,8 +391,14 @@ func (m *Decoder) addOrphanPkgs(sbom *types.SBOM) error {
 	for _, pkgs := range osPkgMap {
 		// TODO: mismatch between the OS and the packages should be rejected.
 		// e.g. OS: debian, Packages: rpm
-		sort.Sort(pkgs)
-		sbom.Packages = append(sbom.Packages, ftypes.PackageInfo{Packages: pkgs})
+
+		// addOSPkgs creates exactly one PackageInfo with empty FilePath, so we can merge directly
+		if len(sbom.Packages) == 0 {
+			sbom.Packages = append(sbom.Packages, ftypes.PackageInfo{})
+		}
+		// Merge with existing PackageInfo (always sbom.Packages[0])
+		sbom.Packages[0].Packages = append(sbom.Packages[0].Packages, pkgs...)
+		sort.Sort(sbom.Packages[0].Packages)
 
 		break // Just take the first element
 	}


Test patch:
diff --git a/pkg/iac/adapters/arm/storage/adapt_test.go b/pkg/iac/adapters/arm/storage/adapt_test.go
index 708594366..56739f97c 100644
--- a/pkg/iac/adapters/arm/storage/adapt_test.go
+++ b/pkg/iac/adapters/arm/storage/adapt_test.go
@@ -26,8 +26,11 @@ func TestAdapt(t *testing.T) {
 }`,
 			expected: storage.Storage{
 				Accounts: []storage.Account{{
+					MinimumTLSVersion: types.StringTest("TLS1_0"),
+					EnforceHTTPS:      types.BoolTest(true),
 					NetworkRules: []storage.NetworkRule{{
-						Bypass: []types.StringValue{types.StringTest("")},
+						Bypass:         []types.StringValue{types.StringTest("AzureServices")},
+						AllowByDefault: types.BoolTest(true),
 					}},
 					PublicNetworkAccess: types.BoolTest(true),
 				}},
diff --git a/pkg/iac/adapters/terraform/google/compute/networks_test.go b/pkg/iac/adapters/terraform/google/compute/networks_test.go
index df1cef261..5018115c5 100644
--- a/pkg/iac/adapters/terraform/google/compute/networks_test.go
+++ b/pkg/iac/adapters/terraform/google/compute/networks_test.go
@@ -122,6 +122,34 @@ func Test_adaptNetworks(t *testing.T) {
 				},
 			},
 		},
+		{
+			name: "private_ip_google_access_enabled",
+			terraform: `
+			resource "google_compute_subnetwork" "example" {
+				name          = "test-subnetwork"
+				network       = google_compute_network.example.id
+				private_ip_google_access = true
+			}
+			resource "google_compute_network" "example" {
+				name = "test-network"
+			}
+			`,
+			expected: []compute.Network{
+				{
+					Metadata: iacTypes.NewTestMetadata(),
+					Firewall: nil,
+					Subnetworks: []compute.SubNetwork{
+						{
+							Metadata:              iacTypes.NewTestMetadata(),
+							Name:                  iacTypes.String("test-subnetwork", iacTypes.NewTestMetadata()),
+							Purpose:               iacTypes.StringDefault("PRIVATE_RFC_1918", iacTypes.NewTestMetadata()),
+							EnableFlowLogs:        iacTypes.Bool(false, iacTypes.NewTestMetadata()),
+							PrivateIPGoogleAccess: iacTypes.Bool(true, iacTypes.NewTestMetadata()),
+						},
+					},
+				},
+			},
+		},
 	}
 
 	for _, test := range tests {
diff --git a/pkg/sbom/io/decode_test.go b/pkg/sbom/io/decode_test.go
index 782e7a3e9..0669499ab 100644
--- a/pkg/sbom/io/decode_test.go
+++ b/pkg/sbom/io/decode_test.go
@@ -87,6 +87,24 @@ var (
 		},
 		Licenses: []string{"GPL-2.0"},
 	}
+
+	orphanedApkComponent = &core.Component{
+		Type:    core.TypeLibrary,
+		Name:    "orphaned-package",
+		Version: "1.0.0",
+		PkgIdentifier: ftypes.PkgIdentifier{
+			PURL: &packageurl.PackageURL{
+				Type:    packageurl.TypeApk,
+				Name:    "orphaned-package",
+				Version: "1.0.0",
+				Qualifiers: packageurl.Qualifiers{
+					{Key: "arch", Value: "aarch64"},
+					{Key: "distro", Value: "wolfi-20230201"},
+				},
+			},
+		},
+		Licenses: []string{"MIT"},
+	}
 )
 
 func TestDecoder_Decode_OSPackages(t *testing.T) {
@@ -141,16 +159,17 @@ func TestDecoder_Decode_OSPackages(t *testing.T) {
 			},
 		},
 		{
-			name: "multiple OS packages without OS metadata should be included",
+			name: "multiple OS packages without OS metadata should be included and merged",
 			setupBOM: func() *core.BOM {
 				bom := core.NewBOM(core.Options{})
 				bom.SerialNumber = "test-multiple-no-os"
 				bom.Version = 1
 
-				// Add multiple APK packages
+				// Add multiple APK packages including orphaned package
 				bom.AddComponent(apkToolsComponent)
 				bom.AddComponent(busyboxComponent)
 				bom.AddComponent(caCertificatesComponent)
+				bom.AddComponent(orphanedApkComponent)
 
 				return bom
 			},
@@ -197,6 +216,18 @@ func TestDecoder_Decode_OSPackages(t *testing.T) {
 									PURL: caCertificatesComponent.PkgIdentifier.PURL,
 								},
 							},
+							{
+								ID:         "orphaned-package@1.0.0",
+								Name:       "orphaned-package",
+								Version:    "1.0.0",
+								Arch:       "aarch64",
+								SrcName:    "orphaned-package",
+								SrcVersion: "1.0.0",
+								Licenses:   []string{"MIT"},
+								Identifier: ftypes.PkgIdentifier{
+									PURL: orphanedApkComponent.PkgIdentifier.PURL,
+								},
+							},
 						},
 					},
 				},
@@ -233,6 +264,66 @@ func TestDecoder_Decode_OSPackages(t *testing.T) {
 				},
 			},
 		},
+		{
+			name: "mixed OS packages (in-graph and out-of-graph) should be merged",
+			setupBOM: func() *core.BOM {
+				bom := core.NewBOM(core.Options{})
+				bom.SerialNumber = "test-mixed-os-packages"
+				bom.Version = 1
+
+				// Add OS component
+				bom.AddComponent(wolfiOSComponent)
+
+				// Add OS packages - busybox is connected to OS (in-graph)
+				bom.AddComponent(busyboxComponent)
+				// Add orphaned package not connected to OS (out-of-graph)
+				bom.AddComponent(orphanedApkComponent)
+
+				// Create relationship between OS and busybox only
+				bom.AddRelationship(wolfiOSComponent, busyboxComponent, core.RelationshipContains)
+				// orphanedApkComponent is not connected to OS component
+
+				return bom
+			},
+			wantSBOM: types.SBOM{
+				Metadata: types.Metadata{
+					OS: &ftypes.OS{
+						Family: ftypes.Wolfi,
+						Name:   "20230201",
+					},
+				},
+				Packages: []ftypes.PackageInfo{
+					{
+						Packages: ftypes.Packages{
+							{
+								ID:         "busybox@1.37.0-r42",
+								Name:       "busybox",
+								Version:    "1.37.0-r42",
+								Arch:       "aarch64",
+								SrcName:    "busybox",
+								SrcVersion: "1.37.0-r42",
+								Licenses:   []string{"GPL-2.0-only"},
+								Identifier: ftypes.PkgIdentifier{
+									PURL: busyboxComponent.PkgIdentifier.PURL,
+								},
+							},
+							{
+								ID:         "orphaned-package@1.0.0",
+								Name:       "orphaned-package",
+								Version:    "1.0.0",
+								Arch:       "aarch64",
+								SrcName:    "orphaned-package",
+								SrcVersion: "1.0.0",
+								Licenses:   []string{"MIT"},
+								Identifier: ftypes.PkgIdentifier{
+									PURL: orphanedApkComponent.PkgIdentifier.PURL,
+								},
+							},
+						},
+					},
+				},
+			},
+		},
 	}
 
 	for _, tt := range tests {
@@ -252,7 +343,7 @@ func TestDecoder_Decode_OSPackages(t *testing.T) {
 			tt.wantSBOM.BOM = gotSBOM.BOM
 
 			// Compare the entire SBOM structure
-			assert.Equal(t, tt.wantSBOM, gotSBOM)
+			assert.EqualExportedValues(t, tt.wantSBOM, gotSBOM)
 		})
 	}
 }