diff --git a/comp/softwareinventory/impl/status_test.go b/comp/softwareinventory/impl/status_test.go
index c5aa5234b89ed1..4cc4cac49af947 100644
--- a/comp/softwareinventory/impl/status_test.go
+++ b/comp/softwareinventory/impl/status_test.go
@@ -24,8 +24,8 @@ func TestStatusFundamentals(t *testing.T) {
func TestGetPayloadRefreshesCachedValues(t *testing.T) {
f := newFixtureWithData(t, true, []software.Entry{
- {DisplayName: "FooApp", ProductCode: "foo"},
- {DisplayName: "BarApp", ProductCode: "bar"},
+ {DisplayName: "FooApp", ProductCode: "foo", Source: "app"},
+ {DisplayName: "BarApp", ProductCode: "bar", Source: "pkg"},
})
is := f.sut().WaitForSystemProbe()
@@ -35,8 +35,11 @@ func TestGetPayloadRefreshesCachedValues(t *testing.T) {
// Assert that the cached values were refreshed
assert.NoError(t, err)
- assert.Len(t, stats, 1)
+ // Now includes: software_inventory_metadata, software_inventory_stats, software_inventory_total
assert.Contains(t, stats, "software_inventory_metadata")
+ assert.Contains(t, stats, "software_inventory_stats")
+ assert.Contains(t, stats, "software_inventory_total")
+ assert.Equal(t, 2, stats["software_inventory_total"])
// Note: The exact structure of stats depends on how the JSON is marshaled
// This test may need adjustment based on the actual output format
f.sysProbeClient.AssertNumberOfCalls(t, "GetCheck", 1)
@@ -146,8 +149,117 @@ func TestStatusTemplateWithNoSoftwareInventoryMetadata(t *testing.T) {
err = is.HTML(false, &buf)
assert.NoError(t, err)
// Just verify the basic structure is present, since it will be empty
- assert.Contains(t, buf.String(), `
Summary: 0 entries")
// The populateStatus caches the values once.
f.sysProbeClient.AssertNumberOfCalls(t, "GetCheck", 1)
}
+
+func TestStatusStatsComputation(t *testing.T) {
+ // Test that stats are correctly computed by software type
+ f := newFixtureWithData(t, true, []software.Entry{
+ {DisplayName: "Safari", ProductCode: "safari", Source: "app"},
+ {DisplayName: "Chrome", ProductCode: "chrome", Source: "app"},
+ {DisplayName: "git", ProductCode: "git", Source: "homebrew"},
+ {DisplayName: "wget", ProductCode: "wget", Source: "homebrew"},
+ {DisplayName: "curl", ProductCode: "curl", Source: "homebrew"},
+ {DisplayName: "Python Driver", ProductCode: "python-driver", Source: "pkg"},
+ })
+ is := f.sut().WaitForSystemProbe()
+
+ stats := make(map[string]interface{})
+ err := is.JSON(false, stats)
+ assert.NoError(t, err)
+
+ // Verify total count
+ assert.Equal(t, 6, stats["software_inventory_total"])
+
+ // Verify stats by type
+ typeStats, ok := stats["software_inventory_stats"].(map[string]int)
+ assert.True(t, ok, "software_inventory_stats should be map[string]int")
+ assert.Equal(t, 2, typeStats["app"])
+ assert.Equal(t, 3, typeStats["homebrew"])
+ assert.Equal(t, 1, typeStats["pkg"])
+
+ // Verify no broken count when no broken entries
+ _, hasBroken := stats["software_inventory_broken"]
+ assert.False(t, hasBroken, "software_inventory_broken should not be present when no broken entries")
+}
+
+func TestStatusBrokenCount(t *testing.T) {
+ // Test that broken entries are correctly counted
+ f := newFixtureWithData(t, true, []software.Entry{
+ {DisplayName: "GoodApp", ProductCode: "good", Source: "app", Status: "installed"},
+ {DisplayName: "BrokenApp", ProductCode: "broken1", Source: "app", Status: "broken"},
+ {DisplayName: "AnotherGood", ProductCode: "good2", Source: "pkg", Status: "installed"},
+ {DisplayName: "AnotherBroken", ProductCode: "broken2", Source: "pkg", Status: "broken"},
+ })
+ is := f.sut().WaitForSystemProbe()
+
+ stats := make(map[string]interface{})
+ err := is.JSON(false, stats)
+ assert.NoError(t, err)
+
+ // Verify total count
+ assert.Equal(t, 4, stats["software_inventory_total"])
+
+ // Verify broken count is present and correct
+ brokenCount, hasBroken := stats["software_inventory_broken"]
+ assert.True(t, hasBroken, "software_inventory_broken should be present when there are broken entries")
+ assert.Equal(t, 2, brokenCount)
+}
+
+func TestStatusTextTemplateWithStats(t *testing.T) {
+ f := newFixtureWithData(t, true, []software.Entry{
+ {DisplayName: "App1", ProductCode: "app1", Source: "app"},
+ {DisplayName: "App2", ProductCode: "app2", Source: "app"},
+ {DisplayName: "Brew1", ProductCode: "brew1", Source: "homebrew"},
+ })
+ is := f.sut().WaitForSystemProbe()
+
+ var buf bytes.Buffer
+ err := is.Text(false, &buf)
+ assert.NoError(t, err)
+
+ text := buf.String()
+ // Verify total count in text output
+ assert.Contains(t, text, "Detected 3 installed software entries")
+ // Verify "By type:" section header
+ assert.Contains(t, text, "By type:")
+}
+
+func TestStatusTextTemplateWithBrokenEntries(t *testing.T) {
+ f := newFixtureWithData(t, true, []software.Entry{
+ {DisplayName: "GoodApp", ProductCode: "good", Source: "app", Status: "installed"},
+ {DisplayName: "BrokenApp", ProductCode: "broken", Source: "app", Status: "broken"},
+ })
+ is := f.sut().WaitForSystemProbe()
+
+ var buf bytes.Buffer
+ err := is.Text(false, &buf)
+ assert.NoError(t, err)
+
+ text := buf.String()
+ // Verify broken count appears in text output
+ assert.Contains(t, text, "Detected 2 installed software entries (1 broken)")
+}
+
+func TestStatusHTMLTemplateWithStats(t *testing.T) {
+ f := newFixtureWithData(t, true, []software.Entry{
+ {DisplayName: "App1", ProductCode: "app1", Source: "app"},
+ {DisplayName: "Brew1", ProductCode: "brew1", Source: "homebrew"},
+ })
+ is := f.sut().WaitForSystemProbe()
+
+ var buf bytes.Buffer
+ err := is.HTML(false, &buf)
+ assert.NoError(t, err)
+
+ html := buf.String()
+ // Verify summary section
+ assert.Contains(t, html, "
Summary: 2 entries")
+ // Verify "By type:" section
+ assert.Contains(t, html, "
By type:")
+}
diff --git a/pkg/inventory/software/collector.go b/pkg/inventory/software/collector.go
index 906ef9f9ab8880..5a4a76883ef342 100644
--- a/pkg/inventory/software/collector.go
+++ b/pkg/inventory/software/collector.go
@@ -46,6 +46,13 @@ func warnf(format string, args ...interface{}) *Warning {
// application, including identification, versioning, installation details,
// and system-specific information.
type Entry struct {
+ // Source indicates the type or source of the software installation
+ // (e.g., Windows: "desktop", "msstore", "msu"; MacOS: "app", "pkg",
+ // "homebrew", "mas", "kext", "sysext"). This field helps categorize
+ // software by its installation method or distribution channel.
+ // Placed first for easy identification when scanning JSON output.
+ Source string `json:"software_type"`
+
// DisplayName is the human-readable name of the software application
// as it appears to users (e.g., "Microsoft Office 365", "Adobe Photoshop").
// This field is used for display purposes and software identification.
@@ -57,17 +64,14 @@ type Entry struct {
Version string `json:"version"`
// InstallDate is the date when the software was installed on the system.
- // The format may vary by platform but is typically in ISO 8601 format
- // or a platform-specific date format (e.g., "2023-01-15T10:30:00Z").
+ // The format is RFC3339 (ISO 8601): "2006-01-02T15:04:05Z07:00"
+ // For example: "2023-01-15T10:30:00Z"
+ // All timestamps are in UTC (indicated by the Z suffix).
+ // When displayed in the GUI/status output, it is formatted as "YYYY/MM/DD" (date only).
// This field is optional and may be empty if the installation date
// cannot be determined.
InstallDate string `json:"deployment_time,omitempty"`
- // Source indicates the type or source of the software installation
- // (e.g., "desktop", "msstore", "msu"). This field helps categorize
- // software by its installation method or distribution channel.
- Source string `json:"software_type"`
-
// UserSID is the Security Identifier of the user who installed the software,
// particularly relevant for user-specific installations on Windows.
// This field is optional and may be empty for system-wide installations.
@@ -87,21 +91,99 @@ type Entry struct {
// the operational state of the software installation.
Status string `json:"deployment_status"`
+ // BrokenReason explains why the software installation is marked as broken.
+ // This field is only populated when Status is "broken" and provides
+ // specific details to help diagnose the issue.
+ // Examples:
+ // - "executable not found: Contents/MacOS/MyApp"
+ // - "install path not found: /usr/local/bin"
+ // - "Info.plist missing CFBundleExecutable" (macOS)
+ // - "MSI record not found in registry" (Windows)
+ // NOTE: Currently excluded from backend payload (json:"-") but kept for
+ // internal use and future backend support.
+ BrokenReason string `json:"-"`
+
// ProductCode is a unique identifier for the software product,
// often used in package management systems or installation databases
// (e.g., Windows Product Code, package identifiers). This field
// provides a stable identifier for tracking software across systems.
ProductCode string `json:"product_code"`
+
+ // InstallSource indicates how the software was installed on macOS.
+ // Possible values:
+ // - "pkg": Installed via a .pkg installer package
+ // - "mas": Installed from the Mac App Store
+ // - "manual": Installed manually (drag-and-drop from DMG, etc.)
+ // This field is macOS-specific and helps understand the installation method.
+ // NOTE: Currently excluded from backend payload (json:"-") but kept for
+ // internal use and future backend support.
+ InstallSource string `json:"-"`
+
+ // PkgID is the package identifier from the macOS installer receipt database.
+ // This field is populated when InstallSource is "pkg" and provides a link
+ // to the corresponding PKG receipt in /var/db/receipts/. This enables
+ // cross-referencing between application entries and their installation records.
+ // Example: "com.microsoft.Word" for Microsoft Word installed via PKG.
+ // NOTE: Currently excluded from backend payload (json:"-") but kept for
+ // internal use and future backend support.
+ PkgID string `json:"-"`
+
+ // InstallPath is the filesystem path where the software is installed.
+ // This field helps identify the exact location of an installation, which is
+ // particularly useful when multiple versions of the same software exist
+ // in different locations (e.g., /Applications vs ~/Applications).
+ // Examples:
+ // - Applications: "/Applications/Safari.app", "~/Applications/MyApp.app"
+ // - Kernel extensions: "/Library/Extensions/SoftRAID.kext"
+ // - System extensions: "/Library/SystemExtensions/.../com.example.extension.systemextension"
+ // For PKG receipts, this may be "N/A" if no single meaningful path exists;
+ // use InstallPaths for the full list of installation directories.
+ // NOTE: Currently excluded from backend payload (json:"-") but kept for
+ // internal use and future backend support.
+ InstallPath string `json:"-"`
+
+ // InstallPaths contains the top-level directories where a PKG installed files.
+ // This field is specific to PKG receipts and provides visibility into where
+ // the package scattered its files across the filesystem.
+ // Unlike InstallPath (single path), this captures all installation locations
+ // for packages that install to multiple directories (e.g., CLI tools that
+ // install binaries to /usr/local/bin and libraries to /usr/local/lib).
+ // Examples: ["/usr/local/bin", "/usr/local/ykman", "/Library/LaunchDaemons"]
+ // NOTE: Currently excluded from backend payload (json:"-") but kept for
+ // internal use and future backend support.
+ InstallPaths []string `json:"-"`
}
// GetID returns a unique identifier for the software entry.
// This method provides a consistent way to identify software entries
-// across different collection runs and system restarts. The current
-// implementation uses the DisplayName as the identifier, but this
-// could be enhanced to use more stable identifiers like ProductCode
-// when available.
+// across different collection runs and system restarts.
+//
+// The ID format is: "{source}:{identifier}:{path}" where:
+// - source: the software type (e.g., "app", "homebrew", "pkg", "pip")
+// - identifier: ProductCode if available, otherwise DisplayName
+// - path: InstallPath to distinguish multiple installations of same software
+//
+// This ensures each installation location is tracked separately.
+// For example, pip packages installed in different Python environments
+// will each have their own entry.
func (se *Entry) GetID() string {
- return se.DisplayName
+ identifier := se.ProductCode
+ if identifier == "" {
+ identifier = se.DisplayName
+ }
+
+ // Build ID with source prefix
+ id := identifier
+ if se.Source != "" {
+ id = se.Source + ":" + identifier
+ }
+
+ // Include InstallPath to make each installation unique
+ if se.InstallPath != "" {
+ id = id + ":" + se.InstallPath
+ }
+
+ return id
}
// GetSoftwareInventoryWithCollectors returns a list of software entries using the provided collectors
diff --git a/pkg/inventory/software/collector_darwin.go b/pkg/inventory/software/collector_darwin.go
new file mode 100644
index 00000000000000..148a026aa55225
--- /dev/null
+++ b/pkg/inventory/software/collector_darwin.go
@@ -0,0 +1,442 @@
+// Unless explicitly stated otherwise all files in this repository are licensed
+// under the Apache License Version 2.0.
+// This product includes software developed at Datadog (https://www.datadoghq.com/).
+// Copyright 2016-present Datadog, Inc.
+
+//go:build darwin
+
+package software
+
+import (
+ "bytes"
+ "encoding/xml"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "regexp"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+)
+
+// Software types for macOS
+const (
+ // softwareTypeApp represents applications from /Applications
+ softwareTypeApp = "app"
+ // softwareTypeSystemApp represents Apple system applications from /System/Applications
+ softwareTypeSystemApp = "system_app"
+ // softwareTypePkg represents software installed via PKG installer
+ softwareTypePkg = "pkg"
+ // softwareTypeMAS represents applications from the Mac App Store
+ softwareTypeMAS = "mas"
+ // softwareTypeKext represents kernel extensions
+ softwareTypeKext = "kext"
+ // softwareTypeSysExt represents system extensions (modern replacement for kexts)
+ softwareTypeSysExt = "sysext"
+ // softwareTypeHomebrew represents software installed via Homebrew package manager
+ softwareTypeHomebrew = "homebrew"
+)
+
+// Install source values for macOS applications
+// These indicate how an application was installed on the system
+const (
+ // installSourcePkg indicates the app was installed via a .pkg installer package
+ installSourcePkg = "pkg"
+ // installSourceMAS indicates the app was installed from the Mac App Store
+ installSourceMAS = "mas"
+ // installSourceManual indicates the app was installed manually (drag-and-drop, etc.)
+ installSourceManual = "manual"
+)
+
+// defaultCollectors returns the default collectors for production use on macOS
+// These collectors focus on system-level software relevant to IT professionals:
+// - Applications (.app bundles)
+// - PKG installer receipts
+// - Kernel extensions (kexts)
+// - System extensions
+// - Homebrew packages
+// - MacPorts packages
+func defaultCollectors() []Collector {
+ return []Collector{
+ &applicationsCollector{},
+ &pkgReceiptsCollector{},
+ &kernelExtensionsCollector{},
+ &systemExtensionsCollector{},
+ &homebrewCollector{},
+ &macPortsCollector{},
+ }
+}
+
+// parsePlistToMap parses plist XML data into a map
+func parsePlistToMap(data []byte) (map[string]string, error) {
+ // Simple plist parser that extracts key-string pairs
+ result := make(map[string]string)
+
+ decoder := xml.NewDecoder(bytes.NewReader(data))
+ var currentKey string
+ var inDict bool
+
+ for {
+ token, err := decoder.Token()
+ if err != nil {
+ break
+ }
+
+ switch t := token.(type) {
+ case xml.StartElement:
+ switch t.Name.Local {
+ case "dict":
+ inDict = true
+ case "key":
+ if inDict {
+ var key string
+ if err := decoder.DecodeElement(&key, &t); err == nil {
+ currentKey = key
+ }
+ }
+ case "string":
+ if inDict && currentKey != "" {
+ var value string
+ if err := decoder.DecodeElement(&value, &t); err == nil {
+ result[currentKey] = value
+ }
+ currentKey = ""
+ }
+ case "date":
+ if inDict && currentKey != "" {
+ var value string
+ if err := decoder.DecodeElement(&value, &t); err == nil {
+ result[currentKey] = value
+ }
+ currentKey = ""
+ }
+ default:
+ // Skip other value types and reset current key
+ if inDict && currentKey != "" {
+ currentKey = ""
+ }
+ }
+ case xml.EndElement:
+ if t.Name.Local == "dict" {
+ // Only process the first dict level
+ break
+ }
+ }
+ }
+
+ return result, nil
+}
+
+// readPlistFile reads a plist file and returns its contents as a map
+// It handles both XML and binary plist formats
+func readPlistFile(path string) (map[string]string, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, err
+ }
+
+ // Check if it's a binary plist (starts with "bplist")
+ if bytes.HasPrefix(data, []byte("bplist")) {
+ // Convert binary plist to XML using plutil
+ cmd := exec.Command("plutil", "-convert", "xml1", "-o", "-", path)
+ output, err := cmd.Output()
+ if err != nil {
+ return nil, err
+ }
+ data = output
+ }
+
+ return parsePlistToMap(data)
+}
+
+// Status constants for broken state detection
+const (
+ statusInstalled = "installed"
+ statusBroken = "broken"
+)
+
+// checkAppBundleIntegrity verifies that an app bundle has its required executable.
+// Returns an empty string if the bundle is OK, or a reason string if it's broken.
+func checkAppBundleIntegrity(appPath string, plistData map[string]string) string {
+ // Get the executable name from Info.plist
+ executableName := plistData["CFBundleExecutable"]
+ if executableName == "" {
+ // If no executable specified, the bundle is incomplete
+ return "Info.plist missing CFBundleExecutable"
+ }
+
+ // Check if the executable exists
+ executablePath := filepath.Join(appPath, "Contents", "MacOS", executableName)
+ if _, err := os.Stat(executablePath); os.IsNotExist(err) {
+ return "executable not found: Contents/MacOS/" + executableName
+ }
+
+ return "" // Bundle is OK
+}
+
+// checkKextBundleIntegrity verifies that a kext bundle has its required executable.
+// Returns an empty string if the bundle is OK, or a reason string if it's broken.
+func checkKextBundleIntegrity(kextPath string, plistData map[string]string) string {
+ // Get the executable name from Info.plist
+ executableName := plistData["CFBundleExecutable"]
+ if executableName == "" {
+ // Some kexts may not have an executable (codeless kexts)
+ // Check if it has a MacOS directory with any executable
+ macOSDir := filepath.Join(kextPath, "Contents", "MacOS")
+ if _, err := os.Stat(macOSDir); os.IsNotExist(err) {
+ // No MacOS directory - might be a codeless kext, consider it OK
+ return ""
+ }
+ }
+
+ // Check if the executable exists
+ executablePath := filepath.Join(kextPath, "Contents", "MacOS", executableName)
+ if _, err := os.Stat(executablePath); os.IsNotExist(err) {
+ return "executable not found: Contents/MacOS/" + executableName
+ }
+
+ return "" // Bundle is OK
+}
+
+// companySuffixes contains common company name suffixes used to identify corporate entities
+// Note: "Company" is intentionally excluded as it's too generic and causes false matches
+// (e.g., "is a Datadog company" would match incorrectly)
+var companySuffixes = []string{
+ "Inc.", "Inc", "LLC", "L.L.C.", "Ltd", "Ltd.", "Limited",
+ "GmbH", "Corp", "Corp.", "Corporation", "Co.",
+ "S.A.", "S.A", "AG", "PLC", "Pty", "B.V.", "BV",
+ "S.r.l.", "S.R.L.", "SRL", "ApS", "A/S",
+}
+
+// looksLikeCompanyName checks if a name appears to be a company rather than an individual
+func looksLikeCompanyName(name string) bool {
+ upperName := strings.ToUpper(name)
+ for _, suffix := range companySuffixes {
+ if strings.Contains(upperName, strings.ToUpper(suffix)) {
+ return true
+ }
+ }
+ return false
+}
+
+// extractCompanyFromCopyright attempts to extract a company name from NSHumanReadableCopyright
+// Examples:
+// - "© 2023 CoScreen GmbH. All rights reserved." → "CoScreen GmbH"
+// - "Copyright 2024 Datadog, Inc." → "Datadog, Inc."
+// - "Copyright © 2025 CoScreen. CoScreen is a Datadog company." → "CoScreen"
+func extractCompanyFromCopyright(copyright string) string {
+ if copyright == "" {
+ return ""
+ }
+
+ // Remove common copyright prefixes and symbols
+ cleaned := copyright
+ cleaned = strings.ReplaceAll(cleaned, "©", "")
+ cleaned = strings.ReplaceAll(cleaned, "(C)", "")
+ cleaned = strings.ReplaceAll(cleaned, "(c)", "")
+ cleaned = strings.ReplaceAll(cleaned, "Copyright", "")
+ cleaned = strings.ReplaceAll(cleaned, "copyright", "")
+ cleaned = strings.TrimSpace(cleaned)
+
+ // Remove leading year or year range (e.g., "2023 CoScreen" or "2019-2025 Munki" → company name)
+ yearPattern := regexp.MustCompile(`^\d{4}(-\d{4})?\s+`)
+ cleaned = yearPattern.ReplaceAllString(cleaned, "")
+
+ // Try to find a company name by looking for company suffixes
+ // Pattern: look for text ending with a company suffix
+ for _, suffix := range companySuffixes {
+ // Create pattern to find "Word(s) Suffix" pattern
+ pattern := regexp.MustCompile(`(?i)([A-Za-z][A-Za-z0-9\s,\-\.&']+\s*` + regexp.QuoteMeta(suffix) + `)`)
+ matches := pattern.FindStringSubmatch(cleaned)
+ if len(matches) >= 2 {
+ result := strings.TrimSpace(matches[1])
+ if result != "" {
+ return result
+ }
+ }
+ }
+
+ // If no company suffix found, extract the first name/word before common delimiters
+ // This handles cases like "CoScreen. CoScreen is a Datadog company." → "CoScreen"
+ // Common delimiters: period followed by space, comma, "All rights", "is a", etc.
+ delimiters := []string{". ", ", a ", " is a ", " - ", " All rights", " all rights"}
+ for _, delim := range delimiters {
+ if idx := strings.Index(cleaned, delim); idx > 0 {
+ result := strings.TrimSpace(cleaned[:idx])
+ // Ensure we got something meaningful (at least 2 chars, not just a year)
+ if len(result) >= 2 && !regexp.MustCompile(`^\d+$`).MatchString(result) {
+ return result
+ }
+ }
+ }
+
+ // Last resort: take everything before "All rights reserved" or similar
+ lowerCleaned := strings.ToLower(cleaned)
+ if idx := strings.Index(lowerCleaned, "all rights"); idx > 0 {
+ result := strings.TrimSpace(cleaned[:idx])
+ // Remove trailing punctuation
+ result = strings.TrimRight(result, ".,;:")
+ if len(result) >= 2 {
+ return result
+ }
+ }
+
+ return ""
+}
+
+// extractPublisherFromBundleID attempts to extract publisher from bundle ID
+// Examples:
+// - "com.microsoft.Word" → "Microsoft"
+// - "com.adobe.Photoshop" → "Adobe"
+// - "com.apple.Safari" → "Apple"
+func extractPublisherFromBundleID(bundleID string) string {
+ // Split by dots and take the second component (company name)
+ parts := strings.Split(bundleID, ".")
+ if len(parts) >= 2 {
+ company := parts[1]
+ // Capitalize first letter
+ if len(company) > 0 {
+ return strings.ToUpper(company[:1]) + company[1:]
+ }
+ }
+ return ""
+}
+
+// getPublisherFromInfoPlist extracts publisher from Info.plist using multiple fields
+// Priority order:
+// 1. NSHumanReadableCopyright (extract company name)
+// 2. CFBundleIdentifier (extract from reverse DNS, e.g., com.microsoft.* → "Microsoft")
+// 3. CFBundleName (fallback, may contain company name)
+func getPublisherFromInfoPlist(bundlePath string) string {
+ infoPlistPath := filepath.Join(bundlePath, "Contents", "Info.plist")
+ plistData, err := readPlistFile(infoPlistPath)
+ if err != nil {
+ return ""
+ }
+
+ // Priority 1: NSHumanReadableCopyright
+ if copyright, ok := plistData["NSHumanReadableCopyright"]; ok && copyright != "" {
+ if publisher := extractCompanyFromCopyright(copyright); publisher != "" {
+ return publisher
+ }
+ }
+
+ // Priority 2: Extract from CFBundleIdentifier (reverse DNS)
+ // e.g., "com.microsoft.Word" → "Microsoft"
+ if bundleID, ok := plistData["CFBundleIdentifier"]; ok && bundleID != "" {
+ if publisher := extractPublisherFromBundleID(bundleID); publisher != "" {
+ return publisher
+ }
+ }
+
+ // Priority 3: Try CFBundleName (may contain company name)
+ // This is less reliable but can help for some apps
+ if bundleName, ok := plistData["CFBundleName"]; ok && bundleName != "" {
+ // Only use if it looks like a company name
+ if looksLikeCompanyName(bundleName) {
+ return bundleName
+ }
+ }
+
+ return ""
+}
+
+// pkgInfo contains information about a package installation from pkgutil
+type pkgInfo struct {
+ // PkgID is the package identifier (e.g., "com.microsoft.Word")
+ PkgID string
+ // Volume is the install volume (e.g., "/")
+ Volume string
+ // InstallTime is the installation timestamp
+ InstallTime string
+}
+
+// getPkgInfo queries the macOS package receipt database to find which PKG installed
+// a specific file or directory. This uses `pkgutil --file-info` which is the official
+// way to link applications to their installer receipts.
+//
+// Parameters:
+// - path: The path to query (e.g., "/Applications/Numbers.app")
+//
+// Returns:
+// - *pkgInfo: Package information if the path was installed by a PKG, nil otherwise
+//
+// Note: Returns nil for apps installed via drag-and-drop (no PKG receipt) or
+// Mac App Store apps (receipt stored inside the app bundle, not in pkgutil database).
+func getPkgInfo(path string) *pkgInfo {
+ // Run pkgutil --file-info to query which package installed this path
+ cmd := exec.Command("pkgutil", "--file-info", path)
+ output, err := cmd.Output()
+ if err != nil {
+ // No package owns this path (drag-and-drop install or error)
+ return nil
+ }
+
+ // Parse the output which looks like:
+ // volume: /
+ // path: Applications/Numbers.app
+ // pkgid: com.apple.pkg.Numbers
+ // pkg-version: 14.0
+ // install-time: 1654432493
+ info := &pkgInfo{}
+ for _, line := range strings.Split(string(output), "\n") {
+ line = strings.TrimSpace(line)
+ if strings.HasPrefix(line, "pkgid: ") {
+ info.PkgID = strings.TrimPrefix(line, "pkgid: ")
+ } else if strings.HasPrefix(line, "volume: ") {
+ info.Volume = strings.TrimPrefix(line, "volume: ")
+ } else if strings.HasPrefix(line, "install-time: ") {
+ // Convert Unix timestamp to ISO 8601 format for cross-platform consistency
+ timestampStr := strings.TrimPrefix(line, "install-time: ")
+ if unixTime, err := strconv.ParseInt(timestampStr, 10, 64); err == nil {
+ info.InstallTime = time.Unix(unixTime, 0).Format(time.RFC3339)
+ }
+ }
+ }
+
+ // Only return if we found a package ID
+ if info.PkgID != "" {
+ return info
+ }
+ return nil
+}
+
+// entryWithPath pairs an Entry with its bundle path for parallel processing
+type entryWithPath struct {
+ entry *Entry
+ path string
+}
+
+// populatePublishersParallel gets publisher info for multiple entries in parallel
+// Uses a worker pool to limit concurrent operations
+func populatePublishersParallel(items []entryWithPath) {
+ const maxWorkers = 10 // Limit concurrent operations
+
+ if len(items) == 0 {
+ return
+ }
+
+ jobs := make(chan *entryWithPath, len(items))
+ for i := range items {
+ jobs <- &items[i]
+ }
+ close(jobs)
+
+ var wg sync.WaitGroup
+ workerCount := maxWorkers
+ if len(items) < maxWorkers {
+ workerCount = len(items)
+ }
+ for i := 0; i < workerCount; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for item := range jobs {
+ item.entry.Publisher = getPublisherFromInfoPlist(item.path)
+ }
+ }()
+ }
+
+ wg.Wait()
+}
diff --git a/pkg/inventory/software/collector_darwin_apps.go b/pkg/inventory/software/collector_darwin_apps.go
new file mode 100644
index 00000000000000..c8e3ba235d1ecc
--- /dev/null
+++ b/pkg/inventory/software/collector_darwin_apps.go
@@ -0,0 +1,278 @@
+// Unless explicitly stated otherwise all files in this repository are licensed
+// under the Apache License Version 2.0.
+// This product includes software developed at Datadog (https://www.datadoghq.com/).
+// Copyright 2016-present Datadog, Inc.
+
+//go:build darwin
+
+package software
+
+import (
+ "io/fs"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "sync"
+ "time"
+)
+
+// applicationsCollector collects software from /Applications directory
+type applicationsCollector struct{}
+
+// userAppDir represents a user's Applications directory with associated metadata
+type userAppDir struct {
+ path string // Full path to the Applications directory
+ username string // Username (empty for system-wide /Applications)
+}
+
+// getLocalUsers returns a list of local user home directories by scanning /Users/
+// It filters out system directories like Shared, Guest, and hidden directories.
+func getLocalUsers() ([]string, []*Warning) {
+ var users []string
+ var warnings []*Warning
+
+ entries, err := os.ReadDir("/Users")
+ if err != nil {
+ warnings = append(warnings, warnf("failed to read /Users directory: %v", err))
+ return users, warnings
+ }
+
+ // Directories to skip - these are not real user home directories
+ skipDirs := map[string]bool{
+ "Shared": true,
+ "Guest": true,
+ ".localized": true,
+ }
+
+ for _, entry := range entries {
+ if !entry.IsDir() {
+ continue
+ }
+
+ name := entry.Name()
+
+ // Skip hidden directories (starting with .)
+ if strings.HasPrefix(name, ".") {
+ continue
+ }
+
+ // Skip known system directories
+ if skipDirs[name] {
+ continue
+ }
+
+ userPath := filepath.Join("/Users", name)
+ users = append(users, userPath)
+ }
+
+ return users, warnings
+}
+
+// appPkgLookup holds info needed for parallel pkgutil lookup
+type appPkgLookup struct {
+ entry *Entry
+ appPath string
+}
+
+// populatePkgInfoParallel queries pkgutil for multiple apps in parallel
+// Uses a worker pool to limit concurrent pkgutil processes
+func populatePkgInfoParallel(items []appPkgLookup) {
+ const maxWorkers = 10 // Limit concurrent pkgutil processes
+
+ if len(items) == 0 {
+ return
+ }
+
+ jobs := make(chan *appPkgLookup, len(items))
+ for i := range items {
+ jobs <- &items[i]
+ }
+ close(jobs)
+
+ var wg sync.WaitGroup
+ workerCount := maxWorkers
+ if len(items) < maxWorkers {
+ workerCount = len(items)
+ }
+
+ for i := 0; i < workerCount; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for item := range jobs {
+ if pkgInfo := getPkgInfo(item.appPath); pkgInfo != nil {
+ item.entry.InstallSource = installSourcePkg
+ item.entry.PkgID = pkgInfo.PkgID
+ }
+ }
+ }()
+ }
+
+ wg.Wait()
+}
+
+// Collect scans the /Applications directory recursively for installed applications.
+// This includes apps in subdirectories like /Applications/SentinelOne/SentinelOne Extensions.app
+// It also scans ~/Applications for all local users on the system.
+func (c *applicationsCollector) Collect() ([]*Entry, []*Warning, error) {
+
+ var entries []*Entry
+ var warnings []*Warning
+ var itemsForPublisher []entryWithPath
+ var itemsForPkgLookup []appPkgLookup
+
+ // Build list of application directories to scan
+ appDirs := []userAppDir{
+ {path: "/Applications", username: ""}, // System-wide applications
+ {path: "/System/Applications", username: ""}, // Apple system applications
+ {path: "/System/Applications/Utilities", username: ""}, // Apple system utilities
+ }
+
+ // Get all local users and add their ~/Applications directories
+ localUsers, userWarnings := getLocalUsers()
+ warnings = append(warnings, userWarnings...)
+
+ for _, userHome := range localUsers {
+ username := filepath.Base(userHome)
+ userAppsPath := filepath.Join(userHome, "Applications")
+
+ // Check if the user's Applications directory exists and is accessible
+ if info, err := os.Stat(userAppsPath); err == nil && info.IsDir() {
+ appDirs = append(appDirs, userAppDir{path: userAppsPath, username: username})
+ }
+ // If directory doesn't exist or can't be accessed, silently skip
+ // (most users won't have ~/Applications)
+ }
+
+ for _, appDir := range appDirs {
+ // Capture username for this directory (used in closure below)
+ currentUsername := appDir.username
+
+ // Use WalkDir for recursive scanning to find .app bundles in subdirectories
+ // e.g., /Applications/SentinelOne/SentinelOne Extensions.app
+ err := filepath.WalkDir(appDir.path, func(appPath string, d fs.DirEntry, err error) error {
+ if err != nil {
+ // Skip directories we can't access
+ return nil
+ }
+
+ // Only process .app bundles (directories ending in .app)
+ if !d.IsDir() || !strings.HasSuffix(d.Name(), ".app") {
+ return nil
+ }
+
+ // Don't descend into .app bundles - they're bundles, not folders to scan
+ // We'll process this .app and then skip its contents
+
+ infoPlistPath := filepath.Join(appPath, "Contents", "Info.plist")
+
+ // Check if Info.plist exists (valid app bundle)
+ if _, err := os.Stat(infoPlistPath); os.IsNotExist(err) {
+ return fs.SkipDir // Skip invalid bundles
+ }
+
+ plistData, err := readPlistFile(infoPlistPath)
+ if err != nil {
+ warnings = append(warnings, warnf("failed to read Info.plist for %s: %v", d.Name(), err))
+ return fs.SkipDir
+ }
+
+ // Get display name (prefer CFBundleDisplayName, fall back to CFBundleName)
+ displayName := plistData["CFBundleDisplayName"]
+ if displayName == "" {
+ displayName = plistData["CFBundleName"]
+ }
+ if displayName == "" {
+ // Use the app bundle name without .app extension
+ displayName = strings.TrimSuffix(d.Name(), ".app")
+ }
+
+ // Get version (prefer CFBundleShortVersionString, fall back to CFBundleVersion)
+ version := plistData["CFBundleShortVersionString"]
+ if version == "" {
+ version = plistData["CFBundleVersion"]
+ }
+
+ // Get bundle identifier as product code
+ bundleID := plistData["CFBundleIdentifier"]
+
+ // Get install date from file modification time
+ var installDate string
+ if info, err := os.Stat(appPath); err == nil {
+ installDate = info.ModTime().UTC().Format(time.RFC3339)
+ }
+
+ // Determine the software type and installation source
+ // Priority: 1) System app, 2) Mac App Store, 3) PKG installer, 4) Manual (drag-and-drop)
+ source := softwareTypeApp
+ installSource := installSourceManual
+ needsPkgLookup := false
+
+ // Check if this is an Apple system app (from /System/Applications)
+ if strings.HasPrefix(appPath, "/System/Applications/") {
+ source = softwareTypeSystemApp
+ installSource = installSourceManual // System apps are pre-installed
+ } else {
+ // Check if this is a Mac App Store app by looking for _MASReceipt folder
+ // MAS apps store their receipt inside the bundle, not in /var/db/receipts
+ masReceiptPath := filepath.Join(appPath, "Contents", "_MASReceipt", "receipt")
+ if _, err := os.Stat(masReceiptPath); err == nil {
+ source = softwareTypeMAS
+ installSource = installSourceMAS
+ } else {
+ // Not a MAS app or system app - will need to check pkgutil later (in parallel)
+ needsPkgLookup = true
+ }
+ }
+
+ // Determine architecture
+ is64Bit := runtime.GOARCH == "amd64" || runtime.GOARCH == "arm64"
+
+ // Check bundle integrity
+ status := statusInstalled
+ brokenReason := checkAppBundleIntegrity(appPath, plistData)
+ if brokenReason != "" {
+ status = statusBroken
+ }
+
+ entry := &Entry{
+ DisplayName: displayName,
+ Version: version,
+ InstallDate: installDate,
+ Source: source,
+ ProductCode: bundleID,
+ Status: status,
+ BrokenReason: brokenReason,
+ Is64Bit: is64Bit,
+ InstallSource: installSource,
+ InstallPath: appPath,
+ UserSID: currentUsername, // Set username for per-user apps (empty for system-wide)
+ }
+
+ entries = append(entries, entry)
+ itemsForPublisher = append(itemsForPublisher, entryWithPath{entry: entry, path: appPath})
+
+ // Queue for parallel pkgutil lookup if needed
+ if needsPkgLookup {
+ itemsForPkgLookup = append(itemsForPkgLookup, appPkgLookup{entry: entry, appPath: appPath})
+ }
+
+ // Skip descending into the .app bundle (it's a bundle, not a folder to scan)
+ return fs.SkipDir
+ })
+
+ if err != nil {
+ warnings = append(warnings, warnf("failed to scan directory %s: %v", appDir.path, err))
+ }
+ }
+
+ // Populate PKG info in parallel for non-MAS apps
+ // This queries pkgutil --file-info to determine if the app was installed via PKG
+ populatePkgInfoParallel(itemsForPkgLookup)
+
+ // Populate publisher info in parallel using Info.plist extraction
+ populatePublishersParallel(itemsForPublisher)
+
+ return entries, warnings, nil
+}
diff --git a/pkg/inventory/software/collector_darwin_homebrew.go b/pkg/inventory/software/collector_darwin_homebrew.go
new file mode 100644
index 00000000000000..c42d3db4e9b602
--- /dev/null
+++ b/pkg/inventory/software/collector_darwin_homebrew.go
@@ -0,0 +1,299 @@
+// Unless explicitly stated otherwise all files in this repository are licensed
+// under the Apache License Version 2.0.
+// This product includes software developed at Datadog (https://www.datadoghq.com/).
+// Copyright 2016-present Datadog, Inc.
+
+//go:build darwin
+
+package software
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "time"
+)
+
+// homebrewCollector collects software installed via Homebrew package manager
+// This includes both system-wide and per-user Homebrew installations.
+// Casks that install to /Applications are skipped (covered by applicationsCollector).
+type homebrewCollector struct{}
+
+// homebrewPrefix represents a Homebrew installation with associated metadata
+type homebrewPrefix struct {
+ path string // Path to Homebrew prefix (e.g., /opt/homebrew)
+ username string // Username (empty for system-wide)
+}
+
+// homebrewReceipt represents the structure of Homebrew's INSTALL_RECEIPT.json
+type homebrewReceipt struct {
+ HomebrewVersion string `json:"homebrew_version"`
+ UsedOptions []any `json:"used_options"`
+ Source struct {
+ Spec string `json:"spec"`
+ Versions map[string]string `json:"versions"`
+ } `json:"source"`
+ InstalledOnRequest bool `json:"installed_on_request"`
+ InstalledAsDep bool `json:"installed_as_dependency"`
+ Time int64 `json:"time"` // Unix timestamp
+ TabFile string `json:"tabfile"`
+ RuntimeDeps []struct {
+ FullName string `json:"full_name"`
+ Version string `json:"version"`
+ } `json:"runtime_dependencies"`
+ SourceModTime int64 `json:"source_modified_time"`
+}
+
+// getHomebrewPrefixes returns all Homebrew installation prefixes on the system
+// This includes both system-wide installations and per-user installations.
+func getHomebrewPrefixes() ([]homebrewPrefix, []*Warning) {
+ var prefixes []homebrewPrefix
+ var warnings []*Warning
+
+ // System-wide Homebrew locations
+ // Apple Silicon: /opt/homebrew
+ // Intel: /usr/local (Homebrew files in /usr/local/Homebrew)
+ systemPrefixes := []string{
+ "/opt/homebrew", // Apple Silicon
+ "/usr/local", // Intel (Cellar is at /usr/local/Cellar)
+ "/home/linuxbrew/.linuxbrew", // Linux (in case of cross-platform)
+ }
+
+ for _, prefix := range systemPrefixes {
+ cellarPath := filepath.Join(prefix, "Cellar")
+ if info, err := os.Stat(cellarPath); err == nil && info.IsDir() {
+ prefixes = append(prefixes, homebrewPrefix{path: prefix, username: ""})
+ }
+ }
+
+ // Per-user Homebrew installations
+ // Users may install Homebrew in their home directories when they don't have admin access
+ localUsers, userWarnings := getLocalUsers()
+ warnings = append(warnings, userWarnings...)
+
+ // Common per-user Homebrew locations
+ userBrewDirs := []string{
+ ".homebrew",
+ "homebrew",
+ ".local/Homebrew",
+ }
+
+ for _, userHome := range localUsers {
+ username := filepath.Base(userHome)
+ for _, brewDir := range userBrewDirs {
+ prefixPath := filepath.Join(userHome, brewDir)
+ cellarPath := filepath.Join(prefixPath, "Cellar")
+ if info, err := os.Stat(cellarPath); err == nil && info.IsDir() {
+ prefixes = append(prefixes, homebrewPrefix{path: prefixPath, username: username})
+ }
+ }
+ }
+
+ return prefixes, warnings
+}
+
+// parseHomebrewReceipt reads and parses an INSTALL_RECEIPT.json file
+func parseHomebrewReceipt(path string) (*homebrewReceipt, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, err
+ }
+
+ var receipt homebrewReceipt
+ if err := json.Unmarshal(data, &receipt); err != nil {
+ return nil, err
+ }
+
+ return &receipt, nil
+}
+
+// Collect scans Homebrew Cellar directories for installed formulae
+// It skips Casks that install to /Applications (covered by applicationsCollector).
+func (c *homebrewCollector) Collect() ([]*Entry, []*Warning, error) {
+ var entries []*Entry
+ var warnings []*Warning
+
+ // Get all Homebrew prefixes (system-wide and per-user)
+ prefixes, prefixWarnings := getHomebrewPrefixes()
+ warnings = append(warnings, prefixWarnings...)
+
+ // If no Homebrew installations found, return empty (not an error)
+ if len(prefixes) == 0 {
+ return entries, warnings, nil
+ }
+
+ // Determine architecture
+ is64Bit := runtime.GOARCH == "amd64" || runtime.GOARCH == "arm64"
+
+ for _, prefix := range prefixes {
+ cellarPath := filepath.Join(prefix.path, "Cellar")
+
+ // Read all formulae in the Cellar
+ formulae, err := os.ReadDir(cellarPath)
+ if err != nil {
+ warnings = append(warnings, warnf("failed to read Homebrew Cellar at %s: %v", cellarPath, err))
+ continue
+ }
+
+ for _, formula := range formulae {
+ if !formula.IsDir() {
+ continue
+ }
+
+ formulaName := formula.Name()
+ formulaPath := filepath.Join(cellarPath, formulaName)
+
+ // Read all versions installed for this formula
+ versions, err := os.ReadDir(formulaPath)
+ if err != nil {
+ warnings = append(warnings, warnf("failed to read versions for %s: %v", formulaName, err))
+ continue
+ }
+
+ for _, versionDir := range versions {
+ if !versionDir.IsDir() {
+ continue
+ }
+
+ version := versionDir.Name()
+ versionPath := filepath.Join(formulaPath, version)
+
+ // Try to read INSTALL_RECEIPT.json for additional metadata
+ var installDate string
+ var installedOnRequest bool
+ receiptPath := filepath.Join(versionPath, "INSTALL_RECEIPT.json")
+ if receipt, err := parseHomebrewReceipt(receiptPath); err == nil {
+ if receipt.Time > 0 {
+ installDate = time.Unix(receipt.Time, 0).UTC().Format(time.RFC3339)
+ }
+ installedOnRequest = receipt.InstalledOnRequest
+ } else {
+ // Fall back to directory modification time
+ if info, err := os.Stat(versionPath); err == nil {
+ installDate = info.ModTime().UTC().Format(time.RFC3339)
+ }
+ }
+
+ // Determine status
+ status := statusInstalled
+
+ // Check if this is the linked (active) version
+ // The active version is symlinked from opt/{formula} -> Cellar/{formula}/{version}
+ optPath := filepath.Join(prefix.path, "opt", formulaName)
+ if target, err := os.Readlink(optPath); err == nil {
+ linkedVersion := filepath.Base(target)
+ if linkedVersion != version {
+ // This version is installed but not active
+ status = "inactive"
+ }
+ }
+
+ entry := &Entry{
+ DisplayName: formulaName,
+ Version: version,
+ InstallDate: installDate,
+ Source: softwareTypeHomebrew,
+ ProductCode: formulaName, // Use formula name as product code
+ Status: status,
+ Is64Bit: is64Bit,
+ InstallPath: versionPath,
+ UserSID: prefix.username, // Set username for per-user installs
+ }
+
+ // Add metadata about whether it was explicitly installed or as a dependency
+ if !installedOnRequest {
+ entry.Status = status + " (dependency)"
+ }
+
+ entries = append(entries, entry)
+ }
+ }
+
+ // Also check Caskroom for Casks that don't install to /Applications
+ // (e.g., fonts, drivers, prefpanes)
+ caskroomPath := filepath.Join(prefix.path, "Caskroom")
+ if info, err := os.Stat(caskroomPath); err == nil && info.IsDir() {
+ casks, err := os.ReadDir(caskroomPath)
+ if err == nil {
+ for _, cask := range casks {
+ if !cask.IsDir() {
+ continue
+ }
+
+ caskName := cask.Name()
+ caskPath := filepath.Join(caskroomPath, caskName)
+
+ // Read versions
+ caskVersions, err := os.ReadDir(caskPath)
+ if err != nil {
+ continue
+ }
+
+ for _, versionDir := range caskVersions {
+ if !versionDir.IsDir() {
+ continue
+ }
+
+ version := versionDir.Name()
+ versionPath := filepath.Join(caskPath, version)
+
+ // Check if this cask installed an app to /Applications
+ // by looking for .app files in the version directory
+ hasAppInApplications := false
+ versionContents, err := os.ReadDir(versionPath)
+ if err == nil {
+ for _, content := range versionContents {
+ if strings.HasSuffix(content.Name(), ".app") {
+ // Check if there's a corresponding app in /Applications
+ appPath := filepath.Join("/Applications", content.Name())
+ if _, err := os.Stat(appPath); err == nil {
+ hasAppInApplications = true
+ break
+ }
+ // Also check user's ~/Applications
+ if prefix.username != "" {
+ userAppPath := filepath.Join("/Users", prefix.username, "Applications", content.Name())
+ if _, err := os.Stat(userAppPath); err == nil {
+ hasAppInApplications = true
+ break
+ }
+ }
+ }
+ }
+ }
+
+ // Skip casks that installed apps to /Applications
+ // (they're already covered by applicationsCollector)
+ if hasAppInApplications {
+ continue
+ }
+
+ // Get install date from directory
+ var installDate string
+ if info, err := os.Stat(versionPath); err == nil {
+ installDate = info.ModTime().UTC().Format(time.RFC3339)
+ }
+
+ entry := &Entry{
+ DisplayName: caskName + " (cask)",
+ Version: version,
+ InstallDate: installDate,
+ Source: softwareTypeHomebrew,
+ ProductCode: caskName,
+ Status: statusInstalled,
+ Is64Bit: is64Bit,
+ InstallPath: versionPath,
+ UserSID: prefix.username,
+ }
+
+ entries = append(entries, entry)
+ }
+ }
+ }
+ }
+ }
+
+ return entries, warnings, nil
+}
diff --git a/pkg/inventory/software/collector_darwin_kext.go b/pkg/inventory/software/collector_darwin_kext.go
new file mode 100644
index 00000000000000..c09130f12a225c
--- /dev/null
+++ b/pkg/inventory/software/collector_darwin_kext.go
@@ -0,0 +1,116 @@
+// Unless explicitly stated otherwise all files in this repository are licensed
+// under the Apache License Version 2.0.
+// This product includes software developed at Datadog (https://www.datadoghq.com/).
+// Copyright 2016-present Datadog, Inc.
+
+//go:build darwin
+
+package software
+
+import (
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "time"
+)
+
+// kernelExtensionsCollector collects kernel extensions (kexts) from the system
+type kernelExtensionsCollector struct{}
+
+// Collect scans kernel extension directories for installed kexts
+func (c *kernelExtensionsCollector) Collect() ([]*Entry, []*Warning, error) {
+ var entries []*Entry
+ var warnings []*Warning
+ var itemsForPublisher []entryWithPath
+
+ // Kernel extension directories
+ // /Library/Extensions - Third-party kexts
+ // /System/Library/Extensions - Apple system kexts (usually protected by SIP)
+ kextDirs := []string{
+ "/Library/Extensions",
+ }
+
+ for _, kextDir := range kextDirs {
+ dirEntries, err := os.ReadDir(kextDir)
+ if err != nil {
+ // Not an error if directory doesn't exist
+ if os.IsNotExist(err) {
+ continue
+ }
+ warnings = append(warnings, warnf("failed to read directory %s: %v", kextDir, err))
+ continue
+ }
+
+ for _, dirEntry := range dirEntries {
+ if !strings.HasSuffix(dirEntry.Name(), ".kext") {
+ continue
+ }
+
+ kextPath := filepath.Join(kextDir, dirEntry.Name())
+ infoPlistPath := filepath.Join(kextPath, "Contents", "Info.plist")
+
+ // Check if Info.plist exists
+ if _, err := os.Stat(infoPlistPath); os.IsNotExist(err) {
+ continue
+ }
+
+ plistData, err := readPlistFile(infoPlistPath)
+ if err != nil {
+ warnings = append(warnings, warnf("failed to read Info.plist for %s: %v", dirEntry.Name(), err))
+ continue
+ }
+
+ // Get display name (prefer CFBundleName, fall back to bundle name)
+ displayName := plistData["CFBundleName"]
+ if displayName == "" {
+ displayName = strings.TrimSuffix(dirEntry.Name(), ".kext")
+ }
+
+ // Get version (prefer CFBundleShortVersionString, fall back to CFBundleVersion)
+ version := plistData["CFBundleShortVersionString"]
+ if version == "" {
+ version = plistData["CFBundleVersion"]
+ }
+
+ // Get bundle identifier as product code
+ bundleID := plistData["CFBundleIdentifier"]
+
+ // Get install date from file modification time
+ var installDate string
+ if info, err := os.Stat(kextPath); err == nil {
+ installDate = info.ModTime().UTC().Format(time.RFC3339)
+ }
+
+ // Determine architecture
+ is64Bit := runtime.GOARCH == "amd64" || runtime.GOARCH == "arm64"
+
+ // Check bundle integrity
+ status := statusInstalled
+ brokenReason := checkKextBundleIntegrity(kextPath, plistData)
+ if brokenReason != "" {
+ status = statusBroken
+ }
+
+ entry := &Entry{
+ DisplayName: displayName,
+ Version: version,
+ InstallDate: installDate,
+ Source: softwareTypeKext,
+ ProductCode: bundleID,
+ Status: status,
+ BrokenReason: brokenReason,
+ Is64Bit: is64Bit,
+ InstallPath: kextPath,
+ }
+
+ entries = append(entries, entry)
+ itemsForPublisher = append(itemsForPublisher, entryWithPath{entry: entry, path: kextPath})
+ }
+ }
+
+ // Populate publisher info in parallel using code signing
+ populatePublishersParallel(itemsForPublisher)
+
+ return entries, warnings, nil
+}
diff --git a/pkg/inventory/software/collector_darwin_macports.go b/pkg/inventory/software/collector_darwin_macports.go
new file mode 100644
index 00000000000000..dcb5d70a93acc4
--- /dev/null
+++ b/pkg/inventory/software/collector_darwin_macports.go
@@ -0,0 +1,156 @@
+// Unless explicitly stated otherwise all files in this repository are licensed
+// under the Apache License Version 2.0.
+// This product includes software developed at Datadog (https://www.datadoghq.com/).
+// Copyright 2016-present Datadog, Inc.
+
+//go:build darwin
+
+package software
+
+import (
+ "database/sql"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "time"
+
+ // SQLite driver for MacPorts registry database
+ _ "github.com/mattn/go-sqlite3"
+)
+
+// softwareTypeMacPorts represents software installed via MacPorts package manager
+const softwareTypeMacPorts = "macports"
+
+// macPortsCollector collects software installed via MacPorts package manager
+// MacPorts stores its registry in a SQLite database at /opt/local/var/macports/registry/registry.db
+type macPortsCollector struct{}
+
+// Collect reads the MacPorts registry database to enumerate installed ports
+func (c *macPortsCollector) Collect() ([]*Entry, []*Warning, error) {
+ var entries []*Entry
+ var warnings []*Warning
+
+ // MacPorts standard installation paths
+ // The default prefix is /opt/local, but users can customize it
+ macPortsPrefixes := []string{
+ "/opt/local", // Default MacPorts prefix
+ }
+
+ // Also check per-user MacPorts installations (rare but possible)
+ localUsers, userWarnings := getLocalUsers()
+ warnings = append(warnings, userWarnings...)
+ for _, userHome := range localUsers {
+ // Some users install MacPorts in their home directory
+ macPortsPrefixes = append(macPortsPrefixes, filepath.Join(userHome, "macports"))
+ macPortsPrefixes = append(macPortsPrefixes, filepath.Join(userHome, ".macports"))
+ }
+
+ // Determine architecture
+ is64Bit := runtime.GOARCH == "amd64" || runtime.GOARCH == "arm64"
+
+ for _, prefix := range macPortsPrefixes {
+ // MacPorts registry database location
+ registryPath := filepath.Join(prefix, "var", "macports", "registry", "registry.db")
+
+ // Check if database exists
+ if _, err := os.Stat(registryPath); os.IsNotExist(err) {
+ continue
+ }
+
+ // Determine username for per-user installations
+ var username string
+ if prefix != "/opt/local" {
+ // Extract username from path like /Users/username/macports
+ for _, userHome := range localUsers {
+ if strings.HasPrefix(prefix, userHome) {
+ username = filepath.Base(userHome)
+ break
+ }
+ }
+ }
+
+ // Open the SQLite database
+ db, err := sql.Open("sqlite3", registryPath+"?mode=ro")
+ if err != nil {
+ warnings = append(warnings, warnf("failed to open MacPorts registry at %s: %v", registryPath, err))
+ continue
+ }
+
+ // Query installed ports from the registry
+ // The ports table contains: id, name, portfile, url, location, epoch, version, revision, variants, negated_variants, state, date, installtype, archs, requested, os_platform, os_major, cxx_stdlib, cxx_stdlib_overridden
+ rows, err := db.Query(`
+ SELECT name, version, revision, variants, date, requested, state, location
+ FROM ports
+ WHERE state = 'installed'
+ `)
+ if err != nil {
+ db.Close()
+ warnings = append(warnings, warnf("failed to query MacPorts registry at %s: %v", registryPath, err))
+ continue
+ }
+
+ for rows.Next() {
+ var name, version, revision, variants, state string
+ var installDate int64
+ var requested int
+ var location sql.NullString
+
+ if err := rows.Scan(&name, &version, &revision, &variants, &installDate, &requested, &state, &location); err != nil {
+ warnings = append(warnings, warnf("failed to scan MacPorts row: %v", err))
+ continue
+ }
+
+ // Build full version string (version_revision+variants)
+ fullVersion := version
+ if revision != "" && revision != "0" {
+ fullVersion += "_" + revision
+ }
+ if variants != "" {
+ fullVersion += variants
+ }
+
+ // Convert Unix timestamp to RFC3339
+ var installDateStr string
+ if installDate > 0 {
+ installDateStr = time.Unix(installDate, 0).UTC().Format(time.RFC3339)
+ }
+
+ // Determine status
+ status := statusInstalled
+ if state != "installed" {
+ status = state // Could be "imaged" or other states
+ }
+
+ // Determine install path
+ installPath := filepath.Join(prefix, "var", "macports", "software", name, fullVersion)
+ if location.Valid && location.String != "" {
+ installPath = location.String
+ }
+
+ // Mark dependencies vs explicitly requested packages
+ if requested == 0 {
+ status = status + " (dependency)"
+ }
+
+ entry := &Entry{
+ DisplayName: name,
+ Version: fullVersion,
+ InstallDate: installDateStr,
+ Source: softwareTypeMacPorts,
+ ProductCode: name, // MacPorts uses name as identifier
+ Status: status,
+ Is64Bit: is64Bit,
+ InstallPath: installPath,
+ UserSID: username,
+ }
+
+ entries = append(entries, entry)
+ }
+
+ rows.Close()
+ db.Close()
+ }
+
+ return entries, warnings, nil
+}
diff --git a/pkg/inventory/software/collector_darwin_pkg.go b/pkg/inventory/software/collector_darwin_pkg.go
new file mode 100644
index 00000000000000..4aa698ab4d0bf0
--- /dev/null
+++ b/pkg/inventory/software/collector_darwin_pkg.go
@@ -0,0 +1,504 @@
+// Unless explicitly stated otherwise all files in this repository are licensed
+// under the Apache License Version 2.0.
+// This product includes software developed at Datadog (https://www.datadoghq.com/).
+// Copyright 2016-present Datadog, Inc.
+
+//go:build darwin
+
+package software
+
+import (
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "sync"
+ "time"
+)
+
+// pkgReceiptsCollector collects software from PKG installer receipts
+// This collector filters out receipts for applications that are already captured
+// by the applicationsCollector (apps in /Applications), to avoid confusing duplicates.
+type pkgReceiptsCollector struct{}
+
+// pkgFilesCacheEntry holds a cached file list with its timestamp for TTL checking
+type pkgFilesCacheEntry struct {
+ Files []string
+ Timestamp time.Time
+}
+
+// pkgFilesCache holds cached results from pkgutil --files queries
+type pkgFilesCache struct {
+ mu sync.RWMutex
+ cache map[string]*pkgFilesCacheEntry // pkgID -> cache entry with files and timestamp
+ ttl time.Duration // Time-to-live for cache entries
+}
+
+// Default TTL for pkgutil --files cache entries
+const defaultPkgFilesCacheTTL = 1 * time.Hour
+
+// Global cache instance for pkgutil --files results
+var (
+ globalPkgFilesCache *pkgFilesCache
+ globalCacheOnce sync.Once
+)
+
+// getGlobalPkgFilesCache returns the global singleton cache instance
+// The cache persists across collection runs within the same process lifetime
+func getGlobalPkgFilesCache() *pkgFilesCache {
+ globalCacheOnce.Do(func() {
+ globalPkgFilesCache = &pkgFilesCache{
+ cache: make(map[string]*pkgFilesCacheEntry),
+ ttl: defaultPkgFilesCacheTTL,
+ }
+ })
+ return globalPkgFilesCache
+}
+
+// newPkgFilesCache creates a new cache for pkgutil --files results
+// Deprecated: Use getGlobalPkgFilesCache() for persistent caching
+func newPkgFilesCache() *pkgFilesCache {
+ return &pkgFilesCache{
+ cache: make(map[string]*pkgFilesCacheEntry),
+ ttl: defaultPkgFilesCacheTTL,
+ }
+}
+
+// get retrieves cached file list for a package, or fetches it if not cached or expired
+func (c *pkgFilesCache) get(pkgID string) []string {
+ now := time.Now()
+
+ // Check cache with read lock
+ c.mu.RLock()
+ entry, ok := c.cache[pkgID]
+ if ok && entry != nil {
+ // Check if entry is still valid (not expired)
+ age := now.Sub(entry.Timestamp)
+ if age < c.ttl {
+ // Cache hit - entry is valid
+ files := entry.Files
+ c.mu.RUnlock()
+ return files
+ }
+ // Entry exists but is expired - will fetch new data below
+ }
+ c.mu.RUnlock()
+
+ // Not in cache or expired, fetch it
+ files := fetchPkgFiles(pkgID)
+
+ // Update cache with write lock
+ c.mu.Lock()
+ c.cache[pkgID] = &pkgFilesCacheEntry{
+ Files: files,
+ Timestamp: now,
+ }
+ c.mu.Unlock()
+
+ return files
+}
+
+// prefetch fetches pkgutil --files for multiple packages in parallel
+// Uses a worker pool to limit concurrent pkgutil processes
+func (c *pkgFilesCache) prefetch(pkgIDs []string) {
+ const maxWorkers = 10 // Limit concurrent pkgutil processes
+
+ if len(pkgIDs) == 0 {
+ return
+ }
+
+ // Create a channel for work items
+ jobs := make(chan string, len(pkgIDs))
+ for _, pkgID := range pkgIDs {
+ jobs <- pkgID
+ }
+ close(jobs)
+
+ // Start worker pool
+ var wg sync.WaitGroup
+ workerCount := maxWorkers
+ if len(pkgIDs) < maxWorkers {
+ workerCount = len(pkgIDs)
+ }
+
+ for i := 0; i < workerCount; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for pkgID := range jobs {
+ c.get(pkgID) // This will fetch and cache if not already cached
+ }
+ }()
+ }
+
+ wg.Wait()
+}
+
+// fetchPkgFiles runs pkgutil --files and returns the list of files
+func fetchPkgFiles(pkgID string) []string {
+ cmd := exec.Command("pkgutil", "--files", pkgID)
+ output, err := cmd.Output()
+ if err != nil {
+ return nil
+ }
+
+ var files []string
+ for _, line := range strings.Split(string(output), "\n") {
+ line = strings.TrimSpace(line)
+ if line != "" {
+ files = append(files, line)
+ }
+ }
+ return files
+}
+
+// pkgInstalledAppFromCache checks if a package installed an application bundle
+// using cached file list instead of calling pkgutil again
+func pkgInstalledAppFromCache(files []string) bool {
+ for _, line := range files {
+ // Check if this line represents an .app bundle
+ // We look for .app in the path and verify it's a bundle (not just a file with .app in name)
+ if strings.Contains(line, ".app") {
+ // Get the first path component to check if it's the app bundle itself
+ // or if it's inside an Applications directory
+ parts := strings.Split(line, "/")
+
+ // Case 1: Direct app bundle (InstallPrefixPath = "Applications")
+ // e.g., "Google Chrome.app" or "Google Chrome.app/Contents"
+ if strings.HasSuffix(parts[0], ".app") {
+ return true
+ }
+
+ // Case 2: App inside Applications folder (InstallPrefixPath = "/")
+ // e.g., "Applications/Numbers.app" or "Applications/Numbers.app/Contents"
+ if len(parts) >= 2 && parts[0] == "Applications" && strings.HasSuffix(parts[1], ".app") {
+ return true
+ }
+ }
+ }
+
+ return false
+}
+
+// isLikelyFile checks if a path looks like a file (not a directory).
+// Files typically have extensions or are in known executable locations.
+func isLikelyFile(path string) bool {
+ // Common file extensions
+ fileExtensions := []string{
+ ".so", ".dylib", ".a", ".o", // Libraries
+ ".py", ".pyc", ".pyo", ".pyd", // Python
+ ".rb", ".pl", ".sh", ".bash", // Scripts
+ ".json", ".yaml", ".yml", ".xml", ".plist", // Config
+ ".txt", ".md", ".rst", ".html", ".css", ".js", // Text/Web
+ ".png", ".jpg", ".jpeg", ".gif", ".ico", ".icns", // Images
+ ".app", ".framework", ".bundle", ".kext", // macOS bundles
+ ".pkg", ".dmg", ".zip", ".tar", ".gz", // Archives
+ ".conf", ".cfg", ".ini", ".log", // Config/logs
+ ".h", ".c", ".cpp", ".m", ".swift", // Source
+ ".strings", ".nib", ".xib", ".storyboard", // macOS resources
+ }
+
+ // Check for file extension
+ for _, ext := range fileExtensions {
+ if strings.HasSuffix(path, ext) {
+ return true
+ }
+ }
+
+ // Check if it's in a bin directory (executables often have no extension)
+ parts := strings.Split(path, "/")
+ for i, part := range parts {
+ if part == "bin" && i < len(parts)-1 {
+ // The item after "bin" is likely an executable
+ return true
+ }
+ }
+
+ // Check for common executable names without extensions
+ lastPart := parts[len(parts)-1]
+ if !strings.Contains(lastPart, ".") && len(lastPart) > 0 {
+ // Files in certain directories are likely files, not directories
+ for _, part := range parts {
+ if part == "bin" || part == "lib" || part == "share" || part == "include" {
+ return true
+ }
+ }
+ }
+
+ return false
+}
+
+// getPkgTopLevelPathsFromCache extracts top-level directories from cached file list
+func getPkgTopLevelPathsFromCache(files []string, prefixPath string) []string {
+ // Normalize prefix path for building absolute paths
+ var basePrefix string
+ if prefixPath == "" || prefixPath == "/" {
+ basePrefix = ""
+ } else if strings.HasPrefix(prefixPath, "/") {
+ basePrefix = prefixPath
+ } else {
+ basePrefix = "/" + prefixPath
+ }
+
+ // Collect file parent directories at appropriate depth
+ dirSet := make(map[string]bool)
+
+ for _, line := range files {
+ // Only process files (items with extensions or in known file locations)
+ if !isLikelyFile(line) {
+ continue
+ }
+
+ // Get path components
+ parts := strings.Split(line, "/")
+ if len(parts) == 0 {
+ continue
+ }
+
+ // Determine the meaningful top-level directory based on path structure
+ // We want to capture the "application directory" level, not every nested dir
+ var topLevelDir string
+
+ switch parts[0] {
+ case "usr":
+ // For /usr paths, capture at the 3rd level (e.g., /usr/local/bin, /usr/local/ykman)
+ if len(parts) >= 3 {
+ topLevelDir = "/" + parts[0] + "/" + parts[1] + "/" + parts[2]
+ }
+ case "Library":
+ // For /Library, capture at 2nd level (e.g., /Library/LaunchDaemons)
+ if len(parts) >= 2 {
+ topLevelDir = "/" + parts[0] + "/" + parts[1]
+ }
+ case "opt":
+ // For /opt, capture the application directory (e.g., /opt/datadog-agent)
+ if len(parts) >= 2 {
+ topLevelDir = "/" + parts[0] + "/" + parts[1]
+ }
+ case "Applications":
+ // For /Applications, capture the app bundle (e.g., /Applications/Chrome.app)
+ if len(parts) >= 2 {
+ topLevelDir = "/" + parts[0] + "/" + parts[1]
+ }
+ case "System", "private", "var":
+ // For system paths, capture at 3rd level
+ if len(parts) >= 3 {
+ topLevelDir = "/" + parts[0] + "/" + parts[1] + "/" + parts[2]
+ } else if len(parts) >= 2 {
+ topLevelDir = "/" + parts[0] + "/" + parts[1]
+ }
+ default:
+ // For paths with a prefix (e.g., "Applications" prefix), combine with first component
+ if basePrefix != "" && basePrefix != "/" {
+ topLevelDir = basePrefix + "/" + parts[0]
+ } else if len(parts) >= 1 {
+ topLevelDir = "/" + parts[0]
+ }
+ }
+
+ // Clean up and add to set
+ if topLevelDir != "" && topLevelDir != "/" {
+ topLevelDir = strings.ReplaceAll(topLevelDir, "//", "/")
+ dirSet[topLevelDir] = true
+ }
+ }
+
+ // Convert map to sorted slice
+ paths := make([]string, 0, len(dirSet))
+ for path := range dirSet {
+ paths = append(paths, path)
+ }
+
+ // Sort for consistent output
+ if len(paths) > 1 {
+ for i := 0; i < len(paths)-1; i++ {
+ for j := i + 1; j < len(paths); j++ {
+ if paths[i] > paths[j] {
+ paths[i], paths[j] = paths[j], paths[i]
+ }
+ }
+ }
+ }
+
+ return paths
+}
+
+// pkgReceiptInfo holds parsed info from a PKG receipt plist
+type pkgReceiptInfo struct {
+ packageID string
+ version string
+ installDate string
+ prefixPath string
+}
+
+// Collect reads PKG installer receipts from /var/db/receipts
+// It filters out:
+// - Mac App Store receipts (ending in _MASReceipt) - these are handled by applicationsCollector
+// - Packages that installed .app bundles to /Applications - already captured by applicationsCollector
+//
+// This ensures PKG receipts only show non-application installations like:
+// - System components and frameworks
+// - Command-line tools
+// - Drivers and kernel extensions
+// - Libraries and shared resources
+func (c *pkgReceiptsCollector) Collect() ([]*Entry, []*Warning, error) {
+
+ var entries []*Entry
+ var warnings []*Warning
+
+ receiptsDir := "/var/db/receipts"
+
+ dirEntries, err := os.ReadDir(receiptsDir)
+ if err != nil {
+ // Not an error if receipts directory doesn't exist
+ if os.IsNotExist(err) {
+ return entries, warnings, nil
+ }
+ return nil, nil, err
+ }
+
+ // First pass: Read all receipt plists and collect package IDs
+ var receipts []pkgReceiptInfo
+ var pkgIDsToFetch []string
+
+ for _, dirEntry := range dirEntries {
+ if !strings.HasSuffix(dirEntry.Name(), ".plist") {
+ continue
+ }
+
+ receiptPath := filepath.Join(receiptsDir, dirEntry.Name())
+ plistData, err := readPlistFile(receiptPath)
+ if err != nil {
+ warnings = append(warnings, warnf("failed to read receipt %s: %v", dirEntry.Name(), err))
+ continue
+ }
+
+ // Get package identifier as both display name and product code
+ packageID := plistData["PackageIdentifier"]
+ if packageID == "" {
+ continue
+ }
+
+ // Skip Mac App Store receipts - these correspond to MAS apps which are
+ // already captured by applicationsCollector with richer metadata
+ if strings.HasSuffix(packageID, "_MASReceipt") {
+ continue
+ }
+
+ // Get install prefix path from receipt
+ prefixPath := plistData["InstallPrefixPath"]
+ if prefixPath == "" {
+ prefixPath = plistData["InstallLocation"]
+ }
+
+ receipts = append(receipts, pkgReceiptInfo{
+ packageID: packageID,
+ version: plistData["PackageVersion"],
+ installDate: plistData["InstallDate"],
+ prefixPath: prefixPath,
+ })
+ pkgIDsToFetch = append(pkgIDsToFetch, packageID)
+ }
+
+ // Prefetch all pkgutil --files results in parallel
+ // Use global cache that persists across collection runs
+ cache := getGlobalPkgFilesCache()
+ cache.prefetch(pkgIDsToFetch)
+
+ // Determine architecture
+ is64Bit := runtime.GOARCH == "amd64" || runtime.GOARCH == "arm64"
+
+ // Second pass: Process receipts using cached data
+ for _, receipt := range receipts {
+ files := cache.get(receipt.packageID)
+
+ // Skip packages that installed applications to /Applications
+ // These are already captured by applicationsCollector
+ if pkgInstalledAppFromCache(files) {
+ continue
+ }
+
+ // Determine install_path for backward compatibility
+ var installPath string
+ if receipt.prefixPath != "" && receipt.prefixPath != "/" {
+ if !strings.HasPrefix(receipt.prefixPath, "/") {
+ installPath = "/" + receipt.prefixPath
+ } else {
+ installPath = receipt.prefixPath
+ }
+ } else {
+ installPath = "N/A"
+ }
+
+ // Get top-level installation directories from cached file list
+ installPaths := getPkgTopLevelPathsFromCache(files, receipt.prefixPath)
+
+ // Filter out generic system directories
+ filteredPaths := make([]string, 0, len(installPaths))
+ for _, p := range installPaths {
+ if p == "/etc" || p == "/var" || p == "/tmp" || p == "/System" {
+ continue
+ }
+ filteredPaths = append(filteredPaths, p)
+ }
+ installPaths = filteredPaths
+
+ // Determine which path field(s) to include
+ if installPath != "N/A" && len(installPaths) > 0 {
+ hasPathsOutside := false
+ installPathWithSlash := installPath + "/"
+ for _, p := range installPaths {
+ if !strings.HasPrefix(p, installPathWithSlash) && p != installPath {
+ hasPathsOutside = true
+ break
+ }
+ }
+ if !hasPathsOutside {
+ installPaths = nil
+ }
+ } else if installPath == "N/A" && len(installPaths) > 0 {
+ if len(installPaths) == 1 {
+ installPath = installPaths[0]
+ installPaths = nil
+ } else {
+ installPath = ""
+ }
+ }
+
+ // Check if the installation location still exists
+ status := statusInstalled
+ var brokenReason string
+ if installPath != "" && installPath != "N/A" {
+ if _, err := os.Stat(installPath); os.IsNotExist(err) {
+ status = statusBroken
+ brokenReason = "install path not found: " + installPath
+ }
+ } else if len(installPaths) > 0 {
+ for _, p := range installPaths {
+ if _, err := os.Stat(p); os.IsNotExist(err) {
+ status = statusBroken
+ brokenReason = "install path not found: " + p
+ break
+ }
+ }
+ }
+
+ entry := &Entry{
+ DisplayName: receipt.packageID,
+ Version: receipt.version,
+ InstallDate: receipt.installDate,
+ Source: softwareTypePkg,
+ ProductCode: receipt.packageID,
+ Status: status,
+ BrokenReason: brokenReason,
+ Is64Bit: is64Bit,
+ InstallPath: installPath,
+ InstallPaths: installPaths,
+ }
+
+ entries = append(entries, entry)
+ }
+
+ return entries, warnings, nil
+}
diff --git a/pkg/inventory/software/collector_darwin_sysext.go b/pkg/inventory/software/collector_darwin_sysext.go
new file mode 100644
index 00000000000000..2fe83f229e27e5
--- /dev/null
+++ b/pkg/inventory/software/collector_darwin_sysext.go
@@ -0,0 +1,229 @@
+// Unless explicitly stated otherwise all files in this repository are licensed
+// under the Apache License Version 2.0.
+// This product includes software developed at Datadog (https://www.datadoghq.com/).
+// Copyright 2016-present Datadog, Inc.
+
+//go:build darwin
+
+package software
+
+import (
+ "bytes"
+ "encoding/xml"
+ "os"
+ "os/exec"
+ "runtime"
+ "strings"
+ "time"
+)
+
+// systemExtensionsCollector collects system extensions from the macOS database
+type systemExtensionsCollector struct{}
+
+// sysExtDBEntry represents an extension entry in db.plist
+type sysExtDBEntry struct {
+ Identifier string
+ Version string
+ BuildVersion string
+ State string
+ TeamID string
+ Categories []string
+ OriginPath string
+ StagedBundlePath string // Extracted from stagedBundleURL
+}
+
+// parseSysExtDatabase parses the /Library/SystemExtensions/db.plist database
+func parseSysExtDatabase(path string) ([]sysExtDBEntry, error) {
+ // Use plutil to convert to XML for parsing
+ cmd := exec.Command("plutil", "-convert", "xml1", "-o", "-", path)
+ output, err := cmd.Output()
+ if err != nil {
+ return nil, err
+ }
+
+ var entries []sysExtDBEntry
+
+ // Parse the XML manually to extract extension information
+ decoder := xml.NewDecoder(bytes.NewReader(output))
+
+ var inExtensions bool
+ var inExtensionDict bool
+ var inBundleVersion bool
+ var inStagedBundleURL bool
+ var currentKey string
+ var currentEntry sysExtDBEntry
+ var dictDepth int
+ var extensionDictDepth int // Track the depth when we entered extension dict
+
+ for {
+ token, err := decoder.Token()
+ if err != nil {
+ break
+ }
+
+ switch t := token.(type) {
+ case xml.StartElement:
+ switch t.Name.Local {
+ case "dict":
+ dictDepth++
+ // Extension dicts are at depth 2 inside the extensions array
+ if inExtensions && !inExtensionDict && dictDepth == 2 {
+ inExtensionDict = true
+ extensionDictDepth = dictDepth
+ currentEntry = sysExtDBEntry{}
+ }
+ // bundleVersion is a nested dict inside the extension dict
+ if inExtensionDict && currentKey == "bundleVersion" {
+ inBundleVersion = true
+ }
+ // stagedBundleURL is a nested dict inside the extension dict
+ if inExtensionDict && currentKey == "stagedBundleURL" {
+ inStagedBundleURL = true
+ }
+ case "array":
+ if currentKey == "extensions" {
+ inExtensions = true
+ }
+ case "key":
+ var key string
+ if err := decoder.DecodeElement(&key, &t); err == nil {
+ currentKey = key
+ }
+ case "string":
+ var value string
+ if err := decoder.DecodeElement(&value, &t); err == nil {
+ if inBundleVersion {
+ switch currentKey {
+ case "CFBundleShortVersionString":
+ currentEntry.Version = value
+ case "CFBundleVersion":
+ currentEntry.BuildVersion = value
+ }
+ } else if inStagedBundleURL {
+ // Parse the "relative" field which contains the file:// URL
+ if currentKey == "relative" {
+ // Convert file:// URL to path
+ if strings.HasPrefix(value, "file://") {
+ currentEntry.StagedBundlePath = strings.TrimPrefix(value, "file://")
+ // Remove trailing slash if present
+ currentEntry.StagedBundlePath = strings.TrimSuffix(currentEntry.StagedBundlePath, "/")
+ }
+ }
+ } else if inExtensionDict && dictDepth == extensionDictDepth {
+ // Only process keys at the extension dict level, not nested dicts
+ switch currentKey {
+ case "identifier":
+ currentEntry.Identifier = value
+ case "state":
+ currentEntry.State = value
+ case "teamID":
+ currentEntry.TeamID = value
+ case "originPath":
+ currentEntry.OriginPath = value
+ }
+ }
+ currentKey = ""
+ }
+ }
+ case xml.EndElement:
+ switch t.Name.Local {
+ case "dict":
+ if inBundleVersion && dictDepth == extensionDictDepth+1 {
+ inBundleVersion = false
+ }
+ if inStagedBundleURL && dictDepth == extensionDictDepth+1 {
+ inStagedBundleURL = false
+ }
+ if inExtensionDict && dictDepth == extensionDictDepth {
+ inExtensionDict = false
+ if currentEntry.Identifier != "" {
+ entries = append(entries, currentEntry)
+ }
+ }
+ dictDepth--
+ case "array":
+ if inExtensions && dictDepth == 1 {
+ inExtensions = false
+ }
+ }
+ }
+ }
+
+ return entries, nil
+}
+
+// Collect reads system extensions from the db.plist database
+func (c *systemExtensionsCollector) Collect() ([]*Entry, []*Warning, error) {
+ var entries []*Entry
+ var warnings []*Warning
+ var itemsForPublisher []entryWithPath
+
+ // System extensions database
+ dbPath := "/Library/SystemExtensions/db.plist"
+
+ // Check if database exists
+ if _, err := os.Stat(dbPath); os.IsNotExist(err) {
+ return entries, warnings, nil
+ }
+
+ // Parse the database
+ sysExtEntries, err := parseSysExtDatabase(dbPath)
+ if err != nil {
+ warnings = append(warnings, warnf("failed to parse system extensions database: %v", err))
+ return entries, warnings, nil
+ }
+
+ // Determine architecture
+ is64Bit := runtime.GOARCH == "amd64" || runtime.GOARCH == "arm64"
+
+ for _, sysExt := range sysExtEntries {
+ // Use version, fall back to build version
+ version := sysExt.Version
+ if version == "" {
+ version = sysExt.BuildVersion
+ }
+
+ // Map state to status
+ status := sysExt.State
+ if status == "" {
+ status = "unknown"
+ }
+
+ // Get install date from the staged bundle if available
+ var installDate string
+ if sysExt.OriginPath != "" {
+ if info, err := os.Stat(sysExt.OriginPath); err == nil {
+ installDate = info.ModTime().UTC().Format(time.RFC3339)
+ }
+ }
+
+ // Determine install path - prefer StagedBundlePath, fall back to OriginPath
+ installPath := sysExt.StagedBundlePath
+ if installPath == "" {
+ installPath = sysExt.OriginPath
+ }
+
+ entry := &Entry{
+ DisplayName: sysExt.Identifier,
+ Version: version,
+ InstallDate: installDate,
+ Source: softwareTypeSysExt,
+ ProductCode: sysExt.Identifier,
+ Status: status,
+ Is64Bit: is64Bit,
+ InstallPath: installPath,
+ }
+
+ entries = append(entries, entry)
+
+ // Use staged bundle path for publisher info if available
+ if sysExt.StagedBundlePath != "" {
+ itemsForPublisher = append(itemsForPublisher, entryWithPath{entry: entry, path: sysExt.StagedBundlePath})
+ }
+ }
+
+ // Populate publisher info in parallel using code signing
+ populatePublishersParallel(itemsForPublisher)
+
+ return entries, warnings, nil
+}
diff --git a/pkg/inventory/software/collector_darwin_test.go b/pkg/inventory/software/collector_darwin_test.go
new file mode 100644
index 00000000000000..85f6eaa0650c58
--- /dev/null
+++ b/pkg/inventory/software/collector_darwin_test.go
@@ -0,0 +1,408 @@
+// Unless explicitly stated otherwise all files in this repository are licensed
+// under the Apache License Version 2.0.
+// This product includes software developed at Datadog (https://www.datadoghq.com/).
+// Copyright 2016-present Datadog, Inc.
+
+//go:build darwin
+
+package software
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestGetLocalUsers verifies that getLocalUsers() correctly enumerates user home
+// directories from /Users while filtering out system directories like Shared,
+// Guest, and hidden directories. This test runs against the real filesystem.
+func TestGetLocalUsers(t *testing.T) {
+ users, warnings := getLocalUsers()
+
+ t.Logf("Found %d users with %d warnings", len(users), len(warnings))
+
+ // Validate returned user paths - log warnings instead of failing
+ issueCount := 0
+ for _, userPath := range users {
+ username := filepath.Base(userPath)
+
+ // Safety check for empty username
+ if username == "" {
+ t.Logf("WARNING: Found user path with empty username: %s", userPath)
+ issueCount++
+ continue
+ }
+
+ // Check for system directories that should be filtered
+ if username == "Shared" || username == "Guest" || username == ".localized" {
+ t.Logf("WARNING: System directory not filtered: %s", username)
+ issueCount++
+ }
+
+ // Check for hidden directories
+ if username[0] == '.' {
+ t.Logf("WARNING: Hidden directory not filtered: %s", username)
+ issueCount++
+ }
+
+ // Verify path is under /Users
+ if !strings.HasPrefix(userPath, "/Users/") {
+ t.Logf("WARNING: User path not under /Users: %s", userPath)
+ issueCount++
+ }
+ }
+
+ if issueCount > 0 {
+ t.Logf("Found %d validation issues (logged as warnings)", issueCount)
+ }
+}
+
+// TestUserAppDir tests the userAppDir struct to ensure it correctly stores
+// both the application path and the associated username for per-user installations.
+func TestUserAppDir(t *testing.T) {
+ dir := userAppDir{
+ path: "/Users/testuser/Applications",
+ username: "testuser",
+ }
+
+ assert.Equal(t, "/Users/testuser/Applications", dir.path)
+ assert.Equal(t, "testuser", dir.username)
+
+ // System-wide should have empty username
+ sysDir := userAppDir{
+ path: "/Applications",
+ username: "",
+ }
+ assert.Empty(t, sysDir.username)
+}
+
+// TestHomebrewPrefix tests the homebrewPrefix struct to verify it correctly
+// stores the Homebrew installation path and associated username for both
+// system-wide and per-user Homebrew installations.
+func TestHomebrewPrefix(t *testing.T) {
+ prefix := homebrewPrefix{
+ path: "/opt/homebrew",
+ username: "",
+ }
+
+ assert.Equal(t, "/opt/homebrew", prefix.path)
+ assert.Empty(t, prefix.username, "System-wide Homebrew should have empty username")
+
+ // Per-user Homebrew
+ userPrefix := homebrewPrefix{
+ path: "/Users/testuser/.homebrew",
+ username: "testuser",
+ }
+ assert.Equal(t, "testuser", userPrefix.username)
+}
+
+// TestParseHomebrewReceipt tests parsing of Homebrew's INSTALL_RECEIPT.json files,
+// verifying extraction of version, installation time, and dependency information.
+func TestParseHomebrewReceipt(t *testing.T) {
+ tempDir := t.TempDir()
+
+ // Create a sample INSTALL_RECEIPT.json
+ receiptContent := `{
+ "homebrew_version": "4.2.0",
+ "used_options": [],
+ "source": {
+ "spec": "stable",
+ "versions": {
+ "stable": "1.7.1"
+ }
+ },
+ "installed_on_request": true,
+ "installed_as_dependency": false,
+ "time": 1704067200,
+ "tabfile": "/opt/homebrew/Cellar/jq/1.7.1/.brew/jq.rb",
+ "runtime_dependencies": [
+ {"full_name": "oniguruma", "version": "6.9.9"}
+ ],
+ "source_modified_time": 1704067000
+ }`
+
+ receiptPath := filepath.Join(tempDir, "INSTALL_RECEIPT.json")
+ err := os.WriteFile(receiptPath, []byte(receiptContent), 0644)
+ require.NoError(t, err)
+
+ // Parse the receipt
+ receipt, err := parseHomebrewReceipt(receiptPath)
+ require.NoError(t, err)
+
+ assert.Equal(t, "4.2.0", receipt.HomebrewVersion)
+ assert.Equal(t, "stable", receipt.Source.Spec)
+ assert.True(t, receipt.InstalledOnRequest)
+ assert.False(t, receipt.InstalledAsDep)
+ assert.Equal(t, int64(1704067200), receipt.Time)
+ assert.Len(t, receipt.RuntimeDeps, 1)
+ assert.Equal(t, "oniguruma", receipt.RuntimeDeps[0].FullName)
+}
+
+// TestParseHomebrewReceiptInvalid tests error handling when parsing invalid
+// Homebrew receipt files, including non-existent files and malformed JSON.
+func TestParseHomebrewReceiptInvalid(t *testing.T) {
+ tempDir := t.TempDir()
+
+ // Test with non-existent file
+ _, err := parseHomebrewReceipt(filepath.Join(tempDir, "nonexistent.json"))
+ assert.Error(t, err)
+
+ // Test with invalid JSON
+ invalidPath := filepath.Join(tempDir, "invalid.json")
+ err = os.WriteFile(invalidPath, []byte("not valid json"), 0644)
+ require.NoError(t, err)
+
+ _, err = parseHomebrewReceipt(invalidPath)
+ assert.Error(t, err)
+}
+
+// TestGetHomebrewPrefixes verifies that getHomebrewPrefixes() correctly discovers
+// Homebrew installation directories on the system. This test runs against the
+// real filesystem and validates that discovered prefixes have valid Cellar directories.
+func TestGetHomebrewPrefixes(t *testing.T) {
+ prefixes, warnings := getHomebrewPrefixes()
+
+ t.Logf("Found %d Homebrew prefixes with %d warnings", len(prefixes), len(warnings))
+
+ // Validate returned prefixes - log warnings instead of failing
+ issueCount := 0
+ for _, prefix := range prefixes {
+ // Path should be non-empty
+ if prefix.path == "" {
+ t.Logf("WARNING: Found prefix with empty path")
+ issueCount++
+ continue
+ }
+
+ // Cellar should exist at this prefix
+ cellarPath := filepath.Join(prefix.path, "Cellar")
+ info, err := os.Stat(cellarPath)
+ if err != nil {
+ t.Logf("WARNING: Cellar not accessible at %s: %v", cellarPath, err)
+ issueCount++
+ continue
+ }
+ if !info.IsDir() {
+ t.Logf("WARNING: Cellar is not a directory at %s", cellarPath)
+ issueCount++
+ }
+
+ // If username is set, validate path location
+ if prefix.username != "" {
+ expectedPrefix := "/Users/" + prefix.username
+ if !strings.HasPrefix(prefix.path, expectedPrefix) {
+ t.Logf("WARNING: Per-user Homebrew path doesn't match username: %s (user: %s)",
+ prefix.path, prefix.username)
+ issueCount++
+ }
+ }
+ }
+
+ if issueCount > 0 {
+ t.Logf("Found %d validation issues (logged as warnings)", issueCount)
+ }
+}
+
+// TestHomebrewCollectorNoHomebrew verifies that the Homebrew collector gracefully
+// handles systems where Homebrew is not installed, returning an empty list without
+// errors. Also validates that any collected entries have the required fields.
+func TestHomebrewCollectorNoHomebrew(t *testing.T) {
+ collector := &homebrewCollector{}
+ entries, warnings, err := collector.Collect()
+
+ // Should not return an error even if Homebrew isn't installed
+ assert.NoError(t, err)
+
+ t.Logf("Found %d Homebrew entries with %d warnings", len(entries), len(warnings))
+
+ // Validate entries - log warnings for issues instead of failing
+ issueCount := 0
+ for i, entry := range entries {
+ if entry.DisplayName == "" {
+ t.Logf("WARNING: Entry %d missing display name", i)
+ issueCount++
+ }
+ if entry.Version == "" {
+ t.Logf("WARNING: Entry %d (%s) missing version", i, entry.DisplayName)
+ issueCount++
+ }
+ if entry.Source != softwareTypeHomebrew {
+ t.Logf("WARNING: Entry %d (%s) has unexpected source: %s", i, entry.DisplayName, entry.Source)
+ issueCount++
+ }
+ if entry.InstallPath == "" {
+ t.Logf("WARNING: Entry %d (%s) missing install path", i, entry.DisplayName)
+ issueCount++
+ }
+ }
+
+ if issueCount > 0 {
+ t.Logf("Found %d validation issues in %d entries (logged as warnings)", issueCount, len(entries))
+ }
+}
+
+// TestApplicationsCollectorIntegration is an integration test that runs against
+// the real filesystem. It verifies that the applications collector runs without
+// errors and produces valid entry structures. Note: On CI runners, /Applications
+// may be empty or have minimal apps, so no minimum count is asserted.
+func TestApplicationsCollectorIntegration(t *testing.T) {
+ collector := &applicationsCollector{}
+ entries, warnings, err := collector.Collect()
+
+ // Collector should not return an error
+ require.NoError(t, err)
+
+ t.Logf("Found %d applications with %d warnings", len(entries), len(warnings))
+
+ // Validate entries - log warnings for issues instead of failing
+ issueCount := 0
+ for i, entry := range entries {
+ if entry.DisplayName == "" {
+ t.Logf("WARNING: Entry %d missing display name", i)
+ issueCount++
+ }
+
+ // Source should be either 'app' or 'mas' (Mac App Store)
+ if entry.Source != softwareTypeApp && entry.Source != softwareTypeMAS {
+ t.Logf("WARNING: Entry %d (%s) has unexpected source: %s (expected 'app' or 'mas')",
+ i, entry.DisplayName, entry.Source)
+ issueCount++
+ }
+
+ // Install path should be an .app bundle
+ if filepath.Ext(entry.InstallPath) != ".app" {
+ t.Logf("WARNING: Entry %d (%s) install path is not .app bundle: %s",
+ i, entry.DisplayName, entry.InstallPath)
+ issueCount++
+ }
+
+ // Log per-user apps for debugging
+ if strings.HasPrefix(entry.InstallPath, "/Users/") {
+ t.Logf("Found per-user app: %s (user: %s)", entry.DisplayName, entry.UserSID)
+ }
+ }
+
+ if issueCount > 0 {
+ t.Logf("Found %d validation issues in %d entries (logged as warnings)", issueCount, len(entries))
+ }
+}
+
+// TestApplicationsCollectorUserSID verifies that the UserSID field is properly
+// populated for per-user applications by creating a mock app bundle structure
+// in a temporary directory and validating that the Info.plist can be parsed.
+func TestApplicationsCollectorUserSID(t *testing.T) {
+ tempDir := t.TempDir()
+
+ // Create a fake user structure
+ fakeUserHome := filepath.Join(tempDir, "Users", "fakeuser")
+ fakeUserApps := filepath.Join(fakeUserHome, "Applications")
+ err := os.MkdirAll(fakeUserApps, 0755)
+ require.NoError(t, err)
+
+ // Create a minimal .app bundle
+ appPath := filepath.Join(fakeUserApps, "TestApp.app", "Contents")
+ err = os.MkdirAll(appPath, 0755)
+ require.NoError(t, err)
+
+ // Create Info.plist
+ infoPlist := `
+
+
+
+ CFBundleName
+ TestApp
+ CFBundleVersion
+ 1.0
+ CFBundleIdentifier
+ com.test.testapp
+
+`
+
+ err = os.WriteFile(filepath.Join(appPath, "Info.plist"), []byte(infoPlist), 0644)
+ require.NoError(t, err)
+
+ // Verify the plist can be read
+ plistData, err := readPlistFile(filepath.Join(appPath, "Info.plist"))
+ require.NoError(t, err)
+ assert.Equal(t, "TestApp", plistData["CFBundleName"])
+ assert.Equal(t, "1.0", plistData["CFBundleVersion"])
+}
+
+// TestSoftwareTypeConstants verifies that all software type constants
+// are unique, non-empty, and have the expected string values.
+func TestSoftwareTypeConstants(t *testing.T) {
+ types := []string{
+ softwareTypeApp,
+ softwareTypeSystemApp,
+ softwareTypePkg,
+ softwareTypeMAS,
+ softwareTypeKext,
+ softwareTypeSysExt,
+ softwareTypeHomebrew,
+ softwareTypeMacPorts,
+ }
+
+ // Check uniqueness
+ seen := make(map[string]bool)
+ for _, st := range types {
+ assert.NotEmpty(t, st, "Software type should not be empty")
+ assert.False(t, seen[st], "Software type %s should be unique", st)
+ seen[st] = true
+ }
+
+ // Verify expected values
+ assert.Equal(t, "app", softwareTypeApp)
+ assert.Equal(t, "system_app", softwareTypeSystemApp)
+ assert.Equal(t, "pkg", softwareTypePkg)
+ assert.Equal(t, "mas", softwareTypeMAS)
+ assert.Equal(t, "kext", softwareTypeKext)
+ assert.Equal(t, "sysext", softwareTypeSysExt)
+ assert.Equal(t, "homebrew", softwareTypeHomebrew)
+ assert.Equal(t, "macports", softwareTypeMacPorts)
+}
+
+// TestInstallSourceConstants verifies that install source constants (pkg, mas,
+// manual) have the expected string values.
+func TestInstallSourceConstants(t *testing.T) {
+ assert.Equal(t, "pkg", installSourcePkg)
+ assert.Equal(t, "mas", installSourceMAS)
+ assert.Equal(t, "manual", installSourceManual)
+}
+
+// TestMacPortsCollectorNoMacPorts verifies that the MacPorts collector gracefully
+// handles systems where MacPorts is not installed, returning an empty list without
+// errors. Also validates that any collected entries have the required fields.
+func TestMacPortsCollectorNoMacPorts(t *testing.T) {
+ collector := &macPortsCollector{}
+ entries, warnings, err := collector.Collect()
+
+ // Should not return an error even if MacPorts isn't installed
+ assert.NoError(t, err)
+
+ t.Logf("Found %d MacPorts entries with %d warnings", len(entries), len(warnings))
+
+ // Validate entries - log warnings for issues instead of failing
+ issueCount := 0
+ for i, entry := range entries {
+ if entry.DisplayName == "" {
+ t.Logf("WARNING: Entry %d missing display name", i)
+ issueCount++
+ }
+ if entry.Version == "" {
+ t.Logf("WARNING: Entry %d (%s) missing version", i, entry.DisplayName)
+ issueCount++
+ }
+ if entry.Source != softwareTypeMacPorts {
+ t.Logf("WARNING: Entry %d (%s) has unexpected source: %s", i, entry.DisplayName, entry.Source)
+ issueCount++
+ }
+ }
+
+ if issueCount > 0 {
+ t.Logf("Found %d validation issues in %d entries (logged as warnings)", issueCount, len(entries))
+ }
+}
diff --git a/pkg/inventory/software/collector_nix.go b/pkg/inventory/software/collector_nix.go
index 8c2a5e593c5fb7..78e7093d85f082 100644
--- a/pkg/inventory/software/collector_nix.go
+++ b/pkg/inventory/software/collector_nix.go
@@ -3,7 +3,7 @@
// This product includes software developed at Datadog (https://www.datadoghq.com/).
// Copyright 2016-present Datadog, Inc.
-//go:build !windows
+//go:build !windows && !darwin
package software
diff --git a/pkg/inventory/software/collector_test.go b/pkg/inventory/software/collector_test.go
index 73e2ed331ad1f5..3f4b2aa3bde7f6 100644
--- a/pkg/inventory/software/collector_test.go
+++ b/pkg/inventory/software/collector_test.go
@@ -6,6 +6,7 @@
package software
import (
+ "encoding/json"
"errors"
"testing"
@@ -183,3 +184,45 @@ func TestWarnings(t *testing.T) {
warn := Warning{Message: "test warning"}
assert.Equal(t, "test warning", warn.Message)
}
+
+func TestPrivateFieldsExcludedFromJSON(t *testing.T) {
+ // Test that private fields (with json:"-") are excluded from JSON serialization
+ // but still accessible in Go code
+ entry := &Entry{
+ DisplayName: "TestApp",
+ Version: "1.0",
+ Source: "app",
+ ProductCode: "com.test.app",
+ BrokenReason: "executable not found",
+ InstallSource: "pkg",
+ PkgID: "com.test.pkg",
+ InstallPath: "/Applications/TestApp.app",
+ InstallPaths: []string{"/Applications", "/Library"},
+ }
+
+ // Verify fields are accessible in Go code
+ assert.Equal(t, "executable not found", entry.BrokenReason)
+ assert.Equal(t, "pkg", entry.InstallSource)
+ assert.Equal(t, "com.test.pkg", entry.PkgID)
+ assert.Equal(t, "/Applications/TestApp.app", entry.InstallPath)
+ assert.Equal(t, []string{"/Applications", "/Library"}, entry.InstallPaths)
+
+ // Marshal to JSON
+ jsonData, err := json.Marshal(entry)
+ assert.NoError(t, err)
+
+ jsonStr := string(jsonData)
+
+ // Verify private fields are NOT in JSON
+ assert.NotContains(t, jsonStr, "broken_reason")
+ assert.NotContains(t, jsonStr, "install_source")
+ assert.NotContains(t, jsonStr, "pkg_id")
+ assert.NotContains(t, jsonStr, "install_path")
+ assert.NotContains(t, jsonStr, "install_paths")
+
+ // Verify public fields ARE in JSON
+ assert.Contains(t, jsonStr, "software_type")
+ assert.Contains(t, jsonStr, "name")
+ assert.Contains(t, jsonStr, "version")
+ assert.Contains(t, jsonStr, "product_code")
+}
diff --git a/pkg/inventory/software/collector_windows.go b/pkg/inventory/software/collector_windows.go
index b61a848a6871a0..bb1d6afb65435e 100644
--- a/pkg/inventory/software/collector_windows.go
+++ b/pkg/inventory/software/collector_windows.go
@@ -66,6 +66,7 @@ func (d *desktopAppCollector) Collect() ([]*Entry, []*Warning, error) {
if regEntry, ok := regMap[msiEntry.GetID()]; !ok {
// Software is present in MSI but not in registry
msiEntry.Status = "broken"
+ msiEntry.BrokenReason = "MSI record not found in registry"
regEntries = append(regEntries, msiEntry)
} else {
if regEntry.InstallDate == "" {
diff --git a/pkg/system-probe/config/config.go b/pkg/system-probe/config/config.go
index 2ba2ab651ab64a..6f203e0e6bdfc3 100644
--- a/pkg/system-probe/config/config.go
+++ b/pkg/system-probe/config/config.go
@@ -193,6 +193,8 @@ func load() (*types.Config, error) {
// module is enabled, to allow the core agent to detect our own crash
c.EnabledModules[WindowsCrashDetectModule] = struct{}{}
}
+ }
+ if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
if swEnabled {
c.EnabledModules[SoftwareInventoryModule] = struct{}{}
}