added macOS .app cataloger (#4490)

* added macOS .app cataloger, fixed #4010

Signed-off-by: Rez Moss <hi@rezmoss.com>
Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* added macOS .app cataloger, fixed #4010

Signed-off-by: Rez Moss <hi@rezmoss.com>
Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* added macOS .app cataloger, fixed #4010

Signed-off-by: Rez Moss <hi@rezmoss.com>
Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* address review comments

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* bump schema to 16.1.7

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* address static analysis failures

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* migrate to testdata

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* expand fields and improve test coverage

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

---------

Signed-off-by: Rez Moss <hi@rezmoss.com>
Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
Co-authored-by: Alex Goodman <wagoodman@users.noreply.github.com>
This commit is contained in:
Rez Moss 2026-06-30 10:32:57 -04:00 committed by GitHub
parent deee79411a
commit 148fe572bc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 5008 additions and 4 deletions

View File

@ -89,6 +89,7 @@ func TestPkgCoverageImage(t *testing.T) {
definedPkgs.Remove(string(pkg.PhpPeclPkg)) // we have coverage for pear instead
definedPkgs.Remove(string(pkg.CondaPkg))
definedPkgs.Remove(string(pkg.ModelPkg))
definedPkgs.Remove(string(pkg.AppleAppBundlePkg))
var cases []testCase
cases = append(cases, commonTestCases...)
@ -164,6 +165,7 @@ func TestPkgCoverageDirectory(t *testing.T) {
definedPkgs.Remove(string(pkg.CondaPkg))
definedPkgs.Remove(string(pkg.PhpPeclPkg)) // this is covered as pear packages
definedPkgs.Remove(string(pkg.ModelPkg))
definedPkgs.Remove(string(pkg.AppleAppBundlePkg))
// for directory scans we should not expect to see any of the following package types
definedPkgs.Remove(string(pkg.KbPkg))

1
go.mod
View File

@ -104,6 +104,7 @@ require (
require (
github.com/pb33f/ordered-map/v2 v2.3.1
github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd
howett.net/plist v1.0.1
)
require (

4
go.sum
View File

@ -626,6 +626,7 @@ github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOl
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/jedib0t/go-pretty/v6 v6.8.1 h1:0fkCNhjrX0zPpwkWaDYU5VMrygg41Tu197mWILIJoqQ=
github.com/jedib0t/go-pretty/v6 v6.8.1/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8=
github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
@ -1489,6 +1490,7 @@ gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/ini.v1 v1.66.2/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
@ -1509,6 +1511,8 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=

View File

@ -3,7 +3,7 @@ package internal
const (
// JSONSchemaVersion is the current schema version output by the JSON encoder
// This is roughly following the "SchemaVer" guidelines for versioning the JSON schema. Please see schema/json/README.md for details on how to increment.
JSONSchemaVersion = "16.1.6"
JSONSchemaVersion = "16.1.7"
// Changelog
// 16.1.0 - reformulated the python pdm fields (added "URL" and removed the unused "path" field).
@ -13,5 +13,6 @@ const (
// 16.1.4 - add BunLockEntry metadata type for bun.lock support
// 16.1.5 - add DenoLockEntry and DenoRemoteLockEntry metadata types for deno.lock support
// 16.1.6 - add Dependencies to ElixirMixLockEntry metadata
// 16.1.7 - add AppleAppBundleEntry metadata type for the apple app bundle cataloger
)

View File

@ -9,6 +9,7 @@ func AllTypes() []any {
return []any{
pkg.AlpmDBEntry{},
pkg.ApkDBEntry{},
pkg.AppleAppBundleEntry{},
pkg.BinarySignature{},
pkg.BitnamiSBOMEntry{},
pkg.BunLockEntry{},

View File

@ -124,6 +124,7 @@ var jsonTypes = makeJSONTypes(
jsonNames(pkg.SnapEntry{}, "snap-entry"),
jsonNames(pkg.WordpressPluginEntry{}, "wordpress-plugin-entry", "WordpressMetadata"),
jsonNames(pkg.HomebrewFormula{}, "homebrew-formula"),
jsonNames(pkg.AppleAppBundleEntry{}, "apple-app-bundle-entry"),
jsonNames(pkg.LuaRocksPackage{}, "luarocks-package"),
jsonNames(pkg.TerraformLockProviderEntry{}, "terraform-lock-provider-entry"),
jsonNames(pkg.DotnetPackagesLockEntry{}, "dotnet-packages-lock-entry"),

View File

@ -5,6 +5,7 @@ import (
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/pkg/cataloger/ai"
"github.com/anchore/syft/syft/pkg/cataloger/alpine"
"github.com/anchore/syft/syft/pkg/cataloger/apple"
"github.com/anchore/syft/syft/pkg/cataloger/arch"
"github.com/anchore/syft/syft/pkg/cataloger/binary"
bitnamiSbomCataloger "github.com/anchore/syft/syft/pkg/cataloger/bitnami"
@ -177,6 +178,7 @@ func DefaultPackageTaskFactories() Factories {
newSimplePackageTaskFactory(wordpress.NewWordpressPluginCataloger, pkgcataloging.DirectoryTag, pkgcataloging.ImageTag, "wordpress"),
newSimplePackageTaskFactory(terraform.NewLockCataloger, pkgcataloging.DeclaredTag, pkgcataloging.DirectoryTag, "terraform"),
newSimplePackageTaskFactory(homebrew.NewCataloger, pkgcataloging.DirectoryTag, pkgcataloging.InstalledTag, pkgcataloging.ImageTag, "homebrew"),
newSimplePackageTaskFactory(apple.NewAppBundleCataloger, pkgcataloging.DirectoryTag, pkgcataloging.InstalledTag, pkgcataloging.ImageTag, "apple"),
newSimplePackageTaskFactory(conda.NewCondaMetaCataloger, pkgcataloging.DirectoryTag, pkgcataloging.InstalledTag, pkgcataloging.PackageTag, "conda"),
newSimplePackageTaskFactory(snap.NewCataloger, pkgcataloging.DirectoryTag, pkgcataloging.InstalledTag, pkgcataloging.ImageTag, "snap"),
newSimplePackageTaskFactory(ai.NewGGUFCataloger, pkgcataloging.DirectoryTag, pkgcataloging.ImageTag, "ai", "model", "gguf", "ml"),

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "anchore.io/schema/syft/json/16.1.6/document",
"$id": "anchore.io/schema/syft/json/16.1.7/document",
"$ref": "#/$defs/Document",
"$defs": {
"AlpmDbEntry": {
@ -249,6 +249,67 @@
],
"description": "ApkFileRecord represents a single file listing and metadata from a APK DB entry (which may have many of these file records)."
},
"AppleAppBundleEntry": {
"properties": {
"bundleIdentifier": {
"type": "string",
"description": "BundleIdentifier is the unique identifier for the bundle (e.g. \"com.apple.Safari\")"
},
"name": {
"type": "string",
"description": "Name is the short name of the bundle"
},
"displayName": {
"type": "string",
"description": "DisplayName is the user-facing name of the bundle"
},
"executable": {
"type": "string",
"description": "Executable is the name of the executable within the bundle"
},
"shortVersion": {
"type": "string",
"description": "ShortVersion is the release (marketing) version of the bundle"
},
"version": {
"type": "string",
"description": "Version is the build version of the bundle, which often differs from the short version"
},
"packageType": {
"type": "string",
"description": "PackageType is the four-letter type code (e.g. \"APPL\" for apps, \"FMWK\" for frameworks, \"BNDL\" for bundles)"
},
"supportedPlatforms": {
"items": {
"type": "string"
},
"type": "array",
"description": "SupportedPlatforms lists the platforms the bundle targets (e.g. \"MacOSX\", \"iPhoneOS\")"
},
"minimumSystemVersion": {
"type": "string",
"description": "MinimumSystemVersion is the minimum macOS version required to run the bundle"
},
"minimumOSVersion": {
"type": "string",
"description": "MinimumOSVersion is the minimum OS version required for non-macOS platforms (e.g. iOS)"
},
"copyright": {
"type": "string",
"description": "Copyright is the human-readable copyright notice for the bundle"
},
"platformName": {
"type": "string",
"description": "PlatformName is the platform name of the SDK used to build the bundle (e.g. \"macosx\")"
},
"sdkName": {
"type": "string",
"description": "SDKName is the name of the SDK used to build the bundle (e.g. \"macosx14.0\")"
}
},
"type": "object",
"description": "AppleAppBundleEntry represents metadata about an Apple application bundle (CFBundle) extracted from Info.plist files."
},
"BinarySignature": {
"properties": {
"matches": {
@ -2674,6 +2735,9 @@
{
"$ref": "#/$defs/ApkDbEntry"
},
{
"$ref": "#/$defs/AppleAppBundleEntry"
},
{
"$ref": "#/$defs/BinarySignature"
},

View File

@ -31,6 +31,7 @@ func Test_OriginatorSupplier(t *testing.T) {
pkg.GolangModuleEntry{},
pkg.GolangSourceEntry{},
pkg.HomebrewFormula{},
pkg.AppleAppBundleEntry{},
pkg.HackageStackYamlLockEntry{},
pkg.HackageStackYamlEntry{},
pkg.LinuxKernel{},

View File

@ -80,6 +80,8 @@ func SourceInfo(p pkg.Package) string {
answer = "acquired package info from found wordpress plugin PHP source files"
case pkg.HomebrewPkg:
answer = "acquired package info from Homebrew formula"
case pkg.AppleAppBundlePkg:
answer = "acquired package info from Apple application bundle Info.plist"
case pkg.TerraformPkg:
answer = "acquired package info from Terraform dependency lock file"
case pkg.ModelPkg:

View File

@ -343,6 +343,14 @@ func Test_SourceInfo(t *testing.T) {
"acquired package info from Homebrew formula",
},
},
{
input: pkg.Package{
Type: pkg.AppleAppBundlePkg,
},
expected: []string{
"acquired package info from Apple application bundle Info.plist",
},
},
{
input: pkg.Package{
Type: pkg.TerraformPkg,

44
syft/pkg/apple.go Normal file
View File

@ -0,0 +1,44 @@
package pkg
// AppleAppBundleEntry represents metadata about an Apple application bundle (CFBundle) extracted from Info.plist files.
// These bundles share the same plist structure across macOS, iOS, watchOS, visionOS, and others.
type AppleAppBundleEntry struct {
// BundleIdentifier is the unique identifier for the bundle (e.g. "com.apple.Safari")
BundleIdentifier string `json:"bundleIdentifier,omitempty"`
// Name is the short name of the bundle
Name string `json:"name,omitempty"`
// DisplayName is the user-facing name of the bundle
DisplayName string `json:"displayName,omitempty"`
// Executable is the name of the executable within the bundle
Executable string `json:"executable,omitempty"`
// ShortVersion is the release (marketing) version of the bundle
ShortVersion string `json:"shortVersion,omitempty"`
// Version is the build version of the bundle, which often differs from the short version
Version string `json:"version,omitempty"`
// PackageType is the four-letter type code (e.g. "APPL" for apps, "FMWK" for frameworks, "BNDL" for bundles)
PackageType string `json:"packageType,omitempty"`
// SupportedPlatforms lists the platforms the bundle targets (e.g. "MacOSX", "iPhoneOS")
SupportedPlatforms []string `json:"supportedPlatforms,omitempty"`
// MinimumSystemVersion is the minimum macOS version required to run the bundle
MinimumSystemVersion string `json:"minimumSystemVersion,omitempty"`
// MinimumOSVersion is the minimum OS version required for non-macOS platforms (e.g. iOS)
MinimumOSVersion string `json:"minimumOSVersion,omitempty"`
// Copyright is the human-readable copyright notice for the bundle
Copyright string `json:"copyright,omitempty"`
// PlatformName is the platform name of the SDK used to build the bundle (e.g. "macosx")
PlatformName string `json:"platformName,omitempty"`
// SDKName is the name of the SDK used to build the bundle (e.g. "macosx14.0")
SDKName string `json:"sdkName,omitempty"`
}

View File

@ -0,0 +1,42 @@
# Cataloger capabilities. See ../README.md for documentation.
catalogers:
- ecosystem: apple # MANUAL
name: apple-app-bundle-cataloger # AUTO-GENERATED
type: generic # AUTO-GENERATED
source: # AUTO-GENERATED
file: syft/pkg/cataloger/apple/cataloger.go
function: NewAppBundleCataloger
selectors: # AUTO-GENERATED
- apple
- directory
- image
- installed
- package
parsers: # AUTO-GENERATED structure
- function: parseInfoPlist
detector: # AUTO-GENERATED
method: glob # AUTO-GENERATED
criteria: # AUTO-GENERATED
- '**/*.app/Contents/Info.plist'
metadata_types: # AUTO-GENERATED
- pkg.AppleAppBundleEntry
package_types: # AUTO-GENERATED
- apple-app-bundle
json_schema_types: # AUTO-GENERATED
- AppleAppBundleEntry
capabilities: # MANUAL - preserved across regeneration
- name: license
default: false
- name: dependency.depth
default: []
- name: dependency.edges
default: ""
- name: dependency.kinds
default: []
- name: package_manager.files.listing
default: false
- name: package_manager.files.digests
default: false
- name: package_manager.package_integrity_hash
default: false

View File

@ -0,0 +1,12 @@
package apple
import (
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/pkg/cataloger/generic"
)
// NewAppBundleCataloger returns a cataloger for Apple application bundles (.app), reading their Info.plist (CFBundle).
func NewAppBundleCataloger() pkg.Cataloger {
return generic.NewCataloger("apple-app-bundle-cataloger").
WithParserByGlobs(parseInfoPlist, "**/*.app/Contents/Info.plist")
}

View File

@ -0,0 +1,180 @@
package apple
import (
"context"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/pkg/cataloger/internal/pkgtest"
)
func Test_AppleAppBundleCataloger_Globs(t *testing.T) {
pkgtest.NewCatalogTester().
FromDirectory(t, "testdata/install-example").
ExpectsResolverContentQueries([]string{
"Applications/Slack.app/Contents/Info.plist",
}).
TestCataloger(t, NewAppBundleCataloger())
}
func Test_AppleAppBundleCataloger(t *testing.T) {
tests := []struct {
name string
fixture string
expected []pkg.Package
}{
{
name: "xml plist (Slack)",
fixture: "testdata/install-example",
expected: []pkg.Package{
{
Name: "Slack",
Version: "4.50.128",
Type: pkg.AppleAppBundlePkg,
FoundBy: "apple-app-bundle-cataloger",
Locations: file.NewLocationSet(
file.NewLocation("Applications/Slack.app/Contents/Info.plist").WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation),
),
PURL: "", // no standard purl type for apple app bundles
Metadata: pkg.AppleAppBundleEntry{
BundleIdentifier: "com.tinyspeck.slackmacgap",
Name: "Slack",
DisplayName: "Slack",
Executable: "Slack",
ShortVersion: "4.50.128",
Version: "450000128",
PackageType: "APPL",
MinimumSystemVersion: "12.0",
Copyright: "©2026 Slack Technologies LLC, a Salesforce company. All rights reserved.",
SDKName: "macosx15.5",
},
},
},
},
{
// platform/array fields; values are from a real Ghostty.app
name: "platform fields (Ghostty)",
fixture: "testdata/native-app-example",
expected: []pkg.Package{
{
Name: "Ghostty",
Version: "1.3.1",
Type: pkg.AppleAppBundlePkg,
FoundBy: "apple-app-bundle-cataloger",
Locations: file.NewLocationSet(
file.NewLocation("Applications/Ghostty.app/Contents/Info.plist").WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation),
),
PURL: "",
Metadata: pkg.AppleAppBundleEntry{
BundleIdentifier: "com.mitchellh.ghostty",
Name: "Ghostty",
DisplayName: "Ghostty",
Executable: "ghostty",
ShortVersion: "1.3.1",
Version: "15212",
PackageType: "APPL",
SupportedPlatforms: []string{"MacOSX"},
MinimumSystemVersion: "13.0",
PlatformName: "macosx",
SDKName: "macosx26.2",
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pkgtest.NewCatalogTester().
FromDirectory(t, tt.fixture).
Expects(tt.expected, nil).
TestCataloger(t, NewAppBundleCataloger())
})
}
}
func Test_parseInfoPlist(t *testing.T) {
tests := []struct {
name string
fixture string
wantName string
wantVersion string
wantBundleID string
wantNoPackage bool
wantParseErr bool
}{
{
name: "name falls back to executable",
fixture: "testdata/parse-cases/name-from-executable.plist",
wantName: "Exec",
wantVersion: "1.0",
},
{
name: "name falls back to bundle identifier",
fixture: "testdata/parse-cases/name-from-identifier.plist",
wantName: "com.example.app",
wantVersion: "1.0",
wantBundleID: "com.example.app",
},
{
name: "version falls back to bundle version",
fixture: "testdata/parse-cases/version-from-bundle-version.plist",
wantName: "Name",
wantVersion: "42",
},
{
name: "no package without a name",
fixture: "testdata/parse-cases/no-name.plist",
wantNoPackage: true,
},
{
name: "no package without a version",
fixture: "testdata/parse-cases/no-version.plist",
wantNoPackage: true,
},
{
name: "malformed plist returns an error",
fixture: "testdata/parse-cases/malformed.plist",
wantParseErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pkgs, _, err := parseFixture(t, tt.fixture)
if tt.wantParseErr {
require.Error(t, err)
return
}
require.NoError(t, err)
if tt.wantNoPackage {
assert.Empty(t, pkgs)
return
}
require.Len(t, pkgs, 1)
assert.Equal(t, tt.wantName, pkgs[0].Name)
assert.Equal(t, tt.wantVersion, pkgs[0].Version)
assert.Equal(t, pkg.AppleAppBundlePkg, pkgs[0].Type)
assert.Empty(t, pkgs[0].PURL)
meta, ok := pkgs[0].Metadata.(pkg.AppleAppBundleEntry)
require.True(t, ok)
assert.Equal(t, tt.wantBundleID, meta.BundleIdentifier)
})
}
}
func parseFixture(t *testing.T, path string) ([]pkg.Package, []artifact.Relationship, error) {
t.Helper()
f, err := os.Open(path)
require.NoError(t, err)
t.Cleanup(func() { _ = f.Close() })
return parseInfoPlist(context.Background(), nil, nil, file.NewLocationReadCloser(file.NewLocation(path), f))
}

View File

@ -0,0 +1,20 @@
package apple
import (
"github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/pkg"
)
func newAppBundlePackage(name, version string, metadata pkg.AppleAppBundleEntry, location file.Location) pkg.Package {
p := pkg.Package{
Name: name,
Version: version,
Type: pkg.AppleAppBundlePkg,
// note: there is no standard purl type for Apple app bundles, so we intentionally leave the PURL empty.
Locations: file.NewLocationSet(location.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation)),
Metadata: metadata,
}
p.SetID()
return p
}

View File

@ -0,0 +1,91 @@
package apple
import (
"context"
"fmt"
"io"
"howett.net/plist"
"github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/pkg/cataloger/generic"
)
// infoPlist captures the raw CFBundle keys read from an Info.plist. It is kept separate from the public
// pkg.AppleAppBundleEntry so the parser can read fields without committing to expressing all of them.
type infoPlist struct {
CFBundleIdentifier string `plist:"CFBundleIdentifier"`
CFBundleName string `plist:"CFBundleName"`
CFBundleDisplayName string `plist:"CFBundleDisplayName"`
CFBundleExecutable string `plist:"CFBundleExecutable"`
CFBundleShortVersionString string `plist:"CFBundleShortVersionString"`
CFBundleVersion string `plist:"CFBundleVersion"`
CFBundlePackageType string `plist:"CFBundlePackageType"`
CFBundleSupportedPlatforms []string `plist:"CFBundleSupportedPlatforms"`
LSMinimumSystemVersion string `plist:"LSMinimumSystemVersion"`
MinimumOSVersion string `plist:"MinimumOSVersion"`
NSHumanReadableCopyright string `plist:"NSHumanReadableCopyright"`
DTPlatformName string `plist:"DTPlatformName"`
DTSDKName string `plist:"DTSDKName"`
}
func parseInfoPlist(_ context.Context, _ file.Resolver, _ *generic.Environment, reader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) {
// 5MB cap matches the convention in syft/linux; Info.plist files are far smaller
data, err := io.ReadAll(io.LimitReader(reader, 5*1024*1024))
if err != nil {
return nil, nil, fmt.Errorf("unable to read file: %w", err)
}
var info infoPlist
if _, err := plist.Unmarshal(data, &info); err != nil {
return nil, nil, fmt.Errorf("unable to parse plist: %w", err)
}
name := info.CFBundleDisplayName
if name == "" {
name = info.CFBundleName
}
if name == "" {
name = info.CFBundleExecutable
}
if name == "" {
// last-resort name so identifiable bundles aren't dropped (e.g. com.apple.Safari)
name = info.CFBundleIdentifier
}
version := info.CFBundleShortVersionString
if version == "" {
version = info.CFBundleVersion
}
if name == "" || version == "" {
return nil, nil, nil
}
pkgs := []pkg.Package{
newAppBundlePackage(name, version, info.toEntry(), reader.Location),
}
return pkgs, nil, nil
}
// toEntry maps the parsed plist into the public metadata type.
func (i infoPlist) toEntry() pkg.AppleAppBundleEntry {
return pkg.AppleAppBundleEntry{
BundleIdentifier: i.CFBundleIdentifier,
Name: i.CFBundleName,
DisplayName: i.CFBundleDisplayName,
Executable: i.CFBundleExecutable,
ShortVersion: i.CFBundleShortVersionString,
Version: i.CFBundleVersion,
PackageType: i.CFBundlePackageType,
SupportedPlatforms: i.CFBundleSupportedPlatforms,
MinimumSystemVersion: i.LSMinimumSystemVersion,
MinimumOSVersion: i.MinimumOSVersion,
Copyright: i.NSHumanReadableCopyright,
PlatformName: i.DTPlatformName,
SDKName: i.DTSDKName,
}
}

View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key>
<string>Slack</string>
<key>CFBundleName</key>
<string>Slack</string>
<key>CFBundleExecutable</key>
<string>Slack</string>
<key>CFBundleIdentifier</key>
<string>com.tinyspeck.slackmacgap</string>
<key>CFBundleShortVersionString</key>
<string>4.50.128</string>
<key>CFBundleVersion</key>
<string>450000128</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
<key>NSHumanReadableCopyright</key>
<string>©2026 Slack Technologies LLC, a Salesforce company. All rights reserved.</string>
<key>DTSDKName</key>
<string>macosx15.5</string>
</dict>
</plist>

View File

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>Ghostty</string>
<key>CFBundleDisplayName</key>
<string>Ghostty</string>
<key>CFBundleExecutable</key>
<string>ghostty</string>
<key>CFBundleIdentifier</key>
<string>com.mitchellh.ghostty</string>
<key>CFBundleShortVersionString</key>
<string>1.3.1</string>
<key>CFBundleVersion</key>
<string>15212</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleSupportedPlatforms</key>
<array>
<string>MacOSX</string>
</array>
<key>LSMinimumSystemVersion</key>
<string>13.0</string>
<key>DTPlatformName</key>
<string>macosx</string>
<key>DTSDKName</key>
<string>macosx26.2</string>
</dict>
</plist>

View File

@ -0,0 +1 @@
this is not a valid plist

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>Exec</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
</dict>
</plist>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string>com.example.app</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
</dict>
</plist>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
</dict>
</plist>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>Name</string>
</dict>
</plist>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>Name</string>
<key>CFBundleVersion</key>
<string>42</string>
</dict>
</plist>

View File

@ -54,6 +54,7 @@ const (
TerraformPkg Type = "terraform"
WordpressPluginPkg Type = "wordpress-plugin"
HomebrewPkg Type = "homebrew"
AppleAppBundlePkg Type = "apple-app-bundle"
ModelPkg Type = "model"
)
@ -99,6 +100,7 @@ var AllPkgs = []Type{
TerraformPkg,
WordpressPluginPkg,
HomebrewPkg,
AppleAppBundlePkg,
ModelPkg,
}

View File

@ -160,8 +160,9 @@ func TestTypeFromPURL(t *testing.T) {
expectedTypes.Remove(string(HomebrewPkg))
expectedTypes.Remove(string(TerraformPkg))
expectedTypes.Remove(string(GraalVMNativeImagePkg))
expectedTypes.Remove(string(ModelPkg)) // no valid purl for ai artifacts currently
expectedTypes.Remove(string(PhpPeclPkg)) // we should always consider this a pear package
expectedTypes.Remove(string(ModelPkg)) // no valid purl for ai artifacts currently
expectedTypes.Remove(string(AppleAppBundlePkg)) // no standard purl type for apple app bundles
expectedTypes.Remove(string(PhpPeclPkg)) // we should always consider this a pear package
for _, test := range tests {
t.Run(string(test.expected), func(t *testing.T) {