Files
Sharon Katz 7d26e7e475 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 -->
2026-05-14 14:46:48 -04:00

369 lines
9.6 KiB
Go

// 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, ", ")
}