Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
a4415c5
SW inventory for MacOS
guohdd Jan 27, 2026
0291f92
fix CI failures
guohdd Jan 27, 2026
2a05318
fix CI failures #2
guohdd Jan 27, 2026
7b47710
remove release notes
guohdd Jan 28, 2026
a4c884a
enable sw inventory for macos
guohdd Jan 28, 2026
fe8b555
use command_notwin.go for cleaner solution
guohdd Jan 28, 2026
6fb15f4
fix sw inventory filter
guohdd Jan 29, 2026
f1b955f
refine the acope of SW inventory
guohdd Jan 30, 2026
38f6fa8
ordered output and consistent installation time
guohdd Feb 3, 2026
04475d7
text template fix and system app addition
guohdd Feb 3, 2026
b6e04b2
Merge branch 'main' into hongshi/mac_sw_inventory
guohdd Feb 3, 2026
3f22692
Merge branch 'main' into hongshi/mac_sw_inventory
guohdd Feb 3, 2026
a95bf2a
changes per code review: bounded parallel processing; removed codesig…
guohdd Feb 10, 2026
703da49
added test for private fields
guohdd Feb 10, 2026
bc14af8
removed unused function
guohdd Feb 10, 2026
0f681cb
Merge branch 'main' into hongshi/mac_sw_inventory
guohdd Feb 11, 2026
24aa527
Merge branch 'main' into hongshi/mac_sw_inventory
guohdd Feb 11, 2026
fb47576
lint issue
guohdd Feb 11, 2026
4618114
hide private fields to align with Windows implementation
guohdd Feb 12, 2026
dc9291f
InstallSource for system apps
guohdd Feb 12, 2026
5d70a35
Merge branch 'main' into hongshi/mac_sw_inventory
guohdd Feb 12, 2026
572f701
avoid reading plist twice
guohdd Feb 20, 2026
293015d
new wire type for agent–system-probe comminucation of sw inventory
guohdd Feb 23, 2026
d56a499
Merge branch 'main' into hongshi/mac_sw_inventory
guohdd Feb 23, 2026
b19d5a4
Merge branch 'main' into hongshi/mac_sw_inventory
guohdd Feb 26, 2026
0ae9eed
changes per code review
guohdd Mar 3, 2026
75a8826
code format
guohdd Mar 3, 2026
68744b9
Merge branch 'main' into hongshi/mac_sw_inventory
guohdd Mar 3, 2026
4b2343a
changes #2 per code review
guohdd Mar 4, 2026
0407b64
get data from top-level dict in parsePlistToMap()
guohdd Mar 4, 2026
8085495
Merge branch 'main' into hongshi/mac_sw_inventory
guohdd Mar 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions LICENSE-3rdparty.csv

Large diffs are not rendered by default.

10 changes: 8 additions & 2 deletions cmd/agent/subcommands/run/command_notwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@
// This product includes software developed at Datadog (https://www.datadoghq.com/).
// Copyright 2016-present Datadog, Inc.

//go:build darwin || windows

package modules

import (
"github.com/DataDog/datadog-agent/pkg/inventory/software"
"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"
Expand All @@ -29,8 +31,7 @@ var SoftwareInventory = &module.Factory{

var _ module.Module = &softwareInventoryModule{}

type softwareInventoryModule struct {
}
type softwareInventoryModule struct{}

func (sim *softwareInventoryModule) Register(httpMux *module.Router) error {
httpMux.HandleFunc("/check", utils.WithConcurrencyLimit(1, func(w http.ResponseWriter, _ *http.Request) {
Expand All @@ -54,6 +55,4 @@ func (sim *softwareInventoryModule) GetStats() map[string]interface{} {
return map[string]interface{}{}
}

func (sim *softwareInventoryModule) Close() {

}
func (sim *softwareInventoryModule) Close() {}
19 changes: 19 additions & 0 deletions comp/core/gui/guiimpl/views/private/js/javascript.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
49 changes: 44 additions & 5 deletions comp/softwareinventory/impl/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
18 changes: 9 additions & 9 deletions comp/softwareinventory/impl/status_templates/inventory.tmpl
Original file line number Diff line number Diff line change
@@ -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 }}
76 changes: 51 additions & 25 deletions comp/softwareinventory/impl/status_templates/inventoryHTML.tmpl
Original file line number Diff line number Diff line change
@@ -1,31 +1,57 @@
<div class="stat">
<span class="stat_title">Software Inventory Metadata</span>
<div class="stat_data inventory-scrollbox" style="max-height: 400px; overflow-y: auto;">
{{- $swMap := index . "software_inventory_metadata" }}
{{- if index $swMap "error" }}
<div>Error refreshing software inventory: {{ index $swMap "error" }}</div>
{{- else }}
{{- range $productCode, $meta := $swMap }}
<details>
<summary>
{{- if $meta.DisplayName }}{{ $meta.DisplayName }}{{ if $meta.Version }} {{ $meta.Version }}{{ end }}{{- else }}{{ $meta.ProductCode }}{{- end }}
{{- if contains $meta.Status "broken" }}
<span class="source-bubble broken">broken</span>
{{- end }}
</summary>
<ul style="margin:1em 0; padding-left:2em;">
{{- if $meta.DisplayName }}<li><strong>Display Name:</strong> {{ $meta.DisplayName }}</li>{{- end }}
{{- if $meta.Version }}<li><strong>Version:</strong> {{ $meta.Version }}</li>{{- end }}
{{- if $meta.InstallDate }}<li><strong>Install Date:</strong> {{ $meta.InstallDate }}</li>{{- end }}
{{- if $meta.Publisher }}<li><strong>Publisher:</strong> {{ $meta.Publisher }}</li>{{- end }}
{{- if $meta.ProductCode }}<li><strong>Product code:</strong> {{ $meta.ProductCode }}</li>{{- end }}
{{- if $meta.Source }}<li><strong>Source:</strong> {{ $meta.Source }}</li>{{- end }}
{{- if $meta.Status }}<li><strong>Status:</strong> {{ $meta.Status }}</li>{{- end }}
{{- if $meta.UserSID }}<li><strong>User SID:</strong> {{ $meta.UserSID }}</li>{{- end }}
<li><strong>64-bit:</strong> {{ $meta.Is64Bit }}</li>
</ul>
</details>
{{- $swSlice := .software_inventory_metadata }}
{{- $total := .software_inventory_total }}
{{- $stats := .software_inventory_stats }}
{{- $broken := .software_inventory_broken }}
{{- if .error }}
<div>Error refreshing software inventory: {{ .error }}</div>
{{- else }}
<div style="margin-bottom: 1em; padding: 0.5em; background: #f5f5f5; border-radius: 4px;">
<strong>Summary:</strong> {{ $total }} entries{{- if $broken }} (<span style="color: #d00;">{{ $broken }} broken</span>){{- end }}
<br/>
<strong>By type:</strong>
<ul style="margin: 0.5em 0; padding-left: 2em; list-style: none;">
{{- range $type, $count := $stats }}
<li>{{ $type }}: {{ $count }}</li>
{{- end }}
</ul>
</div>
<div style="margin-bottom: 1em;">
<label for="sw-type-filter"><strong>Filter by type:</strong></label>
<select id="sw-type-filter" style="margin-left: 0.5em; padding: 0.25em 0.5em; border-radius: 4px; border: 1px solid #ccc;">
<option value="">All ({{ $total }})</option>
{{- range $type, $count := $stats }}
<option value="{{ $type }}">{{ $type }} ({{ $count }})</option>
{{- end }}
</select>
<span id="sw-filter-count" style="margin-left: 1em; color: #666;"></span>
</div>
<div id="sw-entries-container" class="stat_data inventory-scrollbox" style="max-height: 400px; overflow-y: auto;">
{{- range $meta := $swSlice }}
<details class="sw-entry" data-sw-type="{{ $meta.Source }}">
<summary>
{{- if $meta.DisplayName }}{{ $meta.DisplayName }}{{ if $meta.Version }} {{ $meta.Version }}{{ end }}{{- else }}{{ $meta.ProductCode }}{{- end }}
{{- if contains $meta.Status "broken" }}
<span class="source-bubble broken">broken</span>
{{- end }}
</summary>
<ul style="margin:1em 0; padding-left:2em;">
{{- if $meta.DisplayName }}<li><strong>Display Name:</strong> {{ $meta.DisplayName }}</li>{{- end }}
{{- if $meta.Version }}<li><strong>Version:</strong> {{ $meta.Version }}</li>{{- end }}
{{- if $meta.InstallDate }}<li><strong>Install Date:</strong> {{ $meta.InstallDate }}</li>{{- end }}
{{- if $meta.Publisher }}<li><strong>Publisher:</strong> {{ $meta.Publisher }}</li>{{- end }}
{{- if $meta.ProductCode }}<li><strong>Product code:</strong> {{ $meta.ProductCode }}</li>{{- end }}
{{- if $meta.Source }}<li><strong>Source:</strong> {{ $meta.Source }}</li>{{- end }}
{{- if $meta.Status }}<li><strong>Status:</strong> {{ $meta.Status }}</li>{{- end }}
{{- if $meta.BrokenReason }}<li><strong>Broken Reason:</strong> <span style="color: #d00;">{{ $meta.BrokenReason }}</span></li>{{- end }}
{{- if $meta.UserSID }}<li><strong>User SID:</strong> {{ $meta.UserSID }}</li>{{- end }}
{{- if $meta.InstallPath }}<li><strong>Install Path:</strong> {{ $meta.InstallPath }}</li>{{- end }}
{{- if $meta.InstallPaths }}<li><strong>Install Paths:</strong><ul style="margin: 0.25em 0; padding-left: 1.5em;">{{- range $meta.InstallPaths }}<li>{{ . }}</li>{{- end }}</ul></li>{{- end }}
<li><strong>64-bit:</strong> {{ $meta.Is64Bit }}</li>
</ul>
</details>
{{- end }}
</div>
{{- end }}
</div>
120 changes: 116 additions & 4 deletions comp/softwareinventory/impl/status_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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)
Expand Down Expand Up @@ -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(), `<div class="stat_data inventory-scrollbox"`)
assert.Contains(t, buf.String(), `class="stat_data inventory-scrollbox"`)
// Summary should show 0 entries
assert.Contains(t, buf.String(), "<strong>Summary:</strong> 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, "<strong>Summary:</strong> 2 entries")
// Verify "By type:" section
assert.Contains(t, html, "<strong>By type:</strong>")
}
Loading