Files
fleet/orbit/pkg/table/adobe_plugins/adobe_plugins_test.go
T
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

337 lines
9.4 KiB
Go

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