diff --git a/docs/supported_inventory_types.md b/docs/supported_inventory_types.md index 0d04edf0..9f30e406 100644 --- a/docs/supported_inventory_types.md +++ b/docs/supported_inventory_types.md @@ -66,6 +66,7 @@ SCALIBR supports extracting software package information from a variety of OS an * Rust * Cargo.lock * Swift + * Podfile.lock * Package.resolved ## Container inventory diff --git a/extractor/filesystem/language/swift/podfilelock/podfilelock.go b/extractor/filesystem/language/swift/podfilelock/podfilelock.go new file mode 100644 index 00000000..487cefff --- /dev/null +++ b/extractor/filesystem/language/swift/podfilelock/podfilelock.go @@ -0,0 +1,156 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package podfilelock extracts dependencies from Podfile.lock files. +package podfilelock + +import ( + "context" + "path/filepath" + + "github.com/google/osv-scalibr/extractor" + "github.com/google/osv-scalibr/extractor/filesystem" + "github.com/google/osv-scalibr/extractor/filesystem/internal/units" + "github.com/google/osv-scalibr/extractor/filesystem/language/swift/swiftutils" + "github.com/google/osv-scalibr/plugin" + "github.com/google/osv-scalibr/purl" + "github.com/google/osv-scalibr/stats" +) + +// Name is the unique name of this extractor. +const Name = "swift/podfilelock" + +// Config represents the configuration for the extractor. +type Config struct { + Stats stats.Collector + MaxFileSizeBytes int64 +} + +// DefaultConfig returns the default configuration for the extractor. +func DefaultConfig() Config { + return Config{ + Stats: nil, + MaxFileSizeBytes: 10 * units.MiB, + } +} + +// Config returns the configuration of the extractor. +func (e Extractor) Config() Config { + return Config{ + Stats: e.stats, + MaxFileSizeBytes: e.maxFileSizeBytes, + } +} + +// Extractor extracts dependencies from Podfile.lock files. +type Extractor struct { + stats stats.Collector + maxFileSizeBytes int64 +} + +// New creates a new instance of the Podfile.lock extractor. +func New(cfg Config) *Extractor { + return &Extractor{ + stats: cfg.Stats, + maxFileSizeBytes: cfg.MaxFileSizeBytes, + } +} + +// Name returns the extractor's name. +func (e Extractor) Name() string { return Name } + +// Version returns the extractor's version. +func (e Extractor) Version() int { return 0 } + +// Requirements defines the extractor's capabilities. +func (e Extractor) Requirements() *plugin.Capabilities { return &plugin.Capabilities{} } + +// FileRequired checks if a file is named Podfile.lock and meets size constraints. +func (e Extractor) FileRequired(api filesystem.FileAPI) bool { + path := api.Path() + if filepath.Base(path) != "Podfile.lock" { + return false + } + + fileInfo, err := api.Stat() + if err != nil { + return false + } + + if e.maxFileSizeBytes > 0 && fileInfo.Size() > e.maxFileSizeBytes { + e.reportFileRequired(path, fileInfo.Size(), stats.FileRequiredResultSizeLimitExceeded) + return false + } + + e.reportFileRequired(path, fileInfo.Size(), stats.FileRequiredResultOK) + return true +} + +func (e Extractor) reportFileRequired(path string, fileSizeBytes int64, result stats.FileRequiredResult) { + if e.stats == nil { + return + } + e.stats.AfterFileRequired(e.Name(), &stats.FileRequiredStats{ + Path: path, + Result: result, + FileSizeBytes: fileSizeBytes, + }) +} + +// Extract processes and extracts dependency information from a Podfile.lock file. +func (e Extractor) Extract(ctx context.Context, input *filesystem.ScanInput) ([]*extractor.Inventory, error) { + inventory, err := e.extractFromInput(ctx, input) + if e.stats != nil { + var fileSizeBytes int64 + if input.Info != nil { + fileSizeBytes = input.Info.Size() + } + e.stats.AfterFileExtracted(e.Name(), &stats.FileExtractedStats{ + Path: input.Path, + Result: filesystem.ExtractorErrorToFileExtractedResult(err), + FileSizeBytes: fileSizeBytes, + }) + } + return inventory, err +} + +func (e Extractor) extractFromInput(ctx context.Context, input *filesystem.ScanInput) ([]*extractor.Inventory, error) { + pkgs, err := swiftutils.ParsePodfileLock(input.Reader) + if err != nil { + return nil, err + } + + var inventories []*extractor.Inventory + for _, pkg := range pkgs { + inventories = append(inventories, &extractor.Inventory{ + Name: pkg.Name, + Version: pkg.Version, + Locations: []string{input.Path}, + }) + } + + return inventories, nil +} + +// ToPURL converts an inventory item into a Package URL (PURL). +func (e Extractor) ToPURL(i *extractor.Inventory) *purl.PackageURL { + return &purl.PackageURL{ + Type: purl.TypeCocoapods, + Name: i.Name, + Version: i.Version, + } +} + +// Ecosystem returns the OSV ecosystem name for CocoaPods. +func (Extractor) Ecosystem(_ *extractor.Inventory) string { return "CocoaPods" } diff --git a/extractor/filesystem/language/swift/podfilelock/podfilelock_test.go b/extractor/filesystem/language/swift/podfilelock/podfilelock_test.go new file mode 100644 index 00000000..80f5dac5 --- /dev/null +++ b/extractor/filesystem/language/swift/podfilelock/podfilelock_test.go @@ -0,0 +1,266 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package podfilelock_test + +import ( + "context" + "io/fs" + "path/filepath" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/osv-scalibr/extractor" + "github.com/google/osv-scalibr/extractor/filesystem" + "github.com/google/osv-scalibr/extractor/filesystem/internal/units" + "github.com/google/osv-scalibr/extractor/filesystem/language/swift/podfilelock" + "github.com/google/osv-scalibr/extractor/filesystem/simplefileapi" + "github.com/google/osv-scalibr/purl" + "github.com/google/osv-scalibr/stats" + "github.com/google/osv-scalibr/testing/extracttest" + "github.com/google/osv-scalibr/testing/fakefs" + "github.com/google/osv-scalibr/testing/testcollector" +) + +func TestNew(t *testing.T) { + tests := []struct { + name string + cfg podfilelock.Config + wantCfg podfilelock.Config + }{ + { + name: "default", + cfg: podfilelock.DefaultConfig(), + wantCfg: podfilelock.Config{ + MaxFileSizeBytes: 10 * units.MiB, + }, + }, + { + name: "custom", + cfg: podfilelock.Config{ + MaxFileSizeBytes: 10, + }, + wantCfg: podfilelock.Config{ + MaxFileSizeBytes: 10, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := podfilelock.New(tt.cfg) + if diff := cmp.Diff(tt.wantCfg, got.Config()); diff != "" { + t.Errorf("New(%+v).Config(): (-want +got):\n%s", tt.cfg, diff) + } + }) + } +} + +func TestFileRequired(t *testing.T) { + tests := []struct { + name string + path string + fileSizeBytes int64 + maxFileSizeBytes int64 + wantRequired bool + wantResultMetric stats.FileRequiredResult + }{ + { + name: "Podfile.lock file", + path: "Podfile.lock", + wantRequired: true, + wantResultMetric: stats.FileRequiredResultOK, + }, + { + name: "path Podfile.lock file", + path: "path/to/my/Podfile.lock", + wantRequired: true, + wantResultMetric: stats.FileRequiredResultOK, + }, + { + name: "file not required", + path: "test.lock", + wantRequired: false, + }, + { + name: "Podfile.lock file required if file size < max file size", + path: "Podfile.lock", + fileSizeBytes: 100 * units.KiB, + maxFileSizeBytes: 1000 * units.KiB, + wantRequired: true, + wantResultMetric: stats.FileRequiredResultOK, + }, + { + name: "Podfile.lock file required if file size == max file size", + path: "Podfile.lock", + fileSizeBytes: 1000 * units.KiB, + maxFileSizeBytes: 1000 * units.KiB, + wantRequired: true, + wantResultMetric: stats.FileRequiredResultOK, + }, + { + name: "Podfile.lock file not required if file size > max file size", + path: "Podfile.lock", + fileSizeBytes: 1000 * units.KiB, + maxFileSizeBytes: 100 * units.KiB, + wantRequired: false, + wantResultMetric: stats.FileRequiredResultSizeLimitExceeded, + }, + { + name: "Podfile.lock file required if max file size set to 0", + path: "Podfile.lock", + fileSizeBytes: 100 * units.KiB, + maxFileSizeBytes: 0, + wantRequired: true, + wantResultMetric: stats.FileRequiredResultOK, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + collector := testcollector.New() + var e filesystem.Extractor = podfilelock.New(podfilelock.Config{ + Stats: collector, + MaxFileSizeBytes: tt.maxFileSizeBytes, + }) + + fileSizeBytes := tt.fileSizeBytes + if fileSizeBytes == 0 { + fileSizeBytes = 1000 + } + + isRequired := e.FileRequired(simplefileapi.New(tt.path, fakefs.FakeFileInfo{ + FileName: filepath.Base(tt.path), + FileMode: fs.ModePerm, + FileSize: fileSizeBytes, + })) + if isRequired != tt.wantRequired { + t.Fatalf("FileRequired(%s): got %v, want %v", tt.path, isRequired, tt.wantRequired) + } + + gotResultMetric := collector.FileRequiredResult(tt.path) + if tt.wantResultMetric != "" && gotResultMetric != tt.wantResultMetric { + t.Errorf("FileRequired(%s) recorded result metric %v, want result metric %v", tt.path, gotResultMetric, tt.wantResultMetric) + } + }) + } +} + +func TestExtract(t *testing.T) { + tests := []extracttest.TestTableEntry{ + { + Name: "valid Podfile.lock file, map[string] case", + InputConfig: extracttest.ScanInputMockConfig{ + Path: "testdata/valid", + }, + WantInventory: []*extractor.Inventory{ + { + Name: "GlossButtonNode", + Version: "3.1.2", + Locations: []string{"testdata/valid"}, + }, + { + Name: "PINCache", + Version: "3.0.3", + Locations: []string{"testdata/valid"}, + }, + }, + }, + { + Name: "valid Podfile.lock file, string case", + InputConfig: extracttest.ScanInputMockConfig{ + Path: "testdata/valid2", + }, + WantInventory: []*extractor.Inventory{ + { + Name: "GlossButtonNode", + Version: "3.1.2", + Locations: []string{"testdata/valid2"}, + }, + { + Name: "PINCache", + Version: "3.0.3", + Locations: []string{"testdata/valid2"}, + }, + { + Name: "Reveal-SDK", + Version: "1.5.0", + Locations: []string{"testdata/valid2"}, + }, + { + Name: "SwiftGen", + Version: "6.0.0", + Locations: []string{"testdata/valid2"}, + }, + }, + }, + { + Name: "Podfile.lock file not valid", + InputConfig: extracttest.ScanInputMockConfig{ + Path: "testdata/invalid", + }, + WantErr: cmpopts.AnyError, + }, + { + Name: "Podfile.lock file empty", + InputConfig: extracttest.ScanInputMockConfig{ + Path: "testdata/empty", + }, + WantErr: cmpopts.AnyError, + }, + } + + for _, tt := range tests { + t.Run(tt.Name, func(t *testing.T) { + collector := testcollector.New() + var e filesystem.Extractor = podfilelock.New(podfilelock.Config{ + Stats: collector, + MaxFileSizeBytes: 100, + }) + + scanInput := extracttest.GenerateScanInputMock(t, tt.InputConfig) + defer extracttest.CloseTestScanInput(t, scanInput) + + got, err := e.Extract(context.Background(), &scanInput) + + if diff := cmp.Diff(tt.WantErr, err, cmpopts.EquateErrors()); diff != "" { + t.Errorf("%s.Extract(%q) error diff (-want +got):\n%s", e.Name(), tt.InputConfig.Path, diff) + return + } + + if diff := cmp.Diff(tt.WantInventory, got, cmpopts.SortSlices(extracttest.InventoryCmpLess)); diff != "" { + t.Errorf("%s.Extract(%q) diff (-want +got):\n%s", e.Name(), tt.InputConfig.Path, diff) + } + }) + } +} + +func TestToPURL(t *testing.T) { + e := podfilelock.Extractor{} + i := &extractor.Inventory{ + Name: "Name", + Version: "1.2.3", + Locations: []string{"location"}, + } + want := &purl.PackageURL{ + Type: purl.TypeCocoapods, + Name: "Name", + Version: "1.2.3", + } + got := e.ToPURL(i) + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("ToPURL(%v) (-want +got):\n%s", i, diff) + } +} diff --git a/extractor/filesystem/language/swift/podfilelock/testdata/empty b/extractor/filesystem/language/swift/podfilelock/testdata/empty new file mode 100644 index 00000000..e69de29b diff --git a/extractor/filesystem/language/swift/podfilelock/testdata/invalid b/extractor/filesystem/language/swift/podfilelock/testdata/invalid new file mode 100644 index 00000000..190d77f3 --- /dev/null +++ b/extractor/filesystem/language/swift/podfilelock/testdata/invalid @@ -0,0 +1,3 @@ +invalid +2FLayoutSpecBuilder +file diff --git a/extractor/filesystem/language/swift/podfilelock/testdata/valid b/extractor/filesystem/language/swift/podfilelock/testdata/valid new file mode 100644 index 00000000..428fd9e0 --- /dev/null +++ b/extractor/filesystem/language/swift/podfilelock/testdata/valid @@ -0,0 +1,42 @@ +PODS: + - GlossButtonNode (3.1.2): + - Texture/Core (~> 3) + - TextureSwiftSupport (>= 3.10.0) + - PINCache (3.0.3): + - PINCache/Arc-exception-safe (= 3.0.3) + - PINCache/Core (= 3.0.3) + +DEPENDENCIES: + - GlossButtonNode + - Reveal-SDK + - SwiftGen + - Texture + - TextureSwiftSupport + - TinyConstraints + +SPEC REPOS: + trunk: + - GlossButtonNode + - PINCache + - PINOperation + - PINRemoteImage + - Reveal-SDK + - SwiftGen + - Texture + - TextureSwiftSupport + - TinyConstraints + +SPEC CHECKSUMS: + GlossButtonNode: 4ea1197a744f2fb5fb875fe31caf17ded4762e8f + PINCache: 7a8fc1a691173d21dbddbf86cd515de6efa55086 + PINOperation: 00c935935f1e8cf0d1e2d6b542e75b88fc3e5e20 + PINRemoteImage: f1295b29f8c5e640e25335a1b2bd9d805171bd01 + Reveal-SDK: effba1c940b8337195563c425a6b5862ec875caa + SwiftGen: a6d22010845f08fe18fbdf3a07a8e380fd22e0ea + Texture: 2e8ab2519452515f7f5a520f5a8f7e0a413abfa3 + TextureSwiftSupport: c515c7927fab92d0d9485f49b885b8c5de34fbfb + TinyConstraints: 7b7ccc0c485bb3bb47082138ff28bc33cd49897f + +PODFILE CHECKSUM: 07aa55f54421f6e6d3a920c11716a89fc9243d1b + +COCOAPODS: 1.11.2 diff --git a/extractor/filesystem/language/swift/podfilelock/testdata/valid2 b/extractor/filesystem/language/swift/podfilelock/testdata/valid2 new file mode 100644 index 00000000..b148660f --- /dev/null +++ b/extractor/filesystem/language/swift/podfilelock/testdata/valid2 @@ -0,0 +1,33 @@ +PODS: + - GlossButtonNode (3.1.2) + - PINCache (3.0.3) + - Reveal-SDK (1.5.0) + - SwiftGen (6.0.0) + +DEPENDENCIES: + - GlossButtonNode + - Reveal-SDK + - SwiftGen + - Texture + - TinyConstraints + +SPEC REPOS: + trunk: + - GlossButtonNode + - PINCache + - Reveal-SDK + - SwiftGen + - Texture + - TinyConstraints + +SPEC CHECKSUMS: + GlossButtonNode: 4ea1197a744f2fb5fb875fe31caf17ded4762e8f + PINCache: 7a8fc1a691173d21dbddbf86cd515de6efa55086 + Reveal-SDK: effba1c940b8337195563c425a6b5862ec875caa + SwiftGen: a6d22010845f08fe18fbdf3a07a8e380fd22e0ea + Texture: 2e8ab2519452515f7f5a520f5a8f7e0a413abfa3 + TinyConstraints: 7b7ccc0c485bb3bb47082138ff28bc33cd49897f + +PODFILE CHECKSUM: 07aa55f54421f6e6d3a920c11716a89fc9243d1b + +COCOAPODS: 1.11.2 diff --git a/extractor/filesystem/language/swift/swiftutils/podfilelock.go b/extractor/filesystem/language/swift/swiftutils/podfilelock.go new file mode 100644 index 00000000..563d597d --- /dev/null +++ b/extractor/filesystem/language/swift/swiftutils/podfilelock.go @@ -0,0 +1,81 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package swiftutils provides utilities for parsing Swift podfiles. +package swiftutils + +import ( + "fmt" + "io" + "strings" + + "gopkg.in/yaml.v3" +) + +// PodfileLock represents the structure of a Podfile.lock file. +type podfileLock struct { + Pods []any `yaml:"PODS"` +} + +// Package represents a single package parsed from Podfile.lock. +type Package struct { + Name string + Version string +} + +// ParsePodfileLock parses the contents of a Podfile.lock and returns a list of packages. +func ParsePodfileLock(reader io.Reader) ([]Package, error) { + bytes, err := io.ReadAll(reader) + if err != nil { + return nil, fmt.Errorf("unable to read file: %w", err) + } + + // Check if the file is empty + if len(bytes) == 0 { + return nil, fmt.Errorf("file is empty") + } + + var podfile podfileLock + if err = yaml.Unmarshal(bytes, &podfile); err != nil { + return nil, fmt.Errorf("unable to parse YAML: %w", err) + } + + var pkgs []Package + for _, podInterface := range podfile.Pods { + var podBlob string + switch v := podInterface.(type) { + case map[string]any: + for k := range v { + podBlob = k + } + case string: + podBlob = v + default: + return nil, fmt.Errorf("malformed Podfile.lock") + } + + splits := strings.Split(podBlob, " ") + if len(splits) < 2 { + return nil, fmt.Errorf("unexpected format in Pods: %s", podBlob) + } + podName := splits[0] + podVersion := strings.TrimSuffix(strings.TrimPrefix(splits[1], "("), ")") + pkgs = append(pkgs, Package{ + Name: podName, + Version: podVersion, + }) + } + + return pkgs, nil +} diff --git a/extractor/filesystem/list/list.go b/extractor/filesystem/list/list.go index 395f902f..c04c01b6 100644 --- a/extractor/filesystem/list/list.go +++ b/extractor/filesystem/list/list.go @@ -57,6 +57,7 @@ import ( "github.com/google/osv-scalibr/extractor/filesystem/language/ruby/gemspec" "github.com/google/osv-scalibr/extractor/filesystem/language/rust/cargolock" "github.com/google/osv-scalibr/extractor/filesystem/language/swift/packageresolved" + "github.com/google/osv-scalibr/extractor/filesystem/language/swift/podfilelock" "github.com/google/osv-scalibr/extractor/filesystem/os/apk" "github.com/google/osv-scalibr/extractor/filesystem/os/cos" "github.com/google/osv-scalibr/extractor/filesystem/os/dpkg" @@ -134,7 +135,12 @@ var ( // PHP extractors. PHP []filesystem.Extractor = []filesystem.Extractor{&composerlock.Extractor{}} // Swift extractors. - Swift []filesystem.Extractor = []filesystem.Extractor{packageresolved.Extractor{}} + + Swift []filesystem.Extractor = []filesystem.Extractor{ + packageresolved.New(packageresolved.DefaultConfig()), + podfilelock.New(podfilelock.DefaultConfig()), + } + // Containers extractors. Containers []filesystem.Extractor = []filesystem.Extractor{containerd.New(containerd.DefaultConfig())} @@ -196,9 +202,9 @@ var ( "dotnet": Dotnet, "php": PHP, "rust": Rust, + "swift": Swift, "sbom": SBOM, - "swift": Swift, "os": OS, "containers": Containers,