diff --git a/cmd/agent/subcommands/run/command_notwin.go b/cmd/agent/subcommands/run/command_notwin.go index d8b57e07845e63..839cf2002bdc20 100644 --- a/cmd/agent/subcommands/run/command_notwin.go +++ b/cmd/agent/subcommands/run/command_notwin.go @@ -7,8 +7,14 @@ package run -import "go.uber.org/fx" +import ( + "go.uber.org/fx" + + softwareinventoryfx "github.com/DataDog/datadog-agent/comp/softwareinventory/fx" +) func getPlatformModules() fx.Option { - return fx.Options() + return fx.Options( + softwareinventoryfx.Module(), + ) } diff --git a/cmd/system-probe/modules/software_inventory.go b/cmd/system-probe/modules/software_inventory.go new file mode 100644 index 00000000000000..2fe3e1aecf93be --- /dev/null +++ b/cmd/system-probe/modules/software_inventory.go @@ -0,0 +1,112 @@ +// 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 || windows + +package modules + +import ( + "encoding/json" + "net/http" + + "github.com/DataDog/datadog-agent/pkg/inventory/software" + "github.com/DataDog/datadog-agent/pkg/system-probe/api/module" + "github.com/DataDog/datadog-agent/pkg/system-probe/config" + sysconfigtypes "github.com/DataDog/datadog-agent/pkg/system-probe/config/types" + "github.com/DataDog/datadog-agent/pkg/system-probe/utils" + "github.com/DataDog/datadog-agent/pkg/util/log" +) + +// systemProbeEntry is a wrapper around software.Entry that includes InstallPath +// in JSON for system-probe internal communication. This ensures InstallPath +// is preserved when data is serialized/deserialized between system-probe and agent. +type systemProbeEntry struct { + software.Entry + // InstallPath is included in JSON for system-probe communication + // This is needed for proper deduplication (GetID uses InstallPath) + InstallPathInternal string `json:"install_path,omitempty"` +} + +// MarshalJSON customizes JSON marshaling to include InstallPath +func (e *systemProbeEntry) MarshalJSON() ([]byte, error) { + // Create a type alias to avoid infinite recursion + type Alias software.Entry + aux := &struct { + *Alias + InstallPathInternal string `json:"install_path,omitempty"` + }{ + Alias: (*Alias)(&e.Entry), + InstallPathInternal: e.Entry.InstallPath, + } + return json.Marshal(aux) +} + +// UnmarshalJSON customizes JSON unmarshaling to restore InstallPath +func (e *systemProbeEntry) UnmarshalJSON(data []byte) error { + type Alias software.Entry + aux := &struct { + *Alias + InstallPathInternal string `json:"install_path,omitempty"` + }{ + Alias: (*Alias)(&e.Entry), + } + if err := json.Unmarshal(data, aux); err != nil { + return err + } + // Restore InstallPath from the JSON field + e.Entry.InstallPath = aux.InstallPathInternal + return nil +} + +// toSystemProbeEntries converts []*software.Entry to []*systemProbeEntry +func toSystemProbeEntries(entries []*software.Entry) []*systemProbeEntry { + result := make([]*systemProbeEntry, len(entries)) + for i, entry := range entries { + result[i] = &systemProbeEntry{Entry: *entry} + } + return result +} + +func init() { registerModule(SoftwareInventory) } + +// SoftwareInventory Factory +var SoftwareInventory = &module.Factory{ + Name: config.SoftwareInventoryModule, + ConfigNamespaces: []string{"software_inventory"}, + Fn: func(_ *sysconfigtypes.Config, _ module.FactoryDependencies) (module.Module, error) { + return &softwareInventoryModule{}, nil + }, +} + +var _ module.Module = &softwareInventoryModule{} + +type softwareInventoryModule struct{} + +func (sim *softwareInventoryModule) Register(httpMux *module.Router) error { + httpMux.HandleFunc("/check", utils.WithConcurrencyLimit(1, func(w http.ResponseWriter, _ *http.Request) { + log.Infof("Got check request in software inventory") + inventory, warnings, err := software.GetSoftwareInventory() + if err != nil { + log.Errorf("Error getting software inventory: %v", err) + w.WriteHeader(500) + return + } + for _, warning := range warnings { + _ = log.Warnf("warning: %s", warning) + } + // Convert to systemProbeEntry to include InstallPath in JSON + // This ensures InstallPath is preserved for proper deduplication + sysProbeInventory := toSystemProbeEntries(inventory) + utils.WriteAsJSON(w, sysProbeInventory, utils.CompactOutput) + })) + + return nil +} + +func (sim *softwareInventoryModule) GetStats() map[string]interface{} { + return map[string]interface{}{} +} + +func (sim *softwareInventoryModule) Close() {} diff --git a/cmd/system-probe/modules/software_inventory_windows.go b/cmd/system-probe/modules/software_inventory_windows.go deleted file mode 100644 index 15d9f94df84b37..00000000000000 --- a/cmd/system-probe/modules/software_inventory_windows.go +++ /dev/null @@ -1,59 +0,0 @@ -// 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. - -package modules - -import ( - "github.com/DataDog/datadog-agent/pkg/inventory/software" - "net/http" - - "github.com/DataDog/datadog-agent/pkg/system-probe/api/module" - "github.com/DataDog/datadog-agent/pkg/system-probe/config" - sysconfigtypes "github.com/DataDog/datadog-agent/pkg/system-probe/config/types" - "github.com/DataDog/datadog-agent/pkg/system-probe/utils" - "github.com/DataDog/datadog-agent/pkg/util/log" -) - -func init() { registerModule(SoftwareInventory) } - -// SoftwareInventory Factory -var SoftwareInventory = &module.Factory{ - Name: config.SoftwareInventoryModule, - ConfigNamespaces: []string{"software_inventory"}, - Fn: func(_ *sysconfigtypes.Config, _ module.FactoryDependencies) (module.Module, error) { - return &softwareInventoryModule{}, nil - }, -} - -var _ module.Module = &softwareInventoryModule{} - -type softwareInventoryModule struct { -} - -func (sim *softwareInventoryModule) Register(httpMux *module.Router) error { - httpMux.HandleFunc("/check", utils.WithConcurrencyLimit(1, func(w http.ResponseWriter, _ *http.Request) { - log.Infof("Got check request in software inventory") - inventory, warnings, err := software.GetSoftwareInventory() - if err != nil { - log.Errorf("Error getting software inventory: %v", err) - w.WriteHeader(500) - return - } - for _, warning := range warnings { - _ = log.Warnf("warning: %s", warning) - } - utils.WriteAsJSON(w, inventory, utils.CompactOutput) - })) - - return nil -} - -func (sim *softwareInventoryModule) GetStats() map[string]interface{} { - return map[string]interface{}{} -} - -func (sim *softwareInventoryModule) Close() { - -} diff --git a/comp/core/gui/guiimpl/views/private/js/javascript.js b/comp/core/gui/guiimpl/views/private/js/javascript.js index 09cad1fcef321d..44763912f6e69d 100644 --- a/comp/core/gui/guiimpl/views/private/js/javascript.js +++ b/comp/core/gui/guiimpl/views/private/js/javascript.js @@ -251,6 +251,25 @@ $(document).on('click', '.load_more', function (e) { loadMore(); }); +// Delegate change handler for software inventory filter (works after DOMPurify sanitization) +$(document).on('change', '#sw-type-filter', function () { + var type = this.value; + var entries = document.querySelectorAll('.sw-entry'); + var visibleCount = 0; + entries.forEach(function(entry) { + if (type === '' || entry.getAttribute('data-sw-type') === type) { + entry.style.display = ''; + visibleCount++; + } else { + entry.style.display = 'none'; + } + }); + var countSpan = document.getElementById('sw-filter-count'); + if (countSpan) { + countSpan.textContent = type !== '' ? 'Showing ' + visibleCount + ' entries' : ''; + } +}); + // Handler for loading more lines of the currently displayed log file function loadMore() { var data = $(".log_data").html(); diff --git a/comp/softwareinventory/impl/inventorysoftware.go b/comp/softwareinventory/impl/inventorysoftware.go index 232b25b2be1ed7..82c2dffe6d54ab 100644 --- a/comp/softwareinventory/impl/inventorysoftware.go +++ b/comp/softwareinventory/impl/inventorysoftware.go @@ -10,6 +10,7 @@ package softwareinventoryimpl import ( "context" + "encoding/json" "errors" "fmt" "math/rand" @@ -51,6 +52,32 @@ type sysProbeClient interface { GetCheck(module types.ModuleName) ([]software.Entry, error) } +// sysProbeEntryResponse is used to unmarshal system-probe responses that include InstallPath. +// This type matches the systemProbeEntry type used in system-probe modules to ensure +// InstallPath is preserved through JSON serialization/deserialization. +type sysProbeEntryResponse struct { + software.Entry + // InstallPathInternal is the JSON field name used by system-probe + InstallPathInternal string `json:"install_path,omitempty"` +} + +// UnmarshalJSON customizes JSON unmarshaling to restore InstallPath from the JSON field +func (e *sysProbeEntryResponse) UnmarshalJSON(data []byte) error { + type Alias software.Entry + aux := &struct { + *Alias + InstallPathInternal string `json:"install_path,omitempty"` + }{ + Alias: (*Alias)(&e.Entry), + } + if err := json.Unmarshal(data, aux); err != nil { + return err + } + // Restore InstallPath from the JSON field + e.Entry.InstallPath = aux.InstallPathInternal + return nil +} + // sysProbeClientWrapper wraps the real sysprobeclient.CheckClient to implement mockSysProbeClient. // This wrapper provides a clean interface to the System Probe client while maintaining // compatibility with the existing client implementation. @@ -62,13 +89,23 @@ type sysProbeClientWrapper struct { } // GetCheck implements mockSysProbeClient.GetCheck by delegating to the wrapped client. -// This method uses the generic GetCheck function to retrieve software inventory data -// from the System Probe with proper type safety. +// This method uses an intermediate type to preserve InstallPath through JSON serialization, +// then converts back to []software.Entry. func (w *sysProbeClientWrapper) GetCheck(module types.ModuleName) ([]software.Entry, error) { if w.client == nil { w.client = w.clientFn() } - return sysprobeclient.GetCheck[[]software.Entry](w.client, module) + // Unmarshal into sysProbeEntryResponse to preserve InstallPath + responses, err := sysprobeclient.GetCheck[[]sysProbeEntryResponse](w.client, module) + if err != nil { + return nil, err + } + // Convert back to []software.Entry + entries := make([]software.Entry, len(responses)) + for i, resp := range responses { + entries[i] = resp.Entry + } + return entries, nil } // softwareInventory is the implementation of the Component interface. diff --git a/comp/softwareinventory/impl/status.go b/comp/softwareinventory/impl/status.go index b9e5a7c42f64cc..5101fda284b5ff 100644 --- a/comp/softwareinventory/impl/status.go +++ b/comp/softwareinventory/impl/status.go @@ -8,9 +8,12 @@ package softwareinventoryimpl import ( "embed" "io" + "sort" + "strings" "time" "github.com/DataDog/datadog-agent/comp/core/status" + "github.com/DataDog/datadog-agent/pkg/inventory/software" ) //go:embed status_templates @@ -66,20 +69,56 @@ func formatYYYYMMDD(ts string) (string, error) { // populateStatus populates the status map with software inventory data. // This method processes the cached inventory data and formats it for display -// in the status output. It handles date formatting and organizes the data -// by software ID for easy lookup. +// in the status output. It handles date formatting, computes statistics by +// software type, and organizes the data by software ID for easy lookup. +// Entries are sorted by Source (software type) and then by DisplayName for +// easier navigation in the GUI and status output. +// Note: Stats are computed from deduplicated entries to ensure consistency +// between the total count and the breakdown by type. func (is *softwareInventory) populateStatus(status map[string]interface{}) { - data := map[string]interface{}{} - is.cachedInventoryMu.RLock() cachedInventory := is.cachedInventory is.cachedInventoryMu.RUnlock() + // First pass: deduplicate entries by ID and format dates + data := map[string]interface{}{} for _, inventory := range cachedInventory { inventory.InstallDate, _ = formatYYYYMMDD(inventory.InstallDate) data[inventory.GetID()] = inventory } - status["software_inventory_metadata"] = data + + // Convert to slice and sort by Source (category), then DisplayName + sortedEntries := make([]software.Entry, 0, len(data)) + for _, v := range data { + sortedEntries = append(sortedEntries, v.(software.Entry)) + } + sort.Slice(sortedEntries, func(i, j int) bool { + // First sort by Source (software type) + if sortedEntries[i].Source != sortedEntries[j].Source { + return sortedEntries[i].Source < sortedEntries[j].Source + } + // Then sort by DisplayName within each category + return sortedEntries[i].DisplayName < sortedEntries[j].DisplayName + }) + + // Second pass: compute stats from deduplicated entries + // This ensures stats sum matches the total count + stats := map[string]int{} + brokenCount := 0 + for _, inventory := range sortedEntries { + stats[inventory.Source]++ + if strings.Contains(inventory.Status, "broken") { + brokenCount++ + } + } + + status["software_inventory_metadata"] = sortedEntries + status["software_inventory_stats"] = stats + status["software_inventory_total"] = len(sortedEntries) + // Only include broken count if there are broken entries + if brokenCount > 0 { + status["software_inventory_broken"] = brokenCount + } } // getStatusInfo returns the status information map for the software inventory. diff --git a/comp/softwareinventory/impl/status_templates/inventory.tmpl b/comp/softwareinventory/impl/status_templates/inventory.tmpl index 5dbc2df07f2e56..295a6ddcd1616d 100644 --- a/comp/softwareinventory/impl/status_templates/inventory.tmpl +++ b/comp/softwareinventory/impl/status_templates/inventory.tmpl @@ -1,11 +1,11 @@ -{{- $swMap := index . "software_inventory_metadata" }} -{{- if index $swMap "error" }} -Error refreshing software inventory: {{ index $swMap "error" }} -{{- else }} -{{- $count := 0 }} -{{- range $productCode, $meta := $swMap }} - {{- $count = add $count 1 }} +{{- $total := index . "software_inventory_total" }} +{{- $stats := index . "software_inventory_stats" }} +{{- $broken := index . "software_inventory_broken" }} +Detected {{ $total }} installed software entries{{- if $broken }} ({{ $broken }} broken){{- end }}. + +By type: +{{- range $type, $count := $stats }} + {{ $type }}: {{ $count }} {{- end }} -Detected {{ $count }} installed software entries. + The full list is not displayed in this status output, but is available in a flare, the Agent status page in the GUI, or in the Datadog Application. -{{- end }} diff --git a/comp/softwareinventory/impl/status_templates/inventoryHTML.tmpl b/comp/softwareinventory/impl/status_templates/inventoryHTML.tmpl index d1c952f943f325..3c8dad5428143d 100644 --- a/comp/softwareinventory/impl/status_templates/inventoryHTML.tmpl +++ b/comp/softwareinventory/impl/status_templates/inventoryHTML.tmpl @@ -1,31 +1,57 @@
Software Inventory Metadata -
- {{- $swMap := index . "software_inventory_metadata" }} - {{- if index $swMap "error" }} -
Error refreshing software inventory: {{ index $swMap "error" }}
- {{- else }} - {{- range $productCode, $meta := $swMap }} -
- - {{- if $meta.DisplayName }}{{ $meta.DisplayName }}{{ if $meta.Version }} {{ $meta.Version }}{{ end }}{{- else }}{{ $meta.ProductCode }}{{- end }} - {{- if contains $meta.Status "broken" }} - broken - {{- end }} - -
    - {{- if $meta.DisplayName }}
  • Display Name: {{ $meta.DisplayName }}
  • {{- end }} - {{- if $meta.Version }}
  • Version: {{ $meta.Version }}
  • {{- end }} - {{- if $meta.InstallDate }}
  • Install Date: {{ $meta.InstallDate }}
  • {{- end }} - {{- if $meta.Publisher }}
  • Publisher: {{ $meta.Publisher }}
  • {{- end }} - {{- if $meta.ProductCode }}
  • Product code: {{ $meta.ProductCode }}
  • {{- end }} - {{- if $meta.Source }}
  • Source: {{ $meta.Source }}
  • {{- end }} - {{- if $meta.Status }}
  • Status: {{ $meta.Status }}
  • {{- end }} - {{- if $meta.UserSID }}
  • User SID: {{ $meta.UserSID }}
  • {{- end }} -
  • 64-bit: {{ $meta.Is64Bit }}
  • -
-
+ {{- $swSlice := .software_inventory_metadata }} + {{- $total := .software_inventory_total }} + {{- $stats := .software_inventory_stats }} + {{- $broken := .software_inventory_broken }} + {{- if .error }} +
Error refreshing software inventory: {{ .error }}
+ {{- else }} +
+ Summary: {{ $total }} entries{{- if $broken }} ({{ $broken }} broken){{- end }} +
+ By type: +
    + {{- range $type, $count := $stats }} +
  • {{ $type }}: {{ $count }}
  • {{- end }} +
+
+
+ + + +
+
+ {{- range $meta := $swSlice }} +
+ + {{- if $meta.DisplayName }}{{ $meta.DisplayName }}{{ if $meta.Version }} {{ $meta.Version }}{{ end }}{{- else }}{{ $meta.ProductCode }}{{- end }} + {{- if contains $meta.Status "broken" }} + broken + {{- end }} + +
    + {{- if $meta.DisplayName }}
  • Display Name: {{ $meta.DisplayName }}
  • {{- end }} + {{- if $meta.Version }}
  • Version: {{ $meta.Version }}
  • {{- end }} + {{- if $meta.InstallDate }}
  • Install Date: {{ $meta.InstallDate }}
  • {{- end }} + {{- if $meta.Publisher }}
  • Publisher: {{ $meta.Publisher }}
  • {{- end }} + {{- if $meta.ProductCode }}
  • Product code: {{ $meta.ProductCode }}
  • {{- end }} + {{- if $meta.Source }}
  • Source: {{ $meta.Source }}
  • {{- end }} + {{- if $meta.Status }}
  • Status: {{ $meta.Status }}
  • {{- end }} + {{- if $meta.BrokenReason }}
  • Broken Reason: {{ $meta.BrokenReason }}
  • {{- end }} + {{- if $meta.UserSID }}
  • User SID: {{ $meta.UserSID }}
  • {{- end }} + {{- if $meta.InstallPath }}
  • Install Path: {{ $meta.InstallPath }}
  • {{- end }} + {{- if $meta.InstallPaths }}
  • Install Paths:
      {{- range $meta.InstallPaths }}
    • {{ . }}
    • {{- end }}
  • {{- end }} +
  • 64-bit: {{ $meta.Is64Bit }}
  • +
+
{{- end }}
+ {{- end }}
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{}{} }