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 -->
337 lines
9.4 KiB
Go
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")
|
|
}
|