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
co-authored by Alex Goodman
parent deee79411a
commit 148fe572bc
28 changed files with 5008 additions and 4 deletions
@@ -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
+12
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")
}
+180
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))
}
+20
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
}
@@ -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,
}
}
@@ -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>
@@ -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>
@@ -0,0 +1 @@
this is not a valid plist
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>