diff --git a/changes/issue-15406-fleetctl-get-software b/changes/issue-15406-fleetctl-get-software new file mode 100644 index 000000000..207a78e8d --- /dev/null +++ b/changes/issue-15406-fleetctl-get-software @@ -0,0 +1 @@ +* Updated `fleetctl get software` to list software titles, and add optional `--versions` flag to list software versions. diff --git a/cmd/fleetctl/get.go b/cmd/fleetctl/get.go index d0f30c1e6..1a748aacb 100644 --- a/cmd/fleetctl/get.go +++ b/cmd/fleetctl/get.go @@ -1213,12 +1213,16 @@ func getSoftwareCommand() *cli.Command { return &cli.Command{ Name: "software", Aliases: []string{"s"}, - Usage: "List software", + Usage: "List software titles", Flags: []cli.Flag{ &cli.UintFlag{ Name: teamFlagName, Usage: "Only list software of hosts that belong to the specified team", }, + &cli.BoolFlag{ + Name: "versions", + Usage: "List all software versions", + }, jsonFlag(), yamlFlag(), configFlag(), @@ -1242,49 +1246,104 @@ func getSoftwareCommand() *cli.Command { query.Set("team_id", strconv.FormatUint(uint64(teamID), 10)) } - software, err := client.ListSoftware(query.Encode()) - if err != nil { - return fmt.Errorf("could not list software: %w", err) + if c.Bool("versions") { + return printSoftwareVersions(c, client, query) } - - if len(software) == 0 { - log(c, "No software found") - return nil - } - - if c.Bool(jsonFlagName) || c.Bool(yamlFlagName) { - spec := specGeneric{ - Kind: "software", - Version: "1", - Spec: software, - } - err = printSpec(c, spec) - if err != nil { - return err - } - return nil - } - - // Default to printing as table - data := [][]string{} - - for _, s := range software { - data = append(data, []string{ - s.Name, - s.Version, - s.Source, - s.GenerateCPE, - fmt.Sprint(len(s.Vulnerabilities)), - }) - } - columns := []string{"Name", "Version", "Source", "CPE", "# of CVEs"} - printTable(c, columns, data) - - return nil + return printSoftwareTitles(c, client, query) }, } } +func printSoftwareVersions(c *cli.Context, client *service.Client, query url.Values) error { + software, err := client.ListSoftwareVersions(query.Encode()) + if err != nil { + return fmt.Errorf("could not list software versions: %w", err) + } + + if len(software) == 0 { + log(c, "No software versions found") + return nil + } + + if c.Bool(jsonFlagName) || c.Bool(yamlFlagName) { + spec := specGeneric{ + Kind: "software", + Version: "1", + Spec: software, + } + err = printSpec(c, spec) + if err != nil { + return err + } + return nil + } + + // Default to printing as table + data := [][]string{} + + for _, s := range software { + data = append(data, []string{ + s.Name, + s.Version, + s.Source, + fmt.Sprintf("%d vulnerabilities", len(s.Vulnerabilities)), + fmt.Sprint(s.HostsCount), + }) + } + columns := []string{"Name", "Version", "Type", "Vulnerabilities", "Hosts"} + printTable(c, columns, data) + return nil +} + +func printSoftwareTitles(c *cli.Context, client *service.Client, query url.Values) error { + software, err := client.ListSoftwareTitles(query.Encode()) + if err != nil { + return fmt.Errorf("could not list software titles: %w", err) + } + + if len(software) == 0 { + log(c, "No software titles found") + return nil + } + + if c.Bool(jsonFlagName) || c.Bool(yamlFlagName) { + spec := specGeneric{ + Kind: "software_title", + Version: "1", + Spec: software, + } + err = printSpec(c, spec) + if err != nil { + return err + } + return nil + } + + // Default to printing as table + data := [][]string{} + + for _, s := range software { + vulns := make(map[string]bool) + for _, ver := range s.Versions { + if ver.Vulnerabilities != nil { + for _, vuln := range *ver.Vulnerabilities { + vulns[vuln] = true + } + } + } + data = append(data, []string{ + s.Name, + fmt.Sprintf("%d versions", s.VersionsCount), + s.Source, + fmt.Sprintf("%d vulnerabilities", len(vulns)), + fmt.Sprint(s.HostsCount), + }) + } + columns := []string{"Name", "Versions", "Type", "Vulnerabilities", "Hosts"} + printTable(c, columns, data) + return nil +} + func getMDMAppleCommand() *cli.Command { return &cli.Command{ Name: "mdm-apple", diff --git a/cmd/fleetctl/get_test.go b/cmd/fleetctl/get_test.go index 88e4aa70d..79fcaefff 100644 --- a/cmd/fleetctl/get_test.go +++ b/cmd/fleetctl/get_test.go @@ -602,7 +602,159 @@ func TestGetConfig(t *testing.T) { }) } -func TestGetSoftware(t *testing.T) { +func TestGetSoftwareTitles(t *testing.T) { + _, ds := runServerWithMockedDS(t, &service.TestServerOpts{ + License: &fleet.LicenseInfo{ + Tier: fleet.TierPremium, + Expiration: time.Now().Add(24 * time.Hour), + }, + }) + + var gotTeamID *uint + + ds.ListSoftwareTitlesFunc = func(ctx context.Context, opt fleet.SoftwareTitleListOptions) ([]fleet.SoftwareTitle, int, *fleet.PaginationMetadata, error) { + gotTeamID = opt.TeamID + return []fleet.SoftwareTitle{ + { + Name: "foo", + Source: "chrome_extensions", + HostsCount: 2, + VersionsCount: 3, + Versions: []fleet.SoftwareVersion{ + { + Version: "0.0.1", + Vulnerabilities: &fleet.SliceString{"cve-123-456-001", "cve-123-456-002"}, + }, + { + Version: "0.0.2", + Vulnerabilities: &fleet.SliceString{"cve-123-456-001"}, + }, + { + Version: "0.0.3", + Vulnerabilities: &fleet.SliceString{"cve-123-456-003"}, + }, + }, + }, + { + Name: "bar", + Source: "deb_packages", + HostsCount: 0, + VersionsCount: 1, + Versions: []fleet.SoftwareVersion{ + { + Version: "0.0.3", + Vulnerabilities: nil, + }, + }, + }, + }, 0, nil, nil + } + + expected := `+------+------------+-------------------+-------------------+-------+ +| NAME | VERSIONS | TYPE | VULNERABILITIES | HOSTS | ++------+------------+-------------------+-------------------+-------+ +| foo | 3 versions | chrome_extensions | 3 vulnerabilities | 2 | ++------+------------+-------------------+-------------------+-------+ +| bar | 1 versions | deb_packages | 0 vulnerabilities | 0 | ++------+------------+-------------------+-------------------+-------+ +` + + expectedYaml := `--- +apiVersion: "1" +kind: software_title +spec: +- hosts_count: 2 + id: 0 + name: foo + source: chrome_extensions + versions: + - id: 0 + version: 0.0.1 + vulnerabilities: + - cve-123-456-001 + - cve-123-456-002 + - id: 0 + version: 0.0.2 + vulnerabilities: + - cve-123-456-001 + - id: 0 + version: 0.0.3 + vulnerabilities: + - cve-123-456-003 + versions_count: 3 +- hosts_count: 0 + id: 0 + name: bar + source: deb_packages + versions: + - id: 0 + version: 0.0.3 + versions_count: 1 +` + + expectedJson := ` +{ + "kind": "software_title", + "apiVersion": "1", + "spec": [ + { + "id": 0, + "name": "foo", + "source": "chrome_extensions", + "hosts_count": 2, + "versions_count": 3, + "versions": [ + { + "id": 0, + "version": "0.0.1", + "vulnerabilities": [ + "cve-123-456-001", + "cve-123-456-002" + ] + }, + { + "id": 0, + "version": "0.0.2", + "vulnerabilities": [ + "cve-123-456-001" + ] + }, + { + "id": 0, + "version": "0.0.3", + "vulnerabilities": [ + "cve-123-456-003" + ] + } + ] + }, + { + "id": 0, + "name": "bar", + "source": "deb_packages", + "hosts_count": 0, + "versions_count": 1, + "versions": [ + { + "id": 0, + "version": "0.0.3" + } + ] + } + ] +} +` + + assert.Equal(t, expected, runAppForTest(t, []string{"get", "software"})) + assert.YAMLEq(t, expectedYaml, runAppForTest(t, []string{"get", "software", "--yaml"})) + assert.JSONEq(t, expectedJson, runAppForTest(t, []string{"get", "software", "--json"})) + + runAppForTest(t, []string{"get", "software", "--json", "--team", "999"}) + require.NotNil(t, gotTeamID) + assert.Equal(t, uint(999), *gotTeamID) +} + +func TestGetSoftwareVersions(t *testing.T) { _, ds := runServerWithMockedDS(t) foo001 := fleet.Software{ @@ -627,17 +779,17 @@ func TestGetSoftware(t *testing.T) { return 4, nil } - expected := `+------+---------+-------------------+--------------------------+-----------+ -| NAME | VERSION | SOURCE | CPE | # OF CVES | -+------+---------+-------------------+--------------------------+-----------+ -| foo | 0.0.1 | chrome_extensions | somecpe | 2 | -+------+---------+-------------------+--------------------------+-----------+ -| foo | 0.0.2 | chrome_extensions | | 0 | -+------+---------+-------------------+--------------------------+-----------+ -| foo | 0.0.3 | chrome_extensions | someothercpewithoutvulns | 0 | -+------+---------+-------------------+--------------------------+-----------+ -| bar | 0.0.3 | deb_packages | | 0 | -+------+---------+-------------------+--------------------------+-----------+ + expected := `+------+---------+-------------------+-------------------+-------+ +| NAME | VERSION | TYPE | VULNERABILITIES | HOSTS | ++------+---------+-------------------+-------------------+-------+ +| foo | 0.0.1 | chrome_extensions | 2 vulnerabilities | 0 | ++------+---------+-------------------+-------------------+-------+ +| foo | 0.0.2 | chrome_extensions | 0 vulnerabilities | 0 | ++------+---------+-------------------+-------------------+-------+ +| foo | 0.0.3 | chrome_extensions | 0 vulnerabilities | 0 | ++------+---------+-------------------+-------------------+-------+ +| bar | 0.0.3 | deb_packages | 0 vulnerabilities | 0 | ++------+---------+-------------------+-------------------+-------+ ` expectedYaml := `--- @@ -736,11 +888,11 @@ spec: } ` - assert.Equal(t, expected, runAppForTest(t, []string{"get", "software"})) - assert.YAMLEq(t, expectedYaml, runAppForTest(t, []string{"get", "software", "--yaml"})) - assert.JSONEq(t, expectedJson, runAppForTest(t, []string{"get", "software", "--json"})) + assert.Equal(t, expected, runAppForTest(t, []string{"get", "software", "--versions"})) + assert.YAMLEq(t, expectedYaml, runAppForTest(t, []string{"get", "software", "--versions", "--yaml"})) + assert.JSONEq(t, expectedJson, runAppForTest(t, []string{"get", "software", "--versions", "--json"})) - runAppForTest(t, []string{"get", "software", "--json", "--team", "999"}) + runAppForTest(t, []string{"get", "software", "--versions", "--json", "--team", "999"}) require.NotNil(t, gotTeamID) assert.Equal(t, uint(999), *gotTeamID) } diff --git a/server/service/client_software.go b/server/service/client_software.go index 0a2348a42..90e738aa9 100644 --- a/server/service/client_software.go +++ b/server/service/client_software.go @@ -4,8 +4,8 @@ import ( "github.com/fleetdm/fleet/v4/server/fleet" ) -// ListSoftware retrieves the software running across hosts. -func (c *Client) ListSoftware(query string) ([]fleet.Software, error) { +// ListSoftwareVersions retrieves the software versions installed on hosts. +func (c *Client) ListSoftwareVersions(query string) ([]fleet.Software, error) { verb, path := "GET", "/api/latest/fleet/software/versions" var responseBody listSoftwareVersionsResponse err := c.authenticatedRequestWithQuery(nil, verb, path, &responseBody, query) @@ -14,3 +14,14 @@ func (c *Client) ListSoftware(query string) ([]fleet.Software, error) { } return responseBody.Software, nil } + +// ListSoftwareTitles retrieves the software titles installed on hosts. +func (c *Client) ListSoftwareTitles(query string) ([]fleet.SoftwareTitle, error) { + verb, path := "GET", "/api/latest/fleet/software/titles" + var responseBody listSoftwareTitlesResponse + err := c.authenticatedRequestWithQuery(nil, verb, path, &responseBody, query) + if err != nil { + return nil, err + } + return responseBody.SoftwareTitles, nil +} diff --git a/tools/nvd/nvdvuln/nvdvuln.go b/tools/nvd/nvdvuln/nvdvuln.go index 8b785a231..b2f2f565d 100644 --- a/tools/nvd/nvdvuln/nvdvuln.go +++ b/tools/nvd/nvdvuln/nvdvuln.go @@ -310,7 +310,7 @@ func getSoftwareFromURL(url, apiToken string, debug bool) []fleet.Software { } apiClient.SetToken(apiToken) - software, err := apiClient.ListSoftware("") + software, err := apiClient.ListSoftwareVersions("") if err != nil { panic(err) }