diff --git a/orbit/changes/45206-adobe-plugins-table b/orbit/changes/45206-adobe-plugins-table new file mode 100644 index 0000000000..fe76483ba8 --- /dev/null +++ b/orbit/changes/45206-adobe-plugins-table @@ -0,0 +1 @@ +* Added new `adobe_plugins` osquery extension table to fleetd that detects Adobe CEP, UXP, and native plug-ins on macOS and Windows by scanning well-known directories and parsing plugin manifests for name, version, vendor, host application, and other metadata. diff --git a/orbit/pkg/table/adobe_plugins/adobe_plugins.go b/orbit/pkg/table/adobe_plugins/adobe_plugins.go new file mode 100644 index 0000000000..5695a422b4 --- /dev/null +++ b/orbit/pkg/table/adobe_plugins/adobe_plugins.go @@ -0,0 +1,368 @@ +// Package adobe_plugins implements an osquery extension table that detects +// Adobe plugins (CEP extensions, UXP extensions, and native plug-ins) on +// macOS and Windows endpoints by scanning well-known directories and parsing +// plugin manifests. +// +// The table supports a scan_level constraint in the WHERE clause: +// +// SELECT * FROM adobe_plugins; -- standard (default) +// SELECT * FROM adobe_plugins WHERE scan_level = 'deep'; -- includes native plug-ins +// +// Standard: scans CEP and UXP extension directories only. +// Deep: additionally scans application-specific native plug-in directories +// (Photoshop, Premiere Pro, After Effects, Illustrator). +package adobe_plugins + +import ( + "context" + "encoding/json" + "encoding/xml" + "io" + "io/fs" + "os" + "path/filepath" + "runtime" + "slices" + "strings" + + "github.com/fleetdm/fleet/v4/orbit/pkg/table/tablehelpers" + "github.com/osquery/osquery-go/plugin/table" + "github.com/rs/zerolog" +) + +// maxManifestSize is the maximum size of a manifest file we'll read. +// Prevents memory exhaustion from unexpectedly large files (orbit runs as root). +const maxManifestSize = 1 << 20 // 1 MB + +const tableName = "adobe_plugins" + +const ( + colPath = "path" + colName = "name" + colVersion = "version" + colVendor = "vendor" + colBundleID = "bundle_id" + colHostApplication = "host_application" + colExtensionType = "extension_type" + colUser = "user" + colPlatform = "platform" + colScanLevel = "scan_level" +) + +// scanPath describes a directory to scan for Adobe plugins. +type scanPath struct { + basePath string // directory path, may contain glob wildcards + extensionType string // "CEP", "UXP", or "native" + hostApp string // known host application from path context + user string // username for user-scoped installs, empty for system +} + +// hostAppCodes maps Adobe host application codes found in manifests to +// human-readable application names. +var hostAppCodes = map[string]string{ + "PHXS": "Photoshop", + "PHSP": "Photoshop", + "PS": "Photoshop", + "ILST": "Illustrator", + "AI": "Illustrator", + "PPRO": "Premiere Pro", + "AEFT": "After Effects", + "AE": "After Effects", + "IDSN": "InDesign", + "ID": "InDesign", + "FLPR": "Animate", + "DRWV": "Dreamweaver", + "AUDT": "Audition", + "AU": "Audition", + "KBRG": "Bridge", + "LTRM": "Lightroom", + "LRCC": "Lightroom Classic", + "XD": "XD", + "AICY": "InCopy", + "PRLD": "Prelude", +} + +type adobePluginsTable struct { + logger zerolog.Logger +} + +// TablePlugin returns the osquery plugin for the adobe_plugins table. +func TablePlugin(logger zerolog.Logger) *table.Plugin { + t := &adobePluginsTable{ + logger: logger.With().Str("table", tableName).Logger(), + } + return table.NewPlugin(tableName, Columns(), t.generate) +} + +// Columns defines the table schema. +func Columns() []table.ColumnDefinition { + return []table.ColumnDefinition{ + table.TextColumn(colPath), + table.TextColumn(colName), + table.TextColumn(colVersion), + table.TextColumn(colVendor), + table.TextColumn(colBundleID), + table.TextColumn(colHostApplication), + table.TextColumn(colExtensionType), + table.TextColumn(colUser), + table.TextColumn(colPlatform), + // scan_level controls scan depth. Populated in results so osquery's + // post-generate WHERE filter doesn't discard rows. + table.TextColumn(colScanLevel), + } +} + +func (t *adobePluginsTable) generate(ctx context.Context, queryContext table.QueryContext) ([]map[string]string, error) { + scanLevels := tablehelpers.GetConstraints(queryContext, colScanLevel, + tablehelpers.WithDefaults("standard"), + tablehelpers.WithAllowedValues([]string{"standard", "deep"}), + tablehelpers.WithLogger(t.logger), + ) + + level := "standard" + if slices.Contains(scanLevels, "deep") { + level = "deep" + } + + paths, err := getScanPaths(level, t.logger) + if err != nil { + t.logger.Warn().Err(err).Msg("failed to build scan paths") + return nil, nil + } + + var results []map[string]string + seen := make(map[string]struct{}) + + for _, sp := range paths { + if ctx.Err() != nil { + return results, nil + } + + matches, err := filepath.Glob(sp.basePath) + if err != nil { + t.logger.Debug().Err(err).Str("path", sp.basePath).Msg("glob error") + continue + } + + for _, dir := range matches { + if ctx.Err() != nil { + return results, nil + } + + entries, err := os.ReadDir(dir) + if err != nil { + t.logger.Debug().Err(err).Str("dir", dir).Msg("cannot read directory") + continue + } + + for _, entry := range entries { + // Skip symlinks to avoid traversing outside intended scan dirs. + if entry.Type()&fs.ModeSymlink != 0 { + continue + } + + pluginPath := filepath.Join(dir, entry.Name()) + + if _, ok := seen[pluginPath]; ok { + continue + } + seen[pluginPath] = struct{}{} + + row := t.scanEntry(pluginPath, entry, sp) + if row != nil { + row[colScanLevel] = level + results = append(results, row) + } + } + } + } + + return results, nil +} + +func (t *adobePluginsTable) scanEntry(pluginPath string, entry os.DirEntry, sp scanPath) map[string]string { + switch sp.extensionType { + case "CEP": + if !entry.IsDir() { + return nil + } + return t.parseCEPPlugin(pluginPath, sp) + case "UXP": + if !entry.IsDir() { + return nil + } + return t.parseUXPPlugin(pluginPath, sp) + case "native": + if strings.HasPrefix(entry.Name(), ".") { + return nil + } + return parseNativePlugin(pluginPath, entry, sp) + } + return nil +} + +// CEP manifest XML structures (CSXS/manifest.xml) + +type cepManifest struct { + XMLName xml.Name `xml:"ExtensionManifest"` + BundleID string `xml:"ExtensionBundleId,attr"` + Version string `xml:"ExtensionBundleVersion,attr"` + Author struct { + Name string `xml:"Name,attr"` + } `xml:"Author"` + ExecutionEnvironment struct { + HostList struct { + Hosts []struct { + Name string `xml:"Name,attr"` + } `xml:"Host"` + } `xml:"HostList"` + } `xml:"ExecutionEnvironment"` +} + +func (t *adobePluginsTable) parseCEPPlugin(pluginPath string, sp scanPath) map[string]string { + row := map[string]string{ + colPath: pluginPath, + colName: filepath.Base(pluginPath), + colExtensionType: "CEP", + colUser: sp.user, + colPlatform: runtime.GOOS, + } + + manifestPath := filepath.Join(pluginPath, "CSXS", "manifest.xml") + data, err := readFileCapped(manifestPath, maxManifestSize) + if err != nil { + t.logger.Debug().Err(err).Str("path", manifestPath).Msg("no CEP manifest found") + return row + } + + var m cepManifest + if err := xml.Unmarshal(data, &m); err != nil { + t.logger.Debug().Err(err).Str("path", manifestPath).Msg("failed to parse CEP manifest") + return row + } + + row[colVersion] = m.Version + row[colVendor] = m.Author.Name + row[colBundleID] = m.BundleID + + var hostCodes []string + for _, h := range m.ExecutionEnvironment.HostList.Hosts { + hostCodes = append(hostCodes, h.Name) + } + hostApps := resolveHostApps(hostCodes) + if hostApps == "" { + hostApps = sp.hostApp + } + row[colHostApplication] = hostApps + + return row +} + +// UXP manifest JSON structures (manifest.json) + +type uxpManifest struct { + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + Host []struct { + App string `json:"app"` + } `json:"host"` + Metadata struct { + Publisher string `json:"publisher"` + } `json:"metadata"` +} + +func (t *adobePluginsTable) parseUXPPlugin(pluginPath string, sp scanPath) map[string]string { + row := map[string]string{ + colPath: pluginPath, + colName: filepath.Base(pluginPath), + colExtensionType: "UXP", + colUser: sp.user, + colPlatform: runtime.GOOS, + } + + manifestPath := filepath.Join(pluginPath, "manifest.json") + data, err := readFileCapped(manifestPath, maxManifestSize) + if err != nil { + t.logger.Debug().Err(err).Str("path", manifestPath).Msg("no UXP manifest found") + return row + } + + var m uxpManifest + if err := json.Unmarshal(data, &m); err != nil { + t.logger.Debug().Err(err).Str("path", manifestPath).Msg("failed to parse UXP manifest") + return row + } + + name := m.Name + if name == "" { + name = m.ID + } + if name == "" { + name = filepath.Base(pluginPath) + } + row[colName] = name + row[colVersion] = m.Version + row[colVendor] = m.Metadata.Publisher + row[colBundleID] = m.ID + + var hostCodes []string + for _, h := range m.Host { + hostCodes = append(hostCodes, h.App) + } + hostApps := resolveHostApps(hostCodes) + if hostApps == "" { + hostApps = sp.hostApp + } + row[colHostApplication] = hostApps + + return row +} + +func parseNativePlugin(pluginPath string, entry os.DirEntry, sp scanPath) map[string]string { + name := entry.Name() + for _, ext := range []string{".plugin", ".bundle", ".8bf", ".8bi", ".dll", ".aex"} { + if strings.HasSuffix(strings.ToLower(name), ext) { + name = name[:len(name)-len(ext)] + break + } + } + + return map[string]string{ + colPath: pluginPath, + colName: name, + colHostApplication: sp.hostApp, + colExtensionType: "native", + colUser: sp.user, + colPlatform: runtime.GOOS, + } +} + +// readFileCapped reads up to maxBytes from a file. This prevents memory +// exhaustion from unexpectedly large files since orbit runs as root. +func readFileCapped(path string, maxBytes int64) ([]byte, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + return io.ReadAll(io.LimitReader(f, maxBytes)) +} + +// resolveHostApps converts a list of Adobe host application codes to +// human-readable names, deduplicating entries. +func resolveHostApps(codes []string) string { + seen := make(map[string]struct{}) + var apps []string + for _, code := range codes { + app := code + if resolved, ok := hostAppCodes[strings.ToUpper(code)]; ok { + app = resolved + } + if _, exists := seen[app]; !exists { + seen[app] = struct{}{} + apps = append(apps, app) + } + } + return strings.Join(apps, ", ") +} diff --git a/orbit/pkg/table/adobe_plugins/adobe_plugins_darwin.go b/orbit/pkg/table/adobe_plugins/adobe_plugins_darwin.go new file mode 100644 index 0000000000..9a398d4035 --- /dev/null +++ b/orbit/pkg/table/adobe_plugins/adobe_plugins_darwin.go @@ -0,0 +1,99 @@ +//go:build darwin + +package adobe_plugins + +import ( + "os" + "path/filepath" + "strings" + + "github.com/rs/zerolog" +) + +func getScanPaths(level string, logger zerolog.Logger) ([]scanPath, error) { + var paths []scanPath + + // System-wide CEP extensions + paths = append(paths, scanPath{ + basePath: "/Library/Application Support/Adobe/CEP/extensions", + extensionType: "CEP", + }) + // System-wide UXP extensions + paths = append(paths, scanPath{ + basePath: "/Library/Application Support/Adobe/UXP/extensions", + extensionType: "UXP", + }) + + // Per-user CEP and UXP extensions + users, err := listLocalUsers() + if err != nil { + logger.Warn().Err(err).Msg("failed to enumerate local users, skipping per-user paths") + } + for _, u := range users { + paths = append(paths, scanPath{ + basePath: filepath.Join(u.homeDir, "Library", "Application Support", "Adobe", "CEP", "extensions"), + extensionType: "CEP", + user: u.name, + }) + paths = append(paths, scanPath{ + basePath: filepath.Join(u.homeDir, "Library", "Application Support", "Adobe", "UXP", "extensions"), + extensionType: "UXP", + user: u.name, + }) + } + + if level == "deep" { + paths = append(paths, + scanPath{ + basePath: "/Applications/Adobe Photoshop */Plug-ins", + extensionType: "native", + hostApp: "Photoshop", + }, + scanPath{ + basePath: "/Applications/Adobe Premiere Pro */Plug-ins", + extensionType: "native", + hostApp: "Premiere Pro", + }, + scanPath{ + basePath: "/Applications/Adobe After Effects */Plug-ins", + extensionType: "native", + hostApp: "After Effects", + }, + scanPath{ + basePath: "/Applications/Adobe Illustrator */Plug-ins", + extensionType: "native", + hostApp: "Illustrator", + }, + ) + } + + return paths, nil +} + +type localUser struct { + name string + homeDir string +} + +func listLocalUsers() ([]localUser, error) { + entries, err := os.ReadDir("/Users") + if err != nil { + return nil, err + } + + var users []localUser + for _, e := range entries { + if !e.IsDir() { + continue + } + name := e.Name() + if strings.HasPrefix(name, ".") || name == "Shared" { + continue + } + users = append(users, localUser{ + name: name, + homeDir: filepath.Join("/Users", name), + }) + } + return users, nil +} diff --git a/orbit/pkg/table/adobe_plugins/adobe_plugins_stub.go b/orbit/pkg/table/adobe_plugins/adobe_plugins_stub.go new file mode 100644 index 0000000000..34008d37f9 --- /dev/null +++ b/orbit/pkg/table/adobe_plugins/adobe_plugins_stub.go @@ -0,0 +1,11 @@ +//go:build !darwin && !windows + +package adobe_plugins + +import "github.com/rs/zerolog" + +// getScanPaths returns nil on unsupported platforms (Linux, etc.). +// Adobe Creative Cloud does not run on Linux. +func getScanPaths(_ string, _ zerolog.Logger) ([]scanPath, error) { + return nil, nil +} diff --git a/orbit/pkg/table/adobe_plugins/adobe_plugins_test.go b/orbit/pkg/table/adobe_plugins/adobe_plugins_test.go new file mode 100644 index 0000000000..2a19a1057a --- /dev/null +++ b/orbit/pkg/table/adobe_plugins/adobe_plugins_test.go @@ -0,0 +1,336 @@ +package adobe_plugins + +import ( + "os" + "path/filepath" + "testing" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseCEPPlugin(t *testing.T) { + t.Parallel() + + tbl := &adobePluginsTable{logger: zerolog.Nop()} + + t.Run("valid manifest", func(t *testing.T) { + t.Parallel() + pluginPath := filepath.Join("testdata", "cep_plugin") + sp := scanPath{extensionType: "CEP"} + + row := tbl.parseCEPPlugin(pluginPath, sp) + require.NotNil(t, row) + + assert.Equal(t, pluginPath, row[colPath]) + assert.Equal(t, "cep_plugin", row[colName]) + assert.Equal(t, "2.1.0", row[colVersion]) + assert.Equal(t, "Test Vendor", row[colVendor]) + assert.Equal(t, "com.example.test.plugin", row[colBundleID]) + assert.Contains(t, row[colHostApplication], "Photoshop") + assert.Contains(t, row[colHostApplication], "Illustrator") + assert.Equal(t, "CEP", row[colExtensionType]) + }) + + t.Run("missing manifest falls back to dir name", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + pluginPath := filepath.Join(dir, "no_manifest_plugin") + require.NoError(t, os.MkdirAll(pluginPath, 0o755)) + + sp := scanPath{extensionType: "CEP", user: "testuser"} + row := tbl.parseCEPPlugin(pluginPath, sp) + require.NotNil(t, row) + + assert.Equal(t, "no_manifest_plugin", row[colName]) + assert.Equal(t, "CEP", row[colExtensionType]) + assert.Equal(t, "testuser", row[colUser]) + assert.Empty(t, row[colVersion]) + assert.Empty(t, row[colBundleID]) + }) + + t.Run("malformed manifest falls back to dir name", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + pluginPath := filepath.Join(dir, "bad_manifest") + require.NoError(t, os.MkdirAll(filepath.Join(pluginPath, "CSXS"), 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(pluginPath, "CSXS", "manifest.xml"), + []byte("not valid xml {{{"), + 0o644, + )) + + sp := scanPath{extensionType: "CEP"} + row := tbl.parseCEPPlugin(pluginPath, sp) + require.NotNil(t, row) + + assert.Equal(t, "bad_manifest", row[colName]) + assert.Equal(t, "CEP", row[colExtensionType]) + assert.Empty(t, row[colVersion]) + }) +} + +func TestParseUXPPlugin(t *testing.T) { + t.Parallel() + + tbl := &adobePluginsTable{logger: zerolog.Nop()} + + t.Run("valid manifest", func(t *testing.T) { + t.Parallel() + pluginPath := filepath.Join("testdata", "uxp_plugin") + sp := scanPath{extensionType: "UXP"} + + row := tbl.parseUXPPlugin(pluginPath, sp) + require.NotNil(t, row) + + assert.Equal(t, pluginPath, row[colPath]) + assert.Equal(t, "Test UXP Plugin", row[colName]) + assert.Equal(t, "3.0.1", row[colVersion]) + assert.Equal(t, "UXP Test Vendor", row[colVendor]) + assert.Equal(t, "com.example.uxp.plugin", row[colBundleID]) + assert.Contains(t, row[colHostApplication], "Photoshop") + assert.Contains(t, row[colHostApplication], "XD") + assert.Equal(t, "UXP", row[colExtensionType]) + }) + + t.Run("missing manifest falls back to dir name", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + pluginPath := filepath.Join(dir, "some_uxp_ext") + require.NoError(t, os.MkdirAll(pluginPath, 0o755)) + + sp := scanPath{extensionType: "UXP", user: "alice"} + row := tbl.parseUXPPlugin(pluginPath, sp) + require.NotNil(t, row) + + assert.Equal(t, "some_uxp_ext", row[colName]) + assert.Equal(t, "alice", row[colUser]) + assert.Empty(t, row[colVersion]) + }) + + t.Run("manifest with empty name and id falls back to dir name", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + pluginPath := filepath.Join(dir, "dirname-fallback") + require.NoError(t, os.MkdirAll(pluginPath, 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(pluginPath, "manifest.json"), + []byte(`{"version": "1.0"}`), + 0o644, + )) + + sp := scanPath{extensionType: "UXP"} + row := tbl.parseUXPPlugin(pluginPath, sp) + require.NotNil(t, row) + + assert.Equal(t, "dirname-fallback", row[colName]) + assert.Equal(t, "1.0", row[colVersion]) + assert.Empty(t, row[colBundleID]) + }) + + t.Run("manifest with id but no name uses id", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + pluginPath := filepath.Join(dir, "id_only") + require.NoError(t, os.MkdirAll(pluginPath, 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(pluginPath, "manifest.json"), + []byte(`{"id": "com.vendor.idonly", "version": "1.0"}`), + 0o644, + )) + + sp := scanPath{extensionType: "UXP"} + row := tbl.parseUXPPlugin(pluginPath, sp) + require.NotNil(t, row) + + assert.Equal(t, "com.vendor.idonly", row[colName]) + assert.Equal(t, "com.vendor.idonly", row[colBundleID]) + assert.Equal(t, "1.0", row[colVersion]) + }) +} + +func TestParseNativePlugin(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + fileName string + expectedName string + }{ + {"macOS plugin bundle", "MyPlugin.plugin", "MyPlugin"}, + {"Photoshop filter 8bf", "CoolFilter.8bf", "CoolFilter"}, + {"After Effects plugin", "Effect.aex", "Effect"}, + {"Windows DLL plugin", "Plugin.dll", "Plugin"}, + {"no extension", "SomePlugin", "SomePlugin"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + pluginPath := filepath.Join(dir, tt.fileName) + + f, err := os.Create(pluginPath) + require.NoError(t, err) + f.Close() + + entries, err := os.ReadDir(dir) + require.NoError(t, err) + require.Len(t, entries, 1) + + sp := scanPath{extensionType: "native", hostApp: "Photoshop"} + row := parseNativePlugin(pluginPath, entries[0], sp) + require.NotNil(t, row) + + assert.Equal(t, tt.expectedName, row[colName]) + assert.Equal(t, "Photoshop", row[colHostApplication]) + assert.Equal(t, "native", row[colExtensionType]) + }) + } +} + +func TestResolveHostApps(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + codes []string + expected string + }{ + {"single known code", []string{"PHXS"}, "Photoshop"}, + {"multiple codes", []string{"PHXS", "ILST"}, "Photoshop, Illustrator"}, + {"deduplicates same app", []string{"PHXS", "PHSP"}, "Photoshop"}, + {"unknown code passes through", []string{"UNKNOWN"}, "UNKNOWN"}, + {"mixed known and unknown", []string{"PPRO", "CUSTOM"}, "Premiere Pro, CUSTOM"}, + {"empty list", nil, ""}, + {"case insensitive", []string{"phxs"}, "Photoshop"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := resolveHostApps(tt.codes) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestScanEntry(t *testing.T) { + t.Parallel() + + tbl := &adobePluginsTable{logger: zerolog.Nop()} + + t.Run("CEP skips non-directory entries", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + f, err := os.Create(filepath.Join(dir, "notadir.txt")) + require.NoError(t, err) + f.Close() + + entries, err := os.ReadDir(dir) + require.NoError(t, err) + + sp := scanPath{extensionType: "CEP"} + row := tbl.scanEntry(filepath.Join(dir, "notadir.txt"), entries[0], sp) + assert.Nil(t, row) + }) + + t.Run("native skips hidden files", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + f, err := os.Create(filepath.Join(dir, ".DS_Store")) + require.NoError(t, err) + f.Close() + + entries, err := os.ReadDir(dir) + require.NoError(t, err) + + sp := scanPath{extensionType: "native", hostApp: "Photoshop"} + row := tbl.scanEntry(filepath.Join(dir, ".DS_Store"), entries[0], sp) + assert.Nil(t, row) + }) +} + +func TestOversizedManifestFallback(t *testing.T) { + t.Parallel() + + tbl := &adobePluginsTable{logger: zerolog.Nop()} + + t.Run("CEP manifest over 1MB falls back to dir name", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + pluginPath := filepath.Join(dir, "huge-manifest") + require.NoError(t, os.MkdirAll(filepath.Join(pluginPath, "CSXS"), 0o755)) + + // Write a manifest larger than maxManifestSize (1MB) + bigData := make([]byte, maxManifestSize+100) + copy(bigData, []byte("")) + require.NoError(t, os.WriteFile( + filepath.Join(pluginPath, "CSXS", "manifest.xml"), + bigData, + 0o644, + )) + + sp := scanPath{extensionType: "CEP"} + row := tbl.parseCEPPlugin(pluginPath, sp) + require.NotNil(t, row) + + assert.Equal(t, "huge-manifest", row[colName]) + assert.Empty(t, row[colVersion]) + assert.Equal(t, "CEP", row[colExtensionType]) + }) + + t.Run("UXP manifest over 1MB falls back to dir name", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + pluginPath := filepath.Join(dir, "huge-uxp") + require.NoError(t, os.MkdirAll(pluginPath, 0o755)) + + bigData := make([]byte, maxManifestSize+100) + copy(bigData, []byte(`{"name": "Should Not Parse"`)) + require.NoError(t, os.WriteFile( + filepath.Join(pluginPath, "manifest.json"), + bigData, + 0o644, + )) + + sp := scanPath{extensionType: "UXP"} + row := tbl.parseUXPPlugin(pluginPath, sp) + require.NotNil(t, row) + + assert.Equal(t, "huge-uxp", row[colName]) + assert.Empty(t, row[colVersion]) + assert.Equal(t, "UXP", row[colExtensionType]) + }) +} + +func TestSymlinkSkipped(t *testing.T) { + t.Parallel() + + tbl := &adobePluginsTable{logger: zerolog.Nop()} + + dir := t.TempDir() + // Create a real directory and a symlink to it + realDir := filepath.Join(dir, "real-plugin") + require.NoError(t, os.MkdirAll(realDir, 0o755)) + symlinkPath := filepath.Join(dir, "symlink-plugin") + require.NoError(t, os.Symlink(realDir, symlinkPath)) + + entries, err := os.ReadDir(dir) + require.NoError(t, err) + + sp := scanPath{extensionType: "CEP"} + var skipped, kept int + for _, entry := range entries { + row := tbl.scanEntry(filepath.Join(dir, entry.Name()), entry, sp) + if row == nil { + skipped++ + } else { + kept++ + } + } + + assert.Equal(t, 1, skipped, "symlink should be skipped") + assert.Equal(t, 1, kept, "real directory should produce a row") +} diff --git a/orbit/pkg/table/adobe_plugins/adobe_plugins_windows.go b/orbit/pkg/table/adobe_plugins/adobe_plugins_windows.go new file mode 100644 index 0000000000..9bf86be3af --- /dev/null +++ b/orbit/pkg/table/adobe_plugins/adobe_plugins_windows.go @@ -0,0 +1,109 @@ +//go:build windows + +package adobe_plugins + +import ( + "os" + "path/filepath" + "strings" + + "github.com/rs/zerolog" +) + +func getScanPaths(level string, logger zerolog.Logger) ([]scanPath, error) { + var paths []scanPath + + // System-wide CEP extensions + paths = append(paths, scanPath{ + basePath: `C:\Program Files\Common Files\Adobe\CEP\extensions`, + extensionType: "CEP", + }) + paths = append(paths, scanPath{ + basePath: `C:\Program Files (x86)\Common Files\Adobe\CEP\extensions`, + extensionType: "CEP", + }) + + // System-wide UXP extensions + paths = append(paths, scanPath{ + basePath: `C:\Program Files\Common Files\Adobe\UXP\extensions`, + extensionType: "UXP", + }) + + // Per-user CEP and UXP extensions + users, err := listLocalUsers() + if err != nil { + logger.Warn().Err(err).Msg("failed to enumerate local users, skipping per-user paths") + } + for _, u := range users { + paths = append(paths, scanPath{ + basePath: filepath.Join(u.homeDir, "AppData", "Roaming", "Adobe", "CEP", "extensions"), + extensionType: "CEP", + user: u.name, + }) + paths = append(paths, scanPath{ + basePath: filepath.Join(u.homeDir, "AppData", "Roaming", "Adobe", "UXP", "extensions"), + extensionType: "UXP", + user: u.name, + }) + } + + if level == "deep" { + paths = append(paths, + scanPath{ + basePath: `C:\Program Files\Adobe\Adobe Photoshop *\Plug-ins`, + extensionType: "native", + hostApp: "Photoshop", + }, + scanPath{ + basePath: `C:\Program Files\Adobe\Adobe Premiere Pro *\Plug-ins`, + extensionType: "native", + hostApp: "Premiere Pro", + }, + scanPath{ + basePath: `C:\Program Files\Adobe\Adobe After Effects *\Plug-ins`, + extensionType: "native", + hostApp: "After Effects", + }, + ) + } + + return paths, nil +} + +type localUser struct { + name string + homeDir string +} + +func listLocalUsers() ([]localUser, error) { + entries, err := os.ReadDir(`C:\Users`) + if err != nil { + return nil, err + } + + skipNames := map[string]struct{}{ + "public": {}, + "default": {}, + "default user": {}, + "all users": {}, + } + + var users []localUser + for _, e := range entries { + if !e.IsDir() { + continue + } + name := e.Name() + if strings.HasPrefix(name, ".") { + continue + } + if _, skip := skipNames[strings.ToLower(name)]; skip { + continue + } + users = append(users, localUser{ + name: name, + homeDir: filepath.Join(`C:\Users`, name), + }) + } + return users, nil +} diff --git a/orbit/pkg/table/adobe_plugins/testdata/cep_plugin/CSXS/manifest.xml b/orbit/pkg/table/adobe_plugins/testdata/cep_plugin/CSXS/manifest.xml new file mode 100644 index 0000000000..0a0cdc36eb --- /dev/null +++ b/orbit/pkg/table/adobe_plugins/testdata/cep_plugin/CSXS/manifest.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + diff --git a/orbit/pkg/table/adobe_plugins/testdata/uxp_plugin/manifest.json b/orbit/pkg/table/adobe_plugins/testdata/uxp_plugin/manifest.json new file mode 100644 index 0000000000..c3e01b2d87 --- /dev/null +++ b/orbit/pkg/table/adobe_plugins/testdata/uxp_plugin/manifest.json @@ -0,0 +1,12 @@ +{ + "id": "com.example.uxp.plugin", + "name": "Test UXP Plugin", + "version": "3.0.1", + "host": [ + {"app": "PS", "minVersion": "22.0.0"}, + {"app": "XD", "minVersion": "36.0.0"} + ], + "metadata": { + "publisher": "UXP Test Vendor" + } +} diff --git a/orbit/pkg/table/extension_darwin.go b/orbit/pkg/table/extension_darwin.go index 737517444a..38515391fe 100644 --- a/orbit/pkg/table/extension_darwin.go +++ b/orbit/pkg/table/extension_darwin.go @@ -5,6 +5,7 @@ package table import ( "context" + "github.com/fleetdm/fleet/v4/orbit/pkg/table/adobe_plugins" "github.com/fleetdm/fleet/v4/orbit/pkg/table/app_sso_platform" "github.com/fleetdm/fleet/v4/orbit/pkg/table/authdb" "github.com/fleetdm/fleet/v4/orbit/pkg/table/codesign" @@ -52,6 +53,7 @@ import ( func PlatformTables(opts PluginOpts) ([]osquery.OsqueryPlugin, error) { plugins := []osquery.OsqueryPlugin{ // Fleet tables + adobe_plugins.TablePlugin(log.Logger), table.NewPlugin("icloud_private_relay", privaterelay.Columns(), privaterelay.Generate), table.NewPlugin("user_login_settings", user_login_settings.Columns(), user_login_settings.Generate), table.NewPlugin("pwd_policy", pwd_policy.Columns(), pwd_policy.Generate), diff --git a/orbit/pkg/table/extension_windows.go b/orbit/pkg/table/extension_windows.go index 40f5d59aa3..d09e0cda9a 100644 --- a/orbit/pkg/table/extension_windows.go +++ b/orbit/pkg/table/extension_windows.go @@ -5,6 +5,7 @@ package table import ( "fmt" + "github.com/fleetdm/fleet/v4/orbit/pkg/table/adobe_plugins" "github.com/fleetdm/fleet/v4/orbit/pkg/table/bitlocker_key_protectors" cisaudit "github.com/fleetdm/fleet/v4/orbit/pkg/table/cis_audit" "github.com/fleetdm/fleet/v4/orbit/pkg/table/mdm_bridge" @@ -19,6 +20,7 @@ import ( func PlatformTables(_ PluginOpts) ([]osquery.OsqueryPlugin, error) { plugins := []osquery.OsqueryPlugin{ // Fleet tables + adobe_plugins.TablePlugin(log.Logger), table.NewPlugin("cis_audit", cisaudit.Columns(), cisaudit.Generate), bitlocker_key_protectors.TablePlugin(log.Logger), diff --git a/schema/osquery_fleet_schema.json b/schema/osquery_fleet_schema.json index 8cd3e0fb92..56d844199a 100644 --- a/schema/osquery_fleet_schema.json +++ b/schema/osquery_fleet_schema.json @@ -153,6 +153,81 @@ ], "fleetRepoUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/ad_config.yml" }, + { + "name": "adobe_plugins", + "platforms": [ + "darwin", + "windows" + ], + "description": "Detects Adobe plugins (CEP extensions, UXP extensions, and native plug-ins) installed on the host by scanning well-known directories and parsing plugin manifests.", + "examples": "List all detected Adobe plugins (CEP and UXP extensions):\n\n```\nSELECT name, version, vendor, host_application, extension_type FROM adobe_plugins;\n```\n\nInclude native plug-ins from application directories (Photoshop, Premiere, etc.):\n\n```\nSELECT * FROM adobe_plugins WHERE scan_level = 'deep';\n```", + "columns": [ + { + "name": "path", + "type": "text", + "required": false, + "description": "Full filesystem path to the plugin directory or file." + }, + { + "name": "name", + "type": "text", + "required": false, + "description": "Plugin display name. From UXP manifest `name`, else manifest `id`, else directory or file name." + }, + { + "name": "version", + "type": "text", + "required": false, + "description": "Plugin version from the manifest. Empty if no manifest is found." + }, + { + "name": "vendor", + "type": "text", + "required": false, + "description": "Plugin author or publisher from the manifest. Empty if absent." + }, + { + "name": "bundle_id", + "type": "text", + "required": false, + "description": "Plugin bundle identifier from CEP `ExtensionBundleId` or UXP `id`. Empty for native plug-ins." + }, + { + "name": "host_application", + "type": "text", + "required": false, + "description": "Target Adobe application(s) such as Photoshop, Illustrator, or Premiere Pro. Comma-separated if multiple." + }, + { + "name": "extension_type", + "type": "text", + "required": false, + "description": "One of `CEP`, `UXP`, or `native`. Determined by the scan path, not by manifest presence." + }, + { + "name": "user", + "type": "text", + "required": false, + "description": "Local username for user-scoped plugin installs. Empty for system-wide installs." + }, + { + "name": "platform", + "type": "text", + "required": false, + "description": "The host platform, either `darwin` or `windows`." + }, + { + "name": "scan_level", + "type": "text", + "required": false, + "description": "WHERE-clause constraint that controls scan depth. `standard` (default) scans CEP and UXP directories only. `deep` additionally scans application-specific native plug-in directories." + } + ], + "notes": "This table is not a core osquery table. It is included as part of Fleet's agent ([fleetd](https://fleetdm.com/docs/get-started/anatomy#fleetd)).", + "evented": false, + "url": "https://fleetdm.com/tables/adobe_plugins", + "fleetRepoUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/adobe_plugins.yml" + }, { "name": "alf", "description": "Details about the status of the built-in firewall protection on this Mac.", diff --git a/schema/tables/adobe_plugins.yml b/schema/tables/adobe_plugins.yml new file mode 100644 index 0000000000..da16822130 --- /dev/null +++ b/schema/tables/adobe_plugins.yml @@ -0,0 +1,60 @@ +name: adobe_plugins +platforms: + - darwin + - windows +description: Detects Adobe plugins (CEP extensions, UXP extensions, and native plug-ins) installed on the host by scanning well-known directories and parsing plugin manifests. +examples: |- + List all detected Adobe plugins (CEP and UXP extensions): + + ``` + SELECT name, version, vendor, host_application, extension_type FROM adobe_plugins; + ``` + + Include native plug-ins from application directories (Photoshop, Premiere, etc.): + + ``` + SELECT * FROM adobe_plugins WHERE scan_level = 'deep'; + ``` +columns: + - name: path + type: text + required: false + description: Full filesystem path to the plugin directory or file. + - name: name + type: text + required: false + description: Plugin display name. From UXP manifest `name`, else manifest `id`, else directory or file name. + - name: version + type: text + required: false + description: Plugin version from the manifest. Empty if no manifest is found. + - name: vendor + type: text + required: false + description: Plugin author or publisher from the manifest. Empty if absent. + - name: bundle_id + type: text + required: false + description: Plugin bundle identifier from CEP `ExtensionBundleId` or UXP `id`. Empty for native plug-ins. + - name: host_application + type: text + required: false + description: Target Adobe application(s) such as Photoshop, Illustrator, or Premiere Pro. Comma-separated if multiple. + - name: extension_type + type: text + required: false + description: One of `CEP`, `UXP`, or `native`. Determined by the scan path, not by manifest presence. + - name: user + type: text + required: false + description: Local username for user-scoped plugin installs. Empty for system-wide installs. + - name: platform + type: text + required: false + description: The host platform, either `darwin` or `windows`. + - name: scan_level + type: text + required: false + description: WHERE-clause constraint that controls scan depth. `standard` (default) scans CEP and UXP directories only. `deep` additionally scans application-specific native plug-in directories. +notes: This table is not a core osquery table. It is included as part of Fleet's agent ([fleetd](https://fleetdm.com/docs/get-started/anatomy#fleetd)). +evented: false