Feature: software installers (#19050)

for https://github.com/fleetdm/fleet/issues/14921

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

<!-- Note that API documentation changes are now addressed by the
product design team. -->

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://fleetdm.com/docs/contributing/committing-changes#changes-files)
for more information.
- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements)
- [x] Added/updated tests
- [x] If database migrations are included, checked table schema to
confirm autoupdate
- For database migrations:
- [x] Checked schema for all modified table for columns that will
auto-update timestamps during migration.
- [x] Confirmed that updating the timestamps is acceptable, and will not
cause unwanted side effects.
- [x] Ensured the correct collation is explicitly set for character
columns (`COLLATE utf8mb4_unicode_ci`).
- [x] Manual QA for all new/changed functionality
  - For Orbit and Fleet Desktop changes:
- [x] Manual QA must be performed in the three main OSs, macOS, Windows
and Linux.
- [x] Auto-update manual QA, from released version of component to new
version (see [tools/tuf/test](../tools/tuf/test/README.md)).
This commit is contained in:
Jahziel Villasana-Espinoza 2024-05-17 10:37:45 -04:00 committed by GitHub
commit c4923ffecd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
264 changed files with 15280 additions and 2870 deletions

View file

@ -38,5 +38,6 @@
"prettier.requireConfig": true,
"yaml.schemas": {
"https://json.schemastore.org/codecov.json": ".github/workflows/codecov.yml"
}
},
"favorites.sortOrder": "ASC"
}

View file

@ -0,0 +1,4 @@
- Added functionality to filter hosts by software installer status.
- Added endpoints to upload, delete, and download software installers.
- Added endpoints to get host software install results.
- Updated activity feeds to include software installer activities.

View file

@ -0,0 +1,2 @@
- Adds the `/software/install/results/:install_uuid` endpoint, which can be used to get the results
for a software install attempt.

View file

@ -0,0 +1 @@
* Added support to extract package name and version from software installers.

View file

@ -0,0 +1 @@
* Added the `GET /api/v1/fleet/hosts/{id}/software` (and corresponding token-authenticated endpoint for the "My device" page) to list the installed (and available for install) software for the host.

View file

@ -0,0 +1 @@
* Added `software` team setting to add software installers in YAML files for `fleetctl apply` and `fleetctl gitops`.

View file

@ -0,0 +1 @@
* Implemented an S3-based and local filesystem-based storage abstraction for software installers.

View file

@ -0,0 +1 @@
- Adds support to the global activity feed for "Added software" and "Deleted software" actions.

View file

@ -0,0 +1 @@
* Added a `cron` job to periodically remove unused software installers from the store.

View file

@ -0,0 +1 @@
* Added the `POST /api/fleet/orbit/software_install/result` endpoint for fleetd to send results for a software installation attempt.

View file

@ -0,0 +1 @@
* Added software installation to the host's upcoming and past activities.

View file

@ -0,0 +1 @@
* Added the uninstalled but available software installers to the response payload of the "List software titles" endpoint (`GET /software/titles`).

View file

@ -0,0 +1 @@
- add ability to upload software from the UI

View file

@ -0,0 +1 @@
- udpates software page to support new add software feature.

View file

@ -0,0 +1 @@
- Fixes some typos that were in the Powershell scripts for installing Windows software.

View file

@ -0,0 +1 @@
- Adds a missing field `software_package` to the response from the List Software Titles endpoint.

View file

@ -708,6 +708,7 @@ func newCleanupsAndAggregationSchedule(
enrollHostLimiter fleet.EnrollHostLimiter,
config *config.FleetConfig,
commander *apple_mdm.MDMAppleCommander,
softwareInstallStore fleet.SoftwareInstallerStore,
) (*schedule.Schedule, error) {
const (
name = string(fleet.CronCleanupsThenAggregation)
@ -848,6 +849,9 @@ func newCleanupsAndAggregationSchedule(
const maxCount = 5000
return ds.CleanupActivitiesAndAssociatedData(ctx, maxCount, appConfig.ActivityExpirySettings.ActivityExpiryWindow)
}),
schedule.WithJob("cleanup_unused_software_installers", func(ctx context.Context) error {
return ds.CleanupUnusedSoftwareInstallers(ctx, softwareInstallStore)
}),
)
return s, nil

View file

@ -30,6 +30,7 @@ import (
licensectx "github.com/fleetdm/fleet/v4/server/contexts/license"
"github.com/fleetdm/fleet/v4/server/cron"
"github.com/fleetdm/fleet/v4/server/datastore/cached_mysql"
"github.com/fleetdm/fleet/v4/server/datastore/filesystem"
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
"github.com/fleetdm/fleet/v4/server/datastore/mysqlredis"
"github.com/fleetdm/fleet/v4/server/datastore/redis"
@ -626,11 +627,33 @@ the way that the Fleet server works.
initFatal(err, "initializing service")
}
var softwareInstallStore fleet.SoftwareInstallerStore
if license.IsPremium() {
var profileMatcher fleet.ProfileMatcher
if appCfg.MDM.EnabledAndConfigured {
profileMatcher = apple_mdm.NewProfileMatcher(redisPool)
}
if config.S3.Bucket != "" {
store, err := s3.NewSoftwareInstallerStore(config.S3)
if err != nil {
initFatal(err, "initializing S3 software installer store")
}
softwareInstallStore = store
level.Info(logger).Log("msg", "using S3 software installer store", "bucket", config.S3.Bucket)
} else {
installerDir := os.TempDir()
if dir := os.Getenv("FLEET_SOFTWARE_INSTALLER_STORE_DIR"); dir != "" {
installerDir = dir
}
store, err := filesystem.NewSoftwareInstallerStore(installerDir)
if err != nil {
level.Error(logger).Log("err", err, "msg", "failed to configure local filesystem software installer store")
softwareInstallStore = fleet.FailingSoftwareInstallerStore{}
} else {
softwareInstallStore = store
level.Info(logger).Log("msg", "using local filesystem software installer store, this is not suitable for production use", "directory", installerDir)
}
}
svc, err = eeservice.NewService(
svc,
@ -644,6 +667,7 @@ the way that the Fleet server works.
mdmPushCertTopic,
ssoSessionStore,
profileMatcher,
softwareInstallStore,
)
if err != nil {
initFatal(err, "initial Fleet Premium service")
@ -700,7 +724,7 @@ the way that the Fleet server works.
commander = apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService, config.MDM)
}
return newCleanupsAndAggregationSchedule(
ctx, instanceID, ds, logger, redisWrapperDS, &config, commander,
ctx, instanceID, ds, logger, redisWrapperDS, &config, commander, softwareInstallStore,
)
},
); err != nil {

View file

@ -623,9 +623,9 @@ func TestGetSoftwareTitles(t *testing.T) {
var gotTeamID *uint
ds.ListSoftwareTitlesFunc = func(ctx context.Context, opt fleet.SoftwareTitleListOptions, tmFilter fleet.TeamFilter) ([]fleet.SoftwareTitle, int, *fleet.PaginationMetadata, error) {
ds.ListSoftwareTitlesFunc = func(ctx context.Context, opt fleet.SoftwareTitleListOptions, tmFilter fleet.TeamFilter) ([]fleet.SoftwareTitleListResult, int, *fleet.PaginationMetadata, error) {
gotTeamID = opt.TeamID
return []fleet.SoftwareTitle{
return []fleet.SoftwareTitleListResult{
{
Name: "foo",
Source: "chrome_extensions",
@ -677,6 +677,7 @@ spec:
- hosts_count: 2
id: 0
name: foo
software_package: null
source: chrome_extensions
versions:
- id: 0
@ -696,6 +697,7 @@ spec:
- hosts_count: 0
id: 0
name: bar
software_package: null
source: deb_packages
versions:
- id: 0
@ -738,7 +740,8 @@ spec:
"cve-123-456-003"
]
}
]
],
"software_package": null
},
{
"id": 0,
@ -752,7 +755,8 @@ spec:
"version": "0.0.3",
"vulnerabilities": null
}
]
],
"software_package": null
}
]
}
@ -2231,6 +2235,9 @@ func TestGetTeamsYAMLAndApply(t *testing.T) {
declaration.DeclarationUUID = uuid.NewString()
return declaration, nil
}
ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, tmID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error {
return nil
}
actualYaml := runAppForTest(t, []string{"get", "teams", "--yaml"})
yamlFilePath := writeTmpYml(t, actualYaml)

View file

@ -3,7 +3,10 @@ package main
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"slices"
"strings"
"testing"
@ -20,7 +23,11 @@ import (
"github.com/stretchr/testify/require"
)
const teamName = "Team Test"
const (
teamName = "Team Test"
fleetServerURL = "https://fleet.example.com"
orgName = "GitOps Test"
)
func TestBasicGlobalGitOps(t *testing.T) {
// Cannot run t.Parallel() because it sets environment variables
@ -196,6 +203,9 @@ func TestBasicTeamGitOps(t *testing.T) {
ds.DeleteMDMAppleDeclarationByNameFunc = func(ctx context.Context, teamID *uint, name string) error {
return nil
}
ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error {
return nil
}
var enrolledSecrets []*fleet.EnrollSecret
ds.ApplyEnrollSecretsFunc = func(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error {
@ -549,6 +559,9 @@ func TestFullTeamGitOps(t *testing.T) {
appliedQueries = queries
return nil
}
ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error {
return nil
}
var enrolledSecrets []*fleet.EnrollSecret
ds.ApplyEnrollSecretsFunc = func(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error {
@ -736,6 +749,9 @@ func TestBasicGlobalAndTeamGitOps(t *testing.T) {
declaration.DeclarationUUID = uuid.NewString()
return declaration, nil
}
ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error {
return nil
}
globalFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
@ -850,6 +866,123 @@ team_settings:
func TestFullGlobalAndTeamGitOps(t *testing.T) {
// Cannot run t.Parallel() because it sets environment variables
// mdm test configuration must be set so that activating windows MDM works.
ds, savedAppConfigPtr, savedTeamPtr := setupFullGitOpsPremiumServer(t)
var enrolledSecrets []*fleet.EnrollSecret
var enrolledTeamSecrets []*fleet.EnrollSecret
var appliedPolicySpecs []*fleet.PolicySpec
var appliedQueries []*fleet.Query
ds.ApplyEnrollSecretsFunc = func(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error {
if teamID == nil {
enrolledSecrets = secrets
} else {
enrolledTeamSecrets = secrets
}
return nil
}
ds.ApplyPolicySpecsFunc = func(ctx context.Context, authorID uint, specs []*fleet.PolicySpec) error {
appliedPolicySpecs = specs
return nil
}
ds.ApplyQueriesFunc = func(
ctx context.Context, authorID uint, queries []*fleet.Query, queriesToDiscardResults map[uint]struct{},
) error {
appliedQueries = queries
return nil
}
ds.NewTeamFunc = func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) {
team.ID = 1
*savedTeamPtr = team
enrolledTeamSecrets = team.Secrets
return *savedTeamPtr, nil
}
globalFile := "./testdata/gitops/global_config_no_paths.yml"
teamFile := "./testdata/gitops/team_config_no_paths.yml"
// Dry run on global file should fail because Apple BM Default Team does not exist (and has not been provided)
_, err := runAppNoChecks([]string{"gitops", "-f", globalFile, "--dry-run"})
require.Error(t, err)
assert.True(t, strings.Contains(err.Error(), "team name not found"))
// Dry run
_ = runAppForTest(t, []string{"gitops", "-f", globalFile, "-f", teamFile, "--dry-run", "--delete-other-teams"})
assert.False(t, ds.SaveAppConfigFuncInvoked)
assert.Len(t, enrolledSecrets, 0)
assert.Len(t, enrolledTeamSecrets, 0)
assert.Len(t, appliedPolicySpecs, 0)
assert.Len(t, appliedQueries, 0)
// Real run
_ = runAppForTest(t, []string{"gitops", "-f", globalFile, "-f", teamFile, "--delete-other-teams"})
assert.Equal(t, orgName, (*savedAppConfigPtr).OrgInfo.OrgName)
assert.Equal(t, fleetServerURL, (*savedAppConfigPtr).ServerSettings.ServerURL)
assert.Len(t, enrolledSecrets, 2)
require.NotNil(t, *savedTeamPtr)
assert.Equal(t, teamName, (*savedTeamPtr).Name)
require.Len(t, enrolledTeamSecrets, 2)
}
func TestTeamSofwareInstallersGitOps(t *testing.T) {
// start the web server that will serve the installer
b, err := os.ReadFile(filepath.Join("..", "..", "server", "service", "testdata", "software-installers", "ruby.deb"))
require.NoError(t, err)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.Contains(r.URL.Path, "notfound"):
w.WriteHeader(http.StatusNotFound)
return
case strings.HasSuffix(r.URL.Path, ".txt"):
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write([]byte(`a simple text file`))
return
case strings.Contains(r.URL.Path, "toolarge"):
w.Header().Set("Content-Type", "application/vnd.debian.binary-package")
var sz int
for sz < 500*1024*1024 {
n, _ := w.Write(b)
sz += n
}
default:
w.Header().Set("Content-Type", "application/vnd.debian.binary-package")
_, _ = w.Write(b)
}
}))
t.Cleanup(srv.Close)
t.Setenv("SOFTWARE_INSTALLER_URL", srv.URL)
cases := []struct {
file string
wantErr string
}{
{"testdata/gitops/team_software_installer_not_found.yml", "Please make sure that URLs are publicy accessible to the internet."},
{"testdata/gitops/team_software_installer_unsupported.yml", "The file should be .pkg, .msi, .exe or .deb."},
{"testdata/gitops/team_software_installer_too_large.yml", "The maximum file size is 500 MB"},
{"testdata/gitops/team_software_installer_valid.yml", ""},
{"testdata/gitops/team_software_installer_pre_condition_multiple_queries.yml", "should have only one query."},
{"testdata/gitops/team_software_installer_pre_condition_not_found.yml", "no such file or directory"},
{"testdata/gitops/team_software_installer_install_not_found.yml", "no such file or directory"},
{"testdata/gitops/team_software_installer_post_install_not_found.yml", "no such file or directory"},
{"testdata/gitops/team_software_installer_no_url.yml", "software URL is required"},
}
for _, c := range cases {
t.Run(filepath.Base(c.file), func(t *testing.T) {
setupFullGitOpsPremiumServer(t)
_, err := runAppNoChecks([]string{"gitops", "-f", c.file})
if c.wantErr == "" {
require.NoError(t, err)
} else {
require.ErrorContains(t, err, c.wantErr)
}
})
}
}
func setupFullGitOpsPremiumServer(t *testing.T) (*mock.Store, **fleet.AppConfig, **fleet.Team) {
testCert, testKey, err := apple_mdm.NewSCEPCACertKey()
require.NoError(t, err)
testCertPEM := tokenpki.PEMCertificate(testCert.Raw)
@ -884,32 +1017,17 @@ func TestFullGlobalAndTeamGitOps(t *testing.T) {
return nil
}
const (
fleetServerURL = "https://fleet.example.com"
orgName = "GitOps Test"
)
var enrolledSecrets []*fleet.EnrollSecret
var enrolledTeamSecrets []*fleet.EnrollSecret
var appliedPolicySpecs []*fleet.PolicySpec
var appliedQueries []*fleet.Query
var savedTeam *fleet.Team
ds.ApplyEnrollSecretsFunc = func(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error {
if teamID == nil {
enrolledSecrets = secrets
} else {
enrolledTeamSecrets = secrets
}
return nil
}
ds.ApplyPolicySpecsFunc = func(ctx context.Context, authorID uint, specs []*fleet.PolicySpec) error {
appliedPolicySpecs = specs
return nil
}
ds.ApplyQueriesFunc = func(
ctx context.Context, authorID uint, queries []*fleet.Query, queriesToDiscardResults map[uint]struct{},
) error {
appliedQueries = queries
return nil
}
ds.BatchSetMDMProfilesFunc = func(
@ -957,7 +1075,6 @@ func TestFullGlobalAndTeamGitOps(t *testing.T) {
ds.NewTeamFunc = func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) {
team.ID = 1
savedTeam = team
enrolledTeamSecrets = team.Secrets
return savedTeam, nil
}
ds.QueryByNameFunc = func(ctx context.Context, teamID *uint, name string) (*fleet.Query, error) {
@ -985,35 +1102,14 @@ func TestFullGlobalAndTeamGitOps(t *testing.T) {
declaration.DeclarationUUID = uuid.NewString()
return declaration, nil
}
ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error {
return nil
}
t.Setenv("FLEET_SERVER_URL", fleetServerURL)
t.Setenv("ORG_NAME", orgName)
t.Setenv("TEST_TEAM_NAME", teamName)
t.Setenv("APPLE_BM_DEFAULT_TEAM", teamName)
globalFile := "./testdata/gitops/global_config_no_paths.yml"
teamFile := "./testdata/gitops/team_config_no_paths.yml"
// Dry run on global file should fail because Apple BM Default Team does not exist (and has not been provided)
_, err = runAppNoChecks([]string{"gitops", "-f", globalFile, "--dry-run"})
require.Error(t, err)
assert.True(t, strings.Contains(err.Error(), "team name not found"))
// Dry run
_ = runAppForTest(t, []string{"gitops", "-f", globalFile, "-f", teamFile, "--dry-run", "--delete-other-teams"})
assert.False(t, ds.SaveAppConfigFuncInvoked)
assert.Len(t, enrolledSecrets, 0)
assert.Len(t, enrolledTeamSecrets, 0)
assert.Len(t, appliedPolicySpecs, 0)
assert.Len(t, appliedQueries, 0)
// Real run
_ = runAppForTest(t, []string{"gitops", "-f", globalFile, "-f", teamFile, "--delete-other-teams"})
assert.Equal(t, orgName, savedAppConfig.OrgInfo.OrgName)
assert.Equal(t, fleetServerURL, savedAppConfig.ServerSettings.ServerURL)
assert.Len(t, enrolledSecrets, 2)
require.NotNil(t, savedTeam)
assert.Equal(t, teamName, savedTeam.Name)
require.Len(t, enrolledTeamSecrets, 2)
return ds, &savedAppConfig, &savedTeam
}

View file

@ -1,132 +1,132 @@
{
"kind": "config",
"apiVersion": "v1",
"spec": {
"org_info": {
"org_name": "",
"org_logo_url": "",
"org_logo_url_light_background": "",
"contact_url": "https://fleetdm.com/company/contact"
},
"server_settings": {
"server_url": "",
"live_query_disabled": false,
"query_reports_disabled": false,
"enable_analytics": false,
"deferred_save_host": false,
"scripts_disabled": false,
"ai_features_disabled": false
},
"smtp_settings": {
"enable_smtp": false,
"configured": false,
"sender_address": "",
"server": "",
"port": 0,
"authentication_type": "",
"user_name": "",
"password": "",
"enable_ssl_tls": false,
"authentication_method": "",
"domain": "",
"verify_ssl_certs": false,
"enable_start_tls": false
},
"host_expiry_settings": {
"host_expiry_enabled": false,
"host_expiry_window": 0
},
"activity_expiry_settings": {
"activity_expiry_enabled": false,
"activity_expiry_window": 0
},
"features": {
"enable_host_users": true,
"enable_software_inventory": false
},
"sso_settings": {
"entity_id": "",
"issuer_uri": "",
"idp_image_url": "",
"metadata": "",
"metadata_url": "",
"idp_name": "",
"enable_jit_provisioning": false,
"enable_jit_role_sync": false,
"enable_sso": false,
"enable_sso_idp_login": false
},
"fleet_desktop": {
"transparency_url": "https://fleetdm.com/transparency"
},
"vulnerability_settings": {
"databases_path": "/some/path"
},
"webhook_settings": {
"host_status_webhook": {
"enable_host_status_webhook": false,
"destination_url": "",
"host_percentage": 0,
"days_count": 0
},
"failing_policies_webhook": {
"enable_failing_policies_webhook": false,
"destination_url": "",
"policy_ids": null,
"host_batch_size": 0
},
"vulnerabilities_webhook": {
"enable_vulnerabilities_webhook": false,
"destination_url": "",
"host_batch_size": 0
},
"interval": "0s"
},
"integrations": {
"jira": null,
"zendesk": null,
"google_calendar": null
},
"mdm": {
"apple_bm_terms_expired": false,
"apple_bm_enabled_and_configured": false,
"enabled_and_configured": false,
"apple_bm_default_team": "",
"windows_enabled_and_configured": false,
"enable_disk_encryption": false,
"macos_updates": {
"minimum_version": null,
"deadline": null
},
"windows_updates": {
"deadline_days": 7,
"grace_period_days": 3
},
"macos_migration": {
"enable": false,
"mode": "",
"webhook_url": ""
},
"macos_settings": {
"custom_settings": null
},
"macos_setup": {
"bootstrap_package": null,
"enable_end_user_authentication": false,
"macos_setup_assistant": null,
"enable_release_device_manually": false
},
"windows_settings": {
"custom_settings": null
},
"end_user_authentication": {
"entity_id": "",
"issuer_uri": "",
"metadata": "",
"metadata_url": "",
"idp_name": ""
}
},
"scripts": null
}
"kind": "config",
"apiVersion": "v1",
"spec": {
"org_info": {
"org_name": "",
"org_logo_url": "",
"org_logo_url_light_background": "",
"contact_url": "https://fleetdm.com/company/contact"
},
"server_settings": {
"server_url": "",
"live_query_disabled": false,
"query_reports_disabled": false,
"enable_analytics": false,
"deferred_save_host": false,
"scripts_disabled": false,
"ai_features_disabled": false
},
"smtp_settings": {
"enable_smtp": false,
"configured": false,
"sender_address": "",
"server": "",
"port": 0,
"authentication_type": "",
"user_name": "",
"password": "",
"enable_ssl_tls": false,
"authentication_method": "",
"domain": "",
"verify_ssl_certs": false,
"enable_start_tls": false
},
"host_expiry_settings": {
"host_expiry_enabled": false,
"host_expiry_window": 0
},
"activity_expiry_settings": {
"activity_expiry_enabled": false,
"activity_expiry_window": 0
},
"features": {
"enable_host_users": true,
"enable_software_inventory": false
},
"sso_settings": {
"entity_id": "",
"issuer_uri": "",
"idp_image_url": "",
"metadata": "",
"metadata_url": "",
"idp_name": "",
"enable_jit_provisioning": false,
"enable_jit_role_sync": false,
"enable_sso": false,
"enable_sso_idp_login": false
},
"fleet_desktop": {
"transparency_url": "https://fleetdm.com/transparency"
},
"vulnerability_settings": {
"databases_path": "/some/path"
},
"webhook_settings": {
"host_status_webhook": {
"enable_host_status_webhook": false,
"destination_url": "",
"host_percentage": 0,
"days_count": 0
},
"failing_policies_webhook": {
"enable_failing_policies_webhook": false,
"destination_url": "",
"policy_ids": null,
"host_batch_size": 0
},
"vulnerabilities_webhook": {
"enable_vulnerabilities_webhook": false,
"destination_url": "",
"host_batch_size": 0
},
"interval": "0s"
},
"integrations": {
"jira": null,
"zendesk": null,
"google_calendar": null
},
"mdm": {
"apple_bm_terms_expired": false,
"apple_bm_enabled_and_configured": false,
"enabled_and_configured": false,
"apple_bm_default_team": "",
"windows_enabled_and_configured": false,
"enable_disk_encryption": false,
"macos_updates": {
"minimum_version": null,
"deadline": null
},
"windows_updates": {
"deadline_days": 7,
"grace_period_days": 3
},
"macos_migration": {
"enable": false,
"mode": "",
"webhook_url": ""
},
"macos_settings": {
"custom_settings": null
},
"macos_setup": {
"bootstrap_package": null,
"enable_end_user_authentication": false,
"macos_setup_assistant": null,
"enable_release_device_manually": false
},
"windows_settings": {
"custom_settings": null
},
"end_user_authentication": {
"entity_id": "",
"issuer_uri": "",
"metadata": "",
"metadata_url": "",
"idp_name": ""
}
},
"scripts": null
}
}

View file

@ -1,194 +1,194 @@
{
"kind": "config",
"apiVersion": "v1",
"spec": {
"org_info": {
"org_name": "",
"org_logo_url": "",
"org_logo_url_light_background": "",
"contact_url": "https://fleetdm.com/company/contact"
},
"server_settings": {
"server_url": "",
"live_query_disabled": false,
"query_reports_disabled": false,
"enable_analytics": false,
"deferred_save_host": false,
"scripts_disabled": false,
"ai_features_disabled": false
},
"smtp_settings": {
"enable_smtp": false,
"configured": false,
"sender_address": "",
"server": "",
"port": 0,
"authentication_type": "",
"user_name": "",
"password": "",
"enable_ssl_tls": false,
"authentication_method": "",
"domain": "",
"verify_ssl_certs": false,
"enable_start_tls": false
},
"host_expiry_settings": {
"host_expiry_enabled": false,
"host_expiry_window": 0
},
"activity_expiry_settings": {
"activity_expiry_enabled": false,
"activity_expiry_window": 0
},
"features": {
"enable_host_users": true,
"enable_software_inventory": false
},
"mdm": {
"apple_bm_default_team": "",
"apple_bm_terms_expired": false,
"apple_bm_enabled_and_configured": false,
"enabled_and_configured": false,
"windows_enabled_and_configured": false,
"enable_disk_encryption": false,
"macos_updates": {
"minimum_version": null,
"deadline": null
},
"windows_updates": {
"deadline_days": 7,
"grace_period_days": 3
},
"macos_migration": {
"enable": false,
"mode": "",
"webhook_url": ""
},
"macos_settings": {
"custom_settings": null
},
"macos_setup": {
"bootstrap_package": null,
"enable_end_user_authentication": false,
"macos_setup_assistant": null,
"enable_release_device_manually": false
},
"windows_settings": {
"custom_settings": null
},
"end_user_authentication": {
"entity_id": "",
"issuer_uri": "",
"metadata": "",
"metadata_url": "",
"idp_name": ""
}
},
"scripts": null,
"sso_settings": {
"enable_jit_provisioning": false,
"enable_jit_role_sync": false,
"entity_id": "",
"issuer_uri": "",
"idp_image_url": "",
"metadata": "",
"metadata_url": "",
"idp_name": "",
"enable_sso": false,
"enable_sso_idp_login": false
},
"fleet_desktop": {
"transparency_url": "https://fleetdm.com/transparency"
},
"vulnerability_settings": {
"databases_path": "/some/path"
},
"webhook_settings": {
"host_status_webhook": {
"enable_host_status_webhook": false,
"destination_url": "",
"host_percentage": 0,
"days_count": 0
},
"failing_policies_webhook": {
"enable_failing_policies_webhook": false,
"destination_url": "",
"policy_ids": null,
"host_batch_size": 0
},
"vulnerabilities_webhook": {
"enable_vulnerabilities_webhook": false,
"destination_url": "",
"host_batch_size": 0
},
"interval": "0s"
},
"integrations": {
"jira": null,
"zendesk": null,
"google_calendar": null
},
"update_interval": {
"osquery_detail": "1h0m0s",
"osquery_policy": "1h0m0s"
},
"vulnerabilities": {
"databases_path": "",
"periodicity": "0s",
"cpe_database_url": "",
"cpe_translations_url": "",
"cve_feed_prefix_url": "",
"current_instance_checks": "",
"disable_data_sync": false,
"recent_vulnerability_max_age": "0s",
"disable_win_os_vulnerabilities": false
},
"license": {
"tier": "free",
"expiration": "0001-01-01T00:00:00Z"
},
"logging": {
"debug": true,
"json": false,
"result": {
"plugin": "filesystem",
"config": {
"enable_log_compression": false,
"enable_log_rotation": false,
"result_log_file": "/dev/null",
"status_log_file": "/dev/null",
"audit_log_file": "/dev/null",
"max_size": 500,
"max_age": 0,
"max_backups": 0
}
},
"status": {
"plugin": "filesystem",
"config": {
"enable_log_compression": false,
"enable_log_rotation": false,
"result_log_file": "/dev/null",
"status_log_file": "/dev/null",
"audit_log_file": "/dev/null",
"max_size": 500,
"max_age": 0,
"max_backups": 0
}
},
"audit": {
"plugin": "filesystem",
"config": {
"enable_log_compression": false,
"enable_log_rotation": false,
"result_log_file": "/dev/null",
"status_log_file": "/dev/null",
"audit_log_file": "/dev/null",
"max_size": 500,
"max_age": 0,
"max_backups": 0
}
}
}
}
"kind": "config",
"apiVersion": "v1",
"spec": {
"org_info": {
"org_name": "",
"org_logo_url": "",
"org_logo_url_light_background": "",
"contact_url": "https://fleetdm.com/company/contact"
},
"server_settings": {
"server_url": "",
"live_query_disabled": false,
"query_reports_disabled": false,
"enable_analytics": false,
"deferred_save_host": false,
"scripts_disabled": false,
"ai_features_disabled": false
},
"smtp_settings": {
"enable_smtp": false,
"configured": false,
"sender_address": "",
"server": "",
"port": 0,
"authentication_type": "",
"user_name": "",
"password": "",
"enable_ssl_tls": false,
"authentication_method": "",
"domain": "",
"verify_ssl_certs": false,
"enable_start_tls": false
},
"host_expiry_settings": {
"host_expiry_enabled": false,
"host_expiry_window": 0
},
"activity_expiry_settings": {
"activity_expiry_enabled": false,
"activity_expiry_window": 0
},
"features": {
"enable_host_users": true,
"enable_software_inventory": false
},
"mdm": {
"apple_bm_default_team": "",
"apple_bm_terms_expired": false,
"apple_bm_enabled_and_configured": false,
"enabled_and_configured": false,
"windows_enabled_and_configured": false,
"enable_disk_encryption": false,
"macos_updates": {
"minimum_version": null,
"deadline": null
},
"windows_updates": {
"deadline_days": 7,
"grace_period_days": 3
},
"macos_migration": {
"enable": false,
"mode": "",
"webhook_url": ""
},
"macos_settings": {
"custom_settings": null
},
"macos_setup": {
"bootstrap_package": null,
"enable_end_user_authentication": false,
"macos_setup_assistant": null,
"enable_release_device_manually": false
},
"windows_settings": {
"custom_settings": null
},
"end_user_authentication": {
"entity_id": "",
"issuer_uri": "",
"metadata": "",
"metadata_url": "",
"idp_name": ""
}
},
"scripts": null,
"sso_settings": {
"enable_jit_provisioning": false,
"enable_jit_role_sync": false,
"entity_id": "",
"issuer_uri": "",
"idp_image_url": "",
"metadata": "",
"metadata_url": "",
"idp_name": "",
"enable_sso": false,
"enable_sso_idp_login": false
},
"fleet_desktop": {
"transparency_url": "https://fleetdm.com/transparency"
},
"vulnerability_settings": {
"databases_path": "/some/path"
},
"webhook_settings": {
"host_status_webhook": {
"enable_host_status_webhook": false,
"destination_url": "",
"host_percentage": 0,
"days_count": 0
},
"failing_policies_webhook": {
"enable_failing_policies_webhook": false,
"destination_url": "",
"policy_ids": null,
"host_batch_size": 0
},
"vulnerabilities_webhook": {
"enable_vulnerabilities_webhook": false,
"destination_url": "",
"host_batch_size": 0
},
"interval": "0s"
},
"integrations": {
"jira": null,
"zendesk": null,
"google_calendar": null
},
"update_interval": {
"osquery_detail": "1h0m0s",
"osquery_policy": "1h0m0s"
},
"vulnerabilities": {
"databases_path": "",
"periodicity": "0s",
"cpe_database_url": "",
"cpe_translations_url": "",
"cve_feed_prefix_url": "",
"current_instance_checks": "",
"disable_data_sync": false,
"recent_vulnerability_max_age": "0s",
"disable_win_os_vulnerabilities": false
},
"license": {
"tier": "free",
"expiration": "0001-01-01T00:00:00Z"
},
"logging": {
"debug": true,
"json": false,
"result": {
"plugin": "filesystem",
"config": {
"enable_log_compression": false,
"enable_log_rotation": false,
"result_log_file": "/dev/null",
"status_log_file": "/dev/null",
"audit_log_file": "/dev/null",
"max_size": 500,
"max_age": 0,
"max_backups": 0
}
},
"status": {
"plugin": "filesystem",
"config": {
"enable_log_compression": false,
"enable_log_rotation": false,
"result_log_file": "/dev/null",
"status_log_file": "/dev/null",
"audit_log_file": "/dev/null",
"max_size": 500,
"max_age": 0,
"max_backups": 0
}
},
"audit": {
"plugin": "filesystem",
"config": {
"enable_log_compression": false,
"enable_log_rotation": false,
"result_log_file": "/dev/null",
"status_log_file": "/dev/null",
"audit_log_file": "/dev/null",
"max_size": 500,
"max_age": 0,
"max_backups": 0
}
}
}
}
}

View file

@ -53,6 +53,7 @@
}
},
"scripts": null,
"software": null,
"user_count": 99,
"host_count": 42
}
@ -128,6 +129,7 @@
}
},
"scripts": null,
"software": null,
"user_count": 87,
"host_count": 43
}

View file

@ -29,6 +29,7 @@ spec:
enable_release_device_manually: false
macos_setup_assistant:
scripts: null
software: null
webhook_settings:
host_status_webhook: null
name: team1
@ -72,6 +73,7 @@ spec:
enable_release_device_manually: false
macos_setup_assistant:
scripts: null
software: null
webhook_settings:
host_status_webhook: null
name: team2

View file

@ -0,0 +1 @@
echo 'ruby'

View file

@ -0,0 +1 @@
echo 'post ruby'

View file

@ -0,0 +1,11 @@
apiVersion: v1
kind: query
spec:
name: query_ruby
query: select 1
---
apiVersion: v1
kind: query
spec:
name: query_ruby2
query: select 2

View file

@ -0,0 +1,5 @@
apiVersion: v1
kind: query
spec:
name: query_ruby
query: select 1

View file

@ -0,0 +1,18 @@
name: "${TEST_TEAM_NAME}"
team_settings:
secrets:
- secret: "ABC"
features:
enable_host_users: true
enable_software_inventory: true
host_expiry_settings:
host_expiry_enabled: true
host_expiry_window: 30
agent_options:
controls:
policies:
queries:
software:
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
install_script:
path: lib/notfound.sh

View file

@ -0,0 +1,21 @@
name: "${TEST_TEAM_NAME}"
team_settings:
secrets:
- secret: "ABC"
features:
enable_host_users: true
enable_software_inventory: true
host_expiry_settings:
host_expiry_enabled: true
host_expiry_window: 30
agent_options:
controls:
policies:
queries:
software:
- install_script:
path: lib/install_ruby.sh
pre_install_query:
path: lib/query_ruby.yml
post_install_script:
path: lib/post_install_ruby.sh

View file

@ -0,0 +1,16 @@
name: "${TEST_TEAM_NAME}"
team_settings:
secrets:
- secret: "ABC"
features:
enable_host_users: true
enable_software_inventory: true
host_expiry_settings:
host_expiry_enabled: true
host_expiry_window: 30
agent_options:
controls:
policies:
queries:
software:
- url: ${SOFTWARE_INSTALLER_URL}/notfound.deb

View file

@ -0,0 +1,20 @@
name: "${TEST_TEAM_NAME}"
team_settings:
secrets:
- secret: "ABC"
features:
enable_host_users: true
enable_software_inventory: true
host_expiry_settings:
host_expiry_enabled: true
host_expiry_window: 30
agent_options:
controls:
policies:
queries:
software:
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
install_script:
path: lib/install_ruby.sh
post_install_script:
path: lib/notfound.sh

View file

@ -0,0 +1,22 @@
name: "${TEST_TEAM_NAME}"
team_settings:
secrets:
- secret: "ABC"
features:
enable_host_users: true
enable_software_inventory: true
host_expiry_settings:
host_expiry_enabled: true
host_expiry_window: 30
agent_options:
controls:
policies:
queries:
software:
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
install_script:
path: lib/install_ruby.sh
pre_install_query:
path: lib/query_multiple.yml
post_install_script:
path: lib/post_install_ruby.sh

View file

@ -0,0 +1,20 @@
name: "${TEST_TEAM_NAME}"
team_settings:
secrets:
- secret: "ABC"
features:
enable_host_users: true
enable_software_inventory: true
host_expiry_settings:
host_expiry_enabled: true
host_expiry_window: 30
agent_options:
controls:
policies:
queries:
software:
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
install_script:
path: lib/install_ruby.sh
pre_install_query:
path: lib/notfound.yml

View file

@ -0,0 +1,16 @@
name: "${TEST_TEAM_NAME}"
team_settings:
secrets:
- secret: "ABC"
features:
enable_host_users: true
enable_software_inventory: true
host_expiry_settings:
host_expiry_enabled: true
host_expiry_window: 30
agent_options:
controls:
policies:
queries:
software:
- url: ${SOFTWARE_INSTALLER_URL}/toolarge.deb

View file

@ -0,0 +1,16 @@
name: "${TEST_TEAM_NAME}"
team_settings:
secrets:
- secret: "ABC"
features:
enable_host_users: true
enable_software_inventory: true
host_expiry_settings:
host_expiry_enabled: true
host_expiry_window: 30
agent_options:
controls:
policies:
queries:
software:
- url: ${SOFTWARE_INSTALLER_URL}/invalidtype.txt

View file

@ -0,0 +1,22 @@
name: "${TEST_TEAM_NAME}"
team_settings:
secrets:
- secret: "ABC"
features:
enable_host_users: true
enable_software_inventory: true
host_expiry_settings:
host_expiry_enabled: true
host_expiry_window: 30
agent_options:
controls:
policies:
queries:
software:
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
install_script:
path: lib/install_ruby.sh
pre_install_query:
path: lib/query_ruby.yml
post_install_script:
path: lib/post_install_ruby.sh

View file

@ -29,6 +29,7 @@ spec:
deadline_days: null
grace_period_days: null
scripts: null
software: null
webhook_settings:
host_status_webhook: null
name: tm1
@ -62,6 +63,7 @@ spec:
deadline_days: null
grace_period_days: null
scripts: null
software: null
webhook_settings:
host_status_webhook: null
name: tm2

View file

@ -29,6 +29,7 @@ spec:
deadline_days: null
grace_period_days: null
scripts: null
software: null
webhook_settings:
host_status_webhook: null
name: tm1
@ -62,6 +63,7 @@ spec:
deadline_days: null
grace_period_days: null
scripts: null
software: null
webhook_settings:
host_status_webhook: null
name: tm2

View file

@ -29,6 +29,7 @@ spec:
windows_settings:
custom_settings: null
scripts: null
software: null
webhook_settings:
host_status_webhook: null
name: tm1

View file

@ -28,6 +28,7 @@ spec:
deadline_days: null
grace_period_days: null
scripts: null
software: null
webhook_settings:
host_status_webhook: null
name: tm1

View file

@ -280,7 +280,7 @@ This activity contains the following fields:
```json
{
"team_id": 123,
"team_name": "foo"
"team_name": "Workstations"
}
```
@ -297,7 +297,7 @@ This activity contains the following fields:
```json
{
"team_id": 123,
"team_name": "foo"
"team_name": "Workstations"
}
```
@ -357,7 +357,7 @@ This activity contains the following fields:
```json
{
"team_id": 123,
"team_name": "foo",
"team_name": "Workstations",
"global": false
}
```
@ -1127,6 +1127,73 @@ This activity contains the following fields:
}
```
## installed_software
Generated when a software is installed on a host.
This activity contains the following fields:
- "host_id": ID of the host.
- "host_display_name": Display name of the host.
- "install_uuid": ID of the software installation.
- "software_title": Name of the software.
- "status": Status of the software installation.
#### Example
```json
{
"host_id": 1,
"host_display_name": "Anna's MacBook Pro",
"software_title": "Falcon.app",
"install_uuid": "d6cffa75-b5b5-41ef-9230-15073c8a88cf",
"status": "pending"
}
```
## added_software
Generated when a software installer is uploaded to Fleet.
This activity contains the following fields:
- "software_title": Name of the software.
- "software_package": Filename of the installer.
- "team_name": Name of the team to which this software was added. `null` if it was added to no team." +
- "team_id": The ID of the team to which this software was added. `null` if it was added to no team.
#### Example
```json
{
"software_title": "Falcon.app",
"software_package": "FalconSensor-6.44.pkg",
"team_name": "Workstations",
"team_id": 123
}
```
## deleted_software
Generated when a software installer is deleted from Fleet.
This activity contains the following fields:
- "software_title": Name of the software.
- "software_package": Filename of the installer.
- "team_name": Name of the team to which this software was added. `null if it was added to no team.
- "team_id": The ID of the team to which this software was added. `null` if it was added to no team.
#### Example
```json
{
"software_title": "Falcon.app",
"software_package": "FalconSensor-6.44.pkg",
"team_name": "Workstations",
"team_id": 123
}
```
<meta name="title" value="Audit logs">
<meta name="pageOrderInSection" value="1400">

View file

@ -45,7 +45,14 @@ SELECT de.encrypted, m.path FROM disk_encryption de JOIN mounts m ON m.device_al
- Query:
```sql
SELECT 1 FROM bitlocker_info WHERE drive_letter = 'C:' AND protection_status = 1;
WITH encrypted(enabled) AS (
SELECT CASE WHEN
NOT EXISTS(SELECT 1 FROM windows_optional_features WHERE name = 'BitLocker')
OR
(SELECT 1 FROM windows_optional_features WHERE name = 'BitLocker' AND state = 1)
THEN (SELECT 1 FROM bitlocker_info WHERE drive_letter = 'C:' AND protection_status = 1)
END)
SELECT 1 FROM encrypted WHERE enabled IS NOT NULL
```
## disk_space_unix
@ -304,7 +311,7 @@ LIMIT 1;
## orbit_info
- Platforms: all
- Platforms: linux, ubuntu, debian, rhel, centos, sles, kali, gentoo, amzn, pop, arch, linuxmint, void, nixos, endeavouros, manjaro, opensuse-leap, opensuse-tumbleweed, darwin, windows
- Discovery query:
```sql
@ -313,7 +320,7 @@ SELECT 1 FROM osquery_registry WHERE active = true AND registry = 'table' AND na
- Query:
```sql
SELECT version FROM orbit_info
SELECT * FROM orbit_info
```
## os_chrome
@ -779,7 +786,7 @@ select * from uptime limit 1
## users
- Platforms: linux, darwin, windows
- Platforms: linux, ubuntu, debian, rhel, centos, sles, kali, gentoo, amzn, pop, arch, linuxmint, void, nixos, endeavouros, manjaro, opensuse-leap, opensuse-tumbleweed, darwin, windows
- Query:
```sql

View file

@ -81,6 +81,7 @@ func setupMockDatastorePremiumService() (*mock.Store, *eeservice.Service, contex
"",
nil,
nil,
nil,
)
if err != nil {
panic(err)

View file

@ -17,17 +17,18 @@ import (
type Service struct {
fleet.Service
ds fleet.Datastore
logger kitlog.Logger
config config.FleetConfig
clock clock.Clock
authz *authz.Authorizer
depStorage storage.AllDEPStorage
mdmAppleCommander fleet.MDMAppleCommandIssuer
mdmPushCertTopic string
ssoSessionStore sso.SessionStore
depService *apple_mdm.DEPService
profileMatcher fleet.ProfileMatcher
ds fleet.Datastore
logger kitlog.Logger
config config.FleetConfig
clock clock.Clock
authz *authz.Authorizer
depStorage storage.AllDEPStorage
mdmAppleCommander fleet.MDMAppleCommandIssuer
mdmPushCertTopic string
ssoSessionStore sso.SessionStore
depService *apple_mdm.DEPService
profileMatcher fleet.ProfileMatcher
softwareInstallStore fleet.SoftwareInstallerStore
}
func NewService(
@ -42,6 +43,7 @@ func NewService(
mdmPushCertTopic string,
sso sso.SessionStore,
profileMatcher fleet.ProfileMatcher,
softwareInstallStore fleet.SoftwareInstallerStore,
) (*Service, error) {
authorizer, err := authz.NewAuthorizer()
if err != nil {
@ -49,18 +51,19 @@ func NewService(
}
eeservice := &Service{
Service: svc,
ds: ds,
logger: logger,
config: config,
clock: c,
authz: authorizer,
depStorage: depStorage,
mdmAppleCommander: mdmAppleCommander,
mdmPushCertTopic: mdmPushCertTopic,
ssoSessionStore: sso,
depService: apple_mdm.NewDEPService(ds, depStorage, logger),
profileMatcher: profileMatcher,
Service: svc,
ds: ds,
logger: logger,
config: config,
clock: c,
authz: authorizer,
depStorage: depStorage,
mdmAppleCommander: mdmAppleCommander,
mdmPushCertTopic: mdmPushCertTopic,
ssoSessionStore: sso,
depService: apple_mdm.NewDEPService(ds, depStorage, logger),
profileMatcher: profileMatcher,
softwareInstallStore: softwareInstallStore,
}
// Override methods that can't be easily overriden via

View file

@ -0,0 +1,518 @@
package service
import (
"bytes"
"context"
"encoding/hex"
"errors"
"fmt"
"io"
"mime"
"net/http"
"net/url"
"path/filepath"
"github.com/fleetdm/fleet/v4/pkg/file"
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
hostctx "github.com/fleetdm/fleet/v4/server/contexts/host"
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/go-kit/log/level"
"golang.org/x/sync/errgroup"
)
func (svc *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) error {
if err := svc.authz.Authorize(ctx, &fleet.SoftwareInstaller{TeamID: payload.TeamID}, fleet.ActionWrite); err != nil {
return err
}
vc, ok := viewer.FromContext(ctx)
if !ok {
return fleet.ErrNoContext
}
// make sure all scripts use unix-style newlines to prevent errors when
// running them, browsers use windows-style newlines, which breaks the
// shebang when the file is directly executed.
payload.InstallScript = file.Dos2UnixNewlines(payload.InstallScript)
payload.PostInstallScript = file.Dos2UnixNewlines(payload.PostInstallScript)
if _, err := svc.addMetadataToSoftwarePayload(ctx, payload); err != nil {
return ctxerr.Wrap(ctx, err, "adding metadata to payload")
}
if err := svc.storeSoftware(ctx, payload); err != nil {
return ctxerr.Wrap(ctx, err, "storing software installer")
}
// TODO: basic validation of install and post-install script (e.g., supported interpreters)?
// TODO: any validation of pre-install query?
installerID, err := svc.ds.MatchOrCreateSoftwareInstaller(ctx, payload)
if err != nil {
return ctxerr.Wrap(ctx, err, "matching or creating software installer")
}
level.Debug(svc.logger).Log("msg", "software installer uploaded", "installer_id", installerID)
// TODO: QA what breaks when you have a software title with no versions?
var teamName *string
if payload.TeamID != nil {
t, err := svc.ds.Team(ctx, *payload.TeamID)
if err != nil {
return err
}
teamName = &t.Name
}
// Create activity
if err := svc.ds.NewActivity(ctx, vc.User, fleet.ActivityTypeAddedSoftware{
SoftwareTitle: payload.Title,
SoftwarePackage: payload.Filename,
TeamName: teamName,
TeamID: payload.TeamID,
}); err != nil {
return ctxerr.Wrap(ctx, err, "creating activity for added software")
}
return nil
}
func (svc *Service) DeleteSoftwareInstaller(ctx context.Context, titleID uint, teamID *uint) error {
if teamID == nil || *teamID == 0 {
return fleet.NewInvalidArgumentError("team_id", "is required and can't be zero")
}
if err := svc.authz.Authorize(ctx, &fleet.SoftwareInstaller{TeamID: teamID}, fleet.ActionWrite); err != nil {
return err
}
meta, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, teamID, titleID, false)
if err != nil {
return ctxerr.Wrap(ctx, err, "getting software installer metadata")
}
vc, ok := viewer.FromContext(ctx)
if !ok {
return fleet.ErrNoContext
}
if err := svc.ds.DeleteSoftwareInstaller(ctx, meta.InstallerID); err != nil {
return ctxerr.Wrap(ctx, err, "deleting software installer")
}
var teamName *string
if meta.TeamID != nil {
t, err := svc.ds.Team(ctx, *meta.TeamID)
if err != nil {
return ctxerr.Wrap(ctx, err, "getting team name for deleted software")
}
teamName = &t.Name
}
if err := svc.ds.NewActivity(ctx, vc.User, fleet.ActivityTypeDeletedSoftware{
SoftwareTitle: meta.SoftwareTitle,
SoftwarePackage: meta.Name,
TeamName: teamName,
TeamID: meta.TeamID,
}); err != nil {
return ctxerr.Wrap(ctx, err, "creating activity for deleted software")
}
return nil
}
func (svc *Service) GetSoftwareInstallerMetadata(ctx context.Context, titleID uint, teamID *uint) (*fleet.SoftwareInstaller, error) {
if err := svc.authz.Authorize(ctx, &fleet.SoftwareInstaller{TeamID: teamID}, fleet.ActionRead); err != nil {
return nil, err
}
meta, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, teamID, titleID, true)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting software installer metadata")
}
return meta, nil
}
func (svc *Service) DownloadSoftwareInstaller(ctx context.Context, titleID uint, teamID *uint) (*fleet.DownloadSoftwareInstallerPayload, error) {
if teamID == nil || *teamID == 0 {
return nil, fleet.NewInvalidArgumentError("team_id", "is required and can't be zero")
}
meta, err := svc.GetSoftwareInstallerMetadata(ctx, titleID, teamID)
if err != nil {
return nil, err
}
return svc.getSoftwareInstallerBinary(ctx, meta.StorageID, meta.Name)
}
func (svc *Service) OrbitDownloadSoftwareInstaller(ctx context.Context, installerID uint) (*fleet.DownloadSoftwareInstallerPayload, error) {
// this is not a user-authenticated endpoint
svc.authz.SkipAuthorization(ctx)
_, ok := hostctx.FromContext(ctx)
if !ok {
return nil, fleet.OrbitError{Message: "internal error: missing host from request context"}
}
// get the installer's metadata
meta, err := svc.ds.GetSoftwareInstallerMetadataByID(ctx, installerID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting software installer metadata")
}
// Note that we do allow downloading an installer that is on a different team
// than the host's team, because the install request might have come while
// the host was on that team, and then the host got moved to a different team
// but the request is still pending execution.
return svc.getSoftwareInstallerBinary(ctx, meta.StorageID, meta.Name)
}
func (svc *Service) getSoftwareInstallerBinary(ctx context.Context, storageID string, filename string) (*fleet.DownloadSoftwareInstallerPayload, error) {
// check if the installer exists in the store
exists, err := svc.softwareInstallStore.Exists(ctx, storageID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "checking if installer exists")
}
if !exists {
return nil, ctxerr.Wrap(ctx, err, "does not exist in software installer store")
}
// get the installer from the store
installer, size, err := svc.softwareInstallStore.Get(ctx, storageID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting installer from store")
}
return &fleet.DownloadSoftwareInstallerPayload{
Filename: filename,
Installer: installer,
Size: size,
}, nil
}
func (svc *Service) InstallSoftwareTitle(ctx context.Context, hostID uint, softwareTitleID uint) error {
// we need to use ds.Host because ds.HostLite doesn't return the orbit
// node key
host, err := svc.ds.Host(ctx, hostID)
if err != nil {
// if error is because the host does not exist, check first if the user
// had access to install software (to prevent leaking valid host ids).
if fleet.IsNotFound(err) {
if err := svc.authz.Authorize(ctx, &fleet.HostSoftwareInstallerResultAuthz{}, fleet.ActionWrite); err != nil {
return err
}
}
svc.authz.SkipAuthorization(ctx)
return ctxerr.Wrap(ctx, err, "get host")
}
if host.OrbitNodeKey == nil || *host.OrbitNodeKey == "" {
// fleetd is required to install software so if the host is
// enrolled via plain osquery we return an error
svc.authz.SkipAuthorization(ctx)
// TODO(roberto): for cleanup task, confirm with product error message.
return fleet.NewUserMessageError(errors.New("Host doesn't have fleetd installed"), http.StatusUnprocessableEntity)
}
// authorize with the host's team
if err := svc.authz.Authorize(ctx, &fleet.HostSoftwareInstallerResultAuthz{HostTeamID: host.TeamID}, fleet.ActionWrite); err != nil {
return err
}
installer, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, host.TeamID, softwareTitleID, false)
if err != nil {
if fleet.IsNotFound(err) {
return &fleet.BadRequestError{
Message: "Software title has no package added. Please add software package to install.",
InternalErr: ctxerr.WrapWithData(
ctx, err, "couldn't find an installer for software title",
map[string]any{"host_id": host.ID, "team_id": host.TeamID, "title_id": softwareTitleID},
),
}
}
return ctxerr.Wrap(ctx, err, "finding software installer for title")
}
ext := filepath.Ext(installer.Name)
var requiredPlatform string
switch ext {
case ".msi", ".exe":
requiredPlatform = "windows"
case ".pkg":
requiredPlatform = "darwin"
case ".deb":
requiredPlatform = "linux"
default:
// this should never happen
return ctxerr.Errorf(ctx, "software installer has unsupported type %s", ext)
}
if host.FleetPlatform() != requiredPlatform {
return &fleet.BadRequestError{
Message: fmt.Sprintf("Package (%s) can be installed only on %s hosts.", ext, requiredPlatform),
InternalErr: ctxerr.WrapWithData(
ctx, err, "invalid host platform for requested installer",
map[string]any{"host_id": host.ID, "team_id": host.TeamID, "title_id": softwareTitleID},
),
}
}
_, err = svc.ds.InsertSoftwareInstallRequest(ctx, hostID, installer.InstallerID)
return ctxerr.Wrap(ctx, err, "inserting software install request")
}
func (svc *Service) GetSoftwareInstallResults(ctx context.Context, resultUUID string) (*fleet.HostSoftwareInstallerResult, error) {
// Basic auth check
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
return nil, err
}
res, err := svc.ds.GetSoftwareInstallResults(ctx, resultUUID)
if err != nil {
return nil, err
}
// Team specific auth check
if err := svc.authz.Authorize(ctx, &fleet.HostSoftwareInstallerResultAuthz{HostTeamID: res.HostTeamID}, fleet.ActionRead); err != nil {
return nil, err
}
res.EnhanceOutputDetails()
return res, nil
}
func (svc *Service) storeSoftware(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) error {
// check if exists in the installer store
exists, err := svc.softwareInstallStore.Exists(ctx, payload.StorageID)
if err != nil {
return ctxerr.Wrap(ctx, err, "checking if installer exists")
}
if !exists {
if err := svc.softwareInstallStore.Put(ctx, payload.StorageID, payload.InstallerFile); err != nil {
return ctxerr.Wrap(ctx, err, "storing installer")
}
}
return nil
}
func (svc *Service) addMetadataToSoftwarePayload(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) (extension string, err error) {
if payload == nil {
return "", ctxerr.New(ctx, "payload is required")
}
if payload.InstallerFile == nil {
return "", ctxerr.New(ctx, "installer file is required")
}
title, vers, ext, hash, err := file.ExtractInstallerMetadata(payload.InstallerFile)
if err != nil {
if errors.Is(err, file.ErrUnsupportedType) {
return "", &fleet.BadRequestError{
Message: "Couldn't edit software. File type not supported. The file should be .pkg, .msi, .exe or .deb.",
InternalErr: ctxerr.Wrap(ctx, err, "extracting metadata from installer"),
}
}
return "", ctxerr.Wrap(ctx, err, "extracting metadata from installer")
}
if title == "" {
// use the filename if no title from metadata
title = payload.Filename
}
payload.Title = title
payload.Version = vers
payload.StorageID = hex.EncodeToString(hash)
// reset the reader (it was consumed to extract metadata)
if _, err := payload.InstallerFile.Seek(0, 0); err != nil {
return "", ctxerr.Wrap(ctx, err, "resetting installer file reader")
}
if payload.InstallScript == "" {
payload.InstallScript = file.GetInstallScript(ext)
}
source, err := fleet.SofwareInstallerSourceFromExtension(ext)
if err != nil {
return "", ctxerr.Wrap(ctx, err, "determining source from extension")
}
payload.Source = source
platform, err := fleet.SofwareInstallerPlatformFromExtension(ext)
if err != nil {
return "", ctxerr.Wrap(ctx, err, "determining platform from extension")
}
payload.Platform = platform
return ext, nil
}
const maxInstallerSizeBytes int64 = 1024 * 1024 * 500
func (svc *Service) BatchSetSoftwareInstallers(ctx context.Context, tmName string, payloads []fleet.SoftwareInstallerPayload, dryRun bool) error {
if tmName == "" {
svc.authz.SkipAuthorization(ctx) // so that the error message is not replaced by "forbidden"
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("team_name", "must not be empty"))
}
if err := svc.authz.Authorize(ctx, &fleet.Team{}, fleet.ActionRead); err != nil {
return err
}
tm, err := svc.ds.TeamByName(ctx, tmName)
if err != nil {
// If this is a dry run, the team may not have been created yet
if dryRun && fleet.IsNotFound(err) {
return nil
}
return err
}
if err := svc.authz.Authorize(ctx, &fleet.SoftwareInstaller{TeamID: &tm.ID}, fleet.ActionWrite); err != nil {
return ctxerr.Wrap(ctx, err, "validating authorization")
}
g, workerCtx := errgroup.WithContext(ctx)
g.SetLimit(3)
// critical to avoid data race, the slice is pre-allocated and each
// goroutine only writes to its index.
installers := make([]*fleet.UploadSoftwareInstallerPayload, len(payloads))
client := fleethttp.NewClient()
client.Transport = fleethttp.NewSizeLimitTransport(maxInstallerSizeBytes)
for i, p := range payloads {
i, p := i, p
g.Go(func() error {
// validate the URL before doing the request
_, err := url.ParseRequestURI(p.URL)
if err != nil {
return fleet.NewInvalidArgumentError(
"software.url",
fmt.Sprintf("Couldn't edit software. URL (%q) is invalid", p.URL),
)
}
req, err := http.NewRequestWithContext(workerCtx, http.MethodGet, p.URL, nil)
if err != nil {
return ctxerr.Wrapf(ctx, err, "creating request for URL %s", p.URL)
}
resp, err := client.Do(req)
if err != nil {
var maxBytesErr *http.MaxBytesError
if errors.Is(err, fleethttp.ErrMaxSizeExceeded) || errors.As(err, &maxBytesErr) {
return fleet.NewInvalidArgumentError(
"software.url",
fmt.Sprintf("Couldn't edit software. URL (%q). The maximum file size is %d MB", p.URL, maxInstallerSizeBytes/(1024*1024)),
)
}
return ctxerr.Wrapf(ctx, err, "performing request for URL %s", p.URL)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return fleet.NewInvalidArgumentError(
"software.url",
fmt.Sprintf("Couldn't edit software. URL (%q) doesn't exist. Please make sure that URLs are publicy accessible to the internet.", p.URL),
)
}
// Allow all 2xx and 3xx status codes in this pass.
if resp.StatusCode > 400 {
return fleet.NewInvalidArgumentError(
"software.url",
fmt.Sprintf("Couldn't edit software. URL (%q) received response status code %d.", p.URL, resp.StatusCode),
)
}
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
// the max size error can be received either at client.Do or here when
// reading the body if it's caught via a limited body reader.
var maxBytesErr *http.MaxBytesError
if errors.Is(err, fleethttp.ErrMaxSizeExceeded) || errors.As(err, &maxBytesErr) {
return fleet.NewInvalidArgumentError(
"software.url",
fmt.Sprintf("Couldn't edit software. URL (%q). The maximum file size is %d MB", p.URL, maxInstallerSizeBytes/(1024*1024)),
)
}
return ctxerr.Wrapf(ctx, err, "reading installer %q contents", p.URL)
}
installer := &fleet.UploadSoftwareInstallerPayload{
TeamID: &tm.ID,
InstallScript: p.InstallScript,
PreInstallQuery: p.PreInstallQuery,
PostInstallScript: p.PostInstallScript,
InstallerFile: bytes.NewReader(bodyBytes),
}
// set the filename before adding metadata, as it is used as fallback
var filename string
cdh, ok := resp.Header["Content-Disposition"]
if ok && len(cdh) > 0 {
_, params, err := mime.ParseMediaType(cdh[0])
if err == nil {
filename = params["filename"]
}
}
installer.Filename = filename
ext, err := svc.addMetadataToSoftwarePayload(ctx, installer)
if err != nil {
return err
}
// if filename was empty, try to extract it from the URL with the
// now-known extension
if filename == "" {
filename = file.ExtractFilenameFromURLPath(p.URL, ext)
}
// if empty, resort to a default name
if filename == "" {
filename = fmt.Sprintf("package.%s", ext)
}
installer.Filename = filename
if installer.Title == "" {
installer.Title = filename
}
installers[i] = installer
return nil
})
}
if err := g.Wait(); err != nil {
// NOTE: intentionally not wrapping to avoid polluting user
// errors.
return err
}
if dryRun {
return nil
}
for _, payload := range installers {
if err := svc.storeSoftware(ctx, payload); err != nil {
return ctxerr.Wrap(ctx, err, "storing software installer")
}
}
if err := svc.ds.BatchSetSoftwareInstallers(ctx, &tm.ID, installers); err != nil {
return ctxerr.Wrap(ctx, err, "batch set software installers")
}
// Note: per @noahtalerman we don't want activity items for CLI actions
// anymore, so that's intentionally skipped.
return nil
}

View file

@ -1085,6 +1085,10 @@ func (svc *Service) editTeamFromSpec(
team.Config.Scripts = spec.Scripts
}
if spec.Software.Set {
team.Config.Software = spec.Software
}
if len(secrets) > 0 {
team.Secrets = secrets
}

View file

@ -4,6 +4,8 @@ import { pick } from "lodash";
import { normalizeEmptyValues } from "utilities/helpers";
import { HOST_SUMMARY_DATA } from "utilities/constants";
import { IGetHostSoftwareResponse } from "services/entities/hosts";
import { IHostSoftware } from "interfaces/software";
const DEFAULT_HOST_PROFILE_MOCK: IHostMdmProfile = {
profile_uuid: "123-abc",
@ -116,4 +118,52 @@ export const createMockHostSummary = (overrides?: Partial<IHost>) => {
);
};
const DEFAULT_HOST_SOFTWARE_MOCK: IHostSoftware = {
id: 1,
name: "mock software.app",
package_available_for_install: "mockSoftware.app",
source: "apps",
bundle_identifier: "com.test.mock",
status: "installed",
last_install: {
install_uuid: "123-abc",
installed_at: "2022-01-01T12:00:00Z",
},
installed_versions: [
{
version: "1.0.0",
last_opened_at: "2022-01-01T12:00:00Z",
vulnerabilities: ["CVE-2020-0001"],
installed_paths: ["/Applications/mock.app"],
},
],
};
export const createMockHostSoftware = (
overrides?: Partial<IHostSoftware>
): IHostSoftware => {
return {
...DEFAULT_HOST_SOFTWARE_MOCK,
...overrides,
};
};
const DEFAULT_GET_HOST_SOFTWARE_RESPONSE_MOCK: IGetHostSoftwareResponse = {
count: 1,
software: [createMockHostSoftware()],
meta: {
has_next_results: false,
has_previous_results: false,
},
};
export const createMockGetHostSoftwareResponse = (
overrides?: Partial<IGetHostSoftwareResponse>
): IGetHostSoftwareResponse => {
return {
...DEFAULT_GET_HOST_SOFTWARE_RESPONSE_MOCK,
...overrides,
};
};
export default createMockHost;

View file

@ -4,6 +4,7 @@ import {
ISoftwareTitle,
ISoftwareVulnerability,
ISoftwareTitleVersion,
ISoftwarePackage,
} from "interfaces/software";
import {
ISoftwareTitlesResponse,
@ -45,6 +46,7 @@ export const createMockSoftwareTitleVersion = (
const DEFAULT_SOFTWARE_TITLE_MOCK: ISoftwareTitle = {
id: 1,
name: "mock software 1.app",
software_package: null,
versions_count: 1,
source: "apps",
hosts_count: 1,
@ -147,3 +149,24 @@ export const createMockSoftwareVersionResponse = (
): ISoftwareVersionResponse => {
return { ...DEFAULT_SOFTWARE_VERSION_RESPONSE, ...overrides };
};
const DEFAULT_SOFTWAREPACKAGE_MOCK: ISoftwarePackage = {
name: "TestPackage-1.2.3.pkg",
version: "1.2.3",
uploaded_at: "2020-01-01T00:00:00.000Z",
install_script: "sudo installer -pkg /temp/FalconSensor-6.44.pkg -target /",
pre_install_query: "SELECT 1 FROM macos_profiles WHERE uuid='abc123';",
post_install_script:
"sudo /Applications/Falcon.app/Contents/Resources/falconctl license abc123",
status: {
installed: 1,
pending: 2,
failed: 3,
},
};
export const createMockSoftwarePackage = (
overrides?: Partial<ISoftwarePackage>
) => {
return { ...DEFAULT_SOFTWAREPACKAGE_MOCK, ...overrides };
};

View file

@ -1,15 +1,19 @@
import React from "react";
import classnames from "classnames";
const baseClass = "data-set";
interface IDataSetProps {
title: React.ReactNode;
value: React.ReactNode;
className?: string;
}
const DataSet = ({ title, value }: IDataSetProps) => {
const DataSet = ({ title, value, className }: IDataSetProps) => {
const classNames = classnames(baseClass, className);
return (
<div className={`${baseClass}`}>
<div className={classNames}>
<dt>{title}</dt>
<dd>{value}</dd>
</div>

View file

@ -0,0 +1,120 @@
import classnames from "classnames";
import TooltipWrapper from "components/TooltipWrapper";
import React, { ReactNode } from "react";
import AceEditor from "react-ace";
const baseClass = "editor";
interface IEditorProps {
focus?: boolean;
label?: string;
labelTooltip?: string | JSX.Element;
error?: string | null;
readOnly?: boolean;
/**
* Help text to display below the editor.
*/
helpText?: ReactNode;
/** Sets the value of the input. Use this if you'd like the editor
* to be a controlled component */
value?: string;
/** Sets the default value of the input. Use this if you'd like the editor
* to be an uncontrolled component */
defaultValue?: string;
/** Enabled wrapping lines.
* @default false
*/
wrapEnabled?: boolean;
/** A unique name for the editor.
* @default "editor"
*/
name?: string;
maxLines?: number;
className?: string;
onChange?: (value: string, event?: any) => void;
}
/**
* This component is a generic editor that uses the AceEditor component.
* TODO: We should move FleetAce and YamlAce into here and deprecate importing
* them directly. This component should be used for all editor components and
* be configurable from the props. We should look into dynmaic imports for
* this.
*/
const Editor = ({
helpText,
label,
labelTooltip,
error,
focus,
value,
defaultValue,
readOnly = false,
wrapEnabled = false,
name = "editor",
maxLines = 20,
className,
onChange,
}: IEditorProps) => {
const classNames = classnames(baseClass, className, {
[`${baseClass}__error`]: !!error,
});
const renderLabel = () => {
const labelText = error || label;
const labelClassName = classnames(`${baseClass}__label`, {
[`${baseClass}__label--error`]: !!error,
});
if (!labelText) {
return null;
}
if (labelTooltip) {
return (
<TooltipWrapper
className={labelClassName}
tipContent={labelTooltip}
position="top"
>
{labelText}
</TooltipWrapper>
);
}
return <div className={labelClassName}>{labelText}</div>;
};
const renderHelpText = () => {
if (helpText) {
return <div className={`${baseClass}__help-text`}>{helpText}</div>;
}
return null;
};
return (
<div className={classNames}>
{renderLabel()}
<AceEditor
wrapEnabled={wrapEnabled}
name={name}
className={baseClass}
fontSize={14}
theme="fleet"
width="100%"
readOnly={readOnly}
minLines={2}
maxLines={maxLines}
editorProps={{ $blockScrolling: Infinity }}
value={value}
defaultValue={defaultValue}
tabSize={2}
focus={focus}
onChange={onChange}
/>
{renderHelpText()}
</div>
);
};
export default Editor;

View file

@ -0,0 +1,22 @@
.editor {
&__label {
font-size: $x-small;
font-weight: $bold;
margin-bottom: $pad-small;
&--error {
color: $core-vibrant-red;
}
}
&__help-text {
@include help-text;
}
&__error {
.ace-fleet {
border: 1px solid $core-vibrant-red;
}
}
}

View file

@ -0,0 +1 @@
export { default } from "./Editor";

View file

@ -1,10 +1,11 @@
import React from "react";
import React, { ReactNode, useState } from "react";
import classnames from "classnames";
import Button from "components/buttons/Button";
import Card from "components/Card";
import { GraphicNames } from "components/graphics";
import Graphic from "components/Graphic";
import Icon from "components/Icon";
const baseClass = "file-uploader";
@ -32,9 +33,20 @@ interface IFileUploaderProps {
* https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/accept
*/
accept?: string;
/** The text to display on the upload button */
/** The text to display on the upload button
* @default "Upload"
*/
buttonMessage?: string;
className?: string;
/** renders the button to open the file uploader to appear as a button or
* a link.
* @default "button"
*/
buttonType?: "button" | "link";
/** If provided FileUploader will display this component when the file is
* selected. This is used for previewing the file before uploading.
*/
filePreview?: ReactNode;
onFileUpload: (files: FileList | null) => void;
}
@ -47,11 +59,26 @@ const FileUploader = ({
additionalInfo,
isLoading = false,
accept,
buttonMessage = "Upload",
filePreview,
className,
buttonMessage = "Upload",
buttonType = "button",
onFileUpload,
}: IFileUploaderProps) => {
const classes = classnames(baseClass, className);
const [isFileSelected, setIsFileSelected] = useState(false);
const classes = classnames(baseClass, className, {
[`${baseClass}__file-preview`]: filePreview !== undefined && isFileSelected,
});
const buttonVariant = buttonType === "button" ? "brand" : "text-icon";
const onFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
onFileUpload(files);
setIsFileSelected(true);
e.target.value = "";
};
const renderGraphics = () => {
const graphicNamesArr =
@ -64,29 +91,36 @@ const FileUploader = ({
/>
));
};
return (
<Card color="gray" className={classes}>
<div className={`${baseClass}__graphics`}>{renderGraphics()}</div>
<p className={`${baseClass}__message`}>{message}</p>
{additionalInfo && (
<p className={`${baseClass}__additional-info`}>{additionalInfo}</p>
{isFileSelected && filePreview ? (
filePreview
) : (
<>
<div className={`${baseClass}__graphics`}>{renderGraphics()}</div>
<p className={`${baseClass}__message`}>{message}</p>
{additionalInfo && (
<p className={`${baseClass}__additional-info`}>{additionalInfo}</p>
)}
<Button
className={`${baseClass}__upload-button`}
variant={buttonVariant}
isLoading={isLoading}
>
<label htmlFor="upload-file">
{buttonType === "link" && <Icon name="upload" />}
<span>{buttonMessage}</span>
</label>
</Button>
<input
accept={accept}
id="upload-file"
type="file"
onChange={onFileSelect}
/>
</>
)}
<Button
className={`${baseClass}__upload-button`}
variant="brand"
isLoading={isLoading}
>
<label htmlFor="upload-file">{buttonMessage}</label>
</Button>
<input
accept={accept}
id="upload-file"
type="file"
onChange={(e) => {
onFileUpload(e.target.files);
e.target.value = "";
}}
/>
</Card>
);
};

View file

@ -10,6 +10,12 @@
text-align: center;
gap: $pad-small;
// when the file preview is showing, we want the padding to be
// slightly smaller on the top and bottom.
&__file-preview {
padding: $pad-medium $pad-large;
}
&__graphics {
display: flex;
align-items: center;
@ -39,6 +45,7 @@
display: flex;
align-items: center;
justify-content: center;
gap: $pad-small;
&:hover {
cursor: pointer;

View file

@ -1,4 +1,4 @@
import React, { useCallback, useRef } from "react";
import React, { ReactNode, useCallback, useRef } from "react";
import AceEditor from "react-ace";
import ReactAce from "react-ace/lib/ace";
import { IAceEditor } from "react-ace/lib/types";
@ -30,10 +30,13 @@ export interface IFleetAceProps {
name?: string;
value?: string;
readOnly?: boolean;
maxLines?: number;
showGutter?: boolean;
wrapEnabled?: boolean;
/** @deprecated use the prop `className` instead */
wrapperClassName?: string;
helpText?: string;
className?: string;
helpText?: ReactNode;
labelActionComponent?: React.ReactNode;
style?: React.CSSProperties;
onBlur?: (editor?: IAceEditor) => void;
@ -53,9 +56,11 @@ const FleetAce = ({
name = "query-editor",
value,
readOnly,
maxLines = 20,
showGutter = true,
wrapEnabled = false,
wrapperClassName,
className,
helpText,
style,
onBlur,
@ -64,7 +69,7 @@ const FleetAce = ({
handleSubmit = noop,
}: IFleetAceProps): JSX.Element => {
const editorRef = useRef<ReactAce>(null);
const wrapperClass = classnames(wrapperClassName, baseClass, {
const wrapperClass = classnames(className, wrapperClassName, baseClass, {
[`${baseClass}__wrapper--error`]: !!error,
});
@ -250,7 +255,7 @@ const FleetAce = ({
fontSize={fontSize}
mode="fleet"
minLines={2}
maxLines={20}
maxLines={maxLines}
name={name}
onChange={onChange}
onBlur={onBlurHandler}

View file

@ -0,0 +1,90 @@
import React from "react";
import { InjectedRouter } from "react-router";
import ReactTooltip from "react-tooltip";
import { uniqueId } from "lodash";
import Icon from "components/Icon";
import SoftwareIcon from "pages/SoftwarePage/components/icons/SoftwareIcon";
import LinkCell from "../LinkCell";
const baseClass = "software-name-cell";
const InstallIconWithTooltip = () => {
const tooltipId = uniqueId();
return (
<div className={`${baseClass}__install-icon-with-tooltip`}>
<div
className={`${baseClass}__install-icon-tooltip`}
data-tip
data-for={tooltipId}
>
<Icon name="install" className={`${baseClass}__install-icon`} />
</div>
<ReactTooltip
className={`${baseClass}__install-tooltip`}
place="top"
effect="solid"
backgroundColor="#3e4771"
id={tooltipId}
data-html
>
<span className={`${baseClass}__install-tooltip-text`}>
Software can be installed on Host details page.
</span>
</ReactTooltip>
</div>
);
};
interface ISoftwareNameCellProps {
name: string;
source: string;
path?: string;
router?: InjectedRouter;
hasPackage?: boolean;
}
const SoftwareNameCell = ({
name,
source,
path,
router,
hasPackage = false,
}: ISoftwareNameCellProps) => {
// NO path or router means it's not clickable. return
// a non-clickable cell early
if (!router || !path) {
return (
<div className={baseClass}>
<SoftwareIcon name={name} source={source} />
<span className="software-name">{name}</span>
</div>
);
}
const onClickSoftware = (e: React.MouseEvent) => {
// Allows for button to be clickable in a clickable row
e.stopPropagation();
router.push(path);
};
return (
<LinkCell
className={baseClass}
path={path}
customOnClick={onClickSoftware}
value={
<>
<SoftwareIcon name={name} source={source} />
<span className="software-name">{name}</span>
{hasPackage && <InstallIconWithTooltip />}
</>
}
/>
);
};
export default SoftwareNameCell;

View file

@ -0,0 +1,27 @@
.software-name-cell {
// TODO: we do not want to use !important but have to for now. We need to pull
// the .link-cell styles into the LinkCell component in order to
// decrease the specificity of the styles. This will allow us to remove the
// !important from here.
display: flex !important;
align-items: center;
gap: $pad-small;
.software-icon {
width: 24px;
height: 24px;
border: 1px solid $ui-fleet-black-10;
border-radius: 8px;
}
&__install-icon {
// TODO: we do not want to use !important but have to for now. This is
// the same issue as the .software-name-cell class display value.
display: inline-flex !important;
}
&__install-tooltip-text {
font-weight: $regular;
font-size: $xx-small;
}
}

View file

@ -0,0 +1 @@
export { default } from "./SoftwareNameCell";

View file

@ -51,7 +51,7 @@ const TextCell = ({
};
return (
<span className={`text-cell ${classes} ${greyed && "grey-cell"}`}>
<span className={`text-cell ${classes} ${greyed ? "grey-cell" : ""}`}>
{formatter(val) || renderEmptyCell()}
</span>
);

View file

@ -68,6 +68,9 @@ interface ITableContainerProps<T = any> {
primarySelectAction?: IActionButtonProps;
/** Secondary button/s after selecting a row */
secondarySelectActions?: IActionButtonProps[]; // TODO: Combine with primarySelectAction as these are all rendered in the same spot
/**
* @deprecated please use renderCount instead
* */
filteredCount?: number;
searchToolTipText?: string;
searchQueryColumn?: string;

View file

@ -6,6 +6,7 @@ interface IDownload {
color?: Colors;
size?: IconSizes;
}
const Download = ({
color = "ui-fleet-black-75",
size = "medium",

View file

@ -0,0 +1,40 @@
import React from "react";
import { COLORS, Colors } from "styles/var/colors";
import { ICON_SIZES, IconSizes } from "styles/var/icon_sizes";
interface IInstallProps {
color?: Colors;
size?: IconSizes;
}
const Install = ({
color = "ui-fleet-black-50",
size = "medium",
}: IInstallProps) => {
return (
<svg
width={ICON_SIZES[size]}
height={ICON_SIZES[size]}
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g
clipPath="url(#a)"
fillRule="evenodd"
clipRule="evenodd"
fill={COLORS[color]}
>
<path d="M8 14A6 6 0 1 0 8 2a6 6 0 0 0 0 12Zm0 2A8 8 0 1 0 8 0a8 8 0 0 0 0 16Z" />
<path d="M8 3.5a1 1 0 0 1 1 1v4.865l1.36-1.133a1 1 0 1 1 1.28 1.536l-3 2.5a1 1 0 0 1-1.28 0l-3-2.5a1 1 0 1 1 1.28-1.536L7 9.365V4.5a1 1 0 0 1 1-1Z" />
</g>
<defs>
<clipPath id="a">
<path fill="#fff" d="M0 0h16v16H0z" />
</clipPath>
</defs>
</svg>
);
};
export default Install;

View file

@ -0,0 +1,32 @@
import React from "react";
import { COLORS, Colors } from "styles/var/colors";
import { ICON_SIZES, IconSizes } from "styles/var/icon_sizes";
interface ISettingsProps {
color?: Colors;
size?: IconSizes;
}
const Settings = ({
size = "medium",
color = "ui-fleet-black-75",
}: ISettingsProps) => {
return (
<svg
width={ICON_SIZES[size]}
height={ICON_SIZES[size]}
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M6.39.876A1 1 0 0 1 7.384 0h1.234a1 1 0 0 1 .992.876l.175 1.394c.35.109.687.249 1.006.417l1.11-.863a1 1 0 0 1 1.32.083l.873.873a1 1 0 0 1 .082 1.32l-.862 1.11c.168.32.308.656.417 1.006l1.394.175a1 1 0 0 1 .876.992v1.234a1 1 0 0 1-.876.992l-1.394.175c-.109.35-.249.687-.417 1.006l.862 1.11a1 1 0 0 1-.082 1.32l-.873.873a1 1 0 0 1-1.32.082l-1.11-.862c-.32.168-.656.308-1.006.417l-.175 1.394a1 1 0 0 1-.992.876H7.383a1 1 0 0 1-.992-.876l-.175-1.394a5.959 5.959 0 0 1-1.006-.417l-1.11.862a1 1 0 0 1-1.32-.082l-.873-.873a1 1 0 0 1-.083-1.32l.863-1.11a5.962 5.962 0 0 1-.417-1.006L.876 9.609A1 1 0 0 1 0 8.617V7.383a1 1 0 0 1 .876-.992l1.394-.175c.109-.35.249-.687.417-1.006L1.824 4.1a1 1 0 0 1 .083-1.32l.873-.873a1 1 0 0 1 1.32-.083l1.11.863c.32-.168.656-.308 1.006-.417L6.391.876ZM4 8a4 4 0 1 0 6.831-2.826l-.005-.005A4 4 0 0 0 4 8Z"
fill={COLORS[color]}
/>
</svg>
);
};
export default Settings;

View file

@ -56,6 +56,8 @@ import Profile from "./Profile";
import Download from "./Download";
import Upload from "./Upload";
import Refresh from "./Refresh";
import Install from "./Install";
import Settings from "./Settings";
// a mapping of the usable names of icons to the icon source.
export const ICON_MAP = {
@ -116,6 +118,8 @@ export const ICON_MAP = {
download: Download,
upload: Upload,
refresh: Refresh,
install: Install,
settings: Settings,
};
export type IconNames = keyof typeof ICON_MAP;

View file

@ -1,6 +1,6 @@
import { useCallback, useContext, useEffect, useMemo } from "react";
import { InjectedRouter } from "react-router";
import { findLastIndex, trimStart } from "lodash";
import { findLastIndex, over, trimStart } from "lodash";
import { AppContext } from "context/app";
import { TableContext } from "context/table";
@ -16,6 +16,30 @@ import {
import { IUser, IUserRole } from "interfaces/user";
import permissions from "utilities/permissions";
import sort from "utilities/sort";
import { HOSTS_QUERY_PARAMS } from "services/entities/hosts";
type OnTeamChangeFuncShouldStripParam = (
teamIdForApi: number | undefined
) => boolean;
type OnTeamChangeFuncShouldReplaceParam = (
teamIdForApi: number | undefined
) => [boolean, string];
/**
* This type is used to define functions that determine whether a query parameter should be stripped or replaced
* when the team id changes.
*
* The key is the name of the query parameter and the value is a function that receives the new team
* id with a return type of either:
* - boolean indicating whether the query parameter should be stripped
* - tuple of a boolean and a string, where the boolean indicates whether the query parameter should be replaced
* and the string is the new value for the query parameter
*/
export type IConfigOverrideParamsOnTeamChange = Record<
string,
OnTeamChangeFuncShouldReplaceParam | OnTeamChangeFuncShouldStripParam
>;
const splitQueryStringParts = (queryString: string) =>
trimStart(queryString, "?")
@ -27,7 +51,8 @@ const joinQueryStringParts = (parts: string[]) =>
const rebuildQueryStringWithTeamId = (
queryString: string,
newTeamId: number
newTeamId: number,
configAdditionalParams?: IConfigOverrideParamsOnTeamChange
) => {
const parts = splitQueryStringParts(queryString);
@ -67,6 +92,41 @@ const rebuildQueryStringWithTeamId = (
parts.splice(teamIndex, 1); // just remove the old team part
}
if (configAdditionalParams) {
Object.entries(configAdditionalParams).forEach(([paramName, fn]) => {
let shouldStrip = false;
let shouldReplace = false;
let replaceString = "";
const val = fn(newTeamId);
if (Array.isArray(val)) {
[shouldReplace, replaceString] = val;
} else if (typeof val === "boolean") {
shouldStrip = val;
}
if (shouldStrip || shouldReplace) {
const paramIndex = parts.findIndex((p) =>
p.startsWith(`${paramName}=`)
);
if (shouldStrip && paramIndex !== -1) {
parts.splice(paramIndex, 1);
return;
}
if (shouldReplace) {
const newPart = `${paramName}=${replaceString}`;
if (paramIndex === -1) {
parts.splice(paramIndex, 1, newPart);
} else {
parts.push(newPart);
}
}
}
});
}
return joinQueryStringParts(parts);
};
@ -223,6 +283,7 @@ export const useTeamIdParam = ({
includeNoTeam,
permittedAccessByTeamRole,
resetSelectedRowsOnTeamChange = true,
overrideParamsOnTeamChange,
}: {
location?: {
pathname: string;
@ -235,6 +296,7 @@ export const useTeamIdParam = ({
includeNoTeam: boolean;
permittedAccessByTeamRole?: Record<IUserRole, boolean>;
resetSelectedRowsOnTeamChange?: boolean;
overrideParamsOnTeamChange?: IConfigOverrideParamsOnTeamChange;
}) => {
const { hash, pathname, query, search } = location;
const {
@ -282,11 +344,18 @@ export const useTeamIdParam = ({
router.replace(
pathname
.concat(rebuildQueryStringWithTeamId(search, teamId))
.concat(
rebuildQueryStringWithTeamId(
search,
teamId,
overrideParamsOnTeamChange
)
)
.concat(hash || "")
);
},
[
overrideParamsOnTeamChange,
resetSelectedRowsOnTeamChange,
router,
pathname,

View file

@ -71,13 +71,17 @@ export enum ActivityType {
DeletedDeclarationProfile = "deleted_declaration_profile",
EditedDeclarationProfile = "edited_declaration_profile",
ResentConfigurationProfile = "resent_configuration_profile",
AddedSoftware = "added_software",
DeletedSoftware = "deleted_software",
InstalledSoftware = "installed_software",
}
// This is a subset of ActivityType that are shown only for the host past activities
export type IHostPastActivityType =
export type IHostActivityType =
| ActivityType.RanScript
| ActivityType.LockedHost
| ActivityType.UnlockedHost;
| ActivityType.UnlockedHost
| ActivityType.InstalledSoftware;
export interface IActivity {
created_at: string;
@ -90,8 +94,9 @@ export interface IActivity {
details?: IActivityDetails;
}
export type IPastActivity = Omit<IActivity, "type"> & {
type: IHostPastActivityType;
export type IHostActivity = Omit<IActivity, "type" | "details"> & {
type: IHostActivityType;
details: IActivityDetails;
};
export interface IActivityDetails {
@ -118,6 +123,7 @@ export interface IActivityDetails {
host_display_name?: string;
host_display_names?: string[];
host_ids?: number[];
host_id?: number;
host_platform?: string;
installed_from_dep?: boolean;
mdm_platform?: "microsoft" | "apple";
@ -132,5 +138,8 @@ export interface IActivityDetails {
deadline_days?: number;
grace_period_days?: number;
stats?: ISchedulableQueryStats;
host_id?: number;
software_title?: string;
software_package?: string;
status?: string;
install_uuid?: string;
}

View file

@ -50,9 +50,24 @@ export interface ISoftwareTitleVersion {
hosts_count?: number;
}
export interface ISoftwarePackage {
name: string;
version: string;
uploaded_at: string;
install_script: string;
pre_install_query?: string;
post_install_script?: string;
status: {
installed: number;
pending: number;
failed: number;
};
}
export interface ISoftwareTitle {
id: number;
name: string;
software_package: ISoftwarePackage | null;
versions_count: number;
source: string;
hosts_count: number;
@ -123,7 +138,7 @@ export const formatSoftwareType = ({
browser,
}: {
source: string;
browser: string;
browser?: string;
}) => {
let type = SOURCE_TYPE_CONVERSION[source] || "Unknown";
if (browser) {
@ -133,3 +148,71 @@ export const formatSoftwareType = ({
}
return type;
};
/**
* This list comprises all possible states of software install operations.
*/
export const SOFTWARE_INSTALL_STATUSES = [
"failed",
"installed",
"pending",
] as const;
/*
* SoftwareInstallStatus represents the possible states of software install operations.
*/
export type SoftwareInstallStatus = typeof SOFTWARE_INSTALL_STATUSES[number];
export const isValidSoftwareInstallStatus = (
s: string | undefined
): s is SoftwareInstallStatus =>
!!s && SOFTWARE_INSTALL_STATUSES.includes(s as SoftwareInstallStatus);
/**
* ISoftwareInstallResult is the shape of a software install result object
* returned by the Fleet API.
*/
export interface ISoftwareInstallResult {
install_uuid: string;
software_title: string;
software_title_id: number;
software_package: string;
host_id: number;
host_display_name: string;
status: SoftwareInstallStatus;
detail: string;
output: string;
pre_install_query_output: string;
post_install_script_output: string;
}
export interface ISoftwareInstallResults {
results: ISoftwareInstallResult;
}
// ISoftwareInstallerType defines the supported installer types for
// software uploaded by the IT admin.
export type ISoftwareInstallerType = "pkg" | "msi" | "deb" | "exe";
export interface ISoftwareLastInstall {
install_uuid: string;
installed_at: string;
}
export interface ISoftwareInstallVersion {
version: string;
last_opened_at: string | null;
vulnerabilities: string[] | null;
installed_paths: string[];
}
export interface IHostSoftware {
id: number;
name: string;
package_available_for_install?: string | null;
source: string;
bundle_identifier?: string;
status: SoftwareInstallStatus | null;
last_install: ISoftwareLastInstall | null;
installed_versions: ISoftwareInstallVersion[] | null;
}

View file

@ -15,6 +15,9 @@ import Button from "components/buttons/Button";
import Spinner from "components/Spinner";
// @ts-ignore
import FleetIcon from "components/icons/FleetIcon";
import { SoftwareInstallDetailsModal } from "pages/SoftwarePage/components/SoftwareInstallDetails";
import ActivityItem from "./ActivityItem";
import ScriptDetailsModal from "./components/ScriptDetailsModal/ScriptDetailsModal";
@ -35,6 +38,7 @@ const ActivityFeed = ({
const [pageIndex, setPageIndex] = useState(0);
const [showShowQueryModal, setShowShowQueryModal] = useState(false);
const [showScriptDetailsModal, setShowScriptDetailsModal] = useState(false);
const [installedSoftwareUuid, setInstalledSoftwareUuid] = useState("");
const queryShown = useRef("");
const queryImpact = useRef<string | undefined>(undefined);
const scriptExecutionId = useRef("");
@ -81,6 +85,7 @@ const ActivityFeed = ({
activityType: ActivityType,
details: IActivityDetails
) => {
console.log("activityType", activityType);
switch (activityType) {
case ActivityType.LiveQuery:
queryShown.current = details.query_sql ?? "";
@ -93,6 +98,11 @@ const ActivityFeed = ({
scriptExecutionId.current = details.script_execution_id ?? "";
setShowScriptDetailsModal(true);
break;
case ActivityType.InstalledSoftware:
// installUuid.current = details.install_uuid ?? "";
// console.log("installUuid.current", installUuid.current);
setInstalledSoftwareUuid(details.install_uuid ?? "");
break;
default:
break;
}
@ -184,6 +194,12 @@ const ActivityFeed = ({
onCancel={() => setShowScriptDetailsModal(false)}
/>
)}
{installedSoftwareUuid && (
<SoftwareInstallDetailsModal
installUuid={installedSoftwareUuid}
onCancel={() => setInstalledSoftwareUuid("")}
/>
)}
</div>
);
};

View file

@ -16,6 +16,7 @@ import Icon from "components/Icon";
import ReactTooltip from "react-tooltip";
import PremiumFeatureIconWithTooltip from "components/PremiumFeatureIconWithTooltip";
import { COLORS } from "styles/var/colors";
import { getSoftwareInstallStatusPredicate } from "pages/hosts/details/cards/Activity/ActivityItems/InstalledSoftwareActivityItem/InstalledSoftwareActivityItem";
const baseClass = "activity-item";
@ -820,6 +821,76 @@ const TAGGED_TEMPLATES = {
</>
);
},
addedSoftware: (activity: IActivity) => {
return (
<>
{" "}
added <b>{activity.details?.software_title}</b> (
{activity.details?.software_package}) software to{" "}
{activity.details?.team_name ? (
<>
{" "}
the <b>{activity.details?.team_name}</b> team.
</>
) : (
"no team."
)}
</>
);
},
deletedSoftware: (activity: IActivity) => {
return (
<>
{" "}
deleted <b>{activity.details?.software_title}</b> (
{activity.details?.software_package}) software from{" "}
{activity.details?.team_name ? (
<>
{" "}
the <b>{activity.details?.team_name}</b> team.
</>
) : (
"no team."
)}
</>
);
},
installedSoftware: (
activity: IActivity,
onDetailsClick?: (type: ActivityType, details: IActivityDetails) => void
) => {
const { details } = activity;
if (!details) {
return TAGGED_TEMPLATES.defaultActivityTemplate(activity);
}
console.log("onDetailsClick", onDetailsClick);
const {
host_display_name: hostName,
software_title: title,
status,
install_uuid,
} = details;
return (
<>
{" "}
{getSoftwareInstallStatusPredicate(status)} <b>{title}</b> software on{" "}
<b>{hostName}</b>.{" "}
<Button
className={`${baseClass}__show-query-link`}
variant="text-link"
onClick={() =>
onDetailsClick?.(ActivityType.InstalledSoftware, { install_uuid })
}
>
Show details{" "}
<Icon className={`${baseClass}__show-query-icon`} name="eye" />
</Button>
</>
);
},
};
const getDetail = (
@ -993,6 +1064,15 @@ const getDetail = (
case ActivityType.ResentConfigurationProfile: {
return TAGGED_TEMPLATES.resentConfigProfile(activity);
}
case ActivityType.AddedSoftware: {
return TAGGED_TEMPLATES.addedSoftware(activity);
}
case ActivityType.DeletedSoftware: {
return TAGGED_TEMPLATES.deletedSoftware(activity);
}
case ActivityType.InstalledSoftware: {
return TAGGED_TEMPLATES.installedSoftware(activity, onDetailsClick);
}
default: {
return TAGGED_TEMPLATES.defaultActivityTemplate(activity);

View file

@ -98,7 +98,6 @@ const Software = ({
emptyComponent={() => (
<EmptySoftwareTable
isCollectingSoftware={isCollectingInventory}
noSandboxHosts={noSandboxHosts}
/>
)}
showMarkAllPages={false}
@ -125,8 +124,7 @@ const Software = ({
emptyComponent={() => (
<EmptySoftwareTable
isCollectingSoftware={isCollectingInventory}
noSandboxHosts={noSandboxHosts}
isFilterVulnerable
softwareFilter="vulnerableSoftware"
/>
)}
showMarkAllPages={false}

View file

@ -62,6 +62,9 @@ const FileChooser = ({
</div>
);
// TODO: if we reuse this one more time, we should consider moving this
// into FileUploader as a default preview. Currently we have this in
// AddSoftwareForm.tsx and here.
const FileDetails = ({
baseClass,
details: { name, platform },

View file

@ -180,11 +180,7 @@ const SoftwareOSTable = ({
isLoading={isLoading}
resultsTitle="items"
emptyComponent={() => (
<EmptySoftwareTable
isSoftwareDisabled={!isSoftwareEnabled}
isSandboxMode={isSandboxMode}
noSandboxHosts={noSandboxHosts}
/>
<EmptySoftwareTable isSoftwareDisabled={!isSoftwareEnabled} />
)}
defaultSortHeader={orderKey}
defaultSortDirection={orderDirection}

View file

@ -28,6 +28,8 @@ import TeamsHeader from "components/TeamsHeader";
import TabsWrapper from "components/TabsWrapper";
import ManageAutomationsModal from "./components/ManageSoftwareAutomationsModal";
import AddSoftwareModal from "./components/AddSoftwareModal";
import { ISoftwareDropdownFilterVal } from "./SoftwareTitles/SoftwareTable/helpers";
interface ISoftwareSubNavItem {
name: string;
@ -61,6 +63,16 @@ const getTabIndex = (path: string): number => {
});
};
const getSoftwareFilter = (
vulnerable?: string,
installable?: string
): ISoftwareDropdownFilterVal => {
if (installable === "true") return "installableSoftware";
return vulnerable && vulnerable === "true"
? "vulnerableSoftware"
: "allSoftware";
};
// default values for query params used on this page if not provided
const DEFAULT_SORT_DIRECTION = "desc";
const DEFAULT_SORT_HEADER = "hosts_count";
@ -92,6 +104,7 @@ interface ISoftwarePageProps {
query: {
team_id?: string;
vulnerable?: string;
available_for_install?: string;
exploit?: string;
page?: string;
query?: string;
@ -110,6 +123,8 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => {
isGlobalAdmin,
isGlobalMaintainer,
isOnGlobalTeam,
isTeamAdmin,
isTeamMaintainer,
isPremiumTier,
isSandboxMode,
} = useContext(AppContext);
@ -132,16 +147,19 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => {
: DEFAULT_PAGE;
// TODO: move these down into the Software Titles component.
const query = queryParams && queryParams.query ? queryParams.query : "";
const showVulnerableSoftware =
queryParams !== undefined && queryParams.vulnerable === "true";
const showExploitedVulnerabilitiesOnly =
queryParams !== undefined && queryParams.exploit === "true";
const softwareFilter = getSoftwareFilter(
queryParams.vulnerable,
queryParams.available_for_install
);
const [showManageAutomationsModal, setShowManageAutomationsModal] = useState(
false
);
const [showPreviewPayloadModal, setShowPreviewPayloadModal] = useState(false);
const [showPreviewTicketModal, setShowPreviewTicketModal] = useState(false);
const [showAddSoftwareModal, setShowAddSoftwareModal] = useState(false);
const {
currentTeamId,
@ -218,13 +236,14 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => {
const isSoftwareConfigLoaded =
!isFetchingSoftwareConfig && !softwareConfigError && !!softwareConfig;
const canManageAutomations =
isGlobalAdmin && (!isPremiumTier || !isAnyTeamSelected);
const toggleManageAutomationsModal = useCallback(() => {
setShowManageAutomationsModal(!showManageAutomationsModal);
}, [setShowManageAutomationsModal, showManageAutomationsModal]);
const toggleAddSoftwareModal = useCallback(() => {
setShowAddSoftwareModal(!showAddSoftwareModal);
}, [showAddSoftwareModal]);
const togglePreviewPayloadModal = useCallback(() => {
setShowPreviewPayloadModal(!showPreviewPayloadModal);
}, [setShowPreviewPayloadModal, showPreviewPayloadModal]);
@ -295,18 +314,40 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => {
);
};
const renderPageActions = () => {
const canManageAutomations =
isGlobalAdmin && (!isPremiumTier || !isAnyTeamSelected);
const canAddSoftware =
isGlobalAdmin || isGlobalMaintainer || isTeamAdmin || isTeamMaintainer;
if (!isSoftwareConfigLoaded) return null;
return (
<div className={`${baseClass}__action-buttons`}>
{canManageAutomations && (
<Button
onClick={toggleManageAutomationsModal}
className={`${baseClass}__manage-automations`}
variant="text-link"
>
<span>Manage automations</span>
</Button>
)}
{canAddSoftware && (
<Button onClick={toggleAddSoftwareModal} variant="brand">
<span>Add software</span>
</Button>
)}
</div>
);
};
const renderHeaderDescription = () => {
return (
<p>
Search for installed software{" "}
{(isGlobalAdmin || isGlobalMaintainer) &&
(!isPremiumTier || !isAnyTeamSelected) &&
"and manage automations for detected vulnerabilities (CVEs)"}{" "}
on{" "}
{isPremiumTier && isAnyTeamSelected
? "all hosts assigned to this team"
: "all of your hosts"}
.
Manage software and search for installed software, OS and
vulnerabilities {isAnyTeamSelected ? "on this team" : "for all hosts"}.
</p>
);
};
@ -342,8 +383,8 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => {
teamId: teamIdForApi,
// TODO: move down into the Software Titles component
query,
showVulnerableSoftware,
showExploitedVulnerabilitiesOnly,
softwareFilter,
})}
</div>
);
@ -358,15 +399,7 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => {
<div className={`${baseClass}__title`}>{renderTitle()}</div>
</div>
</div>
{canManageAutomations && isSoftwareConfigLoaded && (
<Button
onClick={toggleManageAutomationsModal}
className={`${baseClass}__manage-automations button`}
variant="brand"
>
<span>Manage automations</span>
</Button>
)}
{renderPageActions()}
</div>
<div className={`${baseClass}__description`}>
{renderHeaderDescription()}
@ -386,6 +419,13 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => {
recentVulnerabilityMaxAge={recentVulnerabilityMaxAge}
/>
)}
{showAddSoftwareModal && (
<AddSoftwareModal
teamId={currentTeamId ?? 0}
router={router}
onExit={toggleAddSoftwareModal}
/>
)}
</div>
</MainContent>
);

View file

@ -0,0 +1,91 @@
import React from "react";
// @ts-ignore
import InputField from "components/forms/fields/InputField";
import Modal from "components/Modal";
import Button from "components/buttons/Button";
import FleetAce from "components/FleetAce";
import CustomLink from "components/CustomLink";
import Editor from "components/Editor";
const baseClass = "advanced-options-modal";
interface IAdvancedOptionsModalProps {
installScript: string;
preInstallQuery?: string;
postInstallScript?: string;
onExit: () => void;
}
const AdvancedOptionsModal = ({
installScript,
preInstallQuery,
postInstallScript,
onExit,
}: IAdvancedOptionsModalProps) => {
return (
<Modal className={baseClass} title="Advanced Options" onExit={onExit}>
<>
<p>
Advanced options are read-only. To change options, delete software and
add again.
</p>
<div className={`${baseClass}__form-inputs`}>
<Editor
readOnly
wrapEnabled
maxLines={10}
name="install-script"
value={installScript}
helpText="Fleet will run this command on hosts to install software."
label="Install script"
labelTooltip="For security agents, add the script provided by the vendor."
/>
{preInstallQuery && (
<div className={`${baseClass}__input-field`}>
<span>Pre-install condition:</span>
<FleetAce
readOnly
value={preInstallQuery}
label="Query"
name="preInstallQuery"
maxLines={10}
helpText={
<>
Software will be installed only if the{" "}
<CustomLink
className={`${baseClass}__table-link`}
text="query returns results"
url="https://fleetdm.com/tables"
newTab
/>
</>
}
/>
</div>
)}
{postInstallScript && (
<div className={`${baseClass}__input-field`}>
<span>Post-install script:</span>
<Editor
readOnly
wrapEnabled
name="post-install-script-editor"
maxLines={10}
value={postInstallScript}
helpText="Shell (macOS and Linux) or PowerShell (Windows)."
/>
</div>
)}
</div>
<div className="modal-cta-wrap">
<Button variant="brand" onClick={onExit}>
Done
</Button>
</div>
</>
</Modal>
);
};
export default AdvancedOptionsModal;

View file

@ -0,0 +1,13 @@
.advanced-options-modal {
&__form-inputs {
display: flex;
flex-direction: column;
gap: $pad-large;
}
&__input-field {
display: flex;
flex-direction: column;
gap: $pad-medium
}
}

View file

@ -0,0 +1 @@
export { default } from "./AdvancedOptionsModal";

View file

@ -0,0 +1,54 @@
import React, { useCallback, useContext } from "react";
import softwareAPI from "services/entities/software";
import { NotificationContext } from "context/notification";
import Modal from "components/Modal";
import Button from "components/buttons/Button";
const baseClass = "delete-software-modal";
interface IDeleteSoftwareModalProps {
softwareId: number;
teamId: number;
onExit: () => void;
onSuccess: () => void;
}
const DeleteSoftwareModal = ({
softwareId,
teamId,
onExit,
onSuccess,
}: IDeleteSoftwareModalProps) => {
const { renderFlash } = useContext(NotificationContext);
const onDeleteSoftware = useCallback(async () => {
try {
await softwareAPI.deleteSoftwarePackage(softwareId, teamId);
renderFlash("success", "Software deleted successfully!");
onSuccess();
} catch {
renderFlash("error", "Couldn't delete. Please try again.");
}
onExit();
}, [softwareId, teamId, renderFlash, onSuccess, onExit]);
return (
<Modal className={baseClass} title="Delete software" onExit={onExit}>
<>
<p>Software won&apos;t be uninstalled from existing hosts.</p>
<div className="modal-cta-wrap">
<Button variant="alert" onClick={onDeleteSoftware}>
Delete
</Button>
<Button variant="inverse-alert" onClick={onExit}>
Cancel
</Button>
</div>
</>
</Modal>
);
};
export default DeleteSoftwareModal;

View file

@ -0,0 +1 @@
export { default } from "./DeleteSoftwareModal";

View file

@ -0,0 +1,252 @@
import React, { useCallback, useContext, useState } from "react";
import FileSaver from "file-saver";
import PATHS from "router/paths";
import { AppContext } from "context/app";
import { NotificationContext } from "context/notification";
import { SoftwareInstallStatus, ISoftwarePackage } from "interfaces/software";
import softwareAPI from "services/entities/software";
import { buildQueryStringFromParams } from "utilities/url";
import { internationalTimeFormat } from "utilities/helpers";
import { uploadedFromNow } from "utilities/date_format";
import Card from "components/Card";
import Graphic from "components/Graphic";
import TooltipWrapper from "components/TooltipWrapper";
import DataSet from "components/DataSet";
import Icon from "components/Icon";
import Button from "components/buttons/Button";
import DeleteSoftwareModal from "../DeleteSoftwareModal";
import AdvancedOptionsModal from "../AdvancedOptionsModal";
const baseClass = "software-package-card";
interface IStatusDisplayOption {
displayName: string;
iconName: "success" | "pending-outline" | "error";
tooltip: string;
}
const STATUS_DISPLAY_OPTIONS: Record<
SoftwareInstallStatus,
IStatusDisplayOption
> = {
installed: {
displayName: "Installed",
iconName: "success",
tooltip: "Fleet installed software on these hosts.",
},
pending: {
displayName: "Pending",
iconName: "pending-outline",
tooltip: "Fleet will install software when these hosts come online.",
},
failed: {
displayName: "Failed",
iconName: "error",
tooltip: "Fleet failed to install software on these hosts.",
},
};
interface IPackageStatusCountProps {
softwareId: number;
status: SoftwareInstallStatus;
count: number;
teamId?: number;
}
const PackageStatusCount = ({
softwareId,
status,
count,
teamId,
}: IPackageStatusCountProps) => {
const displayData = STATUS_DISPLAY_OPTIONS[status];
const linkUrl = `${PATHS.MANAGE_HOSTS}?${buildQueryStringFromParams({
software_title_id: softwareId,
software_status: status,
team_id: teamId,
})}`;
return (
<DataSet
title={
<TooltipWrapper
position="top"
tipContent={displayData.tooltip}
underline={false}
>
<div className={`${baseClass}__status-title`}>
<Icon name={displayData.iconName} />
<span>{displayData.displayName}</span>
</div>
</TooltipWrapper>
}
value={
<a className={`${baseClass}__status-count`} href={linkUrl}>
{count} hosts
</a>
}
/>
);
};
interface ISoftwarePackageCardProps {
softwarePackage: ISoftwarePackage;
softwareId: number;
teamId: number;
onDelete: () => void;
}
const SoftwarePackageCard = ({
softwarePackage,
softwareId,
teamId,
onDelete,
}: ISoftwarePackageCardProps) => {
const {
isGlobalAdmin,
isGlobalMaintainer,
isTeamAdmin,
isTeamMaintainer,
} = useContext(AppContext);
const { renderFlash } = useContext(NotificationContext);
const [showAdvancedOptionsModal, setShowAdvancedOptionsModal] = useState(
false
);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const onAdvancedOptionsClick = () => {
setShowAdvancedOptionsModal(true);
};
const onDeleteClick = () => {
setShowDeleteModal(true);
};
const onDeleteSuccess = useCallback(() => {
setShowDeleteModal(false);
onDelete();
}, [onDelete]);
const onDownloadClick = useCallback(async () => {
try {
const resp = await softwareAPI.downloadSoftwarePackage(
softwareId,
teamId
);
const contentLength = parseInt(resp.headers["content-length"], 10);
if (contentLength !== resp.data.size) {
throw new Error(
`Byte size (${resp.data.size}) does not match content-length header (${contentLength})`
);
}
const filename = softwarePackage.name;
const file = new File([resp.data], filename, {
type: "application/octet-stream",
});
if (file.size === 0) {
throw new Error("Downloaded file is empty");
}
if (file.size !== resp.data.size) {
throw new Error(
`File size (${file.size}) does not match expected size (${resp.data.size})`
);
}
FileSaver.saveAs(file);
} catch (e) {
console.log(e);
renderFlash("error", "Couldnt download. Please try again.");
}
}, [renderFlash, softwareId, softwarePackage.name, teamId]);
const showActions =
isGlobalAdmin || isGlobalMaintainer || isTeamAdmin || isTeamMaintainer;
return (
<Card borderRadiusSize="large" includeShadow className={baseClass}>
<div className={`${baseClass}__main-content`}>
{/* TODO: main-info could be a seperate component as its reused on a couple
pages already. Come back and pull this into a component */}
<div className={`${baseClass}__main-info`}>
<Graphic name="file-pkg" />
<div className={`${baseClass}__info`}>
<span className={`${baseClass}__title`}>
{softwarePackage.name}
</span>
<span className={`${baseClass}__details`}>
<span>Version {softwarePackage.version} &bull; </span>
<TooltipWrapper
tipContent={internationalTimeFormat(
new Date(softwarePackage.uploaded_at)
)}
underline={false}
>
{uploadedFromNow(softwarePackage.uploaded_at)}
</TooltipWrapper>
</span>
</div>
</div>
<div className={`${baseClass}__package-statuses`}>
<PackageStatusCount
softwareId={softwareId}
status="installed"
count={softwarePackage.status.installed}
teamId={teamId}
/>
<PackageStatusCount
softwareId={softwareId}
status="pending"
count={softwarePackage.status.pending}
teamId={teamId}
/>
<PackageStatusCount
softwareId={softwareId}
status="failed"
count={softwarePackage.status.failed}
teamId={teamId}
/>
</div>
</div>
{showActions && (
<div className={`${baseClass}__actions`}>
<Button variant="icon" onClick={onAdvancedOptionsClick}>
<Icon name="settings" color={"ui-fleet-black-75"} />
</Button>
{/* TODO: make a component for download icons */}
<Button variant="icon" onClick={onDownloadClick}>
<Icon name="download" color={"ui-fleet-black-75"} />
</Button>
<Button variant="icon" onClick={onDeleteClick}>
<Icon name="trash" color={"ui-fleet-black-75"} />
</Button>
</div>
)}
{showAdvancedOptionsModal && (
<AdvancedOptionsModal
installScript={softwarePackage.install_script}
preInstallQuery={softwarePackage.pre_install_query}
postInstallScript={softwarePackage.post_install_script}
onExit={() => setShowAdvancedOptionsModal(false)}
/>
)}
{showDeleteModal && (
<DeleteSoftwareModal
softwareId={softwareId}
teamId={teamId}
onExit={() => setShowDeleteModal(false)}
onSuccess={onDeleteSuccess}
/>
)}
</Card>
);
};
export default SoftwarePackageCard;

View file

@ -0,0 +1,69 @@
.software-package-card {
display: flex;
justify-content: space-between;
align-items: center;
&__main-content {
display: flex;
align-items: center;
gap: $pad-xxlarge;
}
&__main-info {
display: flex;
gap: $pad-medium;
}
&__info {
display: flex;
flex-direction: column;
gap: $pad-xsmall;
}
&__title {
font-size: $x-small;
font-weight: $bold;
}
&__details {
font-size: $xx-small;
}
&__package-statuses {
display: flex;
gap: $pad-xxlarge;
}
&__status-title {
display: flex;
align-items: center;
gap: $pad-xsmall;
}
&__status-count {
font-weight: normal;
}
&__actions {
display: flex;
justify-content: flex-end;
gap: $pad-medium;
}
&__download-icon {
display: flex;
justify-content: center;
width: 44px;
}
@media (max-width: $break-md) {
align-items: flex-start;
&__main-content {
display: flex;
flex-direction: column;
align-items: center;
gap: $pad-large;
}
}
}

View file

@ -0,0 +1 @@
export { default } from "./SoftwarePackageCard";

View file

@ -6,6 +6,8 @@ import { useErrorHandler } from "react-error-boundary";
import { RouteComponentProps } from "react-router";
import { AxiosError } from "axios";
import paths from "router/paths";
import useTeamIdParam from "hooks/useTeamIdParam";
import { AppContext } from "context/app";
@ -16,7 +18,7 @@ import softwareAPI, {
ISoftwareTitleResponse,
IGetSoftwareTitleQueryKey,
} from "services/entities/software";
import { APP_CONTEXT_ALL_TEAMS_ID } from "interfaces/team";
import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants";
import Spinner from "components/Spinner";
@ -27,6 +29,7 @@ import Card from "components/Card";
import SoftwareDetailsSummary from "../components/SoftwareDetailsSummary";
import SoftwareTitleDetailsTable from "./SoftwareTitleDetailsTable";
import DetailsNoHosts from "../components/DetailsNoHosts";
import SoftwarePackageCard from "./SoftwarePackageCard";
const baseClass = "software-title-details-page";
@ -45,7 +48,13 @@ const SoftwareTitleDetailsPage = ({
routeParams,
location,
}: ISoftwareTitleDetailsPageProps) => {
const { isPremiumTier, isOnGlobalTeam } = useContext(AppContext);
const {
isPremiumTier,
isOnGlobalTeam,
isTeamAdmin,
isTeamMaintainer,
isTeamObserver,
} = useContext(AppContext);
const handlePageError = useErrorHandler();
// TODO: handle non integer values
@ -67,6 +76,7 @@ const SoftwareTitleDetailsPage = ({
data: softwareTitle,
isLoading: isSoftwareTitleLoading,
isError: isSoftwareTitleError,
refetch: refetchSoftwareTitle,
} = useQuery<
ISoftwareTitleResponse,
AxiosError,
@ -87,6 +97,19 @@ const SoftwareTitleDetailsPage = ({
}
);
const onDeleteInstaller = useCallback(() => {
if (softwareTitle?.versions?.length) {
refetchSoftwareTitle();
return;
}
// redirect to software titles page if no versions are available
if (teamIdForApi && teamIdForApi > 0) {
router.push(paths.SOFTWARE_TITLES.concat(`?team_id=${teamIdForApi}`));
} else {
router.push(paths.SOFTWARE_TITLES);
}
}, [refetchSoftwareTitle, router, softwareTitle, teamIdForApi]);
const onTeamChange = useCallback(
(teamId: number) => {
handleTeamChange(teamId);
@ -94,6 +117,15 @@ const SoftwareTitleDetailsPage = ({
[handleTeamChange]
);
const hasPermission = Boolean(
isOnGlobalTeam || isTeamAdmin || isTeamMaintainer || isTeamObserver
);
const hasSoftwarePackage = softwareTitle && softwareTitle.software_package;
const showPackageCard =
currentTeamId !== APP_CONTEXT_ALL_TEAMS_ID &&
hasPermission &&
hasSoftwarePackage;
const renderContent = () => {
if (isSoftwareTitleLoading) {
return <Spinner />;
@ -133,6 +165,16 @@ const SoftwareTitleDetailsPage = ({
name={softwareTitle.name}
source={softwareTitle.source}
/>
{showPackageCard &&
softwareTitle.software_package &&
currentTeamId && (
<SoftwarePackageCard
softwarePackage={softwareTitle.software_package}
softwareId={softwareId}
teamId={currentTeamId}
onDelete={onDeleteInstaller}
/>
)}
<Card
borderRadiusSize="large"
includeShadow

View file

@ -3,17 +3,13 @@ software/titles Software tab > Table
software/versions Software tab > Table (version toggle on)
*/
import React, { useCallback, useContext, useMemo } from "react";
import React, { useCallback, useMemo } from "react";
import { InjectedRouter } from "react-router";
import { Row } from "react-table";
import PATHS from "router/paths";
import { AppContext } from "context/app";
import { getNextLocationPath } from "utilities/helpers";
import {
GITHUB_NEW_ISSUE_LINK,
VULNERABLE_DROPDOWN_OPTIONS,
} from "utilities/constants";
import { GITHUB_NEW_ISSUE_LINK } from "utilities/constants";
import { buildQueryStringFromParams } from "utilities/url";
import {
ISoftwareTitlesResponse,
@ -33,6 +29,11 @@ import EmptySoftwareTable from "pages/SoftwarePage/components/EmptySoftwareTable
import generateTitlesTableConfig from "./SoftwareTitlesTableConfig";
import generateVersionsTableConfig from "./SoftwareVersionsTableConfig";
import {
ISoftwareDropdownFilterVal,
SOFTWARE_TITLES_DROPDOWN_OPTIONS,
SOFTWARE_VERSIONS_DROPDOWN_OPTIONS,
} from "./helpers";
interface IRowProps extends Row {
original: {
@ -58,7 +59,7 @@ interface ISoftwareTableProps {
perPage: number;
orderDirection: "asc" | "desc";
orderKey: string;
showVulnerableSoftware: boolean;
softwareFilter: ISoftwareDropdownFilterVal;
currentPage: number;
teamId?: number;
isLoading: boolean;
@ -75,13 +76,11 @@ const SoftwareTable = ({
perPage,
orderDirection,
orderKey,
showVulnerableSoftware,
softwareFilter,
currentPage,
teamId,
isLoading,
}: ISoftwareTableProps) => {
const { isSandboxMode, noSandboxHosts } = useContext(AppContext);
const currentPath = showVersions
? PATHS.SOFTWARE_VERSIONS
: PATHS.SOFTWARE_TITLES;
@ -96,8 +95,6 @@ const SoftwareTable = ({
return val !== orderDirection;
case "sortHeader":
return val !== orderKey;
case "vulnerable":
return val !== showVulnerableSoftware.toString();
case "pageIndex":
return val !== currentPage;
default:
@ -106,21 +103,29 @@ const SoftwareTable = ({
});
return changedEntry?.[0] ?? "";
},
[currentPage, orderDirection, orderKey, query, showVulnerableSoftware]
[currentPage, orderDirection, orderKey, query]
);
const generateNewQueryParams = useCallback(
(newTableQuery: ITableQueryData, changedParam: string) => {
return {
const newQueryParam: Record<string, string | number | undefined> = {
query: newTableQuery.searchQuery,
team_id: teamId,
order_direction: newTableQuery.sortDirection,
order_key: newTableQuery.sortHeader,
vulnerable: showVulnerableSoftware.toString(),
page: changedParam === "pageIndex" ? newTableQuery.pageIndex : 0,
};
if (softwareFilter === "installableSoftware") {
newQueryParam.available_for_install = true.toString();
} else {
newQueryParam.vulnerable = (
softwareFilter === "vulnerableSoftware"
).toString();
}
return newQueryParam;
},
[showVulnerableSoftware, teamId]
[softwareFilter, teamId]
);
// NOTE: this is called once on initial render and every time the query changes
@ -131,7 +136,9 @@ const SoftwareTable = ({
const changedParam = determineQueryParamChange(newTableQuery);
// if nothing has changed, don't update the route. this can happen when
// this handler is called on the inital render.
// this handler is called on the inital render. Can also happen when
// the filter dropdown is changed. That is handled on the onChange handler
// for the dropdown.
if (changedParam === "") return;
const newRoute = getNextLocationPath({
@ -167,7 +174,7 @@ const SoftwareTable = ({
// determines if a user be able to search in the table
const searchable =
isSoftwareEnabled &&
(!!tableData || query !== "" || showVulnerableSoftware);
(!!tableData || query !== "" || softwareFilter === "vulnerableSoftware");
const getItemsCountText = () => {
const count = data?.count;
@ -187,37 +194,57 @@ const SoftwareTable = ({
};
const handleShowVersionsToggle = () => {
const queryParams: Record<string, string | number | undefined> = {
query,
team_id: teamId,
order_direction: orderDirection,
order_key: orderKey,
page: 0, // resets page index
};
// if we are currently showing installable titles, we want to switch to
// all software versions. If not, we want to keep the current filter.
if (softwareFilter === "installableSoftware") {
queryParams.vulnerable = "false";
} else {
queryParams.vulnerable = (
softwareFilter === "vulnerableSoftware"
).toString();
}
router.replace(
getNextLocationPath({
pathPrefix: showVersions
? PATHS.SOFTWARE_TITLES
: PATHS.SOFTWARE_VERSIONS,
routeTemplate: "",
queryParams: {
query,
team_id: teamId,
order_direction: orderDirection,
order_key: orderKey,
vulnerable: showVulnerableSoftware.toString(),
page: 0, // resets page index
},
queryParams,
})
);
};
const handleVulnFilterDropdownChange = (isFilterVulnerable: string) => {
const handleVulnFilterDropdownChange = (
value: ISoftwareDropdownFilterVal
) => {
const queryParams: Record<string, any> = {
query,
team_id: teamId,
order_direction: orderDirection,
order_key: orderKey,
page: 0, // resets page index
};
if (value === "installableSoftware") {
queryParams.available_for_install = true;
} else {
queryParams.vulnerable = value === "vulnerableSoftware";
}
router.replace(
getNextLocationPath({
pathPrefix: currentPath,
routeTemplate: "",
queryParams: {
query,
team_id: teamId,
order_direction: orderDirection,
order_key: orderKey,
vulnerable: isFilterVulnerable,
page: 0, // resets page index
},
queryParams,
})
);
};
@ -255,6 +282,10 @@ const SoftwareTable = ({
};
const renderCustomFilters = () => {
const options = showVersions
? SOFTWARE_VERSIONS_DROPDOWN_OPTIONS
: SOFTWARE_TITLES_DROPDOWN_OPTIONS;
return (
<div className={`${baseClass}__filter-controls`}>
<div className={`${baseClass}__version-slider`}>
@ -267,9 +298,9 @@ const SoftwareTable = ({
/>
</div>
<Dropdown
value={showVulnerableSoftware}
value={softwareFilter}
className={`${baseClass}__vuln_dropdown`}
options={VULNERABLE_DROPDOWN_OPTIONS}
options={options}
searchable={false}
onChange={handleVulnFilterDropdownChange}
tableFilterDropdown
@ -300,12 +331,10 @@ const SoftwareTable = ({
resultsTitle="items"
emptyComponent={() => (
<EmptySoftwareTable
softwareFilter={softwareFilter}
isSoftwareDisabled={!isSoftwareEnabled}
isFilterVulnerable={showVulnerableSoftware}
isSandboxMode={isSandboxMode}
isCollectingSoftware={false} // TODO: update with new API
isSearching={query !== ""}
noSandboxHosts={noSandboxHosts}
/>
)}
defaultSortHeader={orderKey}
@ -323,7 +352,7 @@ const SoftwareTable = ({
// additionalQueries serves as a trigger for the useDeepEffect hook
// to fire onQueryChange for events happeing outside of
// the TableContainer.
additionalQueries={showVulnerableSoftware ? "vulnerable" : ""}
// additionalQueries={softwareFilter}
customControl={searchable ? renderCustomFilters : undefined}
stackControls
renderCount={renderSoftwareCount}

View file

@ -2,11 +2,7 @@ import React from "react";
import { CellProps, Column } from "react-table";
import { InjectedRouter } from "react-router";
import {
ISoftwareTitleVersion,
ISoftwareTitle,
formatSoftwareType,
} from "interfaces/software";
import { ISoftwareTitle, formatSoftwareType } from "interfaces/software";
import PATHS from "router/paths";
import { buildQueryStringFromParams } from "utilities/url";
@ -14,12 +10,11 @@ import { IHeaderProps, IStringCellProps } from "interfaces/datatable_config";
import HeaderCell from "components/TableContainer/DataTable/HeaderCell";
import TextCell from "components/TableContainer/DataTable/TextCell";
import LinkCell from "components/TableContainer/DataTable/LinkCell/LinkCell";
import ViewAllHostsLink from "components/ViewAllHostsLink";
import SoftwareNameCell from "components/TableContainer/DataTable/SoftwareNameCell";
import VersionCell from "../../components/VersionCell";
import VulnerabilitiesCell from "../../components/VulnerabilitiesCell";
import SoftwareIcon from "../../components/icons/SoftwareIcon";
// NOTE: cellProps come from react-table
// more info here https://react-table.tanstack.com/docs/api/useTable#cell-properties
@ -36,7 +31,11 @@ type IViewAllHostsLinkProps = CellProps<ISoftwareTitle>;
type ITableHeaderProps = IHeaderProps<ISoftwareTitle>;
const getVulnerabilities = (versions: ISoftwareTitleVersion[]) => {
export const getVulnerabilities = <
T extends { vulnerabilities: string[] | null }
>(
versions: T[]
) => {
if (!versions) {
return [];
}
@ -64,43 +63,25 @@ const generateTableHeaders = (
disableSortBy: false,
accessor: "name",
Cell: (cellProps: ITableStringCellProps) => {
const { id, name, source } = cellProps.row.original;
const { id, name, source, software_package } = cellProps.row.original;
const teamQueryParam = buildQueryStringFromParams({ team_id: teamId });
const softwareTitleDetailsPath = `${PATHS.SOFTWARE_TITLE_DETAILS(
id.toString()
)}?${teamQueryParam}`;
const onClickSoftware = (e: React.MouseEvent) => {
// Allows for button to be clickable in a clickable row
e.stopPropagation();
router?.push(softwareTitleDetailsPath);
};
return (
<LinkCell
<SoftwareNameCell
name={name}
source={source}
path={softwareTitleDetailsPath}
customOnClick={onClickSoftware}
value={
<>
<SoftwareIcon name={name} source={source} />
<span className="software-name">{name}</span>
</>
}
router={router}
hasPackage={Boolean(software_package)}
/>
);
},
sortType: "caseInsensitive",
},
{
Header: "Version",
disableSortBy: true,
accessor: "versions",
Cell: (cellProps: IVersionsCellProps) => (
<VersionCell versions={cellProps.cell.value} />
),
},
{
Header: "Type",
disableSortBy: true,
@ -109,6 +90,14 @@ const generateTableHeaders = (
<TextCell value={formatSoftwareType(cellProps.row.original)} />
),
},
{
Header: "Version",
disableSortBy: true,
accessor: "versions",
Cell: (cellProps: IVersionsCellProps) => (
<VersionCell versions={cellProps.cell.value} />
),
},
// the "vulnerabilities" accessor is used but the data is actually coming
// from the version attribute. We do this as we already have a "versions"
// attribute used for the "Version" column and we cannot reuse. This is a

View file

@ -13,10 +13,10 @@ import PATHS from "router/paths";
import HeaderCell from "components/TableContainer/DataTable/HeaderCell";
import TextCell from "components/TableContainer/DataTable/TextCell";
import LinkCell from "components/TableContainer/DataTable/LinkCell/LinkCell";
import ViewAllHostsLink from "components/ViewAllHostsLink";
import SoftwareNameCell from "components/TableContainer/DataTable/SoftwareNameCell";
import VulnerabilitiesCell from "../../components/VulnerabilitiesCell";
import SoftwareIcon from "../../components/icons/SoftwareIcon";
// NOTE: cellProps come from react-table
// more info here https://react-table.tanstack.com/docs/api/useTable#cell-properties
@ -52,36 +52,17 @@ const generateTableHeaders = (
id.toString()
)}?${teamQueryParam}`;
const onClickSoftware = (e: React.MouseEvent) => {
// Allows for button to be clickable in a clickable row
e.stopPropagation();
router?.push(softwareVersionDetailsPath);
};
return (
<LinkCell
<SoftwareNameCell
name={name}
source={source}
path={softwareVersionDetailsPath}
customOnClick={onClickSoftware}
value={
<>
<SoftwareIcon name={name} source={source} />
<span className="software-name">{name}</span>
</>
}
router={router}
/>
);
},
sortType: "caseInsensitive",
},
{
Header: "Version",
disableSortBy: true,
accessor: "version",
Cell: (cellProps: ITableStringCellProps) => (
<TextCell value={cellProps.cell.value} />
),
},
{
Header: "Type",
disableSortBy: true,
@ -90,6 +71,14 @@ const generateTableHeaders = (
<TextCell value={formatSoftwareType(cellProps.row.original)} />
),
},
{
Header: "Version",
disableSortBy: true,
accessor: "version",
Cell: (cellProps: ITableStringCellProps) => (
<TextCell value={cellProps.cell.value} />
),
},
{
Header: "Vulnerabilities",
disableSortBy: true,

View file

@ -124,12 +124,6 @@
}
}
.link-cell {
display: flex;
align-items: center;
gap: $pad-small;
}
.hosts_count__cell {
.hosts-cell__wrapper {
display: flex;

View file

@ -0,0 +1,30 @@
export type ISoftwareDropdownFilterVal =
| "allSoftware"
| "vulnerableSoftware"
| "installableSoftware";
export const SOFTWARE_VERSIONS_DROPDOWN_OPTIONS = [
{
disabled: false,
label: "All software",
value: "allSoftware",
helpText: "All software installed on your hosts.",
},
{
disabled: false,
label: "Vulnerable software",
value: "vulnerableSoftware",
helpText:
"All software installed on your hosts with detected vulnerabilities.",
},
];
export const SOFTWARE_TITLES_DROPDOWN_OPTIONS = [
...SOFTWARE_VERSIONS_DROPDOWN_OPTIONS,
{
disabled: false,
label: "Available for install",
value: "installableSoftware",
helpText: "Software that can be installed on your hosts.",
},
];

View file

@ -1,8 +1,7 @@
/**
/**
software/titles Software tab
software/versions Software tab (version toggle on)
software/versions Software tab (version toggle on)
*/
import React from "react";
import { InjectedRouter } from "react-router";
import { useQuery } from "react-query";
@ -16,7 +15,9 @@ import softwareAPI, {
import Spinner from "components/Spinner";
import TableDataError from "components/DataError";
import SoftwareTable from "./SoftwareTable";
import { ISoftwareDropdownFilterVal } from "./SoftwareTable/helpers";
const baseClass = "software-titles";
@ -41,7 +42,7 @@ interface ISoftwareTitlesProps {
perPage: number;
orderDirection: "asc" | "desc";
orderKey: string;
showVulnerableSoftware: boolean;
softwareFilter: ISoftwareDropdownFilterVal;
currentPage: number;
teamId?: number;
}
@ -53,12 +54,31 @@ const SoftwareTitles = ({
perPage,
orderDirection,
orderKey,
showVulnerableSoftware,
softwareFilter,
currentPage,
teamId,
}: ISoftwareTitlesProps) => {
const showVersions = location.pathname === PATHS.SOFTWARE_VERSIONS;
const generateSoftwareTitlesQueryKey = (): ISoftwareTitlesQueryKey => {
const queryKey: ISoftwareTitlesQueryKey = {
scope: "software-titles",
page: currentPage,
perPage,
query,
orderDirection,
orderKey,
teamId,
};
if (softwareFilter === "installableSoftware") {
queryKey.availableForInstall = true;
} else {
queryKey.vulnerable = softwareFilter === "vulnerableSoftware";
}
return queryKey;
};
// request to get software data
const {
data: titlesData,
@ -71,18 +91,7 @@ const SoftwareTitles = ({
ISoftwareTitlesResponse,
ISoftwareTitlesQueryKey[]
>(
[
{
scope: "software-titles",
page: currentPage,
perPage,
query,
orderDirection,
orderKey,
teamId,
vulnerable: showVulnerableSoftware,
},
],
[generateSoftwareTitlesQueryKey()],
({ queryKey }) => softwareAPI.getSoftwareTitles(queryKey[0]),
{
...QUERY_OPTIONS,
@ -111,7 +120,7 @@ const SoftwareTitles = ({
orderDirection,
orderKey,
teamId,
vulnerable: showVulnerableSoftware,
vulnerable: softwareFilter === "vulnerableSoftware",
},
],
({ queryKey }) => softwareAPI.getSoftwareVersions(queryKey[0]),
@ -140,7 +149,7 @@ const SoftwareTitles = ({
perPage={perPage}
orderDirection={orderDirection}
orderKey={orderKey}
showVulnerableSoftware={showVulnerableSoftware}
softwareFilter={softwareFilter}
currentPage={currentPage}
teamId={teamId}
isLoading={isTitlesFetching || isVersionsFetching}

View file

@ -267,11 +267,7 @@ const SoftwareVulnerabilitiesTable = ({
isLoading={isLoading}
resultsTitle={"items"}
emptyComponent={() => (
<EmptySoftwareTable
isSoftwareDisabled={!isSoftwareEnabled}
isSandboxMode={isSandboxMode}
noSandboxHosts={noSandboxHosts}
/>
<EmptySoftwareTable isSoftwareDisabled={!isSoftwareEnabled} />
)}
defaultSortHeader={orderKey}
defaultSortDirection={orderDirection}

View file

@ -25,6 +25,12 @@
}
}
&__action-buttons {
display: flex;
align-items: center;
gap: $pad-medium;
}
&__text {
margin-right: $pad-large;
}
@ -61,14 +67,5 @@
.component__tabs-wrapper {
margin-bottom: $pad-xxlarge;
}
.table-container {
.software-icon {
width: 24px;
height: 24px;
border: 1px solid $ui-fleet-black-10;
border-radius: 8px;
}
}
}
}

View file

@ -0,0 +1,110 @@
import React, { useState } from "react";
import Editor from "components/Editor";
import CustomLink from "components/CustomLink";
import FleetAce from "components/FleetAce";
import RevealButton from "components/buttons/RevealButton";
import Checkbox from "components/forms/fields/Checkbox";
const baseClass = "add-software-advanced-options";
interface IAddSoftwareAdvancedOptionsProps {
errors: { preInstallCondition?: string; postInstallScript?: string };
showPreInstallCondition: boolean;
showPostInstallScript: boolean;
preInstallCondition?: string;
postInstallScript?: string;
onTogglePreInstallCondition: (value: boolean) => void;
onTogglePostInstallScript: (value: boolean) => void;
onChangePreInstallCondition: (value?: string) => void;
onChangePostInstallScript: (value?: string) => void;
}
const AddSoftwareAdvancedOptions = ({
errors,
showPreInstallCondition,
showPostInstallScript,
preInstallCondition,
postInstallScript,
onTogglePreInstallCondition,
onTogglePostInstallScript,
onChangePreInstallCondition,
onChangePostInstallScript,
}: IAddSoftwareAdvancedOptionsProps) => {
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
const onChangePreInstallCheckbox = () => {
onTogglePreInstallCondition(!showPreInstallCondition);
};
const onChangePostInstallCheckbox = () => {
onTogglePostInstallScript(!showPostInstallScript);
};
return (
<div className={baseClass}>
<RevealButton
className={`${baseClass}__accordion-title`}
isShowing={showAdvancedOptions}
showText="Advanced options"
hideText="Advanced options"
caretPosition="after"
onClick={() => setShowAdvancedOptions(!showAdvancedOptions)}
/>
{showAdvancedOptions && (
<div className={`${baseClass}__input-fields`}>
<Checkbox
value={showPreInstallCondition}
onChange={onChangePreInstallCheckbox}
>
Pre-install condition
</Checkbox>
{showPreInstallCondition && (
<FleetAce
focus
error={errors.preInstallCondition}
value={preInstallCondition}
label="Query"
name="preInstallQuery"
maxLines={10}
onChange={onChangePreInstallCondition}
helpText={
<>
Software will be installed only if the{" "}
<CustomLink
className={`${baseClass}__table-link`}
text="query returns results"
url="https://fleetdm.com/tables"
newTab
/>
</>
}
/>
)}
<Checkbox
value={showPostInstallScript}
onChange={onChangePostInstallCheckbox}
>
Post-install script
</Checkbox>
{showPostInstallScript && (
<>
<Editor
focus
error={errors.postInstallScript}
wrapEnabled
name="post-install-script-editor"
maxLines={10}
onChange={onChangePostInstallScript}
value={postInstallScript}
helpText="Shell (macOS and Linux) or PowerShell (Windows)."
/>
</>
)}
</div>
)}
</div>
);
};
export default AddSoftwareAdvancedOptions;

View file

@ -0,0 +1,17 @@
.add-software-advanced-options {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: $pad-large;
&__input-fields {
width: 100%;
display: flex;
flex-direction: column;
gap: $pad-medium;
}
&__table-link {
font-size: $xx-small;
}
}

View file

@ -0,0 +1 @@
export { default } from "./AddSoftwareAdvancedOptions";

View file

@ -0,0 +1,225 @@
import React, { useState } from "react";
import getInstallScript from "utilities/software_install_scripts";
import Spinner from "components/Spinner";
import Button from "components/buttons/Button";
import FileUploader from "components/FileUploader";
import Graphic from "components/Graphic";
import Editor from "components/Editor";
import AddSoftwareAdvancedOptions from "../AddSoftwareAdvancedOptions";
import { generateFormValidation, getFileDetails } from "./helpers";
const baseClass = "add-software-form";
const UploadingSoftware = () => {
return (
<div className={`${baseClass}__uploading-message`}>
<Spinner centered={false} />
<p>Uploading. It may take few minutes to finish.</p>
</div>
);
};
// TODO: if we reuse this one more time, we should consider moving this
// into FileUploader as a default preview. Currently we have this in
// AddProfileModal.tsx and here.
const FileDetails = ({
details: { name, platform },
}: {
details: {
name: string;
platform: string;
};
}) => (
<div className={`${baseClass}__selected-file`}>
<Graphic name="file-pkg" />
<div className={`${baseClass}__selected-file--details`}>
<div className={`${baseClass}__selected-file--details--name`}>{name}</div>
<div className={`${baseClass}__selected-file--details--platform`}>
{platform}
</div>
</div>
</div>
);
export interface IAddSoftwareFormData {
software: File | null;
installScript: string;
preInstallCondition?: string;
postInstallScript?: string;
}
export interface IFormValidation {
isValid: boolean;
software: { isValid: boolean };
preInstallCondition?: { isValid: boolean; message?: string };
postInstallScript?: { isValid: boolean; message?: string };
}
interface IAddSoftwareFormProps {
isUploading: boolean;
onCancel: () => void;
onSubmit: (formData: IAddSoftwareFormData) => void;
}
const AddSoftwareForm = ({
isUploading,
onCancel,
onSubmit,
}: IAddSoftwareFormProps) => {
const [showPreInstallCondition, setShowPreInstallCondition] = useState(false);
const [showPostInstallScript, setShowPostInstallScript] = useState(false);
const [formData, setFormData] = useState<IAddSoftwareFormData>({
software: null,
installScript: "",
preInstallCondition: undefined,
postInstallScript: undefined,
});
const [formValidation, setFormValidation] = useState<IFormValidation>({
isValid: false,
software: { isValid: false },
});
const onFileUpload = (files: FileList | null) => {
if (files && files.length > 0) {
const file = files[0];
const newData = {
...formData,
software: file,
installScript: getInstallScript(file.name),
};
setFormData(newData);
setFormValidation(
generateFormValidation(
newData,
showPreInstallCondition,
showPostInstallScript
)
);
}
};
const onFormSubmit = (evt: React.FormEvent<HTMLFormElement>) => {
evt.preventDefault();
onSubmit(formData);
};
const onTogglePreInstallConditionCheckbox = (value: boolean) => {
const newData = { ...formData, preInstallCondition: undefined };
setShowPreInstallCondition(value);
setFormData(newData);
setFormValidation(
generateFormValidation(newData, value, showPostInstallScript)
);
};
const onTogglePostInstallScriptCheckbox = (value: boolean) => {
const newData = { ...formData, postInstallScript: undefined };
setShowPostInstallScript(value);
setFormData(newData);
setFormValidation(
generateFormValidation(newData, showPreInstallCondition, value)
);
};
const onChangeInstallScript = (value: string) => {
setFormData({ ...formData, installScript: value });
};
const onChangePreInstallCondition = (value?: string) => {
const newData = { ...formData, preInstallCondition: value };
setFormData(newData);
setFormValidation(
generateFormValidation(
newData,
showPreInstallCondition,
showPostInstallScript
)
);
};
const onChangePostInstallScript = (value?: string) => {
const newData = { ...formData, postInstallScript: value };
setFormData(newData);
setFormValidation(
generateFormValidation(
newData,
showPreInstallCondition,
showPostInstallScript
)
);
};
const isSubmitDisabled = !formValidation.isValid;
return (
<div className={baseClass}>
{isUploading ? (
<UploadingSoftware />
) : (
<form className={`${baseClass}__form`} onSubmit={onFormSubmit}>
<FileUploader
graphicName={"file-pkg"}
accept=".pkg,.msi,.exe,.deb"
message=".pkg, .msi, .exe, or .deb"
onFileUpload={onFileUpload}
buttonMessage="Choose file"
buttonType="link"
className={`${baseClass}__file-uploader`}
filePreview={
formData.software && (
<FileDetails details={getFileDetails(formData.software)} />
)
}
/>
{formData.software && (
<Editor
wrapEnabled
maxLines={10}
name="install-script"
onChange={onChangeInstallScript}
value={formData.installScript}
helpText="Fleet will run this command on hosts to install software."
label="Install script"
labelTooltip={
<>
For security agents, add the script provided by the vendor.
<br />
In custom scripts, you can use the $INSTALLER_PATH variable to
point to the installer.
</>
}
/>
)}
<AddSoftwareAdvancedOptions
errors={{
preInstallCondition: formValidation.preInstallCondition?.message,
postInstallScript: formValidation.postInstallScript?.message,
}}
showPreInstallCondition={showPreInstallCondition}
showPostInstallScript={showPostInstallScript}
preInstallCondition={formData.preInstallCondition}
postInstallScript={formData.postInstallScript}
onTogglePreInstallCondition={onTogglePreInstallConditionCheckbox}
onTogglePostInstallScript={onTogglePostInstallScriptCheckbox}
onChangePreInstallCondition={onChangePreInstallCondition}
onChangePostInstallScript={onChangePostInstallScript}
/>
<div className="modal-cta-wrap">
<Button type="submit" variant="brand" disabled={isSubmitDisabled}>
Add software
</Button>
<Button onClick={onCancel} variant="inverse">
Cancel
</Button>
</div>
</form>
)}
</div>
);
};
export default AddSoftwareForm;

View file

@ -0,0 +1,43 @@
.add-software-form {
&__uploading-message {
display: flex;
align-items: center;
flex-direction: column;
gap: $pad-large;
p {
margin: 0
}
}
&__form {
display: flex;
flex-direction: column;
gap: $pad-large;
}
&__file-uploader {
box-sizing: border-box;
}
&__selected-file {
display: flex;
gap: $pad-medium;
align-items: center;
width: 100%;
text-align: left;
&--details {
&--name {
font-size: $x-small;
font-weight: $bold;
}
&--platform {
font-size: $xx-small;
color: $ui-fleet-black-75;
}
}
}
}

View file

@ -0,0 +1,163 @@
import validator from "validator";
// @ts-ignore
import validateQuery from "components/forms/validators/validate_query";
import { getPlatformDisplayName } from "utilities/file/fileUtils";
import { IAddSoftwareFormData, IFormValidation } from "./AddSoftwareForm";
type IAddSoftwareFormValidatorKey = Exclude<
keyof IAddSoftwareFormData,
"installScript"
>;
type IMessageFunc = (formData: IAddSoftwareFormData) => string;
type IValidationMessage = string | IMessageFunc;
interface IValidation {
name: string;
isValid: (
formData: IAddSoftwareFormData,
enabledPreInstallCondition?: boolean,
enabledPostInstallScript?: boolean
) => boolean;
message?: IValidationMessage;
}
/** configuration defines validations for each filed in the form. It defines rules
* to determine if a field is valid, and rules for generating an error message.
*/
const FORM_VALIDATION_CONFIG: Record<
IAddSoftwareFormValidatorKey,
{ validations: IValidation[] }
> = {
software: {
validations: [
{
name: "required",
isValid: (formData) => formData.software !== null,
},
],
},
preInstallCondition: {
validations: [
{
name: "required",
isValid: (
formData: IAddSoftwareFormData,
enabledPreInstallCondition
) => {
if (!enabledPreInstallCondition) {
return true;
}
return (
formData.preInstallCondition !== undefined &&
!validator.isEmpty(formData.preInstallCondition)
);
},
message: (formData) => {
// we dont want an error message until the user has interacted with
// the field. This is why we check for undefined here.
if (formData.preInstallCondition === undefined) {
return "";
}
return "Pre-install condition is required when enabled.";
},
},
{
name: "invalidQuery",
isValid: (formData, enabledPreInstallCondition) => {
if (!enabledPreInstallCondition) {
return true;
}
return (
formData.preInstallCondition !== undefined &&
validateQuery(formData.preInstallCondition).valid
);
},
message: (formData) =>
validateQuery(formData.preInstallCondition).error,
},
],
},
postInstallScript: {
validations: [
{
name: "required",
message: (formData) => {
// we dont want an error message until the user has interacted with
// the field. This is why we check for undefined here.
if (formData.postInstallScript === undefined) {
return "";
}
return "Post-install script is required when enabled.";
},
isValid: (formData, _, enabledPostInstallScript) => {
if (!enabledPostInstallScript) {
return true;
}
return (
formData.postInstallScript !== undefined &&
!validator.isEmpty(formData.postInstallScript)
);
},
},
],
},
};
const getErrorMessage = (
formData: IAddSoftwareFormData,
message?: IValidationMessage
) => {
if (message === undefined || typeof message === "string") {
return message;
}
return message(formData);
};
export const generateFormValidation = (
formData: IAddSoftwareFormData,
showingPreInstallCondition: boolean,
showingPostInstallScript: boolean
) => {
const formValidation: IFormValidation = {
isValid: true,
software: {
isValid: false,
},
};
Object.keys(FORM_VALIDATION_CONFIG).forEach((key) => {
const objKey = key as keyof typeof FORM_VALIDATION_CONFIG;
const failedValidation = FORM_VALIDATION_CONFIG[objKey].validations.find(
(validation) =>
!validation.isValid(
formData,
showingPreInstallCondition,
showingPostInstallScript
)
);
if (!failedValidation) {
formValidation[objKey] = {
isValid: true,
};
} else {
formValidation.isValid = false;
formValidation[objKey] = {
isValid: false,
message: getErrorMessage(formData, failedValidation.message),
};
}
});
return formValidation;
};
export const getFileDetails = (file: File) => {
return {
name: file.name,
platform: getPlatformDisplayName(file),
};
};

View file

@ -0,0 +1 @@
export { default } from "./AddSoftwareForm";

View file

@ -0,0 +1,144 @@
import React, { useContext, useEffect, useState } from "react";
import { InjectedRouter } from "react-router";
import PATHS from "router/paths";
import { APP_CONTEXT_ALL_TEAMS_ID } from "interfaces/team";
import { getErrorReason } from "interfaces/errors";
import softwareAPI from "services/entities/software";
import { NotificationContext } from "context/notification";
import { buildQueryStringFromParams } from "utilities/url";
import Modal from "components/Modal";
import Button from "components/buttons/Button";
import AddSoftwareForm from "../AddSoftwareForm";
import { IAddSoftwareFormData } from "../AddSoftwareForm/AddSoftwareForm";
// 2 minutes
const UPLOAD_TIMEOUT = 120000;
const MAX_FILE_SIZE_MB = 500;
const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024;
const baseClass = "add-software-modal";
interface IAllTeamsMessageProps {
onExit: () => void;
}
const AllTeamsMessage = ({ onExit }: IAllTeamsMessageProps) => {
return (
<>
<p>
Please select a team first. Software can&apos;t be added when{" "}
<b>All teams</b> is selected.
</p>
<div className="modal-cta-wrap">
<Button variant="brand" onClick={onExit}>
Done
</Button>
</div>
</>
);
};
interface IAddSoftwareModalProps {
teamId: number;
router: InjectedRouter;
onExit: () => void;
}
const AddSoftwareModal = ({
teamId,
router,
onExit,
}: IAddSoftwareModalProps) => {
const { renderFlash } = useContext(NotificationContext);
const [isUploading, setIsUploading] = useState(false);
useEffect(() => {
let timeout: NodeJS.Timeout;
const beforeUnloadHandler = (e: BeforeUnloadEvent) => {
e.preventDefault();
// Next line with e.returnValue is included for legacy support
// e.g.Chrome / Edge < 119
e.returnValue = true;
};
// set up event listener to prevent user from leaving page while uploading
if (isUploading) {
addEventListener("beforeunload", beforeUnloadHandler);
timeout = setTimeout(() => {
removeEventListener("beforeunload", beforeUnloadHandler);
}, UPLOAD_TIMEOUT);
} else {
removeEventListener("beforeunload", beforeUnloadHandler);
}
// clean up event listener and timeout on component unmount
return () => {
removeEventListener("beforeunload", beforeUnloadHandler);
clearTimeout(timeout);
};
}, [isUploading]);
const onAddSoftware = async (formData: IAddSoftwareFormData) => {
setIsUploading(true);
if (formData.software && formData.software.size > MAX_FILE_SIZE_BYTES) {
renderFlash(
"error",
`Couldnt add. The maximum file size is ${MAX_FILE_SIZE_MB} MB.`
);
onExit();
setIsUploading(false);
return;
}
try {
await softwareAPI.addSoftwarePackage(formData, teamId);
renderFlash(
"success",
<>
<b>{formData.software?.name}</b> successfully added. Go to Host
details page to install software.
</>
);
onExit();
router.push(
`${PATHS.SOFTWARE_TITLES}?${buildQueryStringFromParams({
available_for_install: true,
team_id: teamId,
})}`
);
} catch (e) {
renderFlash("error", getErrorReason(e));
onExit();
}
setIsUploading(false);
};
return (
<Modal
title="Add software"
onExit={onExit}
width="large"
className={baseClass}
>
<>
{teamId === APP_CONTEXT_ALL_TEAMS_ID ? (
<AllTeamsMessage onExit={onExit} />
) : (
<AddSoftwareForm
isUploading={isUploading}
onCancel={onExit}
onSubmit={onAddSoftware}
/>
)}
</>
</Modal>
);
};
export default AddSoftwareModal;

Some files were not shown because too many files have changed in this diff Show more