Add adobe_plugins osquery extension table (#45208)

Closes #45206

## Summary

- Adds a new `adobe_plugins` osquery extension table to fleetd (macOS +
Windows)
- Parses CEP (`CSXS/manifest.xml`) and UXP (`manifest.json`) manifests
for rich metadata
- Falls back to filesystem info for native plug-ins where no manifest
exists
- Supports a `scan_level` WHERE constraint: `standard` (default) or
`deep`

## Table schema

| Column | Type | Description |
| --- | --- | --- |
| `path` | TEXT | Full path to the plugin directory or file |
| `name` | TEXT | Plugin display name (from manifest or directory name)
|
| `version` | TEXT | Plugin version (from manifest) |
| `vendor` | TEXT | Plugin author/publisher (from manifest) |
| `bundle_id` | TEXT | Plugin bundle identifier (from manifest) |
| `host_application` | TEXT | Target app(s): Photoshop, Illustrator,
Premiere Pro, etc. |
| `extension_type` | TEXT | `CEP`, `UXP`, or `native` |
| `user` | TEXT | Username for user-scoped installs; empty for
system-wide |
| `platform` | TEXT | `darwin` or `windows` |
| `scan_level` | TEXT | WHERE constraint only — `standard` (default) or
`deep` |

## How I tested it

> **Note:** Manual testing was done by installing two real open-source
CEP extensions (downloaded from GitHub) on a macOS host without a full
Adobe CC installation. This validates the table logic, manifest parsing,
and osquery integration end-to-end against real-world manifest formats.
**QA should test against machines with full Adobe Creative Cloud
installations** (Photoshop, Premiere, Illustrator, etc.) to verify the
scan paths match what Adobe actually ships, and to exercise `scan_level
= 'deep'` with real native plug-in directories. Expect a few more dev
cycles after QA feedback.

### 1. Unit tests — 22 passing

```
$ go test ./orbit/pkg/table/adobe_plugins/... -v
--- PASS: TestParseCEPPlugin/valid_manifest
--- PASS: TestParseCEPPlugin/missing_manifest_falls_back_to_dir_name
--- PASS: TestParseCEPPlugin/malformed_manifest_falls_back_to_dir_name
--- PASS: TestParseUXPPlugin/valid_manifest
--- PASS: TestParseUXPPlugin/missing_manifest_falls_back_to_dir_name
--- PASS: TestParseUXPPlugin/manifest_with_id_but_no_name_uses_id
--- PASS: TestParseNativePlugin/* (5 subtests)
--- PASS: TestResolveHostApps/* (7 subtests)
--- PASS: TestScanEntry/* (2 subtests)
PASS
```

### 2. Cross-platform compilation

```
$ go build ./orbit/pkg/table/adobe_plugins/...                  # macOS 
$ GOOS=windows go build ./orbit/pkg/table/adobe_plugins/...     # Windows 
$ GOOS=linux go build ./orbit/pkg/table/adobe_plugins/...       # Linux stub 
$ go build ./orbit/cmd/fleetd_tables/                           # Full fleetd binary 
$ go vet ./orbit/pkg/table/adobe_plugins/...                    # Clean 
```

### 3. Manual end-to-end testing on macOS (osquery 5.23.0)

#### Setup

Built the fleetd extension binary, then installed two **real open-source
CEP extensions** from GitHub into the user-scoped scan path
(`~/Library/Application Support/Adobe/CEP/extensions/`):

1. **[adobe-discord-rpc](https://github.com/Kuredew/adobe-discord-rpc)**
— a real CEP extension targeting 11 Adobe apps. Has no `<Author>`
element (tests missing-vendor edge case). Complex manifest with many
host app codes.

2. **[cep-template](https://github.com/khanyuinc/cep-template)** — a CEP
starter template targeting After Effects only. Minimal manifest.

```bash
# Build extension
go build -o build/fleetd-tables-test ./orbit/cmd/fleetd_tables/

# Install real extensions
CEP_DIR="$HOME/Library/Application Support/Adobe/CEP/extensions"
mkdir -p "$CEP_DIR/adobe-discord-rpc/CSXS"
# downloaded CSXS/manifest.xml from GitHub into the directory
mkdir -p "$CEP_DIR/cep-template/CSXS"
# downloaded CSXS/manifest.xml from GitHub into the directory
```

#### Running the query

```bash
OSQUERYD="/opt/orbit/bin/osqueryd/macos-app/stable/osquery.app/Contents/MacOS/osqueryd"
$OSQUERYD -S --allow_unsafe --extensions_timeout=10 \
  --extensions_require=com.fleetdm.fleetd_tables.osquery_extension.v1 \
  --extension build/fleetd-tables-test \
  --json "SELECT * FROM adobe_plugins;"
```

#### Actual output (verbatim)

```json
[
  {
    "bundle_id": "com.kureichi.discordrpc",
    "extension_type": "CEP",
    "host_application": "After Effects, Photoshop, Premiere Pro, InCopy, Audition, Dreamweaver, Animate, InDesign, Illustrator, Prelude",
    "name": "adobe-discord-rpc",
    "path": "/Users/sharonkatz/Library/Application Support/Adobe/CEP/extensions/adobe-discord-rpc",
    "platform": "darwin",
    "scan_level": "",
    "user": "sharonkatz",
    "vendor": "",
    "version": "3.1.1"
  },
  {
    "bundle_id": "com.yourcompany",
    "extension_type": "CEP",
    "host_application": "After Effects",
    "name": "cep-template",
    "path": "/Users/sharonkatz/Library/Application Support/Adobe/CEP/extensions/cep-template",
    "platform": "darwin",
    "scan_level": "",
    "user": "sharonkatz",
    "vendor": "",
    "version": "1.0"
  }
]
```

#### osqueryi table output

```
+-------------------+---------+-------------------------+----------------------------------------------------------------------------------------------------------------+----------------+------------+
| name              | version | bundle_id               | host_application                                                                                               | extension_type | user       |
+-------------------+---------+-------------------------+----------------------------------------------------------------------------------------------------------------+----------------+------------+
| adobe-discord-rpc | 3.1.1   | com.kureichi.discordrpc | After Effects, Photoshop, Premiere Pro, InCopy, Audition, Dreamweaver, Animate, InDesign, Illustrator, Prelude | CEP            | sharonkatz |
| cep-template      | 1.0     | com.yourcompany         | After Effects                                                                                                  | CEP            | sharonkatz |
+-------------------+---------+-------------------------+----------------------------------------------------------------------------------------------------------------+----------------+------------+
```

#### What this verified

| Scenario | Result |
| --- | --- |
| Real CEP manifest with 11 host apps |  All codes resolved (AEFT→After
Effects, PHSP/PHXS→Photoshop, PPRO→Premiere Pro, etc.) |
| Missing `<Author>` element |  `vendor` is empty string, no crash |
| Minimal CEP manifest (single host) |  `host_application=After
Effects`, version/bundle_id correct |
| User-scoped detection |  `user=sharonkatz` populated |
| Schema registration |  `.schema adobe_plugins` shows all 10 columns |
| No Adobe installed + no plugins |  0 rows, no error |
| Deep scan with no app bundles |  0 extra rows, no error |

### Windows

Not tested yet — Windows paths are implemented and cross-compile, but
need manual verification on a Windows host with Adobe CC.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

## Release Notes

* **New Features**
  * Added Adobe plugins osquery table for macOS and Windows platforms
  * Discovers and catalogs Adobe CEP, UXP, and native plugins
* Extracts plugin metadata including version, vendor, host applications,
and installation paths
  * Supports configurable scan depth for comprehensive plugin discovery

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/fleetdm/fleet/pull/45208)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Sharon Katz
2026-05-14 14:46:48 -04:00
committed by GitHub
parent 182307ac3b
commit 7d26e7e475
12 changed files with 1091 additions and 0 deletions
+1
View File
@@ -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.
@@ -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, ", ")
}
@@ -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
}
@@ -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
}
@@ -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("<ExtensionManifest>"))
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")
}
@@ -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
}
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<ExtensionManifest xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
ExtensionBundleId="com.example.test.plugin"
ExtensionBundleVersion="2.1.0"
Version="7.0">
<Author Name="Test Vendor"/>
<ExtensionList>
<Extension Id="com.example.test.plugin.panel" Version="2.1.0"/>
</ExtensionList>
<ExecutionEnvironment>
<HostList>
<Host Name="PHXS" Version="[15.0,99.9]"/>
<Host Name="ILST" Version="[19.0,99.9]"/>
</HostList>
</ExecutionEnvironment>
</ExtensionManifest>
@@ -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"
}
}
+2
View File
@@ -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),
+2
View File
@@ -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),
+75
View File
@@ -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.",
+60
View File
@@ -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