Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fetch installed apps from iPhone/iPad devices. #20733

Merged
merged 9 commits into from
Jul 28, 2024
Merged
1 change: 1 addition & 0 deletions changes/19447-ios-ipados-software
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
- iOS and iPadOS device details refetch can now be triggered with the existing `POST /api/latest/fleet/hosts/:id/refetch` endpoint.
- iOS and iPadOS user-installed apps can be viewed in Fleet
7 changes: 6 additions & 1 deletion cmd/fleet/cron.go
Original file line number Diff line number Diff line change
Expand Up @@ -1305,7 +1305,12 @@ func newIPhoneIPadRefetcher(
}
logger.Log("msg", "sending commands to refetch", "count", len(uuids), "lookup-duration", time.Since(start))
commandUUID := fleet.RefetchCommandUUIDPrefix + uuid.NewString()
if err := commander.DeviceInformation(ctx, uuids, commandUUID); err != nil {
err = commander.InstalledApplicationList(ctx, uuids, fleet.RefetchAppsCommandUUIDPrefix+commandUUID)
if err != nil {
return ctxerr.Wrap(ctx, err, "send InstalledApplicationList commands to ios and ipados devices")
}
// DeviceInformation is last because the refetch response clears the refetch_requested flag
if err := commander.DeviceInformation(ctx, uuids, fleet.RefetchCommandUUIDPrefix+commandUUID); err != nil {
return ctxerr.Wrap(ctx, err, "send DeviceInformation commands to ios and ipados devices")
}
return nil
Expand Down
16 changes: 16 additions & 0 deletions cmd/osquery-perf/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,19 @@ Run the following command in the shell before running the Fleet server _and_ bef
``` sh
ulimit -n 64000
```

## Running with MDM

Set up MDM on your server. To extract the SCEP challenge, you can use the [MDM asset extractor](https://github.com/fleetdm/fleet/tree/main/tools/mdm/assets).

For your server, disable Apple push notifications since we will be using devices with fake UUIDs:

```
export FLEET_DEV_MDM_APPLE_DISABLE_PUSH=1
```

Example of running the agent with MDM. Note that `enroll_secret` is not needed for iPhone/iPad devices:

```
go run agent.go --os_templates ipad_13.18,iphone_14.6 --host_count 10 --mdm_scep_challenge 0d53306e-6d7a-9d14-a372-f9e53f9d62db
```
136 changes: 116 additions & 20 deletions cmd/osquery-perf/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,27 @@ func (n *nodeKeyManager) Add(nodekey string) {
}
}

type mdmAgent struct {
agentIndex int
MDMCheckInInterval time.Duration
model string
serverAddress string
softwareCount softwareEntityCount
stats *Stats
strings map[string]string
}

// stats, model, *serverURL, *mdmSCEPChallenge, *mdmCheckInInterval
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

leftover?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I'll clean up in the next PR.


func (a *mdmAgent) CachedString(key string) string {
if val, ok := a.strings[key]; ok {
return val
}
val := randomString(12)
a.strings[key] = val
return val
}

type agent struct {
agentIndex int
softwareCount softwareEntityCount
Expand Down Expand Up @@ -1510,6 +1531,53 @@ func (a *agent) softwareMacOS() []map[string]string {
return software
}

func (a *mdmAgent) softwareIOSandIPadOS(source string) []fleet.Software {
commonSoftware := make([]map[string]string, a.softwareCount.common)
for i := 0; i < len(commonSoftware); i++ {
commonSoftware[i] = map[string]string{
"name": fmt.Sprintf("Common_%d", i),
"version": "0.0.1",
"bundle_identifier": fmt.Sprintf("com.fleetdm.osquery-perf.common_%d", i),
"source": source,
}
}
if a.softwareCount.commonSoftwareUninstallProb > 0.0 && rand.Float64() <= a.softwareCount.commonSoftwareUninstallProb {
rand.Shuffle(len(commonSoftware), func(i, j int) {
commonSoftware[i], commonSoftware[j] = commonSoftware[j], commonSoftware[i]
})
commonSoftware = commonSoftware[:a.softwareCount.common-a.softwareCount.commonSoftwareUninstallCount]
}
uniqueSoftware := make([]map[string]string, a.softwareCount.unique)
for i := 0; i < len(uniqueSoftware); i++ {
uniqueSoftware[i] = map[string]string{
"name": fmt.Sprintf("Unique_%s_%d", a.CachedString("hostname"), i),
"version": "1.1.1",
"bundle_identifier": fmt.Sprintf("com.fleetdm.osquery-perf.unique_%s_%d", a.CachedString("hostname"), i),
"source": source,
}
}
if a.softwareCount.uniqueSoftwareUninstallProb > 0.0 && rand.Float64() <= a.softwareCount.uniqueSoftwareUninstallProb {
rand.Shuffle(len(uniqueSoftware), func(i, j int) {
uniqueSoftware[i], uniqueSoftware[j] = uniqueSoftware[j], uniqueSoftware[i]
})
uniqueSoftware = uniqueSoftware[:a.softwareCount.unique-a.softwareCount.uniqueSoftwareUninstallCount]
}
software := append(commonSoftware, uniqueSoftware...)
rand.Shuffle(len(software), func(i, j int) {
software[i], software[j] = software[j], software[i]
})
fleetSoftware := make([]fleet.Software, len(software))
for i, s := range software {
fleetSoftware[i] = fleet.Software{
Name: s["name"],
Version: s["version"],
BundleIdentifier: s["bundle_identifier"],
Source: s["source"],
}
}
return fleetSoftware
}

func (a *agent) softwareVSCodeExtensions() []map[string]string {
commonVSCodeExtensionsSoftware := make([]map[string]string, a.softwareVSCodeExtensionsCount.common)
for i := 0; i < len(commonVSCodeExtensionsSoftware); i++ {
Expand Down Expand Up @@ -2160,48 +2228,57 @@ func (a *agent) submitLogs(results []resultLog) error {
return nil
}

func runAppleIDeviceMDMLoop(i int, stats *Stats, model string, serverURL string, mdmSCEPChallenge string, mdmCheckInInterval time.Duration) {
func (a *mdmAgent) runAppleIDeviceMDMLoop(mdmSCEPChallenge string) {
udid := mdmtest.RandUDID()

mdmClient := mdmtest.NewTestMDMClientAppleDirect(mdmtest.AppleEnrollInfo{
SCEPChallenge: mdmSCEPChallenge,
SCEPURL: serverURL + apple_mdm.SCEPPath,
MDMURL: serverURL + apple_mdm.MDMPath,
}, model)
SCEPURL: a.serverAddress + apple_mdm.SCEPPath,
MDMURL: a.serverAddress + apple_mdm.MDMPath,
}, a.model)
mdmClient.UUID = udid
mdmClient.SerialNumber = mdmtest.RandSerialNumber()
deviceName := fmt.Sprintf("%s-%d", model, i)
productName := model
deviceName := fmt.Sprintf("%s-%d", a.model, a.agentIndex)
productName := a.model
softwareSource := "ios_apps"
if strings.HasPrefix(a.model, "iPad") {
softwareSource = "ipados_apps"
}

if err := mdmClient.Enroll(); err != nil {
log.Printf("%s MDM enroll failed: %s", model, err)
stats.IncrementMDMErrors()
log.Printf("%s MDM enroll failed: %s", a.model, err)
a.stats.IncrementMDMErrors()
return
}

stats.IncrementMDMEnrollments()
a.stats.IncrementMDMEnrollments()

mdmCheckInTicker := time.Tick(mdmCheckInInterval)
mdmCheckInTicker := time.Tick(a.MDMCheckInInterval)

for range mdmCheckInTicker {
mdmCommandPayload, err := mdmClient.Idle()
if err != nil {
log.Printf("MDM Idle request failed: %s: %s", model, err)
stats.IncrementMDMErrors()
log.Printf("MDM Idle request failed: %s: %s", a.model, err)
a.stats.IncrementMDMErrors()
continue
}
stats.IncrementMDMSessions()
a.stats.IncrementMDMSessions()

for mdmCommandPayload != nil {
stats.IncrementMDMCommandsReceived()
if mdmCommandPayload.Command.RequestType == "DeviceInformation" {
mdmCommandPayload, err = mdmClient.AcknowledgeDeviceInformation(udid, mdmCommandPayload.CommandUUID, deviceName, productName)
} else {
a.stats.IncrementMDMCommandsReceived()
switch mdmCommandPayload.Command.RequestType {
case "DeviceInformation":
mdmCommandPayload, err = mdmClient.AcknowledgeDeviceInformation(udid, mdmCommandPayload.CommandUUID, deviceName,
productName)
case "InstalledApplicationList":
software := a.softwareIOSandIPadOS(softwareSource)
mdmCommandPayload, err = mdmClient.AcknowledgeInstalledApplicationList(udid, mdmCommandPayload.CommandUUID, software)
default:
mdmCommandPayload, err = mdmClient.Acknowledge(mdmCommandPayload.CommandUUID)
}
if err != nil {
log.Printf("MDM Acknowledge request failed: %s: %s", model, err)
stats.IncrementMDMErrors()
log.Printf("MDM Acknowledge request failed: %s: %s", a.model, err)
a.stats.IncrementMDMErrors()
break
}
}
Expand Down Expand Up @@ -2416,7 +2493,26 @@ func main() {
if tmpl.Name() == "ipad_13.18.tmpl" {
model = "iPad 13,18"
}
go runAppleIDeviceMDMLoop(i, stats, model, *serverURL, *mdmSCEPChallenge, *mdmCheckInInterval)
mobileDevice := mdmAgent{
agentIndex: i + 1,
MDMCheckInInterval: *mdmCheckInInterval,
model: model,
serverAddress: *serverURL,
softwareCount: softwareEntityCount{
entityCount: entityCount{
common: *commonSoftwareCount,
unique: *uniqueSoftwareCount,
},
vulnerable: *vulnerableSoftwareCount,
commonSoftwareUninstallCount: *commonSoftwareUninstallCount,
commonSoftwareUninstallProb: *commonSoftwareUninstallProb,
uniqueSoftwareUninstallCount: *uniqueSoftwareUninstallCount,
uniqueSoftwareUninstallProb: *uniqueSoftwareUninstallProb,
},
stats: stats,
strings: make(map[string]string),
}
go mobileDevice.runAppleIDeviceMDMLoop(*mdmSCEPChallenge)
time.Sleep(sleepTime)
continue
}
Expand Down
20 changes: 20 additions & 0 deletions pkg/mdm/mdmtest/apple.go
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,26 @@ func (c *TestAppleMDMClient) AcknowledgeDeviceInformation(udid, cmdUUID, deviceN
return c.sendAndDecodeCommandResponse(payload)
}

func (c *TestAppleMDMClient) AcknowledgeInstalledApplicationList(udid, cmdUUID string, software []fleet.Software) (*mdm.Command, error) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

my personal preference would be to keep the methods of the mdmtest client reduced to only the protocol operations, it's a bit more verbose for the caller, so I understand the motivation for this.

mdmSoftware := make([]map[string]interface{}, 0, len(software))
for _, s := range software {
mdmSoftware = append(mdmSoftware, map[string]interface{}{
"Name": s.Name,
"ShortVersion": s.Version,
"Identifier": s.BundleIdentifier,
})
}

payload := map[string]any{
"Status": "Acknowledged",
"UDID": udid,
"CommandUUID": cmdUUID,
"InstalledApplicationList": mdmSoftware,
}

return c.sendAndDecodeCommandResponse(payload)
}

func (c *TestAppleMDMClient) GetBootstrapToken() ([]byte, error) {
payload := map[string]any{
"MessageType": "GetBootstrapToken",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package tables

import (
"database/sql"
"fmt"
)

func init() {
MigrationClient.AddMigration(Up_20240725182118, Down_20240725182118)
}

func Up_20240725182118(tx *sql.Tx) error {
if columnExists(tx, "software_titles", "additional_identifier") {
return nil
}

_, err := tx.Exec(`
ALTER TABLE software_titles
ADD COLUMN additional_identifier TINYINT UNSIGNED GENERATED ALWAYS AS
(CASE
WHEN source = 'ios_apps' then 1
WHEN source = 'ipados_apps' then 2
WHEN bundle_identifier IS NOT NULL THEN 0
ELSE NULL
END) VIRTUAL`)
if err != nil {
return fmt.Errorf("adding additional_identifier to software_titles: %w", err)
}

_, err = tx.Exec(`ALTER TABLE software_titles DROP INDEX idx_software_titles_bundle_identifier`)
if err != nil {
return fmt.Errorf("dropping unique key for bundle_identifier in software_titles: %w", err)
}

_, err = tx.Exec(`ALTER TABLE software_titles ADD UNIQUE KEY idx_software_titles_bundle_identifier (bundle_identifier, additional_identifier)`)
if err != nil {
return fmt.Errorf("adding unique key for identifiers in software_titles: %w", err)
}

return nil
}

func Down_20240725182118(_ *sql.Tx) error {
return nil
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package tables

import (
"testing"

"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestUp_20240725182118(t *testing.T) {
db := applyUpToPrev(t)

// Data before ios and ipados apps were added
dataStmt := `
INSERT INTO software_titles (id, name, source, browser, bundle_identifier) VALUES
(1, 'Foo.app', 'apps', '', 'com.example.foo'),
(2, 'Foo2.app', 'apps', '', 'com.example.foo2'),
(3, 'Chrome Extension', 'chrome_extensions', 'chrome', NULL),
(4, 'Microsoft Teams.exe', 'programs', '', NULL);
`

_, err := db.Exec(dataStmt)
require.NoError(t, err)
applyNext(t, db)

type softwareTitle struct {
Name string `db:"name"`
Source string `db:"source"`
Browser string `db:"browser"`
BundleIdentifier *string `db:"bundle_identifier"`
AdditionalIdentifier *uint32 `db:"additional_identifier"`
}

var titles []softwareTitle
err = db.Select(&titles, `SELECT name, source, browser, bundle_identifier, additional_identifier FROM software_titles`)
require.NoError(t, err)
zero := uint32(0)
expectedTitles := []softwareTitle{
{"Foo.app", "apps", "", ptr.String("com.example.foo"), &zero},
{"Foo2.app", "apps", "", ptr.String("com.example.foo2"), &zero},
{"Chrome Extension", "chrome_extensions", "chrome", nil, nil},
{"Microsoft Teams.exe", "programs", "", nil, nil},
}
assert.ElementsMatch(t, expectedTitles, titles)

// Ensure that the unique key is enforced
dataStmt = `
INSERT INTO software_titles (id, name, source, browser, bundle_identifier) VALUES
(100, 'Foo3', 'foo', '', 'com.example.foo');
`
_, err = db.Exec(dataStmt)
assert.ErrorContains(t, err, "Duplicate entry")

// Add ios and ipados apps
dataStmt = `
INSERT INTO software_titles (id, name, source, browser, bundle_identifier) VALUES
(5, 'Foo', 'ios_apps', '', 'com.example.foo'),
(6, 'Foo', 'ipados_apps', '', 'com.example.foo'),
(7, 'Bar-Pocket', 'ios_apps', '', 'com.example.bar-pocket'),
(8, 'Bar', 'ipados_apps', '', 'com.example.bar');
`
_, err = db.Exec(dataStmt)
require.NoError(t, err)

err = db.Select(&titles, `SELECT name, source, browser, bundle_identifier, additional_identifier FROM software_titles`)
require.NoError(t, err)
one := uint32(1)
two := uint32(2)
expectedTitles = append(expectedTitles, []softwareTitle{
{"Foo", "ios_apps", "", ptr.String("com.example.foo"), &one},
{"Foo", "ipados_apps", "", ptr.String("com.example.foo"), &two},
{"Bar-Pocket", "ios_apps", "", ptr.String("com.example.bar-pocket"), &one},
{"Bar", "ipados_apps", "", ptr.String("com.example.bar"), &two},
}...)
assert.ElementsMatch(t, expectedTitles, titles)

// Ensure that the unique key is enforced
dataStmt = `
INSERT INTO software_titles (id, name, source, browser, bundle_identifier) VALUES
(200, 'Foo-Pocket', 'ipados_apps', '', 'com.example.foo');
`
_, err = db.Exec(dataStmt)
assert.ErrorContains(t, err, "Duplicate entry")

}
Loading
Loading