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 --> [](https://app.coderabbit.ai/change-stack/fleetdm/fleet/pull/45208) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
369 lines
9.6 KiB
Go
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, ", ")
|
|
}
|