14753 windows ps1 api (#15113)
# 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/` or `orbit/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] Manual QA for all new/changed functionality
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
- Updated `POST /scripts` to allow `.ps1` scripts for Windows
|
||||
- Updated `fleetctl` output to reflect support for `.ps1` scripts
|
||||
- Updated `GET /hosts/{id}/scripts` to return `.sh` scripts for MacOS hosts and `.ps1` scripts for
|
||||
Windows hosts.
|
||||
@@ -365,8 +365,8 @@ func (svc *Service) GetHostScriptDetails(ctx context.Context, hostID uint, opt f
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if h.Platform != "darwin" {
|
||||
// only darwin is supported for now, all other platforms return empty results
|
||||
if h.Platform != "darwin" && h.Platform != "windows" {
|
||||
// darwin and windows are supported for now, all other platforms return empty results
|
||||
level.Debug(svc.logger).Log("msg", "unsupported platform for host script details", "platform", h.Platform, "host_id", h.ID)
|
||||
return []*fleet.HostScriptDetail{}, &fleet.PaginationMetadata{}, nil
|
||||
}
|
||||
@@ -381,7 +381,7 @@ func (svc *Service) GetHostScriptDetails(ctx context.Context, hostID uint, opt f
|
||||
// always include metadata for scripts
|
||||
opt.IncludeMetadata = true
|
||||
|
||||
return svc.ds.GetHostScriptDetails(ctx, h.ID, h.TeamID, opt)
|
||||
return svc.ds.GetHostScriptDetails(ctx, h.ID, h.TeamID, opt, h.Platform)
|
||||
}
|
||||
|
||||
func (svc *Service) BatchSetScripts(ctx context.Context, maybeTmID *uint, maybeTmName *string, payloads []fleet.ScriptPayload, dryRun bool) error {
|
||||
|
||||
@@ -243,12 +243,21 @@ WHERE
|
||||
return scripts, metaData, nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) GetHostScriptDetails(ctx context.Context, hostID uint, teamID *uint, opt fleet.ListOptions) ([]*fleet.HostScriptDetail, *fleet.PaginationMetadata, error) {
|
||||
func (ds *Datastore) GetHostScriptDetails(ctx context.Context, hostID uint, teamID *uint, opt fleet.ListOptions, hostPlatform string) ([]*fleet.HostScriptDetail, *fleet.PaginationMetadata, error) {
|
||||
var globalOrTeamID uint
|
||||
if teamID != nil {
|
||||
globalOrTeamID = *teamID
|
||||
}
|
||||
|
||||
var extension string
|
||||
switch hostPlatform {
|
||||
case "darwin":
|
||||
extension = `%.sh`
|
||||
break
|
||||
case "windows":
|
||||
extension = `%.ps1`
|
||||
}
|
||||
|
||||
type row struct {
|
||||
ScriptID uint `db:"script_id"`
|
||||
Name string `db:"name"`
|
||||
@@ -295,9 +304,16 @@ FROM
|
||||
ON s.id = hsr.script_id
|
||||
WHERE
|
||||
(hsr.host_id IS NULL OR hsr.host_id = ?)
|
||||
AND s.global_or_team_id = ?`
|
||||
AND s.global_or_team_id = ?
|
||||
`
|
||||
|
||||
args := []any{hostID, hostID, hostID, globalOrTeamID}
|
||||
if len(extension) > 0 {
|
||||
args = append(args, extension)
|
||||
sql += `
|
||||
AND s.name LIKE ?
|
||||
`
|
||||
}
|
||||
stmt, args := appendListOptionsWithCursorToSQL(sql, args, &opt)
|
||||
|
||||
var rows []*row
|
||||
@@ -424,5 +440,4 @@ ON DUPLICATE KEY UPDATE
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
@@ -363,7 +363,7 @@ func testListScripts(t *testing.T, ds *Datastore) {
|
||||
func testGetHostScriptDetails(t *testing.T, ds *Datastore) {
|
||||
ctx := context.Background()
|
||||
|
||||
names := []string{"script-1", "script-2", "script-3", "script-4", "script-5"}
|
||||
names := []string{"script-1.sh", "script-2.sh", "script-3.sh", "script-4.sh", "script-5.sh"}
|
||||
for _, r := range append(names[1:], names[0]) {
|
||||
_, err := ds.NewScript(ctx, &fleet.Script{
|
||||
Name: r,
|
||||
@@ -372,9 +372,16 @@ func testGetHostScriptDetails(t *testing.T, ds *Datastore) {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// create a windows script as well
|
||||
_, err := ds.NewScript(ctx, &fleet.Script{
|
||||
Name: "script-6.ps1",
|
||||
ScriptContents: `Write-Host "Hello, World!"`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
scripts, _, err := ds.ListScripts(ctx, nil, fleet.ListOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, scripts, 5)
|
||||
require.Len(t, scripts, 6)
|
||||
|
||||
insertResults := func(t *testing.T, hostID uint, script *fleet.Script, createdAt time.Time, execID string, exitCode *int64) {
|
||||
stmt := `
|
||||
@@ -421,9 +428,9 @@ VALUES
|
||||
insertResults(t, 42, &fleet.Script{Name: "script-6", ScriptContents: "echo script-6"}, now.Add(-1*time.Minute), "execution-6-1", ptr.Int64(0))
|
||||
|
||||
t.Run("results match expected formatting and filtering", func(t *testing.T) {
|
||||
res, _, err := ds.GetHostScriptDetails(ctx, 42, nil, fleet.ListOptions{})
|
||||
res, _, err := ds.GetHostScriptDetails(ctx, 42, nil, fleet.ListOptions{}, "")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res, 5)
|
||||
require.Len(t, res, 6)
|
||||
for _, r := range res {
|
||||
switch r.ScriptID {
|
||||
case scripts[0].ID:
|
||||
@@ -453,6 +460,9 @@ VALUES
|
||||
case scripts[4].ID:
|
||||
require.Equal(t, scripts[4].Name, r.Name)
|
||||
require.Nil(t, r.LastExecution)
|
||||
case scripts[5].ID:
|
||||
require.Equal(t, scripts[5].Name, r.Name)
|
||||
require.Nil(t, r.LastExecution)
|
||||
default:
|
||||
t.Errorf("unexpected script id: %d", r.ScriptID)
|
||||
}
|
||||
@@ -460,7 +470,7 @@ VALUES
|
||||
})
|
||||
|
||||
t.Run("empty slice returned if no scripts", func(t *testing.T) {
|
||||
res, _, err := ds.GetHostScriptDetails(ctx, 42, ptr.Uint(1), fleet.ListOptions{}) // team 1 has no scripts
|
||||
res, _, err := ds.GetHostScriptDetails(ctx, 42, ptr.Uint(1), fleet.ListOptions{}, "") // team 1 has no scripts
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, res)
|
||||
require.Len(t, res, 0)
|
||||
@@ -500,7 +510,7 @@ VALUES
|
||||
c.opts.IncludeMetadata = true
|
||||
// custom ordering is not supported, always by name
|
||||
c.opts.OrderKey = "name"
|
||||
results, meta, err := ds.GetHostScriptDetails(ctx, 42, nil, c.opts)
|
||||
results, meta, err := ds.GetHostScriptDetails(ctx, 42, nil, c.opts, "darwin")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, len(c.wantNames), len(results))
|
||||
@@ -517,6 +527,14 @@ VALUES
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("windows ps1 scripts are supported", func(t *testing.T) {
|
||||
res, _, err := ds.GetHostScriptDetails(ctx, 42, nil, fleet.ListOptions{}, "windows")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, res)
|
||||
require.Len(t, res, 1)
|
||||
require.Equal(t, "script-6.ps1", res[0].Name)
|
||||
})
|
||||
}
|
||||
|
||||
func testBatchSetScripts(t *testing.T, ds *Datastore) {
|
||||
|
||||
@@ -1159,7 +1159,7 @@ type Datastore interface {
|
||||
|
||||
// GetHostScriptDetails returns the list of host script details for saved scripts applicable to
|
||||
// a given host.
|
||||
GetHostScriptDetails(ctx context.Context, hostID uint, teamID *uint, opts ListOptions) ([]*HostScriptDetail, *PaginationMetadata, error)
|
||||
GetHostScriptDetails(ctx context.Context, hostID uint, teamID *uint, opts ListOptions, hostPlatform string) ([]*HostScriptDetail, *PaginationMetadata, error)
|
||||
|
||||
// BatchSetScripts sets the scripts for the given team or no team.
|
||||
BatchSetScripts(ctx context.Context, tmID *uint, scripts []*Script) error
|
||||
|
||||
@@ -34,8 +34,8 @@ func (s *Script) Validate() error {
|
||||
if s.Name == "" {
|
||||
return errors.New("The file name must not be empty.")
|
||||
}
|
||||
if filepath.Ext(s.Name) != ".sh" {
|
||||
return errors.New("The file should be a .sh file.")
|
||||
if filepath.Ext(s.Name) != ".sh" && filepath.Ext(s.Name) != ".ps1" {
|
||||
return errors.New("File type not supported. Only .sh and .ps1 file type is allowed.")
|
||||
}
|
||||
|
||||
if err := ValidateHostScriptContents(s.ScriptContents); err != nil {
|
||||
|
||||
@@ -37,7 +37,7 @@ func TestScriptValidate(t *testing.T) {
|
||||
Name: "test.txt",
|
||||
ScriptContents: "valid",
|
||||
},
|
||||
wantErr: errors.New("The file should be a .sh file."),
|
||||
wantErr: errors.New("File type not supported. Only .sh and .ps1 file type is allowed."),
|
||||
},
|
||||
{
|
||||
name: "invalid script content",
|
||||
|
||||
@@ -738,7 +738,7 @@ type DeleteScriptFunc func(ctx context.Context, id uint) error
|
||||
|
||||
type ListScriptsFunc func(ctx context.Context, teamID *uint, opt fleet.ListOptions) ([]*fleet.Script, *fleet.PaginationMetadata, error)
|
||||
|
||||
type GetHostScriptDetailsFunc func(ctx context.Context, hostID uint, teamID *uint, opts fleet.ListOptions) ([]*fleet.HostScriptDetail, *fleet.PaginationMetadata, error)
|
||||
type GetHostScriptDetailsFunc func(ctx context.Context, hostID uint, teamID *uint, opts fleet.ListOptions, hostPlatform string) ([]*fleet.HostScriptDetail, *fleet.PaginationMetadata, error)
|
||||
|
||||
type BatchSetScriptsFunc func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error
|
||||
|
||||
@@ -4352,11 +4352,11 @@ func (s *DataStore) ListScripts(ctx context.Context, teamID *uint, opt fleet.Lis
|
||||
return s.ListScriptsFunc(ctx, teamID, opt)
|
||||
}
|
||||
|
||||
func (s *DataStore) GetHostScriptDetails(ctx context.Context, hostID uint, teamID *uint, opts fleet.ListOptions) ([]*fleet.HostScriptDetail, *fleet.PaginationMetadata, error) {
|
||||
func (s *DataStore) GetHostScriptDetails(ctx context.Context, hostID uint, teamID *uint, opts fleet.ListOptions, hostPlatform string) ([]*fleet.HostScriptDetail, *fleet.PaginationMetadata, error) {
|
||||
s.mu.Lock()
|
||||
s.GetHostScriptDetailsFuncInvoked = true
|
||||
s.mu.Unlock()
|
||||
return s.GetHostScriptDetailsFunc(ctx, hostID, teamID, opts)
|
||||
return s.GetHostScriptDetailsFunc(ctx, hostID, teamID, opts, hostPlatform)
|
||||
}
|
||||
|
||||
func (s *DataStore) BatchSetScripts(ctx context.Context, tmID *uint, scripts []*fleet.Script) error {
|
||||
|
||||
@@ -4316,7 +4316,7 @@ func (s *integrationEnterpriseTestSuite) TestSavedScripts() {
|
||||
"not_sh.txt", []byte(`echo "hello"`), s.token)
|
||||
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusUnprocessableEntity, headers)
|
||||
errMsg = extractServerErrorText(res.Body)
|
||||
require.Contains(t, errMsg, "The file should be a .sh file")
|
||||
require.Contains(t, errMsg, "Validation Failed: File type not supported. Only .sh and .ps1 file type is allowed.")
|
||||
|
||||
// file content is empty
|
||||
body, headers = generateNewScriptMultipartRequest(t, nil,
|
||||
@@ -4368,6 +4368,16 @@ func (s *integrationEnterpriseTestSuite) TestSavedScripts() {
|
||||
tmScriptID := newScriptResp.ScriptID
|
||||
s.lastActivityMatches("added_script", fmt.Sprintf(`{"script_name": %q, "team_name": %q, "team_id": %d}`, "script1.sh", tm.Name, tm.ID), 0)
|
||||
|
||||
// create a windows script
|
||||
body, headers = generateNewScriptMultipartRequest(t, &tm.ID,
|
||||
"script2.ps1", []byte(`Write-Host "Hello, World!"`), s.token)
|
||||
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusOK, headers)
|
||||
err = json.NewDecoder(res.Body).Decode(&newScriptResp)
|
||||
require.NoError(t, err)
|
||||
require.NotZero(t, newScriptResp.ScriptID)
|
||||
require.NotEqual(t, noTeamScriptID, newScriptResp.ScriptID)
|
||||
s.lastActivityMatches("added_script", fmt.Sprintf(`{"script_name": %q, "team_name": %q, "team_id": %d}`, "script2.ps1", tm.Name, tm.ID), 0)
|
||||
|
||||
// get team's script
|
||||
getScriptResp = getScriptResponse{}
|
||||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/scripts/%d", tmScriptID), nil, http.StatusOK, &getScriptResp)
|
||||
@@ -4542,17 +4552,23 @@ func (s *integrationEnterpriseTestSuite) TestHostScriptDetails() {
|
||||
require.NoError(t, err)
|
||||
tm3, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "test-script-details-team3"})
|
||||
require.NoError(t, err)
|
||||
tm4, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "test-script-details-team4-windows"})
|
||||
require.NoError(t, err)
|
||||
|
||||
// create 5 scripts for no team and team 1
|
||||
for i := 0; i < 5; i++ {
|
||||
_, err = s.ds.NewScript(ctx, &fleet.Script{Name: fmt.Sprintf("test-script-details-%d", i), ScriptContents: "echo"})
|
||||
_, err = s.ds.NewScript(ctx, &fleet.Script{Name: fmt.Sprintf("test-script-details-%d.sh", i), ScriptContents: "echo"})
|
||||
require.NoError(t, err)
|
||||
_, err = s.ds.NewScript(ctx, &fleet.Script{Name: fmt.Sprintf("test-script-details-%d", i), TeamID: &tm1.ID, ScriptContents: "echo"})
|
||||
_, err = s.ds.NewScript(ctx, &fleet.Script{Name: fmt.Sprintf("test-script-details-%d.sh", i), TeamID: &tm1.ID, ScriptContents: "echo"})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// add a windows script to team 4
|
||||
_, err = s.ds.NewScript(ctx, &fleet.Script{Name: "test-script-details-windows.ps1", TeamID: &tm4.ID, ScriptContents: `Write-Host "Hello, World!"`})
|
||||
require.NoError(t, err)
|
||||
|
||||
// create a single script for team 2
|
||||
_, err = s.ds.NewScript(ctx, &fleet.Script{Name: "test-script-details-team-2", TeamID: &tm2.ID, ScriptContents: "echo"})
|
||||
_, err = s.ds.NewScript(ctx, &fleet.Script{Name: "test-script-details-team-2.sh", TeamID: &tm2.ID, ScriptContents: "echo"})
|
||||
require.NoError(t, err)
|
||||
|
||||
// create a host without a team
|
||||
@@ -4599,7 +4615,7 @@ func (s *integrationEnterpriseTestSuite) TestHostScriptDetails() {
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// create a Windows host (unsupported)
|
||||
// create a Windows host
|
||||
host3, err := s.ds.NewHost(ctx, &fleet.Host{
|
||||
DetailUpdatedAt: time.Now(),
|
||||
LabelUpdatedAt: time.Now(),
|
||||
@@ -4610,7 +4626,7 @@ func (s *integrationEnterpriseTestSuite) TestHostScriptDetails() {
|
||||
UUID: uuid.New().String(),
|
||||
Hostname: "host3",
|
||||
Platform: "windows",
|
||||
TeamID: nil,
|
||||
TeamID: &tm4.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -4811,16 +4827,15 @@ VALUES
|
||||
require.Len(t, resp.Scripts, 0)
|
||||
})
|
||||
|
||||
t.Run("unsupported platform windows", func(t *testing.T) {
|
||||
require.Nil(t, host3.TeamID)
|
||||
noTeamScripts, _, err := s.ds.ListScripts(ctx, nil, fleet.ListOptions{})
|
||||
t.Run("windows", func(t *testing.T) {
|
||||
team4Scripts, _, err := s.ds.ListScripts(ctx, &tm4.ID, fleet.ListOptions{})
|
||||
require.NoError(t, err)
|
||||
require.True(t, len(noTeamScripts) > 0)
|
||||
require.Len(t, team4Scripts, 1)
|
||||
|
||||
var resp getHostScriptDetailsResponse
|
||||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/scripts", host3.ID), nil, http.StatusOK, &resp)
|
||||
require.NotNil(t, resp.Scripts)
|
||||
require.Len(t, resp.Scripts, 0)
|
||||
require.Len(t, resp.Scripts, 1)
|
||||
})
|
||||
|
||||
t.Run("unsupported platform linux", func(t *testing.T) {
|
||||
|
||||
@@ -796,7 +796,7 @@ func TestHostScriptDetailsAuth(t *testing.T) {
|
||||
require.Equal(t, uint(42), hostID)
|
||||
return &fleet.Host{ID: hostID}, nil
|
||||
}
|
||||
ds.GetHostScriptDetailsFunc = func(ctx context.Context, hostID uint, teamID *uint, opts fleet.ListOptions) ([]*fleet.HostScriptDetail, *fleet.PaginationMetadata, error) {
|
||||
ds.GetHostScriptDetailsFunc = func(ctx context.Context, hostID uint, teamID *uint, opts fleet.ListOptions, hostPlatform string) ([]*fleet.HostScriptDetail, *fleet.PaginationMetadata, error) {
|
||||
require.Nil(t, teamID)
|
||||
return []*fleet.HostScriptDetail{}, nil, nil
|
||||
}
|
||||
@@ -809,7 +809,7 @@ func TestHostScriptDetailsAuth(t *testing.T) {
|
||||
require.Equal(t, uint(42), hostID)
|
||||
return &fleet.Host{ID: hostID, TeamID: ptr.Uint(1)}, nil
|
||||
}
|
||||
ds.GetHostScriptDetailsFunc = func(ctx context.Context, hostID uint, teamID *uint, opts fleet.ListOptions) ([]*fleet.HostScriptDetail, *fleet.PaginationMetadata, error) {
|
||||
ds.GetHostScriptDetailsFunc = func(ctx context.Context, hostID uint, teamID *uint, opts fleet.ListOptions, hostPlatform string) ([]*fleet.HostScriptDetail, *fleet.PaginationMetadata, error) {
|
||||
require.NotNil(t, teamID)
|
||||
require.Equal(t, uint(1), *teamID)
|
||||
return []*fleet.HostScriptDetail{}, nil, nil
|
||||
@@ -844,7 +844,7 @@ func TestHostScriptDetailsSupportedPlatform(t *testing.T) {
|
||||
return &fleet.AppConfig{}, nil
|
||||
}
|
||||
|
||||
ds.GetHostScriptDetailsFunc = func(ctx context.Context, hostID uint, teamID *uint, opts fleet.ListOptions) ([]*fleet.HostScriptDetail, *fleet.PaginationMetadata, error) {
|
||||
ds.GetHostScriptDetailsFunc = func(ctx context.Context, hostID uint, teamID *uint, opts fleet.ListOptions, hostPlatform string) ([]*fleet.HostScriptDetail, *fleet.PaginationMetadata, error) {
|
||||
return []*fleet.HostScriptDetail{{HostID: hostID, ScriptID: 1337, Name: "some-script.sh"}}, nil, nil
|
||||
}
|
||||
|
||||
@@ -857,7 +857,7 @@ func TestHostScriptDetailsSupportedPlatform(t *testing.T) {
|
||||
{"centos", false},
|
||||
{"rhel", false},
|
||||
{"debian", false},
|
||||
{"windows", false},
|
||||
{"windows", true},
|
||||
} {
|
||||
t.Run(tt.platform, func(t *testing.T) {
|
||||
ds.GetHostScriptDetailsFuncInvoked = false
|
||||
|
||||
Reference in New Issue
Block a user