diff --git a/.github/workflows/dogfood-deploy.yml b/.github/workflows/dogfood-deploy.yml index f48da405acc5..f9872e23a813 100644 --- a/.github/workflows/dogfood-deploy.yml +++ b/.github/workflows/dogfood-deploy.yml @@ -65,7 +65,7 @@ jobs: - uses: hashicorp/setup-terraform@633666f66e0061ca3b725c73b2ec20cd13a8fdd1 # v2.0.3 with: - terraform_version: 1.6.3 + terraform_version: 1.10.2 terraform_wrapper: false - name: Terraform Init id: init diff --git a/CODEOWNERS b/CODEOWNERS index e4d1312134c0..c8512f309c58 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -65,9 +65,9 @@ go.mod @fleetdm/go # # (see website/config/custom.js for DRIs of other paths not listed here) ############################################################################################## -/docs @eashaw -/docs/REST\ API/rest-api.md @iansltx # « REST API reference documentation -/docs/Contributing/API-for-contributors.md @iansltx # « Advanced / contributors-only API reference documentation +/docs @rachaelshaw +/docs/REST\ API/rest-api.md @rachaelshaw # « REST API reference documentation +/docs/Contributing/API-for-contributors.md @rachaelshaw # « Advanced / contributors-only API reference documentation /schema @eashaw # « Data tables (osquery/fleetd schema) documentation /render.yaml @edwardsb diff --git a/articles/role-based-access.md b/articles/role-based-access.md index 125bbffaed9a..08356c86f9cd 100644 --- a/articles/role-based-access.md +++ b/articles/role-based-access.md @@ -34,7 +34,7 @@ GitOps is an API-only and write-only role that can be used on CI/CD pipelines. ## User permissions | **Action** | Observer | Observer+* | Maintainer | Admin | GitOps* | -| ------------------------------------------------------------------------------------------------------------------------------------------ | -------- | ---------- | ---------- | ----- | ------- | +| ------------------------------------------------------------------------------------------------------------------------------------------ | :------: | :--------: | :--------: | :---: | :-----: | | View all [activity](https://fleetdm.com/docs/using-fleet/rest-api#activities) | ✅ | ✅ | ✅ | ✅ | | | Manage [activity automations](https://fleetdm.com/docs/using-fleet/audit-logs) | | | | ✅ | ✅ | | View all hosts | ✅ | ✅ | ✅ | ✅ | | @@ -123,7 +123,7 @@ Users can be assigned to multiple teams in Fleet. Users with access to multiple teams can be assigned different roles for each team. For example, a user can be given access to the "Workstations" team and assigned the "Observer" role. This same user can be given access to the "Servers" team and assigned the "Maintainer" role. | **Action** | Team observer | Team observer+ | Team maintainer | Team admin | Team GitOps | -| -------------------------------------------------------------------------------------------------------------------------------- | ------------- | -------------- | --------------- | ---------- | ----------- | +| -------------------------------------------------------------------------------------------------------------------------------- | :-----------: | :------------: | :-------------: | :--------: | :---------: | | View hosts | ✅ | ✅ | ✅ | ✅ | | | View a host by identifier | ✅ | ✅ | ✅ | ✅ | ✅ | | Filter hosts using [labels](https://fleetdm.com/docs/using-fleet/rest-api#labels) | ✅ | ✅ | ✅ | ✅ | | diff --git a/changes/23512-clarify-expected-behavior-of-host-counts b/changes/23512-clarify-expected-behavior-of-host-counts new file mode 100644 index 000000000000..98e0b64acb81 --- /dev/null +++ b/changes/23512-clarify-expected-behavior-of-host-counts @@ -0,0 +1,2 @@ +- Clarify expected behavior of policy host counts, dashboard controls software count, and controls + os updates versions count. diff --git a/changes/23823-cloudfront-cdn b/changes/23823-cloudfront-cdn new file mode 100644 index 000000000000..21db000a6965 --- /dev/null +++ b/changes/23823-cloudfront-cdn @@ -0,0 +1,4 @@ +Allow delivery of bootstrap packages and software installers using signed URLs from CloudFront CDN. To enable, configure server settings: +- s3_software_installers_cloudfront_url +- s3_software_installers_cloudfront_url_signing_public_key_id +- s3_software_installers_cloudfront_url_signing_private_key diff --git a/changes/24366-success-email-message b/changes/24366-success-email-message new file mode 100644 index 000000000000..9212848a409a --- /dev/null +++ b/changes/24366-success-email-message @@ -0,0 +1 @@ +- Improve readability of success message on email update by never including the sender address. diff --git a/changes/24629-ui-os-updates-table b/changes/24629-ui-os-updates-table new file mode 100644 index 000000000000..29bbd1ea3f23 --- /dev/null +++ b/changes/24629-ui-os-updates-table @@ -0,0 +1,2 @@ +- Fixed UI bug on the "Controls" page where incorrect timestamp information was displayed while the + "Current versions" table was loading. diff --git a/changes/24795-host-count b/changes/24795-host-count new file mode 100644 index 000000000000..20dd5dcf4c09 --- /dev/null +++ b/changes/24795-host-count @@ -0,0 +1 @@ +- Fleet UI: Added timestamp for software, OS, and vulnerability detail pages for host count last update time diff --git a/changes/24804-deleted-profiles b/changes/24804-deleted-profiles new file mode 100644 index 000000000000..2e1e1e32945a --- /dev/null +++ b/changes/24804-deleted-profiles @@ -0,0 +1 @@ +Fixed issue where deleted Apple config profiles were installing on devices because devices were offline when the profile was added. diff --git a/changes/25004-fleetctl-packge-cli-instructions b/changes/25004-fleetctl-packge-cli-instructions new file mode 100644 index 000000000000..dc1fa6fa2bee --- /dev/null +++ b/changes/25004-fleetctl-packge-cli-instructions @@ -0,0 +1 @@ +- Display command line installation instructions when a package is generated diff --git a/changes/25144-uninstall-after-mdm-action b/changes/25144-uninstall-after-mdm-action new file mode 100644 index 000000000000..136a2ff7776e --- /dev/null +++ b/changes/25144-uninstall-after-mdm-action @@ -0,0 +1 @@ +* Fixed reporting of software uninstall results after a host has been locked/unlocked diff --git a/cmd/fleet/main.go b/cmd/fleet/main.go index b39cbc2ee942..c60fc5a55ac0 100644 --- a/cmd/fleet/main.go +++ b/cmd/fleet/main.go @@ -104,24 +104,25 @@ func applyDevFlags(cfg *config.FleetConfig) { cfg.Prometheus.BasicAuth.Password = "insecure" } - cfg.S3 = config.S3Config{ - CarvesBucket: "carves-dev", - CarvesRegion: "minio", - CarvesPrefix: "dev-prefix", - CarvesEndpointURL: "localhost:9000", - CarvesAccessKeyID: "minio", - CarvesSecretAccessKey: "minio123!", - CarvesDisableSSL: true, - CarvesForceS3PathStyle: true, - - SoftwareInstallersBucket: "software-installers-dev", - SoftwareInstallersRegion: "minio", - SoftwareInstallersPrefix: "dev-prefix", - SoftwareInstallersEndpointURL: "localhost:9000", - SoftwareInstallersAccessKeyID: "minio", - SoftwareInstallersSecretAccessKey: "minio123!", - SoftwareInstallersDisableSSL: true, - SoftwareInstallersForceS3PathStyle: true, + cfg.S3.CarvesBucket = "carves-dev" + cfg.S3.CarvesRegion = "minio" + cfg.S3.CarvesPrefix = "dev-prefix" + cfg.S3.CarvesEndpointURL = "localhost:9000" + cfg.S3.CarvesAccessKeyID = "minio" + cfg.S3.CarvesSecretAccessKey = "minio123!" + cfg.S3.CarvesDisableSSL = true + cfg.S3.CarvesForceS3PathStyle = true + + // Allow the software installers bucket to be overridden in dev mode + if cfg.S3.SoftwareInstallersBucket == "" { + cfg.S3.SoftwareInstallersBucket = "software-installers-dev" + cfg.S3.SoftwareInstallersRegion = "minio" + cfg.S3.SoftwareInstallersPrefix = "dev-prefix" + cfg.S3.SoftwareInstallersEndpointURL = "localhost:9000" + cfg.S3.SoftwareInstallersAccessKeyID = "minio" + cfg.S3.SoftwareInstallersSecretAccessKey = "minio123!" + cfg.S3.SoftwareInstallersDisableSSL = true + cfg.S3.SoftwareInstallersForceS3PathStyle = true } cfg.Packaging.S3 = config.S3Config{ diff --git a/cmd/fleet/serve.go b/cmd/fleet/serve.go index 2fdab8223f7e..9c227735a4b5 100644 --- a/cmd/fleet/serve.go +++ b/cmd/fleet/serve.go @@ -779,6 +779,8 @@ the way that the Fleet server works. } bootstrapPackageStore = bstore level.Info(logger).Log("msg", "using S3 bootstrap package store", "bucket", config.S3.SoftwareInstallersBucket) + + config.S3.ValidateCloudfrontURL(initFatal) } else { installerDir := os.TempDir() if dir := os.Getenv("FLEET_SOFTWARE_INSTALLER_STORE_DIR"); dir != "" { diff --git a/cmd/fleetctl/package.go b/cmd/fleetctl/package.go index 1cf3978c72c7..281cec25e9a5 100644 --- a/cmd/fleetctl/package.go +++ b/cmd/fleetctl/package.go @@ -377,13 +377,31 @@ func packageCommand() *cli.Command { } path, _ = filepath.Abs(path) + pathBase := filepath.Base(path) + var installInstructions = "double-click the installer" + var deviceType string + switch c.String("type") { + case "pkg": + installInstructions += fmt.Sprintf(" or run the command `sudo installer -pkg \"%s\" -target /`", pathBase) + deviceType = "macOS" + case "deb": + installInstructions += fmt.Sprintf(" or run the command `sudo apt install \"%s\"`", pathBase) + deviceType = "Debian-based Linux" + case "rpm": + installInstructions += fmt.Sprintf(" or run the command `sudo dnf install \"%s\"`", pathBase) + deviceType = "RPM-based Linux" + case "msi": + installInstructions += fmt.Sprintf(" or run the command `msiexec /i \"%s\"` as administrator", pathBase) + deviceType = "Windows" + } + fmt.Printf(` Success! You generated fleetd at %s -To add this device to Fleet, double-click to install fleetd. +To add a new %s device to Fleet, %s. To add other devices to Fleet, distribute fleetd using Chef, Ansible, Jamf, or Puppet. Learn how: https://fleetdm.com/learn-more-about/enrolling-hosts -`, path) +`, path, deviceType, installInstructions) if !disableOpenFolder { open.Start(filepath.Dir(path)) //nolint:errcheck } diff --git a/docs/Configuration/agent-configuration.md b/docs/Configuration/agent-configuration.md index eb594e14c5d0..e859ee4b3483 100644 --- a/docs/Configuration/agent-configuration.md +++ b/docs/Configuration/agent-configuration.md @@ -497,7 +497,7 @@ How to update agent options: The agents may take several seconds to update because Fleet has to wait for the hosts to check in. Additionally, hosts enrolled with removed enroll secrets must properly rotate their secret to have the new changes take effect. - +> When configuring a value for [`script_execution_timeout`](https://fleetdm.com/docs/configuration/agent-configuration#script-execution-timeout) in the UI, make sure to put the key at the top level of the YAML, _not_ as a child of `config`. diff --git a/docs/REST API/rest-api.md b/docs/REST API/rest-api.md index fec0670a4af8..a8f27e1db011 100644 --- a/docs/REST API/rest-api.md +++ b/docs/REST API/rest-api.md @@ -2634,39 +2634,6 @@ Returns the information of the specified host. "host": { "created_at": "2021-08-19T02:02:22Z", "updated_at": "2021-08-19T21:14:58Z", - "software": [ - { - "id": 408, - "name": "osquery", - "version": "4.5.1", - "source": "rpm_packages", - "browser": "", - "generated_cpe": "", - "vulnerabilities": null, - "installed_paths": ["/usr/lib/some-path-1"] - }, - { - "id": 1146, - "name": "tar", - "version": "1.30", - "source": "rpm_packages", - "browser": "", - "generated_cpe": "", - "vulnerabilities": null - }, - { - "id": 321, - "name": "SomeApp.app", - "version": "1.0", - "source": "apps", - "browser": "", - "bundle_identifier": "com.some.app", - "last_opened_at": "2021-08-18T21:14:00Z", - "generated_cpe": "", - "vulnerabilities": null, - "installed_paths": ["/usr/lib/some-path-2"] - } - ], "id": 1, "detail_updated_at": "2021-08-19T21:07:53Z", "last_restarted_at": "2020-11-01T03:01:45Z", @@ -2714,6 +2681,31 @@ Returns the information of the specified host. "percent_disk_space_available": 74, "gigs_total_disk_space": 160, "disk_encryption_enabled": true, + "status": "online", + "display_text": "23cfc9caacf0", + "issues": { + "failing_policies_count": 1, + "critical_vulnerabilities_count": 2, // Available in Fleet Premium + "total_issues_count": 3 + }, + "batteries": [ + { + "cycle_count": 999, + "health": "Normal" + } + ], + "geolocation": { + "country_iso": "US", + "city_name": "New York", + "geometry": { + "type": "point", + "coordinates": [40.6799, -74.0028] + } + }, + "maintenance_window": { + "starts_at": "2024-06-18T13:27:18−04:00", + "timezone": "America/New_York" + }, "users": [ { "uid": 0, @@ -2766,8 +2758,6 @@ Returns the information of the specified host. } ], "packs": [], - "status": "online", - "display_text": "23cfc9caacf0", "policies": [ { "id": 2, @@ -2800,29 +2790,39 @@ Returns the information of the specified host. "critical": false } ], - "issues": { - "failing_policies_count": 1, - "critical_vulnerabilities_count": 2, // Fleet Premium only - "total_issues_count": 3 - }, - "batteries": [ + "software": [ { - "cycle_count": 999, - "health": "Normal" + "id": 408, + "name": "osquery", + "version": "4.5.1", + "source": "rpm_packages", + "browser": "", + "generated_cpe": "", + "vulnerabilities": null, + "installed_paths": ["/usr/lib/some-path-1"] + }, + { + "id": 1146, + "name": "tar", + "version": "1.30", + "source": "rpm_packages", + "browser": "", + "generated_cpe": "", + "vulnerabilities": null + }, + { + "id": 321, + "name": "SomeApp.app", + "version": "1.0", + "source": "apps", + "browser": "", + "bundle_identifier": "com.some.app", + "last_opened_at": "2021-08-18T21:14:00Z", + "generated_cpe": "", + "vulnerabilities": null, + "installed_paths": ["/usr/lib/some-path-2"] } ], - "geolocation": { - "country_iso": "US", - "city_name": "New York", - "geometry": { - "type": "point", - "coordinates": [40.6799, -74.0028] - } - }, - "maintenance_window": { - "starts_at": "2024-06-18T13:27:18−04:00", - "timezone": "America/New_York" - }, "mdm": { "encryption_key_available": true, "enrollment_status": "On (manual)", diff --git a/ee/server/service/software_installers.go b/ee/server/service/software_installers.go index 81b539c5b1f4..4bdba884a577 100644 --- a/ee/server/service/software_installers.go +++ b/ee/server/service/software_installers.go @@ -438,10 +438,41 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet. payload.SelfService = &existingInstaller.SelfService } + // Get the hosts that are NOT in label scope currently (before the update happens) + var hostsNotInScope map[uint]struct{} + if dirty["Labels"] { + hostsNotInScope, err = svc.ds.GetExcludedHostIDMapForSoftwareInstaller(ctx, payload.InstallerID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting hosts not in scope for installer") + } + } + if err := svc.ds.SaveInstallerUpdates(ctx, payload); err != nil { return nil, ctxerr.Wrap(ctx, err, "saving installer updates") } + if dirty["Labels"] { + // Get the hosts that are now IN label scope (after the update) + hostsInScope, err := svc.ds.GetIncludedHostIDMapForSoftwareInstaller(ctx, payload.InstallerID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting hosts in scope for installer") + } + + var hostsToClear []uint + for id := range hostsInScope { + if _, ok := hostsNotInScope[id]; ok { + // it was not in scope but now it is, so we should clear policy status + hostsToClear = append(hostsToClear, id) + } + } + + // We clear the policy status here because otherwise the policy automation machinery + // won't pick this up and the software won't install. + if err := svc.ds.ClearAutoInstallPolicyStatusForHosts(ctx, payload.InstallerID, hostsToClear); err != nil { + return nil, ctxerr.Wrap(ctx, err, "failed to clear auto install policy status for host") + } + } + // if we're updating anything other than self-service, we cancel pending installs/uninstalls, // and if we're updating the package we reset counts. This is run in its own transaction internally // for consistency, but independent of the installer update query as the main update should stick @@ -484,7 +515,8 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet. } func (svc *Service) validateEmbeddedSecretsOnScript(ctx context.Context, scriptName string, script *string, - argErr *fleet.InvalidArgumentError) *fleet.InvalidArgumentError { + argErr *fleet.InvalidArgumentError, +) *fleet.InvalidArgumentError { if script != nil { if errScript := svc.ds.ValidateEmbeddedSecrets(ctx, []string{*script}); errScript != nil { if argErr != nil { diff --git a/frontend/components/LastUpdatedHostCount/LastUpdatedHostCount.stories.tsx b/frontend/components/LastUpdatedHostCount/LastUpdatedHostCount.stories.tsx new file mode 100644 index 000000000000..2516d5311217 --- /dev/null +++ b/frontend/components/LastUpdatedHostCount/LastUpdatedHostCount.stories.tsx @@ -0,0 +1,23 @@ +import { Meta, StoryObj } from "@storybook/react"; + +import LastUpdatedHostCount from "./LastUpdatedHostCount"; + +const meta: Meta = { + title: "Components/LastUpdatedHostCount", + component: LastUpdatedHostCount, + args: { + hostCount: 40, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Basic: Story = {}; + +export const WithLastUpdatedAt: Story = { + args: { + lastUpdatedAt: "2021-01-01T00:00:00Z", + }, +}; diff --git a/frontend/components/LastUpdatedHostCount/LastUpdatedHostCount.tests.tsx b/frontend/components/LastUpdatedHostCount/LastUpdatedHostCount.tests.tsx new file mode 100644 index 000000000000..ecde3d189679 --- /dev/null +++ b/frontend/components/LastUpdatedHostCount/LastUpdatedHostCount.tests.tsx @@ -0,0 +1,37 @@ +import React from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; + +import LastUpdatedHostCount from "."; + +describe("Last updated host count", () => { + it("renders host count and updated text", () => { + const currentDate = new Date(); + currentDate.setDate(currentDate.getDate() - 2); + const twoDaysAgo = currentDate.toISOString(); + + render(); + + const hostCount = screen.getByText(/40/i); + const updateText = screen.getByText("Updated 2 days ago"); + + expect(hostCount).toBeInTheDocument(); + expect(updateText).toBeInTheDocument(); + }); + it("renders never if missing timestamp", () => { + render(); + + const text = screen.getByText("Updated never"); + + expect(text).toBeInTheDocument(); + }); + + it("renders tooltip on hover", async () => { + render(); + + await fireEvent.mouseEnter(screen.getByText("Updated never")); + + expect( + screen.getByText(/last time host data was updated/i) + ).toBeInTheDocument(); + }); +}); diff --git a/frontend/components/LastUpdatedHostCount/LastUpdatedHostCount.tsx b/frontend/components/LastUpdatedHostCount/LastUpdatedHostCount.tsx new file mode 100644 index 000000000000..8cd29f6d16a1 --- /dev/null +++ b/frontend/components/LastUpdatedHostCount/LastUpdatedHostCount.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import LastUpdatedText from "components/LastUpdatedText"; + +const baseClass = "last-updated-host-count"; + +interface ILastUpdatedHostCount { + hostCount?: string | number; + lastUpdatedAt?: string; +} + +const LastUpdatedHostCount = ({ + hostCount, + lastUpdatedAt, +}: ILastUpdatedHostCount): JSX.Element => { + const tooltipContent = ( + <> + The last time host data was updated.
+ Click View all hosts to see the most +
up-to-date host count. + + ); + + return ( +
+ <>{hostCount} + +
+ ); +}; + +export default LastUpdatedHostCount; diff --git a/frontend/components/LastUpdatedHostCount/_styles.scss b/frontend/components/LastUpdatedHostCount/_styles.scss new file mode 100644 index 000000000000..9dd4eb3b3344 --- /dev/null +++ b/frontend/components/LastUpdatedHostCount/_styles.scss @@ -0,0 +1,6 @@ +.last-updated-host-count { + display: flex; + align-items: baseline; + gap: $pad-small; + font-size: $x-small; +} diff --git a/frontend/components/LastUpdatedHostCount/index.ts b/frontend/components/LastUpdatedHostCount/index.ts new file mode 100644 index 000000000000..d4955ba278ed --- /dev/null +++ b/frontend/components/LastUpdatedHostCount/index.ts @@ -0,0 +1 @@ +export { default } from "./LastUpdatedHostCount"; diff --git a/frontend/components/LastUpdatedText/_styles.scss b/frontend/components/LastUpdatedText/_styles.scss index 1198fd096bb6..3094d4753bbf 100644 --- a/frontend/components/LastUpdatedText/_styles.scss +++ b/frontend/components/LastUpdatedText/_styles.scss @@ -1,5 +1,6 @@ .component__last-updated-text { font-size: $xx-small; + font-weight: $regular; color: $ui-fleet-black-75; .tooltip { diff --git a/frontend/components/MainContent/MainContent.tsx b/frontend/components/MainContent/MainContent.tsx index 6733a437cb7f..cc9157cb6d66 100644 --- a/frontend/components/MainContent/MainContent.tsx +++ b/frontend/components/MainContent/MainContent.tsx @@ -1,12 +1,9 @@ import React, { ReactNode, useContext } from "react"; import classnames from "classnames"; -import { formatDistanceToNow } from "date-fns"; -import { hasLicenseExpired, willExpireWithinXDays } from "utilities/helpers"; +import { hasLicenseExpired } from "utilities/helpers"; -import SandboxExpiryMessage from "components/Sandbox/SandboxExpiryMessage"; import AppleBMTermsMessage from "components/MDM/AppleBMTermsMessage"; -import SandboxGate from "components/Sandbox/SandboxGate"; import { AppContext } from "context/app"; import LicenseExpirationBanner from "components/LicenseExpirationBanner"; import ApplePNCertRenewalMessage from "components/MDM/ApplePNCertRenewalMessage"; @@ -33,10 +30,8 @@ const MainContent = ({ }: IMainContentProps): JSX.Element => { const classes = classnames(baseClass, className); const { - sandboxExpiry, config, isPremiumTier, - noSandboxHosts, isApplePnsExpired, isAppleBmExpired, isVppExpired, @@ -46,11 +41,6 @@ const MainContent = ({ willVppExpire, } = useContext(AppContext); - const sandboxExpiryTime = - sandboxExpiry === undefined - ? "..." - : formatDistanceToNow(new Date(sandboxExpiry)); - const renderAppWideBanner = () => { const isFleetLicenseExpired = hasLicenseExpired( config?.license.expiration || "" @@ -73,24 +63,18 @@ const MainContent = ({ } if (banner) { - return
{banner}
; + return ( +
+
{banner}
+
+ ); } return null; }; return (
-
- {renderAppWideBanner()} - ( - - )} - /> -
+ {renderAppWideBanner()} {children}
); diff --git a/frontend/pages/DashboardPage/DashboardPage.tsx b/frontend/pages/DashboardPage/DashboardPage.tsx index 056acc965c17..51f846941fc2 100644 --- a/frontend/pages/DashboardPage/DashboardPage.tsx +++ b/frontend/pages/DashboardPage/DashboardPage.tsx @@ -322,7 +322,15 @@ const DashboardPage = ({ router, location }: IDashboardProps): JSX.Element => { setSoftwareTitleDetail( + Fleet periodically queries all hosts to +
+ retrieve software. Click to view +
+ hosts for the most up-to-date lists. + + } /> ); setShowSoftwareCard(true); diff --git a/frontend/pages/DashboardPage/_styles.scss b/frontend/pages/DashboardPage/_styles.scss index cf62942732dc..cc84a830f556 100644 --- a/frontend/pages/DashboardPage/_styles.scss +++ b/frontend/pages/DashboardPage/_styles.scss @@ -24,10 +24,7 @@ } &__header { - height: 38px; - display: flex; - align-items: center; - justify-content: space-between; + @include normalize-team-header; margin-bottom: $pad-large; .Select-control { diff --git a/frontend/pages/ManageControlsPage/OSUpdates/components/CurrentVersionSection/CurrentVersionSection.tsx b/frontend/pages/ManageControlsPage/OSUpdates/components/CurrentVersionSection/CurrentVersionSection.tsx index 7e1cc7f085d5..956048b96358 100644 --- a/frontend/pages/ManageControlsPage/OSUpdates/components/CurrentVersionSection/CurrentVersionSection.tsx +++ b/frontend/pages/ManageControlsPage/OSUpdates/components/CurrentVersionSection/CurrentVersionSection.tsx @@ -81,7 +81,17 @@ const CurrentVersionSection = ({ return ( + Fleet periodically queries all hosts to +
+ retrieve operating systems. Click to +
+ view hosts for the most up-to-date +
+ lists. + + } /> ); }; @@ -135,7 +145,7 @@ const CurrentVersionSection = ({
{renderTable()} diff --git a/frontend/pages/ManageControlsPage/_styles.scss b/frontend/pages/ManageControlsPage/_styles.scss index 8895cc204d45..a193f7fe3c9e 100644 --- a/frontend/pages/ManageControlsPage/_styles.scss +++ b/frontend/pages/ManageControlsPage/_styles.scss @@ -1,9 +1,6 @@ .manage-controls-page { &__header-wrap { - display: flex; - align-items: center; - justify-content: space-between; - height: 38px; + @include normalize-team-header; .button-wrap { display: flex; diff --git a/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/FleetMaintainedAppDetailsPage.tsx b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/FleetMaintainedAppDetailsPage.tsx index 8516ff248378..0f2967547bfa 100644 --- a/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/FleetMaintainedAppDetailsPage.tsx +++ b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/FleetMaintainedAppDetailsPage.tsx @@ -2,6 +2,7 @@ import React, { useContext, useState } from "react"; import { Location } from "history"; import { useQuery } from "react-query"; import { InjectedRouter } from "react-router"; +import { useErrorHandler } from "react-error-boundary"; import PATHS from "router/paths"; import { buildQueryStringFromParams } from "utilities/url"; @@ -105,6 +106,7 @@ const FleetMaintainedAppDetailsPage = ({ } const { renderFlash } = useContext(NotificationContext); + const handlePageError = useErrorHandler(); const { isPremiumTier } = useContext(AppContext); const { selectedOsqueryTable, setSelectedOsqueryTable } = useContext( QueryContext @@ -125,7 +127,9 @@ const FleetMaintainedAppDetailsPage = ({ { ...DEFAULT_USE_QUERY_OPTIONS, enabled: isPremiumTier, + retry: false, select: (res) => res.fleet_maintained_app, + onError: (error) => handlePageError(error), } ); diff --git a/frontend/pages/SoftwarePage/SoftwareOSDetailsPage/SoftwareOSDetailsPage.tsx b/frontend/pages/SoftwarePage/SoftwareOSDetailsPage/SoftwareOSDetailsPage.tsx index 39a69bb01fd2..889f232cb9d7 100644 --- a/frontend/pages/SoftwarePage/SoftwareOSDetailsPage/SoftwareOSDetailsPage.tsx +++ b/frontend/pages/SoftwarePage/SoftwareOSDetailsPage/SoftwareOSDetailsPage.tsx @@ -72,13 +72,13 @@ const SoftwareOSDetailsPage = ({ }); const { - data: osVersionDetails, + data: { os_version: osVersionDetails, counts_updated_at } = {}, isLoading, isError: isOsVersionError, } = useQuery< IOSVersionResponse, AxiosError, - IOperatingSystemVersion, + IOSVersionResponse, IGetOsVersionQueryKey[] >( [ @@ -93,7 +93,10 @@ const SoftwareOSDetailsPage = ({ ...DEFAULT_USE_QUERY_OPTIONS, retry: false, enabled: !!osVersionIdFromURL, - select: (data) => data.os_version, + select: (data) => ({ + os_version: data.os_version, + counts_updated_at: data.counts_updated_at, + }), onError: (error) => { if (!ignoreAxiosError(error, [403, 404])) { handlePageError(error); @@ -154,7 +157,7 @@ const SoftwareOSDetailsPage = ({ onTeamChange={onTeamChange} /> )} - {isOsVersionError ? ( + {isOsVersionError || !osVersionDetails ? ( } /> - + + } + /> ); diff --git a/frontend/pages/SoftwarePage/SoftwareVulnerabilityDetailsPage/_styles.scss b/frontend/pages/SoftwarePage/SoftwareVulnerabilityDetailsPage/_styles.scss index 7bee8edd59d5..e14267bb1080 100644 --- a/frontend/pages/SoftwarePage/SoftwareVulnerabilityDetailsPage/_styles.scss +++ b/frontend/pages/SoftwarePage/SoftwareVulnerabilityDetailsPage/_styles.scss @@ -2,6 +2,10 @@ background-color: $ui-off-white; @include page; + .team-dropdown-wrapper { + @include normalize-team-header; + } + .card { display: flex; flex-direction: column; diff --git a/frontend/pages/SoftwarePage/_styles.scss b/frontend/pages/SoftwarePage/_styles.scss index 2704458768fb..5154d532e4fa 100644 --- a/frontend/pages/SoftwarePage/_styles.scss +++ b/frontend/pages/SoftwarePage/_styles.scss @@ -1,9 +1,6 @@ .software-page { &__header-wrap { - display: flex; - align-items: center; - justify-content: space-between; - height: 38px; + @include normalize-team-header; .button-wrap { display: flex; diff --git a/frontend/pages/SoftwarePage/components/SoftwareDetailsSummary/SoftwareDetailsSummary.tsx b/frontend/pages/SoftwarePage/components/SoftwareDetailsSummary/SoftwareDetailsSummary.tsx index 2dd8c25dc114..52222a5e7e2e 100644 --- a/frontend/pages/SoftwarePage/components/SoftwareDetailsSummary/SoftwareDetailsSummary.tsx +++ b/frontend/pages/SoftwarePage/components/SoftwareDetailsSummary/SoftwareDetailsSummary.tsx @@ -10,6 +10,7 @@ import { QueryParams } from "utilities/url"; import ViewAllHostsLink from "components/ViewAllHostsLink"; import DataSet from "components/DataSet"; +import LastUpdatedHostCount from "components/LastUpdatedHostCount"; import SoftwareIcon from "../icons/SoftwareIcon"; @@ -19,6 +20,7 @@ interface ISoftwareDetailsSummaryProps { title: string; type?: string; hosts: number; + countsUpdatedAt?: string; /** The query param that will be added when user clicks on "View all hosts" link */ queryParams: QueryParams; name?: string; @@ -31,6 +33,7 @@ const SoftwareDetailsSummary = ({ title, type, hosts, + countsUpdatedAt, queryParams, name, source, @@ -46,7 +49,15 @@ const SoftwareDetailsSummary = ({ {!!type && } {!!versions && } - + + } + />
diff --git a/frontend/pages/SoftwarePage/helpers.ts b/frontend/pages/SoftwarePage/helpers.ts index 48901f0efc52..a323982309b5 100644 --- a/frontend/pages/SoftwarePage/helpers.ts +++ b/frontend/pages/SoftwarePage/helpers.ts @@ -20,8 +20,11 @@ export const generateSecretErrMsg = (err: unknown) => { } if (errorType === "profile") { - return reason - .split(":")[1] + // for profiles we can get two different error messages. One contains a colon + // and the other doesn't. We need to handle both cases. + const message = reason.split(":").pop() ?? ""; + + return message .replace(/Secret variables?/i, "Variable") .replace("missing from database", "doesn't exist."); } diff --git a/frontend/pages/admin/UserManagementPage/components/UsersTable/UsersTable.tsx b/frontend/pages/admin/UserManagementPage/components/UsersTable/UsersTable.tsx index fbf674e60fa4..ed375c7cc64c 100644 --- a/frontend/pages/admin/UserManagementPage/components/UsersTable/UsersTable.tsx +++ b/frontend/pages/admin/UserManagementPage/components/UsersTable/UsersTable.tsx @@ -300,10 +300,7 @@ const UsersTable = ({ router }: IUsersTableProps): JSX.Element => { let userUpdatedFlashMessage = `Successfully edited ${formData.name}`; if (userData?.email !== formData.email) { - const senderAddressMessage = config?.smtp_settings?.sender_address - ? ` from ${config?.smtp_settings?.sender_address}` - : ""; - userUpdatedFlashMessage += `: A confirmation email was sent${senderAddressMessage} to ${formData.email}`; + userUpdatedFlashMessage += `. A confirmation email was sent to ${formData.email}.`; } const userUpdatedEmailError = "A user with this email address already exists"; diff --git a/frontend/pages/errors/Fleet404/_styles.scss b/frontend/pages/errors/Fleet404/_styles.scss index 5b67d8ee7659..28eff66bfc08 100644 --- a/frontend/pages/errors/Fleet404/_styles.scss +++ b/frontend/pages/errors/Fleet404/_styles.scss @@ -5,7 +5,6 @@ } button { - margin: $pad-medium; width: 197px; font-size: $small; @@ -17,6 +16,10 @@ } &__button-wrapper { + display: flex; + align-items: center; + justify-content: center; + gap: $pad-medium; margin-bottom: $pad-large; } diff --git a/frontend/pages/hosts/ManageHostsPage/_styles.scss b/frontend/pages/hosts/ManageHostsPage/_styles.scss index 7052d86396fe..1376eb5be69a 100644 --- a/frontend/pages/hosts/ManageHostsPage/_styles.scss +++ b/frontend/pages/hosts/ManageHostsPage/_styles.scss @@ -1,10 +1,7 @@ .manage-hosts { .header-wrap { - display: flex; - align-items: center; - justify-content: space-between; + @include normalize-team-header; margin-bottom: $pad-medium; - height: 38px; .button-wrap { display: flex; diff --git a/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx b/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx index dd0d122aae58..3b2ce1cb427b 100644 --- a/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx +++ b/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx @@ -4,6 +4,7 @@ import { useQuery } from "react-query"; import { InjectedRouter } from "react-router/lib/Router"; import PATHS from "router/paths"; import { isEqual } from "lodash"; +import { formatDistanceToNowStrict } from "date-fns"; import { getNextLocationPath, wait } from "utilities/helpers"; @@ -44,6 +45,7 @@ import Spinner from "components/Spinner"; import TeamsDropdown from "components/TeamsDropdown"; import TableDataError from "components/DataError"; import MainContent from "components/MainContent"; +import LastUpdatedText from "components/LastUpdatedText"; import PoliciesTable from "./components/PoliciesTable"; import OtherWorkflowsModal from "./components/OtherWorkflowsModal"; @@ -776,22 +778,43 @@ const ManagePolicyPage = ({ } } - const renderPoliciesCount = (count?: number) => { + const renderPoliciesCountAndLastUpdated = ( + count?: number, + policies?: IPolicyStats[] + ) => { // Hide count if fetching count || there are errors OR there are no policy results with no a search filter const isFetchingCount = !isAllTeamsSelected ? isFetchingTeamCountMergeInherited : isFetchingGlobalCount; - const hideCount = + const hide = isFetchingCount || policiesErrors || (!policyResults && searchQuery === ""); - if (hideCount) { + if (hide) { return null; } + // Figure the time since the host counts were updated by finding first policy item with host_count_updated_at. + const updatedAt = + policies?.find((p) => !!p.host_count_updated_at)?.host_count_updated_at || + ""; - return ; + return ( + <> + + + Counts are updated hourly. Click host +
+ counts for the most up-to-date count. + + } + /> + + ); }; const renderMainTable = () => { @@ -815,7 +838,12 @@ const ManagePolicyPage = ({ currentTeam={currentTeamSummary} currentAutomatedPolicies={currentAutomatedPolicies} isPremiumTier={isPremiumTier} - renderPoliciesCount={() => renderPoliciesCount(globalPoliciesCount)} + renderPoliciesCount={() => + renderPoliciesCountAndLastUpdated( + globalPoliciesCount, + globalPolicies + ) + } searchQuery={searchQuery} sortHeader={sortHeader} sortDirection={sortDirection} @@ -844,7 +872,10 @@ const ManagePolicyPage = ({ currentTeam={currentTeamSummary} currentAutomatedPolicies={currentAutomatedPolicies} renderPoliciesCount={() => - renderPoliciesCount(teamPoliciesCountMergeInherited) + renderPoliciesCountAndLastUpdated( + teamPoliciesCountMergeInherited, + teamPolicies + ) } isPremiumTier={isPremiumTier} searchQuery={searchQuery} diff --git a/frontend/pages/policies/ManagePoliciesPage/_styles.scss b/frontend/pages/policies/ManagePoliciesPage/_styles.scss index 4edf0c5432d9..632e60e819be 100644 --- a/frontend/pages/policies/ManagePoliciesPage/_styles.scss +++ b/frontend/pages/policies/ManagePoliciesPage/_styles.scss @@ -1,9 +1,6 @@ .manage-policies-page { &__header-wrap { - display: flex; - align-items: center; - justify-content: space-between; - height: 38px; + @include normalize-team-header; .button-wrap { display: flex; diff --git a/frontend/pages/policies/ManagePoliciesPage/components/PassingColumnHeader/PassingColumnHeader.tsx b/frontend/pages/policies/ManagePoliciesPage/components/PassingColumnHeader/PassingColumnHeader.tsx index c3910fd4c0a1..7f9f85ca3c3a 100644 --- a/frontend/pages/policies/ManagePoliciesPage/components/PassingColumnHeader/PassingColumnHeader.tsx +++ b/frontend/pages/policies/ManagePoliciesPage/components/PassingColumnHeader/PassingColumnHeader.tsx @@ -1,37 +1,20 @@ import Icon from "components/Icon"; import React from "react"; -import TooltipWrapper from "components/TooltipWrapper"; interface IPassingColumnHeaderProps { isPassing: boolean; - timeSinceHostCountUpdate: string; } const baseClass = "passing-column-header"; -const PassingColumnHeader = ({ - isPassing, - timeSinceHostCountUpdate, -}: IPassingColumnHeaderProps) => { +const PassingColumnHeader = ({ isPassing }: IPassingColumnHeaderProps) => { const iconName = isPassing ? "success" : "error"; const columnText = isPassing ? "Yes" : "No"; - const updateText = timeSinceHostCountUpdate - ? `Host count updated ${timeSinceHostCountUpdate}.` - : ""; return (
- - {updateText} -
Counts are updated hourly. - - } - > - {columnText} -
+ {columnText}
); }; diff --git a/frontend/pages/policies/ManagePoliciesPage/components/PoliciesTable/PoliciesTable.tsx b/frontend/pages/policies/ManagePoliciesPage/components/PoliciesTable/PoliciesTable.tsx index 6db439a07392..88dc32998330 100644 --- a/frontend/pages/policies/ManagePoliciesPage/components/PoliciesTable/PoliciesTable.tsx +++ b/frontend/pages/policies/ManagePoliciesPage/components/PoliciesTable/PoliciesTable.tsx @@ -97,7 +97,6 @@ const PoliciesTable = ({ selectedTeamId: currentTeam?.id, hasPermissionAndPoliciesToDelete, }, - policiesList, isPremiumTier )} data={generateDataSet( diff --git a/frontend/pages/policies/ManagePoliciesPage/components/PoliciesTable/PoliciesTableConfig.tsx b/frontend/pages/policies/ManagePoliciesPage/components/PoliciesTable/PoliciesTableConfig.tsx index 76e785d7416d..6f09884cb92c 100644 --- a/frontend/pages/policies/ManagePoliciesPage/components/PoliciesTable/PoliciesTableConfig.tsx +++ b/frontend/pages/policies/ManagePoliciesPage/components/PoliciesTable/PoliciesTableConfig.tsx @@ -2,11 +2,7 @@ // disable this rule as it was throwing an error in Header and Cell component // definitions for the selection row for some reason when we dont really need it. import React from "react"; -import { - formatDistanceToNowStrict, - millisecondsToHours, - millisecondsToMinutes, -} from "date-fns"; +import { millisecondsToHours, millisecondsToMinutes } from "date-fns"; import { Tooltip as ReactTooltip5 } from "react-tooltip-5"; // @ts-ignore import Checkbox from "components/forms/fields/Checkbox"; @@ -91,27 +87,10 @@ const generateTableHeaders = ( hasPermissionAndPoliciesToDelete?: boolean; tableType?: string; }, - policiesList: IPolicyStats[] = [], isPremiumTier?: boolean ): IDataColumn[] => { const { selectedTeamId, hasPermissionAndPoliciesToDelete } = options; const viewingTeamPolicies = selectedTeamId !== -1; - // Figure the time since the host counts were updated. - // First, find first policy item with host_count_updated_at. - const updatedAt = - policiesList.find((p) => !!p.host_count_updated_at) - ?.host_count_updated_at || ""; - let timeSinceHostCountUpdate = ""; - if (updatedAt) { - try { - timeSinceHostCountUpdate = formatDistanceToNowStrict( - new Date(updatedAt), - { addSuffix: true } - ); - } catch (e) { - // Do nothing. - } - } const tableHeaders: IDataColumn[] = [ { @@ -170,12 +149,7 @@ const generateTableHeaders = ( title: "Yes", Header: (cellProps) => ( - } + value={} isSortedDesc={cellProps.column.isSortedDesc} /> ), @@ -221,12 +195,7 @@ const generateTableHeaders = ( title: "No", Header: (cellProps) => ( - } + value={} isSortedDesc={cellProps.column.isSortedDesc} /> ), diff --git a/frontend/pages/queries/ManageQueriesPage/_styles.scss b/frontend/pages/queries/ManageQueriesPage/_styles.scss index ea724a00c250..570e79406baf 100644 --- a/frontend/pages/queries/ManageQueriesPage/_styles.scss +++ b/frontend/pages/queries/ManageQueriesPage/_styles.scss @@ -1,9 +1,6 @@ .manage-queries-page { &__header-wrap { - height: 38px; - display: flex; - align-items: center; - justify-content: space-between; + @include normalize-team-header; } &__header { diff --git a/frontend/services/entities/operating_systems.ts b/frontend/services/entities/operating_systems.ts index 2aa36227bd83..df843067cf22 100644 --- a/frontend/services/entities/operating_systems.ts +++ b/frontend/services/entities/operating_systems.ts @@ -48,6 +48,7 @@ export interface IGetOsVersionQueryKey extends IGetOsVersionOptions { } export interface IOSVersionResponse { + counts_updated_at?: string; os_version: IOperatingSystemVersion; } diff --git a/frontend/styles/var/mixins.scss b/frontend/styles/var/mixins.scss index d55db4c93327..08d7107d7f75 100644 --- a/frontend/styles/var/mixins.scss +++ b/frontend/styles/var/mixins.scss @@ -25,6 +25,14 @@ $max-width: 2560px; } } +// Used to normalize styling of team header wrapper on various pages +@mixin normalize-team-header { + display: flex; + align-items: center; + height: 38px; + justify-content: space-between; +} + // Used to keep the settings description sticky under the main nav and sub nav. // TODO: figure out how to calculate these values with variables. Will be tedious to change otherwise @mixin sticky-settings-description { diff --git a/handbook/company/README.md b/handbook/company/README.md index 3a149c17ad44..706d433bf880 100644 --- a/handbook/company/README.md +++ b/handbook/company/README.md @@ -131,7 +131,7 @@ Fleet raised its Series A funding round. The world now has at least 1.65 millio Fleet added support for [scripting and management capabilities](https://fleetdm.com/announcements/fleet-introduces-windows-mdm) on macOS, Windows, _and_ Linux devices, allowing IT departments to manage devices more consistently using modern tooling and best practices. This allowed many customers to simplify their management practices. In several cases, Fleet was also able to save customers several hundreds of thousands of dollars (USD) by cutting tool overlap across platforms such as Jamf, Airwatch, Intune, MobileIron, Nexthink, Tanium, Uptycs, and Rapid7. ### 2024: Fleet is growing globally -Fleet has expanded into 90+ countries, with 100+ customers and 2.24 million computers and virtual hosts enrolled (including the world's most powerful computer). +Fleet has expanded into 90+ countries, with 100+ customers and 2.24 million computers and virtual hosts enrolled (including the world's most powerful computers). diff --git a/handbook/company/open-positions.yml b/handbook/company/open-positions.yml index ecb13653550e..e36e4740ac3c 100644 --- a/handbook/company/open-positions.yml +++ b/handbook/company/open-positions.yml @@ -32,8 +32,30 @@ - 🛠️ Produce quality code, raising the bar for team performance and speed. - 📖 Mentor junior team members. - 🤝 Collaboration: You work best in a participatory, team-based environment. - - 🚀 Prototype-first: You embrace speed and failure as we iterate towards the right solution. You have hands-on experience in creating low and high fidelity prototypes. You’re comfortable accepting suboptimal designs in favor of iteration. + - 🚀 Prototype-first: You embrace speed and failure as we iterate towards the right solution. You have hands-on experience in creating low and high-fidelity prototypes. You’re comfortable accepting suboptimal designs in favor of iteration. - 🧬 Simplicity: You love complex questions and use your work to simplify that complexity for users. - 🛠️ Technical: You understand the software development processes. You understand that software quality matters. - 🟣 Openness: You are flexible and open to new ideas and ways of working. - ➕ Bonus: Cybersecurity or IT background. + +- jobTitle: 🚀 Customer Support Engineer + department: Sales + hiringManagerName: Zay Hanlon + hiringManagerGithubUsername: zayhanlon + hiringManagerLinkedInUrl: https://www.linkedin.com/in/zayhanlon/ + responsibilities: | + - 🏋🏻 Train under our customer support and engineering team to learn the ins and outs of Fleet, frequently asked customer questions, develop an understanding of our troubleshooting guide, and learn how to search through documentation and Fleet repo. + - 🚀 Deploy Fleet on your own to better understand the customer experience and how the product works. + - ⏫ Work hand-in-hand with the customer success team by participating in calls with customers to discuss any support issues they may have. + - 🥇 Be the first line of defense in customer Slack channels for any reported problems, how-to questions, feature request intake, and bug report filling. + - 🎯 Strong attention to detail and can act as an encyclopedia of knowledge about how Fleet works - our customers represent a wide range of needs across many different use cases. Be adaptable to learning new things quickly and then share this knowledge with others. + - 💡 Excellent communication and collaboration skills, with the ability to work closely with customer success, engineering, and product teams. + - 👥 A customer-centric mindset, focusing on delivering value and a positive user experience. + experience: | + - 🦉 Experience: 2-3 years of work experience supporting Windows, Linux, and MacOS devices. Experience with AWS, SQL, Redis, Terraform, and osquery. + - 🛠️ Communication: You are outgoing, customer-facing, and enjoy problem-solving while assisting external stakeholders. + - 🟣 Openness: You are flexible and open to new ideas and ways of working. + - 💭 IT background, experience with device management solutions like Fleet, Intune, Jamf Pro, Workspace One, etc. + - 💖 An excellent understanding of macOS, Windows, Linux and core services like Autopilot, ABM/ASM, MDM, ADE, APNs, syslog, etc. + - ✍️ Familiarity with SQLite, shell scripting, Python, Powershell, and using the terminal to execute commands or run scripts is a bonus. + - 🧑‍🔬 Experience working with enterprise customers to help resolve complex technical issues. diff --git a/handbook/company/pricing-features-table.yml b/handbook/company/pricing-features-table.yml index 58730dfb7b83..5b68e432de78 100644 --- a/handbook/company/pricing-features-table.yml +++ b/handbook/company/pricing-features-table.yml @@ -929,8 +929,8 @@ # ╩ ╩╩ ╩╩═╝╚╩╝╩ ╩╩╚═╚═╝ ═╩╝╚═╝ ╩ ╚═╝╚═╝ ╩ ╩╚═╝╝╚╝ └─ ╩ ╩ ╩╩╚═╩ ╩─┘ - industryName: Malware detection (YARA/custom IoCs) # TODO: consider: technically more than YARA, consider generalizing this and including the concept of comparing known binary hashes and other IoCs (either via live query or in the data lake to compare threat intel feed) friendlyName: Scan files for zero days and malware signatures - description: Use YARA signatures to report and trigger automations when zero days, malware, or unexpected files are detected on a host. - documentationUrl: https://fleetdm.com/tables/yara + description: Deploy YARA signatures (rules) to report and trigger automations when zero days, malware, or unexpected files are detected on a host. YARA rules can be deployed remotely and privately. + documentationUrl: https://fleetdm.com/guides/remote-yara-rules tier: Free jamfProHasFeature: no jamfProtectHasFeature: yes diff --git a/handbook/company/why-this-way.md b/handbook/company/why-this-way.md index c520a7dfa6c6..e3ed816d86a1 100644 --- a/handbook/company/why-this-way.md +++ b/handbook/company/why-this-way.md @@ -313,7 +313,7 @@ In sentence case, we write and capitalize words as if they were in sentences: > Ask questions about your servers, containers, and laptops running Linux, Windows, and macOS -As we use sentence case, only the first word is capitalized. But, if a word would normally be capitalized in the sentence (e.g., a proper noun, an acronym, or a stylization) it should remain capitalized. User roles (e.g., "observer" or "maintainer") and features (e.g. "automations") in the Fleet product aren't treated as proper nouns and shouldn't be capitalized. +As we use sentence case, only the first word is capitalized. But, if a word would normally be capitalized in the sentence (e.g., a proper noun, an acronym, or a stylization) it should remain capitalized. User roles (e.g., "observer" or "maintainer") and features (e.g. "automations") in the Fleet product aren't treated as proper nouns and shouldn't be capitalized. Words/phrases that follow steps numbers (e.g. "Step 1: create") in the documentation shouldn't be capitalized. The reason for sentence case at Fleet is that everyone capitalizes differently in English, and capitalization conventions have not been taught very consistently in schools. Sentence case simplifies capitalization rules so that contributors can deliver more natural, even-looking content with a voice that feels similar no matter where you're reading it. diff --git a/handbook/product-design/product-design.rituals.yml b/handbook/product-design/product-design.rituals.yml index f3ea2b9602f7..992deabd93a4 100644 --- a/handbook/product-design/product-design.rituals.yml +++ b/handbook/product-design/product-design.rituals.yml @@ -44,7 +44,7 @@ task: "Quarterly roadmap blog post" startedOn: "2024-11-05" frequency: "Monthly" - description: "Every quarter, Head of Product Design (HPD) writes a short blog post with the theme and an embedded YouTube video in which HPD walks through this." + description: "Every quarter, Head of Product Design (HPD) meets with the CEO to run through the current quarter objectives and the 3 biggest open opportunities in the product and the solutions we're envisioning for next quarter. Then, the HPD writes a short blog with the objectives, opportunities, and an embedded YouTube video in which HPD walks through this." moreInfoUrl: "" dri: "noahtalerman" autoIssue: diff --git a/infrastructure/dogfood/terraform/aws-tf-module/.terraform.lock.hcl b/infrastructure/dogfood/terraform/aws-tf-module/.terraform.lock.hcl index caa67b71decb..0460a0d425a4 100644 --- a/infrastructure/dogfood/terraform/aws-tf-module/.terraform.lock.hcl +++ b/infrastructure/dogfood/terraform/aws-tf-module/.terraform.lock.hcl @@ -2,149 +2,142 @@ # Manual edits may be lost in future updates. provider "registry.terraform.io/hashicorp/archive" { - version = "2.4.0" + version = "2.7.0" hashes = [ - "h1:EtN1lnoHoov3rASpgGmh6zZ/W6aRCTgKC7iMwvFY1yc=", - "h1:cJokkjeH1jfpG4QEHdRx0t2j8rr52H33A7C/oX73Ok4=", - "zh:18e408596dd53048f7fc8229098d0e3ad940b92036a24287eff63e2caec72594", - "zh:392d4216ecd1a1fd933d23f4486b642a8480f934c13e2cae3c13b6b6a7e34a7b", - "zh:655dd1fa5ca753a4ace21d0de3792d96fff429445717f2ce31c125d19c38f3ff", - "zh:70dae36c176aa2b258331ad366a471176417a94dd3b4985a911b8be9ff842b00", + "h1:1niS9AcwxN8CrWemnJS2Xf6vM72+48Xh3xFSS3DFWQo=", + "zh:04e23bebca7f665a19a032343aeecd230028a3822e546e6f618f24c47ff87f67", + "zh:5bb38114238e25c45bf85f5c9f627a2d0c4b98fe44a0837e37d48574385f8dad", + "zh:64584bc1db4c390abd81c76de438d93acf967c8a33e9b923d68da6ed749d55bd", + "zh:697695ab9cce351adf91a1823bdd72ce6f0d219138f5124ef7645cedf8f59a1f", "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", - "zh:7d8c8e3925f1e21daf73f85983894fbe8868e326910e6df3720265bc657b9c9c", - "zh:a032ec0f0aee27a789726e348e8ad20778c3a1c9190ef25e7cff602c8d175f44", - "zh:b8e50de62ba185745b0fe9713755079ad0e9f7ac8638d204de6762cc36870410", - "zh:c8ad0c7697a3d444df21ff97f3473a8604c8639be64afe3f31b8ec7ad7571e18", - "zh:df736c5a2a7c3a82c5493665f659437a22f0baf8c2d157e45f4dd7ca40e739fc", - "zh:e8ffbf578a0977074f6d08aa8734e36c726e53dc79894cfc4f25fadc4f45f1df", - "zh:efea57ff23b141551f92b2699024d356c7ffd1a4ad62931da7ed7a386aef7f1f", + "zh:7edefb1d1e2fead8fd155f7b50a2cb49f2f3fed154ac3ef5f991ccaff93d6120", + "zh:807fb15b75910bf14795f2ad1a2d41b069f9ef52c242131b2964c8527312e235", + "zh:821d9148d261df1d1a8e5a4812df2a6a3ffaf0d2070dad3c785382e489069239", + "zh:a7d92251118fb723048c482154a6ac6368aad583d28d15fffc6f5dafd9507463", + "zh:b627d4cef192b3c12ddaf9cb2c4f98c10d0129883c8c2a9c0049983f9de7030d", + "zh:dfb70306fcc0ad1d512ab7c24765703783cc286062d4849de4fbe23526f5dc8e", + "zh:f21de276f857b7e51fa2593d8fef05a7faafb0a7b62db14ac58a03ce1be7d881", ] } provider "registry.terraform.io/hashicorp/aws" { - version = "5.26.0" + version = "5.82.2" constraints = ">= 2.67.0, >= 3.0.0, >= 4.6.0, >= 4.8.0, >= 4.9.0, >= 4.18.0, >= 4.27.0, >= 4.30.0, >= 4.40.0, >= 5.0.0, ~> 5.0" hashes = [ - "h1:McIRw8larBNW5TeXxyiWd8fD55oj1szEbMOuSQOSDBs=", - "h1:UkBMGEScvNP+9JDzKXGrgj931LngYpIB8TBBUY+mvdg=", - "zh:11a4062491e574c8e96b6bc7ced67b5e9338ccfa068223fc9042f9e1e7eda47a", - "zh:4331f85aeb22223ab656d04b48337a033f44f02f685c8def604c4f8f4687d10f", - "zh:915d6c996390736709f7ac7582cd41418463cfc07696218af6fea4a282df744a", - "zh:9306c306dbb2e1597037c54d20b1bd5f22a9cdcdb2b2b7bad657c8230bea2298", - "zh:93371860b9df369243219606711bfd3cfbd263db67838c06d5d5848cf47b6ede", - "zh:98338c17764a7b9322ddb6efd3af84e6890a4a0687f846eefdfb0fa03cec892d", + "h1:ce6Dw2y4PpuqAPtnQ0dO270dRTmwEARqnfffrE1VYJ8=", + "zh:0262fc96012fb7e173e1b7beadd46dfc25b1dc7eaef95b90e936fc454724f1c8", + "zh:397413613d27f4f54d16efcbf4f0a43c059bd8d827fe34287522ae182a992f9b", + "zh:436c0c5d56e1da4f0a4c13129e12a0b519d12ab116aed52029b183f9806866f3", + "zh:4d942d173a2553d8d532a333a0482a090f4e82a2238acf135578f163b6e68470", + "zh:624aebc549bfbce06cc2ecfd8631932eb874ac7c10eb8466ce5b9a2fbdfdc724", "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", - "zh:a28c9d77a5be25bac42d99418365757e4eb65a2c7c6788828263772cf2774869", - "zh:bd9c4648a090622d6b8c3c91dad513eec81e54db3dfe940ab6d155e5f37735e5", - "zh:bde63db136cccdeb282489e2ec2b3f9a7566edc9df27911a296352ab00832261", - "zh:ccd33f9490ce3f2d89efab995abf3b30e75579585f6a8a5b1f756246903d3518", - "zh:d73d1c461eb9d22833251f6533fc214cf014bc1d3165c5bfaa8ca29cd295ffb2", - "zh:db4ffb7eec5d0e1d0dbd0d65e1a3eaa6173a3337058105aec41fd0b2af5a2b46", - "zh:eb36b933419e9f6563330f3b7d53d4f1b09e62d27f7786d5dc6c4a2d0f6de182", - "zh:ec85ce1976e43f7d7fa10fa191c0a85e97326a3cb22387c0ed8b74d426ec94fd", + "zh:9e632dee2dfdf01b371cca7854b1ec63ceefa75790e619b0642b34d5514c6733", + "zh:a07567acb115b60a3df8f6048d12735b9b3bcf85ec92a62f77852e13d5a3c096", + "zh:ab7002df1a1be6432ac0eb1b9f6f0dd3db90973cd5b1b0b33d2dae54553dfbd7", + "zh:bc1ff65e2016b018b3e84db7249b2cd0433cb5c81dc81f9f6158f2197d6b9fde", + "zh:bcad84b1d767f87af6e1ba3dc97fdb8f2ad5de9224f192f1412b09aba798c0a8", + "zh:cf917dceaa0f9d55d9ff181b5dcc4d1e10af21b6671811b315ae2a6eda866a2a", + "zh:d8e90ecfb3216f3cc13ccde5a16da64307abb6e22453aed2ac3067bbf689313b", + "zh:d9054e0e40705df729682ad34c20db8695d57f182c65963abd151c6aba1ab0d3", + "zh:ecf3a4f3c57eb7e89f71b8559e2a71e4cdf94eea0118ec4f2cb37e4f4d71a069", ] } provider "registry.terraform.io/hashicorp/external" { - version = "2.3.2" + version = "2.3.4" constraints = ">= 1.0.0" hashes = [ - "h1:7F6FVQh7OcCgIH3YEJg1SJDSb1CU4qrCtGuI2EBHnL8=", - "h1:cy50n4q+Ir4GYppAfuYhQbBJVxMZbJUlIvM6FVK2axs=", - "zh:020bf652739ecd841d696e6c1b85ce7dd803e9177136df8fb03aa08b87365389", - "zh:0c7ea5a1cbf2e01a8627b8a84df69c93683f39fe947b288e958e72b9d12a827f", - "zh:25a68604c7d6aa736d6e99225051279eaac3a7cf4cab33b00ff7eae7096166f6", - "zh:34f46d82ca34604f6522de3b36eda19b7ad3be1e38947afc6ac31656eab58c8a", - "zh:6959f8f2f3de93e61e0abb90dbec41e28a66daec1607c46f43976bd6da50bcfd", + "h1:cCabxnWQ5fX1lS7ZqgUzsvWmKZw9FA7NRxAZ94vcTcc=", + "zh:037fd82cd86227359bc010672cd174235e2d337601d4686f526d0f53c87447cb", + "zh:0ea1db63d6173d01f2fa8eb8989f0809a55135a0d8d424b08ba5dabad73095fa", + "zh:17a4d0a306566f2e45778fbac48744b6fd9c958aaa359e79f144c6358cb93af0", + "zh:298e5408ab17fd2e90d2cd6d406c6d02344fe610de5b7dae943a58b958e76691", + "zh:38ecfd29ee0785fd93164812dcbe0664ebbe5417473f3b2658087ca5a0286ecb", + "zh:59f6a6f31acf66f4ea3667a555a70eba5d406c6e6d93c2c641b81d63261eeace", "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", - "zh:a81e5d65a343da9caa6f1d17ae0aced9faecb36b4f8554bd445dbd4f8be21ab6", - "zh:b1d3f1557214d652c9120862ce27e9a7b61cb5aec5537a28240a5a37bf0b1413", - "zh:b71588d006471ae2d4a7eca2c51d69fd7c5dec9b088315599b794e2ad0cc5e90", - "zh:cfdaae4028b644dff3530c77b49d31f7e6f4c4e2a9e5c8ac6a88e383c80c9e9c", - "zh:dbde15154c2eb38a5f54d0e7646bc67510004179696f3cc2bc1d877cecacf83b", - "zh:fb681b363f83fb5f64dfa6afbf32d100d0facd2a766cf3493b8ddb0398e1b0f7", + "zh:ad0279dfd09d713db0c18469f585e58d04748ca72d9ada83883492e0dd13bd58", + "zh:c69f66fd21f5e2c8ecf7ca68d9091c40f19ad913aef21e3ce23836e91b8cbb5f", + "zh:d4a56f8c48aa86fc8e0c233d56850f5783f322d6336f3bf1916e293246b6b5d4", + "zh:f2b394ebd4af33f343835517e80fc876f79361f4688220833bc3c77655dd2202", + "zh:f31982f29f12834e5d21e010856eddd19d59cd8f449adf470655bfd19354377e", ] } provider "registry.terraform.io/hashicorp/local" { - version = "2.4.0" + version = "2.5.2" constraints = ">= 1.0.0" hashes = [ - "h1:R97FTYETo88sT2VHfMgkPU3lzCsZLunPftjSI5vfKe8=", - "h1:ZUEYUmm2t4vxwzxy1BvN1wL6SDWrDxfH7pxtzX8c6d0=", - "zh:53604cd29cb92538668fe09565c739358dc53ca56f9f11312b9d7de81e48fab9", - "zh:66a46e9c508716a1c98efbf793092f03d50049fa4a83cd6b2251e9a06aca2acf", - "zh:70a6f6a852dd83768d0778ce9817d81d4b3f073fab8fa570bff92dcb0824f732", + "h1:IyFbOIO6mhikFNL/2h1iZJ6kyN3U00jgkpCLUCThAfE=", + "zh:136299545178ce281c56f36965bf91c35407c11897f7082b3b983d86cb79b511", + "zh:3b4486858aa9cb8163378722b642c57c529b6c64bfbfc9461d940a84cd66ebea", + "zh:4855ee628ead847741aa4f4fc9bed50cfdbf197f2912775dd9fe7bc43fa077c0", + "zh:4b8cd2583d1edcac4011caafe8afb7a95e8110a607a1d5fb87d921178074a69b", + "zh:52084ddaff8c8cd3f9e7bcb7ce4dc1eab00602912c96da43c29b4762dc376038", + "zh:71562d330d3f92d79b2952ffdda0dad167e952e46200c767dd30c6af8d7c0ed3", "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", - "zh:82a803f2f484c8b766e2e9c32343e9c89b91997b9f8d2697f9f3837f62926b35", - "zh:9708a4e40d6cc4b8afd1352e5186e6e1502f6ae599867c120967aebe9d90ed04", - "zh:973f65ce0d67c585f4ec250c1e634c9b22d9c4288b484ee2a871d7fa1e317406", - "zh:c8fa0f98f9316e4cfef082aa9b785ba16e36ff754d6aba8b456dab9500e671c6", - "zh:cfa5342a5f5188b20db246c73ac823918c189468e1382cb3c48a9c0c08fc5bf7", - "zh:e0e2b477c7e899c63b06b38cd8684a893d834d6d0b5e9b033cedc06dd7ffe9e2", - "zh:f62d7d05ea1ee566f732505200ab38d94315a4add27947a60afa29860822d3fc", - "zh:fa7ce69dde358e172bd719014ad637634bbdabc49363104f4fca759b4b73f2ce", + "zh:805f81ade06ff68fa8b908d31892eaed5c180ae031c77ad35f82cb7a74b97cf4", + "zh:8b6b3ebeaaa8e38dd04e56996abe80db9be6f4c1df75ac3cccc77642899bd464", + "zh:ad07750576b99248037b897de71113cc19b1a8d0bc235eb99173cc83d0de3b1b", + "zh:b9f1c3bfadb74068f5c205292badb0661e17ac05eb23bfe8bd809691e4583d0e", + "zh:cc4cbcd67414fefb111c1bf7ab0bc4beb8c0b553d01719ad17de9a047adff4d1", ] } provider "registry.terraform.io/hashicorp/null" { - version = "3.2.2" + version = "3.2.3" constraints = ">= 2.0.0" hashes = [ - "h1:IMVAUHKoydFrlPrl9OzasDnw/8ntZFerCC9iXw1rXQY=", - "h1:zT1ZbegaAYHwQa+QwIFugArWikRJI9dqohj8xb0GY88=", - "zh:3248aae6a2198f3ec8394218d05bd5e42be59f43a3a7c0b71c66ec0df08b69e7", - "zh:32b1aaa1c3013d33c245493f4a65465eab9436b454d250102729321a44c8ab9a", - "zh:38eff7e470acb48f66380a73a5c7cdd76cc9b9c9ba9a7249c7991488abe22fe3", - "zh:4c2f1faee67af104f5f9e711c4574ff4d298afaa8a420680b0cb55d7bbc65606", - "zh:544b33b757c0b954dbb87db83a5ad921edd61f02f1dc86c6186a5ea86465b546", - "zh:696cf785090e1e8cf1587499516b0494f47413b43cb99877ad97f5d0de3dc539", - "zh:6e301f34757b5d265ae44467d95306d61bef5e41930be1365f5a8dcf80f59452", + "h1:I0Um8UkrMUb81Fxq/dxbr3HLP2cecTH2WMJiwKSrwQY=", + "zh:22d062e5278d872fe7aed834f5577ba0a5afe34a3bdac2b81f828d8d3e6706d2", + "zh:23dead00493ad863729495dc212fd6c29b8293e707b055ce5ba21ee453ce552d", + "zh:28299accf21763ca1ca144d8f660688d7c2ad0b105b7202554ca60b02a3856d3", + "zh:55c9e8a9ac25a7652df8c51a8a9a422bd67d784061b1de2dc9fe6c3cb4e77f2f", + "zh:756586535d11698a216291c06b9ed8a5cc6a4ec43eee1ee09ecd5c6a9e297ac1", "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", - "zh:913a929070c819e59e94bb37a2a253c228f83921136ff4a7aa1a178c7cce5422", - "zh:aa9015926cd152425dbf86d1abdbc74bfe0e1ba3d26b3db35051d7b9ca9f72ae", - "zh:bb04798b016e1e1d49bcc76d62c53b56c88c63d6f2dfe38821afef17c416a0e1", - "zh:c23084e1b23577de22603cff752e59128d83cfecc2e6819edadd8cf7a10af11e", + "zh:9d5eea62fdb587eeb96a8c4d782459f4e6b73baeece4d04b4a40e44faaee9301", + "zh:a6355f596a3fb8fc85c2fb054ab14e722991533f87f928e7169a486462c74670", + "zh:b5a65a789cff4ada58a5baffc76cb9767dc26ec6b45c00d2ec8b1b027f6db4ed", + "zh:db5ab669cf11d0e9f81dc380a6fdfcac437aea3d69109c7aef1a5426639d2d65", + "zh:de655d251c470197bcbb5ac45d289595295acb8f829f6c781d4a75c8c8b7c7dd", + "zh:f5c68199f2e6076bce92a12230434782bf768103a427e9bb9abee99b116af7b5", ] } provider "registry.terraform.io/hashicorp/random" { - version = "3.5.1" + version = "3.6.3" constraints = ">= 2.2.0" hashes = [ - "h1:IL9mSatmwov+e0+++YX2V6uel+dV6bn+fC/cnGDK3Ck=", - "h1:VSnd9ZIPyfKHOObuQCaKfnjIHRtR7qTw19Rz8tJxm+k=", - "zh:04e3fbd610cb52c1017d282531364b9c53ef72b6bc533acb2a90671957324a64", - "zh:119197103301ebaf7efb91df8f0b6e0dd31e6ff943d231af35ee1831c599188d", - "zh:4d2b219d09abf3b1bb4df93d399ed156cadd61f44ad3baf5cf2954df2fba0831", - "zh:6130bdde527587bbe2dcaa7150363e96dbc5250ea20154176d82bc69df5d4ce3", - "zh:6cc326cd4000f724d3086ee05587e7710f032f94fc9af35e96a386a1c6f2214f", + "h1:zG9uFP8l9u+yGZZvi5Te7PV62j50azpgwPunq2vTm1E=", + "zh:04ceb65210251339f07cd4611885d242cd4d0c7306e86dda9785396807c00451", + "zh:448f56199f3e99ff75d5c0afacae867ee795e4dfda6cb5f8e3b2a72ec3583dd8", + "zh:4b4c11ccfba7319e901df2dac836b1ae8f12185e37249e8d870ee10bb87a13fe", + "zh:4fa45c44c0de582c2edb8a2e054f55124520c16a39b2dfc0355929063b6395b1", + "zh:588508280501a06259e023b0695f6a18149a3816d259655c424d068982cbdd36", + "zh:737c4d99a87d2a4d1ac0a54a73d2cb62974ccb2edbd234f333abd079a32ebc9e", "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", - "zh:b6d88e1d28cf2dfa24e9fdcc3efc77adcdc1c3c3b5c7ce503a423efbdd6de57b", - "zh:ba74c592622ecbcef9dc2a4d81ed321c4e44cddf7da799faa324da9bf52a22b2", - "zh:c7c5cde98fe4ef1143bd1b3ec5dc04baf0d4cc3ca2c5c7d40d17c0e9b2076865", - "zh:dac4bad52c940cd0dfc27893507c1e92393846b024c5a9db159a93c534a3da03", - "zh:de8febe2a2acd9ac454b844a4106ed295ae9520ef54dc8ed2faf29f12716b602", - "zh:eab0d0495e7e711cca367f7d4df6e322e6c562fc52151ec931176115b83ed014", + "zh:a357ab512e5ebc6d1fda1382503109766e21bbfdfaa9ccda43d313c122069b30", + "zh:c51bfb15e7d52cc1a2eaec2a903ac2aff15d162c172b1b4c17675190e8147615", + "zh:e0951ee6fa9df90433728b96381fb867e3db98f66f735e0c3e24f8f16903f0ad", + "zh:e3cdcb4e73740621dabd82ee6a37d6cfce7fee2a03d8074df65086760f5cf556", + "zh:eff58323099f1bd9a0bec7cb04f717e7f1b2774c7d612bf7581797e1622613a0", ] } provider "registry.terraform.io/hashicorp/tls" { - version = "4.0.4" + version = "4.0.6" hashes = [ - "h1:GZcFizg5ZT2VrpwvxGBHQ/hO9r6g0vYdQqx3bFD3anY=", - "h1:pe9vq86dZZKCm+8k1RhzARwENslF3SXb9ErHbQfgjXU=", - "zh:23671ed83e1fcf79745534841e10291bbf34046b27d6e68a5d0aab77206f4a55", - "zh:45292421211ffd9e8e3eb3655677700e3c5047f71d8f7650d2ce30242335f848", - "zh:59fedb519f4433c0fdb1d58b27c210b27415fddd0cd73c5312530b4309c088be", - "zh:5a8eec2409a9ff7cd0758a9d818c74bcba92a240e6c5e54b99df68fff312bbd5", - "zh:5e6a4b39f3171f53292ab88058a59e64825f2b842760a4869e64dc1dc093d1fe", - "zh:810547d0bf9311d21c81cc306126d3547e7bd3f194fc295836acf164b9f8424e", - "zh:824a5f3617624243bed0259d7dd37d76017097dc3193dac669be342b90b2ab48", - "zh:9361ccc7048be5dcbc2fafe2d8216939765b3160bd52734f7a9fd917a39ecbd8", - "zh:aa02ea625aaf672e649296bce7580f62d724268189fe9ad7c1b36bb0fa12fa60", - "zh:c71b4cd40d6ec7815dfeefd57d88bc592c0c42f5e5858dcc88245d371b4b8b1e", - "zh:dabcd52f36b43d250a3d71ad7abfa07b5622c69068d989e60b79b2bb4f220316", + "h1:n3M50qfWfRSpQV9Pwcvuse03pEizqrmYEryxKky4so4=", + "zh:10de0d8af02f2e578101688fd334da3849f56ea91b0d9bd5b1f7a243417fdda8", + "zh:37fc01f8b2bc9d5b055dc3e78bfd1beb7c42cfb776a4c81106e19c8911366297", + "zh:4578ca03d1dd0b7f572d96bd03f744be24c726bfd282173d54b100fd221608bb", + "zh:6c475491d1250050765a91a493ef330adc24689e8837a0f07da5a0e1269e11c1", + "zh:81bde94d53cdababa5b376bbc6947668be4c45ab655de7aa2e8e4736dfd52509", + "zh:abdce260840b7b050c4e401d4f75c7a199fafe58a8b213947a258f75ac18b3e8", + "zh:b754cebfc5184873840f16a642a7c9ef78c34dc246a8ae29e056c79939963c7a", + "zh:c928b66086078f9917aef0eec15982f2e337914c5c4dbc31dd4741403db7eb18", + "zh:cded27bee5f24de6f2ee0cfd1df46a7f88e84aaffc2ecbf3ff7094160f193d50", + "zh:d65eb3867e8f69aaf1b8bb53bd637c99c6b649ba3db16ded50fa9a01076d1a27", + "zh:ecb0c8b528c7a619fa71852bb3fb5c151d47576c5aab2bf3af4db52588722eeb", "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", ] } diff --git a/infrastructure/dogfood/terraform/aws-tf-module/main.tf b/infrastructure/dogfood/terraform/aws-tf-module/main.tf index c7d9c2fa4da6..1cdb6b8c345a 100644 --- a/infrastructure/dogfood/terraform/aws-tf-module/main.tf +++ b/infrastructure/dogfood/terraform/aws-tf-module/main.tf @@ -370,7 +370,8 @@ module "osquery-carve" { module "monitoring" { source = "github.com/fleetdm/fleet//terraform/addons/monitoring?ref=tf-mod-addon-monitoring-v1.5.0" - customer_prefix = local.customer + customer_prefix = local.customer + fleet_ecs_service_name = module.main.byo-vpc.byo-db.byo-ecs.service.name albs = [ { name = module.main.byo-vpc.byo-db.alb.lb_dns_name, @@ -389,11 +390,11 @@ module "monitoring" { threshold = 0 } } - } + } ] sns_topic_arns_map = { - alb_httpcode_5xx = [module.notify_slack.slack_topic_arn] - cron_monitoring = [module.notify_slack.slack_topic_arn] + alb_httpcode_5xx = [module.notify_slack.slack_topic_arn] + cron_monitoring = [module.notify_slack.slack_topic_arn] cron_job_failure_monitoring = [module.notify_slack_p2.slack_topic_arn] } mysql_cluster_members = module.main.byo-vpc.rds.cluster_members @@ -490,7 +491,8 @@ module "notify_slack_p2" { source = "terraform-aws-modules/notify-slack/aws" version = "5.5.0" - sns_topic_name = "fleet-dogfood-p2-alerts" + lambda_function_name = "notify_slack_p2" + sns_topic_name = "fleet-dogfood-p2-alerts" slack_webhook_url = var.slack_p2_webhook slack_channel = "#help-p2" diff --git a/orbit/changes/8986-systemdrive-env-passthrough b/orbit/changes/8986-systemdrive-env-passthrough new file mode 100644 index 000000000000..472460428128 --- /dev/null +++ b/orbit/changes/8986-systemdrive-env-passthrough @@ -0,0 +1 @@ +- Pass `SystemDrive` Environemt variable to osqueryd when present in Orbit diff --git a/orbit/cmd/orbit/orbit.go b/orbit/cmd/orbit/orbit.go index 2cf8138045fd..c7d41f11acb7 100644 --- a/orbit/cmd/orbit/orbit.go +++ b/orbit/cmd/orbit/orbit.go @@ -771,6 +771,12 @@ func main() { ) } + if runtime.GOOS == "windows" { + if systemDrive, ok := os.LookupEnv("SystemDrive"); ok { + options = append(options, osquery.WithEnv([]string{fmt.Sprintf("SystemDrive=%s", systemDrive)})) + } + } + var certPath string if fleetURL != "https://" && c.Bool("insecure") { proxy, err := insecure.NewTLSProxy(fleetURL) diff --git a/pkg/mdm/mdmtest/apple.go b/pkg/mdm/mdmtest/apple.go index e6cbbb0e1a06..e78aea43a1f6 100644 --- a/pkg/mdm/mdmtest/apple.go +++ b/pkg/mdm/mdmtest/apple.go @@ -25,8 +25,8 @@ import ( "github.com/fleetdm/fleet/v4/server/fleet" apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" - "github.com/fleetdm/fleet/v4/server/mdm/scep/cryptoutil/x509util" scepserver "github.com/fleetdm/fleet/v4/server/mdm/scep/server" + "github.com/fleetdm/fleet/v4/server/mdm/scep/x509util" httptransport "github.com/go-kit/kit/transport/http" "github.com/go-kit/log" kitlog "github.com/go-kit/log" diff --git a/server/config/config.go b/server/config/config.go index bdfe0898dcf9..cdb932bd5fff 100644 --- a/server/config/config.go +++ b/server/config/config.go @@ -316,16 +316,48 @@ type S3Config struct { CarvesDisableSSL bool `yaml:"carves_disable_ssl"` CarvesForceS3PathStyle bool `yaml:"carves_force_s3_path_style"` - SoftwareInstallersBucket string `yaml:"software_installers_bucket"` - SoftwareInstallersPrefix string `yaml:"software_installers_prefix"` - SoftwareInstallersRegion string `yaml:"software_installers_region"` - SoftwareInstallersEndpointURL string `yaml:"software_installers_endpoint_url"` - SoftwareInstallersAccessKeyID string `yaml:"software_installers_access_key_id"` - SoftwareInstallersSecretAccessKey string `yaml:"software_installers_secret_access_key"` - SoftwareInstallersStsAssumeRoleArn string `yaml:"software_installers_sts_assume_role_arn"` - SoftwareInstallersStsExternalID string `yaml:"software_installers_sts_external_id"` - SoftwareInstallersDisableSSL bool `yaml:"software_installers_disable_ssl"` - SoftwareInstallersForceS3PathStyle bool `yaml:"software_installers_force_s3_path_style"` + SoftwareInstallersBucket string `yaml:"software_installers_bucket"` + SoftwareInstallersPrefix string `yaml:"software_installers_prefix"` + SoftwareInstallersRegion string `yaml:"software_installers_region"` + SoftwareInstallersEndpointURL string `yaml:"software_installers_endpoint_url"` + SoftwareInstallersAccessKeyID string `yaml:"software_installers_access_key_id"` + SoftwareInstallersSecretAccessKey string `yaml:"software_installers_secret_access_key"` + SoftwareInstallersStsAssumeRoleArn string `yaml:"software_installers_sts_assume_role_arn"` + SoftwareInstallersStsExternalID string `yaml:"software_installers_sts_external_id"` + SoftwareInstallersDisableSSL bool `yaml:"software_installers_disable_ssl"` + SoftwareInstallersForceS3PathStyle bool `yaml:"software_installers_force_s3_path_style"` + SoftwareInstallersCloudfrontURL string `yaml:"software_installers_cloudfront_url"` + SoftwareInstallersCloudfrontURLSigningPublicKeyID string `yaml:"software_installers_cloudfront_url_signing_public_key_id"` + SoftwareInstallersCloudfrontURLSigningPrivateKey string `yaml:"software_installers_cloudfront_url_signing_private_key"` +} + +func (s S3Config) ValidateCloudfrontURL(initFatal func(err error, msg string)) { + if s.SoftwareInstallersCloudfrontURL != "" { + cloudfrontURL, err := url.Parse(s.SoftwareInstallersCloudfrontURL) + if err != nil { + initFatal(err, "S3 software installers cloudfront URL") + return + } + if cloudfrontURL.Scheme != "https" { + initFatal(errors.New("cloudfront url scheme must be https"), "S3 software installers cloudfront URL") + return + } + if s.SoftwareInstallersCloudfrontURLSigningPrivateKey != "" && s.SoftwareInstallersCloudfrontURLSigningPublicKeyID == "" || + s.SoftwareInstallersCloudfrontURLSigningPrivateKey == "" && s.SoftwareInstallersCloudfrontURLSigningPublicKeyID != "" { + initFatal(errors.New("Couldn't configure. Both `s3_software_installers_cloudfront_url_signing_public_key_id` and `s3_software_installers_cloudfront_url_signing_private_key` must be set for URL signing."), + "S3 software installers cloudfront URL") + return + } + if s.SoftwareInstallersCloudfrontURLSigningPrivateKey == "" && s.SoftwareInstallersCloudfrontURLSigningPublicKeyID == "" { + initFatal(errors.New("Couldn't configure. Both `s3_software_installers_cloudfront_url_signing_public_key_id` and `s3_software_installers_cloudfront_url_signing_private_key` must be set when CloudFront distribution URL is set."), + "S3 software installers cloudfront URL") + return + } + } else if s.SoftwareInstallersCloudfrontURLSigningPrivateKey != "" || s.SoftwareInstallersCloudfrontURLSigningPublicKeyID != "" { + initFatal(errors.New("Couldn't configure. `s3_software_installers_cloudfront_url` must be set to use `s3_software_installers_cloudfront_url_signing_public_key_id` and `s3_software_installers_cloudfront_url_signing_private_key`."), + "S3 software installers cloudfront URL") + return + } } func (s S3Config) BucketsAndPrefixesMatch() bool { @@ -1197,6 +1229,9 @@ func (man Manager) addConfigs() { man.addConfigString("s3.software_installers_sts_external_id", "", "Optional unique identifier that can be used by the principal assuming the role to assert its identity.") man.addConfigBool("s3.software_installers_disable_ssl", false, "Disable SSL (typically for local testing)") man.addConfigBool("s3.software_installers_force_s3_path_style", false, "Set this to true to force path-style addressing, i.e., `http://s3.amazonaws.com/BUCKET/KEY`") + man.addConfigString("s3.software_installers_cloudfront_url", "", "CloudFront URL for software installers") + man.addConfigString("s3.software_installers_cloudfront_url_signing_public_key_id", "", "CloudFront public key ID for URL signing") + man.addConfigString("s3.software_installers_cloudfront_url_signing_private_key", "", "CloudFront private key for URL signing") // PubSub man.addConfigString("pubsub.project", "", "Google Cloud Project to use") @@ -1622,16 +1657,19 @@ func (man Manager) loadS3Config() S3Config { DisableSSL: man.getConfigBool("s3.disable_ssl"), ForceS3PathStyle: man.getConfigBool("s3.force_s3_path_style"), - SoftwareInstallersBucket: man.getConfigString("s3.software_installers_bucket"), - SoftwareInstallersPrefix: man.getConfigString("s3.software_installers_prefix"), - SoftwareInstallersRegion: man.getConfigString("s3.software_installers_region"), - SoftwareInstallersEndpointURL: man.getConfigString("s3.software_installers_endpoint_url"), - SoftwareInstallersAccessKeyID: man.getConfigString("s3.software_installers_access_key_id"), - SoftwareInstallersSecretAccessKey: man.getConfigString("s3.software_installers_secret_access_key"), - SoftwareInstallersStsAssumeRoleArn: man.getConfigString("s3.software_installers_sts_assume_role_arn"), - SoftwareInstallersStsExternalID: man.getConfigString("s3.software_installers_sts_external_id"), - SoftwareInstallersDisableSSL: man.getConfigBool("s3.software_installers_disable_ssl"), - SoftwareInstallersForceS3PathStyle: man.getConfigBool("s3.software_installers_force_s3_path_style"), + SoftwareInstallersBucket: man.getConfigString("s3.software_installers_bucket"), + SoftwareInstallersPrefix: man.getConfigString("s3.software_installers_prefix"), + SoftwareInstallersRegion: man.getConfigString("s3.software_installers_region"), + SoftwareInstallersEndpointURL: man.getConfigString("s3.software_installers_endpoint_url"), + SoftwareInstallersAccessKeyID: man.getConfigString("s3.software_installers_access_key_id"), + SoftwareInstallersSecretAccessKey: man.getConfigString("s3.software_installers_secret_access_key"), + SoftwareInstallersStsAssumeRoleArn: man.getConfigString("s3.software_installers_sts_assume_role_arn"), + SoftwareInstallersStsExternalID: man.getConfigString("s3.software_installers_sts_external_id"), + SoftwareInstallersDisableSSL: man.getConfigBool("s3.software_installers_disable_ssl"), + SoftwareInstallersForceS3PathStyle: man.getConfigBool("s3.software_installers_force_s3_path_style"), + SoftwareInstallersCloudfrontURL: man.getConfigString("s3.software_installers_cloudfront_url"), + SoftwareInstallersCloudfrontURLSigningPublicKeyID: man.getConfigString("s3.software_installers_cloudfront_url_signing_public_key_id"), + SoftwareInstallersCloudfrontURLSigningPrivateKey: man.getConfigString("s3.software_installers_cloudfront_url_signing_private_key"), } } diff --git a/server/config/config_test.go b/server/config/config_test.go index c2df31584041..95aa2cd1ac0c 100644 --- a/server/config/config_test.go +++ b/server/config/config_test.go @@ -695,3 +695,44 @@ e+Z1cALnWREYhEPv4JrR5U0VvqeIdExDD6Ida61yvd7oc59pn0kpfKjozPJr6FsU // prevent static analysis tools from raising issues due to detection of private key // in code. func testingKey(s string) string { return strings.ReplaceAll(s, "TESTING KEY", "PRIVATE KEY") } + +func TestValidateCloudfrontURL(t *testing.T) { + t.Parallel() + cases := []struct { + name string + url string + publicKey string + privateKey string + errMatches string + }{ + {"happy path", "https://example.com", "public", "private", ""}, + {"bad URL", "bozo!://example.com", "public", "private", "parse"}, + {"non-HTTPS URL", "http://example.com", "public", "private", "cloudfront url scheme must be https"}, + {"missing URL", "", "public", "private", "`s3_software_installers_cloudfront_url` must be set"}, + {"missing public key", "https://example.com", "", "private", + "Both `s3_software_installers_cloudfront_url_signing_public_key_id` and `s3_software_installers_cloudfront_url_signing_private_key` must be set"}, + {"missing private key", "https://example.com", "public", "", + "Both `s3_software_installers_cloudfront_url_signing_public_key_id` and `s3_software_installers_cloudfront_url_signing_private_key` must be set"}, + {"missing keys", "https://example.com", "", "", + "Both `s3_software_installers_cloudfront_url_signing_public_key_id` and `s3_software_installers_cloudfront_url_signing_private_key` must be set"}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + s3 := S3Config{ + SoftwareInstallersCloudfrontURL: c.url, + SoftwareInstallersCloudfrontURLSigningPublicKeyID: c.publicKey, + SoftwareInstallersCloudfrontURLSigningPrivateKey: c.privateKey, + } + initFatal := func(err error, msg string) { + if c.errMatches != "" { + require.Error(t, err) + require.Regexp(t, c.errMatches, err.Error()) + } else { + t.Errorf("unexpected error: %v", err) + } + } + s3.ValidateCloudfrontURL(initFatal) + }) + } +} diff --git a/server/datastore/mysql/apple_mdm.go b/server/datastore/mysql/apple_mdm.go index f49f215ef460..8991eac117f1 100644 --- a/server/datastore/mysql/apple_mdm.go +++ b/server/datastore/mysql/apple_mdm.go @@ -2719,7 +2719,8 @@ func (ds *Datastore) BulkUpsertMDMAppleHostProfiles(ctx context.Context, payload detail, command_uuid, checksum, - secrets_updated_at + secrets_updated_at, + ignore_error ) VALUES %s ON DUPLICATE KEY UPDATE @@ -2728,6 +2729,7 @@ func (ds *Datastore) BulkUpsertMDMAppleHostProfiles(ctx context.Context, payload detail = VALUES(detail), checksum = VALUES(checksum), secrets_updated_at = VALUES(secrets_updated_at), + ignore_error = VALUES(ignore_error), profile_identifier = VALUES(profile_identifier), profile_name = VALUES(profile_name), command_uuid = VALUES(command_uuid)`, @@ -2747,9 +2749,9 @@ func (ds *Datastore) BulkUpsertMDMAppleHostProfiles(ctx context.Context, payload } generateValueArgs := func(p *fleet.MDMAppleBulkUpsertHostProfilePayload) (string, []any) { - valuePart := "(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)," + valuePart := "(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)," args := []any{p.ProfileUUID, p.ProfileIdentifier, p.ProfileName, p.HostUUID, p.Status, p.OperationType, p.Detail, p.CommandUUID, - p.Checksum, p.SecretsUpdatedAt} + p.Checksum, p.SecretsUpdatedAt, p.IgnoreError} return valuePart, args } @@ -2767,14 +2769,25 @@ func (ds *Datastore) BulkUpsertMDMAppleHostProfiles(ctx context.Context, payload } func (ds *Datastore) UpdateOrDeleteHostMDMAppleProfile(ctx context.Context, profile *fleet.HostMDMAppleProfile) error { - if profile.OperationType == fleet.MDMOperationTypeRemove && - profile.Status != nil && - (*profile.Status == fleet.MDMDeliveryVerifying || *profile.Status == fleet.MDMDeliveryVerified) { - _, err := ds.writer(ctx).ExecContext(ctx, ` + if profile.OperationType == fleet.MDMOperationTypeRemove && profile.Status != nil { + var ignoreError bool + if *profile.Status == fleet.MDMDeliveryFailed { + // Check whether we should ignore the error. + err := sqlx.GetContext(ctx, ds.reader(ctx), &ignoreError, ` + SELECT ignore_error FROM host_mdm_apple_profiles WHERE host_uuid = ? AND command_uuid = ?`, + profile.HostUUID, profile.CommandUUID) + if err != nil { + return ctxerr.Wrap(ctx, err, "get ignore error") + } + } + if ignoreError || + (*profile.Status == fleet.MDMDeliveryVerifying || *profile.Status == fleet.MDMDeliveryVerified) { + _, err := ds.writer(ctx).ExecContext(ctx, ` DELETE FROM host_mdm_apple_profiles WHERE host_uuid = ? AND command_uuid = ? `, profile.HostUUID, profile.CommandUUID) - return err + return err + } } detail := profile.Detail diff --git a/server/datastore/mysql/common_mysql/batch.go b/server/datastore/mysql/common_mysql/batch.go new file mode 100644 index 000000000000..ea24476005f9 --- /dev/null +++ b/server/datastore/mysql/common_mysql/batch.go @@ -0,0 +1,26 @@ +package common_mysql + +// BatchProcessSimple is a simple utility function to batch process a slice of payloads. +// Provide a slice of payloads, a batch size, and a function to execute on each batch. +func BatchProcessSimple[T any]( + payloads []T, + batchSize int, + executeBatch func(payloadsInThisBatch []T) error, +) error { + if len(payloads) == 0 || batchSize <= 0 || executeBatch == nil { + return nil + } + + for i := 0; i < len(payloads); i += batchSize { + start := i + end := i + batchSize + if end > len(payloads) { + end = len(payloads) + } + if err := executeBatch(payloads[start:end]); err != nil { + return err + } + } + + return nil +} diff --git a/server/datastore/mysql/common_mysql/batch_test.go b/server/datastore/mysql/common_mysql/batch_test.go new file mode 100644 index 000000000000..5f561e44de8f --- /dev/null +++ b/server/datastore/mysql/common_mysql/batch_test.go @@ -0,0 +1,52 @@ +package common_mysql + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBatchProcessSimple(t *testing.T) { + payloads := []int{1, 2, 3, 4, 5} + executeBatch := func(payloadsInThisBatch []int) error { + t.Fatal("executeBatch should not be called") + return nil + } + + // No payloads + err := BatchProcessSimple(nil, 10, executeBatch) + assert.NoError(t, err) + + // No batch size + err = BatchProcessSimple(payloads, 0, executeBatch) + assert.NoError(t, err) + + // No executeBatch + err = BatchProcessSimple(payloads, 10, nil) + assert.NoError(t, err) + + // Large batch size -- all payloads executed in one batch + executeBatch = func(payloadsInThisBatch []int) error { + assert.Equal(t, payloads, payloadsInThisBatch) + return nil + } + err = BatchProcessSimple(payloads, 10, executeBatch) + assert.NoError(t, err) + + // Small batch size + numCalls := 0 + executeBatch = func(payloadsInThisBatch []int) error { + numCalls++ + switch numCalls { + case 1: + assert.Equal(t, []int{1, 2, 3}, payloadsInThisBatch) + case 2: + assert.Equal(t, []int{4, 5}, payloadsInThisBatch) + default: + t.Errorf("Unexpected number of calls to executeBatch: %d", numCalls) + } + return nil + } + err = BatchProcessSimple(payloads, 3, executeBatch) + assert.NoError(t, err) +} diff --git a/server/datastore/mysql/migrations/tables/20250102121439_AddIgnoreErrorToHostProfiles.go b/server/datastore/mysql/migrations/tables/20250102121439_AddIgnoreErrorToHostProfiles.go new file mode 100644 index 000000000000..3e7638127f9e --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20250102121439_AddIgnoreErrorToHostProfiles.go @@ -0,0 +1,23 @@ +package tables + +import ( + "database/sql" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20250102121439, Down_20250102121439) +} + +func Up_20250102121439(tx *sql.Tx) error { + _, err := tx.Exec(`ALTER TABLE host_mdm_apple_profiles + ADD COLUMN ignore_error TINYINT(1) NOT NULL DEFAULT 0`) + if err != nil { + return fmt.Errorf("failed to add ignore_error to host_mdm_apple_profiles table: %w", err) + } + return nil +} + +func Down_20250102121439(_ *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/policies.go b/server/datastore/mysql/policies.go index 61b2187e6af9..8f25fd467768 100644 --- a/server/datastore/mysql/policies.go +++ b/server/datastore/mysql/policies.go @@ -416,6 +416,34 @@ func (ds *Datastore) RecordPolicyQueryExecutions(ctx context.Context, host *flee return nil } +func (ds *Datastore) ClearAutoInstallPolicyStatusForHosts(ctx context.Context, installerID uint, hostIDs []uint) error { + if len(hostIDs) == 0 { + return nil + } + + stmt := ` +UPDATE + policies p + JOIN policy_membership pm ON pm.policy_id = p.id +SET + passes = NULL +WHERE + p.software_installer_id = ? + AND pm.host_id IN (?) + ` + + stmt, args, err := sqlx.In(stmt, installerID, hostIDs) + if err != nil { + return ctxerr.Wrap(ctx, err, "building in statement for clearing auto install policy status") + } + + if _, err := ds.writer(ctx).ExecContext(ctx, stmt, args...); err != nil { + return ctxerr.Wrap(ctx, err, "clearing auto install policy status") + } + + return nil +} + func (ds *Datastore) ListGlobalPolicies(ctx context.Context, opts fleet.ListOptions) ([]*fleet.Policy, error) { return listPoliciesDB(ctx, ds.reader(ctx), nil, opts) } diff --git a/server/datastore/mysql/policies_test.go b/server/datastore/mysql/policies_test.go index 5ce3c95a7604..7059ff736af6 100644 --- a/server/datastore/mysql/policies_test.go +++ b/server/datastore/mysql/policies_test.go @@ -72,6 +72,7 @@ func TestPolicies(t *testing.T) { {"TestPoliciesTeamPoliciesWithScript", testTeamPoliciesWithScript}, {"TeamPoliciesNoTeam", testTeamPoliciesNoTeam}, {"TestPoliciesBySoftwareTitleID", testPoliciesBySoftwareTitleID}, + {"TestClearAutoInstallPolicyStatusForHost", testClearAutoInstallPolicyStatusForHost}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -5371,3 +5372,83 @@ func testPoliciesBySoftwareTitleID(t *testing.T, ds *Datastore) { require.NoError(t, err) require.Len(t, policies, 0) } + +func testClearAutoInstallPolicyStatusForHost(t *testing.T, ds *Datastore) { + ctx := context.Background() + + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) + team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1" + t.Name()}) + require.NoError(t, err) + + // create a regular policy + policy1 := newTestPolicy(t, ds, user1, "policy 1"+t.Name(), "darwin", &team1.ID) + + // create an automatic install policy + policy2 := newTestPolicy(t, ds, user1, "policy 2"+t.Name(), "darwin", &team1.ID) + installer, err := fleet.NewTempFileReader(strings.NewReader("hello"), t.TempDir) + require.NoError(t, err) + + installer1ID, _, err := ds.MatchOrCreateSoftwareInstaller(context.Background(), &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "hello", + PreInstallQuery: "SELECT 1", + PostInstallScript: "world", + InstallerFile: installer, + StorageID: "storage1", + Filename: "file1", + Title: "file1", + Version: "1.0", + Source: "apps", + UserID: user1.ID, + TeamID: &team1.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + }) + require.NoError(t, err) + policy2.SoftwareInstallerID = ptr.Uint(installer1ID) + err = ds.SavePolicy(context.Background(), policy2, false, false) + require.NoError(t, err) + + // create a host + host, err := ds.NewHost(ctx, &fleet.Host{ + OsqueryHostID: ptr.String(uuid.New().String()), + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now(), + NodeKey: ptr.String(uuid.New().String()), + UUID: uuid.New().String(), + Hostname: "host" + t.Name(), + TeamID: &team1.ID, + Platform: "darwin", + }) + require.NoError(t, err) + + // record a policy run for both policies + err = ds.RecordPolicyQueryExecutions(ctx, host, map[uint]*bool{ + policy1.ID: ptr.Bool(true), + policy2.ID: ptr.Bool(false), // software isn't installed on host, so Fleet should install it + }, time.Now(), false) + require.NoError(t, err) + + hostPolicies, err := ds.ListPoliciesForHost(ctx, host) + require.NoError(t, err) + require.Len(t, hostPolicies, 2) + sort.Slice(hostPolicies, func(i, j int) bool { + return hostPolicies[i].ID < hostPolicies[j].ID + }) + require.Equal(t, hostPolicies[0].Response, "pass") + require.Equal(t, hostPolicies[1].Response, "fail") + + // clear status for automatic install policy + err = ds.ClearAutoInstallPolicyStatusForHosts(ctx, installer1ID, []uint{host.ID}) + require.NoError(t, err) + + // the status should be NULL for the automatic install policy but not the "regular" one + hostPolicies, err = ds.ListPoliciesForHost(ctx, host) + require.NoError(t, err) + require.Len(t, hostPolicies, 2) + sort.Slice(hostPolicies, func(i, j int) bool { + return hostPolicies[i].ID < hostPolicies[j].ID + }) + require.Equal(t, hostPolicies[0].Response, "pass") + require.Empty(t, hostPolicies[1].Response) +} diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index d76eb1745d12..fce32fea06b2 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -447,6 +447,7 @@ CREATE TABLE `host_mdm_apple_profiles` ( `created_at` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updated_at` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), `secrets_updated_at` datetime(6) DEFAULT NULL, + `ignore_error` tinyint(1) NOT NULL DEFAULT '0', PRIMARY KEY (`host_uuid`,`profile_uuid`), KEY `status` (`status`), KEY `operation_type` (`operation_type`), @@ -1110,9 +1111,9 @@ CREATE TABLE `migration_status_tables` ( `is_applied` tinyint(1) NOT NULL, `tstamp` timestamp NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) -) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=346 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=347 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'),(276,20240702123921,1,'2020-01-01 01:01:01'),(277,20240703154849,1,'2020-01-01 01:01:01'),(278,20240707134035,1,'2020-01-01 01:01:01'),(279,20240707134036,1,'2020-01-01 01:01:01'),(280,20240709124958,1,'2020-01-01 01:01:01'),(281,20240709132642,1,'2020-01-01 01:01:01'),(282,20240709183940,1,'2020-01-01 01:01:01'),(283,20240710155623,1,'2020-01-01 01:01:01'),(284,20240723102712,1,'2020-01-01 01:01:01'),(285,20240725152735,1,'2020-01-01 01:01:01'),(286,20240725182118,1,'2020-01-01 01:01:01'),(287,20240726100517,1,'2020-01-01 01:01:01'),(288,20240730171504,1,'2020-01-01 01:01:01'),(289,20240730174056,1,'2020-01-01 01:01:01'),(290,20240730215453,1,'2020-01-01 01:01:01'),(291,20240730374423,1,'2020-01-01 01:01:01'),(292,20240801115359,1,'2020-01-01 01:01:01'),(293,20240802101043,1,'2020-01-01 01:01:01'),(294,20240802113716,1,'2020-01-01 01:01:01'),(295,20240814135330,1,'2020-01-01 01:01:01'),(296,20240815000000,1,'2020-01-01 01:01:01'),(297,20240815000001,1,'2020-01-01 01:01:01'),(298,20240816103247,1,'2020-01-01 01:01:01'),(299,20240820091218,1,'2020-01-01 01:01:01'),(300,20240826111228,1,'2020-01-01 01:01:01'),(301,20240826160025,1,'2020-01-01 01:01:01'),(302,20240829165448,1,'2020-01-01 01:01:01'),(303,20240829165605,1,'2020-01-01 01:01:01'),(304,20240829165715,1,'2020-01-01 01:01:01'),(305,20240829165930,1,'2020-01-01 01:01:01'),(306,20240829170023,1,'2020-01-01 01:01:01'),(307,20240829170033,1,'2020-01-01 01:01:01'),(308,20240829170044,1,'2020-01-01 01:01:01'),(309,20240905105135,1,'2020-01-01 01:01:01'),(310,20240905140514,1,'2020-01-01 01:01:01'),(311,20240905200000,1,'2020-01-01 01:01:01'),(312,20240905200001,1,'2020-01-01 01:01:01'),(313,20241002104104,1,'2020-01-01 01:01:01'),(314,20241002104105,1,'2020-01-01 01:01:01'),(315,20241002104106,1,'2020-01-01 01:01:01'),(316,20241002210000,1,'2020-01-01 01:01:01'),(317,20241003145349,1,'2020-01-01 01:01:01'),(318,20241004005000,1,'2020-01-01 01:01:01'),(319,20241008083925,1,'2020-01-01 01:01:01'),(320,20241009090010,1,'2020-01-01 01:01:01'),(321,20241017163402,1,'2020-01-01 01:01:01'),(322,20241021224359,1,'2020-01-01 01:01:01'),(323,20241022140321,1,'2020-01-01 01:01:01'),(324,20241025111236,1,'2020-01-01 01:01:01'),(325,20241025112748,1,'2020-01-01 01:01:01'),(326,20241025141855,1,'2020-01-01 01:01:01'),(327,20241110152839,1,'2020-01-01 01:01:01'),(328,20241110152840,1,'2020-01-01 01:01:01'),(329,20241110152841,1,'2020-01-01 01:01:01'),(330,20241116233322,1,'2020-01-01 01:01:01'),(331,20241122171434,1,'2020-01-01 01:01:01'),(332,20241125150614,1,'2020-01-01 01:01:01'),(333,20241203125346,1,'2020-01-01 01:01:01'),(334,20241203130032,1,'2020-01-01 01:01:01'),(335,20241205122800,1,'2020-01-01 01:01:01'),(336,20241209164540,1,'2020-01-01 01:01:01'),(337,20241210140021,1,'2020-01-01 01:01:01'),(338,20241219180042,1,'2020-01-01 01:01:01'),(339,20241220100000,1,'2020-01-01 01:01:01'),(340,20241220114903,1,'2020-01-01 01:01:01'),(341,20241220114904,1,'2020-01-01 01:01:01'),(342,20241224000000,1,'2020-01-01 01:01:01'),(343,20241230000000,1,'2020-01-01 01:01:01'),(344,20241231112624,1,'2020-01-01 01:01:01'),(345,20250106162751,1,'2020-01-01 01:01:01'); +INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'),(276,20240702123921,1,'2020-01-01 01:01:01'),(277,20240703154849,1,'2020-01-01 01:01:01'),(278,20240707134035,1,'2020-01-01 01:01:01'),(279,20240707134036,1,'2020-01-01 01:01:01'),(280,20240709124958,1,'2020-01-01 01:01:01'),(281,20240709132642,1,'2020-01-01 01:01:01'),(282,20240709183940,1,'2020-01-01 01:01:01'),(283,20240710155623,1,'2020-01-01 01:01:01'),(284,20240723102712,1,'2020-01-01 01:01:01'),(285,20240725152735,1,'2020-01-01 01:01:01'),(286,20240725182118,1,'2020-01-01 01:01:01'),(287,20240726100517,1,'2020-01-01 01:01:01'),(288,20240730171504,1,'2020-01-01 01:01:01'),(289,20240730174056,1,'2020-01-01 01:01:01'),(290,20240730215453,1,'2020-01-01 01:01:01'),(291,20240730374423,1,'2020-01-01 01:01:01'),(292,20240801115359,1,'2020-01-01 01:01:01'),(293,20240802101043,1,'2020-01-01 01:01:01'),(294,20240802113716,1,'2020-01-01 01:01:01'),(295,20240814135330,1,'2020-01-01 01:01:01'),(296,20240815000000,1,'2020-01-01 01:01:01'),(297,20240815000001,1,'2020-01-01 01:01:01'),(298,20240816103247,1,'2020-01-01 01:01:01'),(299,20240820091218,1,'2020-01-01 01:01:01'),(300,20240826111228,1,'2020-01-01 01:01:01'),(301,20240826160025,1,'2020-01-01 01:01:01'),(302,20240829165448,1,'2020-01-01 01:01:01'),(303,20240829165605,1,'2020-01-01 01:01:01'),(304,20240829165715,1,'2020-01-01 01:01:01'),(305,20240829165930,1,'2020-01-01 01:01:01'),(306,20240829170023,1,'2020-01-01 01:01:01'),(307,20240829170033,1,'2020-01-01 01:01:01'),(308,20240829170044,1,'2020-01-01 01:01:01'),(309,20240905105135,1,'2020-01-01 01:01:01'),(310,20240905140514,1,'2020-01-01 01:01:01'),(311,20240905200000,1,'2020-01-01 01:01:01'),(312,20240905200001,1,'2020-01-01 01:01:01'),(313,20241002104104,1,'2020-01-01 01:01:01'),(314,20241002104105,1,'2020-01-01 01:01:01'),(315,20241002104106,1,'2020-01-01 01:01:01'),(316,20241002210000,1,'2020-01-01 01:01:01'),(317,20241003145349,1,'2020-01-01 01:01:01'),(318,20241004005000,1,'2020-01-01 01:01:01'),(319,20241008083925,1,'2020-01-01 01:01:01'),(320,20241009090010,1,'2020-01-01 01:01:01'),(321,20241017163402,1,'2020-01-01 01:01:01'),(322,20241021224359,1,'2020-01-01 01:01:01'),(323,20241022140321,1,'2020-01-01 01:01:01'),(324,20241025111236,1,'2020-01-01 01:01:01'),(325,20241025112748,1,'2020-01-01 01:01:01'),(326,20241025141855,1,'2020-01-01 01:01:01'),(327,20241110152839,1,'2020-01-01 01:01:01'),(328,20241110152840,1,'2020-01-01 01:01:01'),(329,20241110152841,1,'2020-01-01 01:01:01'),(330,20241116233322,1,'2020-01-01 01:01:01'),(331,20241122171434,1,'2020-01-01 01:01:01'),(332,20241125150614,1,'2020-01-01 01:01:01'),(333,20241203125346,1,'2020-01-01 01:01:01'),(334,20241203130032,1,'2020-01-01 01:01:01'),(335,20241205122800,1,'2020-01-01 01:01:01'),(336,20241209164540,1,'2020-01-01 01:01:01'),(337,20241210140021,1,'2020-01-01 01:01:01'),(338,20241219180042,1,'2020-01-01 01:01:01'),(339,20241220100000,1,'2020-01-01 01:01:01'),(340,20241220114903,1,'2020-01-01 01:01:01'),(341,20241220114904,1,'2020-01-01 01:01:01'),(342,20241224000000,1,'2020-01-01 01:01:01'),(343,20241230000000,1,'2020-01-01 01:01:01'),(344,20241231112624,1,'2020-01-01 01:01:01'),(345,20250102121439,1,'2020-01-01 01:01:01'),(346,20250106162751,1,'2020-01-01 01:01:01'); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `mobile_device_management_solutions` ( diff --git a/server/datastore/mysql/scripts.go b/server/datastore/mysql/scripts.go index aa23668b10fb..6118f3786675 100644 --- a/server/datastore/mysql/scripts.go +++ b/server/datastore/mysql/scripts.go @@ -164,6 +164,12 @@ func (ds *Datastore) SetHostScriptExecutionResult(ctx context.Context, result *f execution_id = ?` const hostMDMActionsStmt = ` + SELECT 'uninstall' AS action + FROM + host_software_installs + WHERE + execution_id = :execution_id AND host_id = :host_id + UNION -- host_mdm_actions query (and thus row in union) must be last to avoid #25144 SELECT CASE WHEN lock_ref = :execution_id THEN 'lock_ref' @@ -175,12 +181,6 @@ func (ds *Datastore) SetHostScriptExecutionResult(ctx context.Context, result *f host_mdm_actions WHERE host_id = :host_id - UNION - SELECT 'uninstall' AS action - FROM - host_software_installs - WHERE - execution_id = :execution_id AND host_id = :host_id ` output := truncateScriptResult(result.Output) diff --git a/server/datastore/mysql/software_installers.go b/server/datastore/mysql/software_installers.go index 0abc88d950b6..dd7a0aa091dc 100644 --- a/server/datastore/mysql/software_installers.go +++ b/server/datastore/mysql/software_installers.go @@ -1755,3 +1755,109 @@ func (ds *Datastore) IsSoftwareInstallerLabelScoped(ctx context.Context, install return res, nil } + +const labelScopedFilter = ` +SELECT + 1 +FROM ( + -- no labels + SELECT + 0 AS count_installer_labels, + 0 AS count_host_labels, + 0 AS count_host_updated_after_labels + WHERE NOT EXISTS ( SELECT 1 FROM software_installer_labels sil WHERE sil.software_installer_id = ?) + + UNION + + -- include any + SELECT + COUNT(*) AS count_installer_labels, + COUNT(lm.label_id) AS count_host_labels, + 0 AS count_host_updated_after_labels + FROM + software_installer_labels sil + LEFT OUTER JOIN label_membership lm ON lm.label_id = sil.label_id + AND lm.host_id = h.id + WHERE + sil.software_installer_id = ? + AND sil.exclude = 0 + HAVING + count_installer_labels > 0 + AND count_host_labels > 0 + + UNION + + -- exclude any, ignore software that depends on labels created + -- _after_ the label_updated_at timestamp of the host (because + -- we don't have results for that label yet, the host may or may + -- not be a member). + SELECT + COUNT(*) AS count_installer_labels, + COUNT(lm.label_id) AS count_host_labels, + SUM( + CASE WHEN lbl.created_at IS NOT NULL + AND( + SELECT + label_updated_at FROM hosts + WHERE + id = 1) >= lbl.created_at THEN + 1 + ELSE + 0 + END) AS count_host_updated_after_labels + FROM + software_installer_labels sil + LEFT OUTER JOIN labels lbl ON lbl.id = sil.label_id + LEFT OUTER JOIN label_membership lm ON lm.label_id = sil.label_id + AND lm.host_id = h.id +WHERE + sil.software_installer_id = ? + AND sil.exclude = 1 +HAVING + count_installer_labels > 0 + AND count_installer_labels = count_host_updated_after_labels + AND count_host_labels = 0) t` + +func (ds *Datastore) GetIncludedHostIDMapForSoftwareInstaller(ctx context.Context, installerID uint) (map[uint]struct{}, error) { + stmt := fmt.Sprintf(`SELECT + h.id +FROM + hosts h +WHERE + EXISTS (%s) +`, labelScopedFilter) + + var hostIDs []uint + if err := sqlx.SelectContext(ctx, ds.reader(ctx), &hostIDs, stmt, installerID, installerID, installerID); err != nil { + return nil, ctxerr.Wrap(ctx, err, "listing hosts included in software installer scope") + } + + res := make(map[uint]struct{}, len(hostIDs)) + for _, id := range hostIDs { + res[id] = struct{}{} + } + + return res, nil +} + +func (ds *Datastore) GetExcludedHostIDMapForSoftwareInstaller(ctx context.Context, installerID uint) (map[uint]struct{}, error) { + stmt := fmt.Sprintf(`SELECT + h.id +FROM + hosts h +WHERE + NOT EXISTS (%s) +`, labelScopedFilter) + + var hostIDs []uint + if err := sqlx.SelectContext(ctx, ds.reader(ctx), &hostIDs, stmt, installerID, installerID, installerID); err != nil { + return nil, ctxerr.Wrap(ctx, err, "listing hosts excluded from software installer scope") + } + + res := make(map[uint]struct{}, len(hostIDs)) + for _, id := range hostIDs { + res[id] = struct{}{} + } + + return res, nil +} diff --git a/server/datastore/mysql/software_test.go b/server/datastore/mysql/software_test.go index cbba49884b63..d654a9e0ad4f 100644 --- a/server/datastore/mysql/software_test.go +++ b/server/datastore/mysql/software_test.go @@ -5321,6 +5321,10 @@ func testListHostSoftwareWithLabelScoping(t *testing.T, ds *Datastore) { require.NoError(t, err) require.True(t, scoped) + hostsInScope, err := ds.GetIncludedHostIDMapForSoftwareInstaller(ctx, installerID1) + require.NoError(t, err) + require.Contains(t, hostsInScope, host.ID) + label1, err := ds.NewLabel(ctx, &fleet.Label{Name: "label1" + t.Name()}) require.NoError(t, err) @@ -5343,6 +5347,10 @@ func testListHostSoftwareWithLabelScoping(t *testing.T, ds *Datastore) { require.NoError(t, err) require.Empty(t, software) + hostsNotInScope, err := ds.GetExcludedHostIDMapForSoftwareInstaller(ctx, installerID1) + require.NoError(t, err) + require.Contains(t, hostsNotInScope, host.ID) + // installer1 should be out of scope since the label is "exclude any" scoped, err = ds.IsSoftwareInstallerLabelScoped(ctx, installerID1, host.ID) require.NoError(t, err) diff --git a/server/fleet/apple_mdm.go b/server/fleet/apple_mdm.go index 3a23b4cbe18c..940f6152b25d 100644 --- a/server/fleet/apple_mdm.go +++ b/server/fleet/apple_mdm.go @@ -326,14 +326,27 @@ type MDMAppleProfilePayload struct { OperationType MDMOperationType `db:"operation_type"` Detail string `db:"detail"` CommandUUID string `db:"command_uuid"` + IgnoreError bool `db:"ignore_error"` } // DidNotInstallOnHost indicates whether this profile was not installed on the host (and // therefore is not, as far as Fleet knows, currently on the host). +// The profile in Pending status could be on the host, but Fleet has not received an Acknowledged status yet. func (p *MDMAppleProfilePayload) DidNotInstallOnHost() bool { return p.Status != nil && (*p.Status == MDMDeliveryFailed || *p.Status == MDMDeliveryPending) && p.OperationType == MDMOperationTypeInstall } +// FailedInstallOnHost indicates whether this profile failed to install on the host. +func (p *MDMAppleProfilePayload) FailedInstallOnHost() bool { + return p.Status != nil && *p.Status == MDMDeliveryFailed && p.OperationType == MDMOperationTypeInstall +} + +// PendingInstallOnHost indicates whether this profile is pending to install on the host. +// The profile in Pending status could be on the host, but Fleet has not received an Acknowledged status yet. +func (p *MDMAppleProfilePayload) PendingInstallOnHost() bool { + return p.Status != nil && *p.Status == MDMDeliveryPending && p.OperationType == MDMOperationTypeInstall +} + type MDMAppleBulkUpsertHostProfilePayload struct { ProfileUUID string ProfileIdentifier string @@ -345,6 +358,7 @@ type MDMAppleBulkUpsertHostProfilePayload struct { Detail string Checksum []byte SecretsUpdatedAt *time.Time + IgnoreError bool } // MDMAppleFileVaultSummary reports the number of macOS hosts being managed with Apples disk diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 489145fae596..8d9f173f55fa 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -1706,6 +1706,18 @@ type Datastore interface { // Software installers // + // GetIncludedHostIDMapForSoftwareInstaller gets the set of hosts that are targeted/in scope for the + // given software installer, based label membership. + GetIncludedHostIDMapForSoftwareInstaller(ctx context.Context, installerID uint) (map[uint]struct{}, error) + + // GetExcludedHostIDMapForSoftwareInstaller gets the set of hosts that are NOT targeted/in scope for the + // given software installer, based label membership. + GetExcludedHostIDMapForSoftwareInstaller(ctx context.Context, installerID uint) (map[uint]struct{}, error) + + // ClearAutoInstallPolicyStatusForHosts clears out the status of the policy related to the given + // software installer for all the given hosts. + ClearAutoInstallPolicyStatusForHosts(ctx context.Context, installerID uint, hostIDs []uint) error + // GetSoftwareInstallDetails returns details required to fetch and // run software installers GetSoftwareInstallDetails(ctx context.Context, executionId string) (*SoftwareInstallDetails, error) diff --git a/server/mdm/apple/commander.go b/server/mdm/apple/commander.go index 4f1a279d7e49..15914ac393b7 100644 --- a/server/mdm/apple/commander.go +++ b/server/mdm/apple/commander.go @@ -425,6 +425,11 @@ func (svc *MDMAppleCommander) SendNotifications(ctx context.Context, hostUUIDs [ return nil } +// BulkDeleteHostUserCommandsWithoutResults calls the storage method with the same name. +func (svc *MDMAppleCommander) BulkDeleteHostUserCommandsWithoutResults(ctx context.Context, commandToIDs map[string][]string) error { + return svc.storage.BulkDeleteHostUserCommandsWithoutResults(ctx, commandToIDs) +} + // APNSDeliveryError records an error and the associated host UUIDs in which it // occurred. type APNSDeliveryError struct { diff --git a/server/mdm/cryptoutil/cryptoutil.go b/server/mdm/cryptoutil/cryptoutil.go new file mode 100644 index 000000000000..1b0395145db8 --- /dev/null +++ b/server/mdm/cryptoutil/cryptoutil.go @@ -0,0 +1,68 @@ +package cryptoutil + +import ( + "crypto" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/asn1" + "encoding/pem" + "errors" + "fmt" +) + +// GenerateSubjectKeyID generates Subject Key Identifier (SKI) using SHA-256 +// hash of the public key bytes according to RFC 7093 section 2. +func GenerateSubjectKeyID(pub crypto.PublicKey) ([]byte, error) { + var pubBytes []byte + var err error + switch pub := pub.(type) { + case *rsa.PublicKey: + pubBytes, err = asn1.Marshal(*pub) + if err != nil { + return nil, err + } + case *ecdsa.PublicKey: + pubBytes = elliptic.Marshal(pub.Curve, pub.X, pub.Y) + default: + return nil, errors.New("only ECDSA and RSA public keys are supported") + } + + hash := sha256.Sum256(pubBytes) + + // According to RFC 7093, The keyIdentifier is composed of the leftmost + // 160-bits of the SHA-256 hash of the value of the BIT STRING + // subjectPublicKey (excluding the tag, length, and number of unused bits). + return hash[:20], nil +} + +// ParsePrivateKey parses a PEM encoded private key and returns a crypto.PrivateKey. +// It can be used for private keys passed in from environment variables or command line or files. +func ParsePrivateKey(privKeyPEM []byte, keyName string) (crypto.PrivateKey, error) { + block, _ := pem.Decode(privKeyPEM) + if block == nil { + return nil, fmt.Errorf("failed to decode %s", keyName) + } + + // The code below is based on tls.parsePrivateKey + // https://cs.opensource.google/go/go/+/release-branch.go1.23:src/crypto/tls/tls.go;l=355-372 + if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil { + return key, nil + } + if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil { + switch key := key.(type) { + case *rsa.PrivateKey, *ecdsa.PrivateKey, ed25519.PrivateKey: + return key, nil + default: + return nil, fmt.Errorf("unmarshaled PKCS8 %s is not an RSA, ECDSA, or Ed25519 private key", keyName) + } + } + if key, err := x509.ParseECPrivateKey(block.Bytes); err == nil { + return key, nil + } + + return nil, fmt.Errorf("failed to parse %s of type %s", keyName, block.Type) +} diff --git a/server/mdm/cryptoutil/cryptoutil_test.go b/server/mdm/cryptoutil/cryptoutil_test.go new file mode 100644 index 000000000000..0224c7e7906f --- /dev/null +++ b/server/mdm/cryptoutil/cryptoutil_test.go @@ -0,0 +1,94 @@ +package cryptoutil + +import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "math/big" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGenerateSubjectKeyID(t *testing.T) { + ecKey, err := ecdsa.GenerateKey(elliptic.P224(), rand.Reader) + if err != nil { + t.Fatal(err) + } + for _, test := range []struct { + testName string + pub crypto.PublicKey + }{ + {"RSA", &rsa.PublicKey{N: big.NewInt(123), E: 65537}}, + {"ECDSA", ecKey.Public()}, + } { + test := test + t.Run(test.testName, func(t *testing.T) { + t.Parallel() + ski, err := GenerateSubjectKeyID(test.pub) + if err != nil { + t.Fatal(err) + } + if len(ski) != 20 { + t.Fatalf("unexpected subject public key identifier length: %d", len(ski)) + } + ski2, err := GenerateSubjectKeyID(test.pub) + if err != nil { + t.Fatal(err) + } + if !testSKIEq(ski, ski2) { + t.Fatal("subject key identifier generation is not deterministic") + } + }) + } +} + +func testSKIEq(a, b []byte) bool { + if len(a) != len(b) { + return false + } + + for i := range a { + if a[i] != b[i] { + return false + } + } + + return true +} + +func TestParsePrivateKey(t *testing.T) { + t.Parallel() + // nil block not allowed + _, err := ParsePrivateKey(nil, "APNS private key") + assert.ErrorContains(t, err, "failed to decode") + + // encrypted pkcs8 not supported + pkcs8Encrypted, err := os.ReadFile("testdata/pkcs8-encrypted.key") + require.NoError(t, err) + _, err = ParsePrivateKey(pkcs8Encrypted, "APNS private key") + assert.ErrorContains(t, err, "failed to parse APNS private key of type ENCRYPTED PRIVATE KEY") + + // X25519 pkcs8 not supported + pkcs8Encrypted, err = os.ReadFile("testdata/pkcs8-x25519.key") + require.NoError(t, err) + _, err = ParsePrivateKey(pkcs8Encrypted, "APNS private key") + assert.ErrorContains(t, err, "unmarshaled PKCS8 APNS private key is not") + + // In this test, the pkcs1 key and pkcs8 keys are the same key, just different formats + pkcs1, err := os.ReadFile("testdata/pkcs1.key") + require.NoError(t, err) + pkcs1Key, err := ParsePrivateKey(pkcs1, "APNS private key") + require.NoError(t, err) + + pkcs8, err := os.ReadFile("testdata/pkcs8-rsa.key") + require.NoError(t, err) + pkcs8Key, err := ParsePrivateKey(pkcs8, "APNS private key") + require.NoError(t, err) + + assert.Equal(t, pkcs1Key, pkcs8Key) +} diff --git a/server/mdm/scep/cryptoutil/doc.go b/server/mdm/cryptoutil/doc.go similarity index 100% rename from server/mdm/scep/cryptoutil/doc.go rename to server/mdm/cryptoutil/doc.go diff --git a/server/service/testdata/pkcs1.key b/server/mdm/cryptoutil/testdata/pkcs1.key similarity index 100% rename from server/service/testdata/pkcs1.key rename to server/mdm/cryptoutil/testdata/pkcs1.key diff --git a/server/service/testdata/pkcs8-encrypted.key b/server/mdm/cryptoutil/testdata/pkcs8-encrypted.key similarity index 100% rename from server/service/testdata/pkcs8-encrypted.key rename to server/mdm/cryptoutil/testdata/pkcs8-encrypted.key diff --git a/server/service/testdata/pkcs8-rsa.key b/server/mdm/cryptoutil/testdata/pkcs8-rsa.key similarity index 100% rename from server/service/testdata/pkcs8-rsa.key rename to server/mdm/cryptoutil/testdata/pkcs8-rsa.key diff --git a/server/service/testdata/pkcs8-x25519.key b/server/mdm/cryptoutil/testdata/pkcs8-x25519.key similarity index 100% rename from server/service/testdata/pkcs8-x25519.key rename to server/mdm/cryptoutil/testdata/pkcs8-x25519.key diff --git a/server/mdm/maintainedapps/testdata/adobe-acrobat-reader.json b/server/mdm/maintainedapps/testdata/adobe-acrobat-reader.json index 719d8386f7ca..2f1e0693bd90 100644 --- a/server/mdm/maintainedapps/testdata/adobe-acrobat-reader.json +++ b/server/mdm/maintainedapps/testdata/adobe-acrobat-reader.json @@ -6,7 +6,7 @@ "name": [ "Adobe Acrobat Reader" ], - "desc": "View, print, and comment on PDF documents", + "desc": "Adobe Acrobat Reader is the industry-standard tool for viewing, printing, and commenting on PDF documents.", "homepage": "https://www.adobe.com/acrobat/pdf-reader.html", "url": "https://ardownload2.adobe.com/pub/adobe/reader/mac/AcrobatDC/2400221005/AcroRdrDC_2400221005_MUI.dmg", "url_specs": {}, diff --git a/server/mdm/maintainedapps/testdata/box-drive.json b/server/mdm/maintainedapps/testdata/box-drive.json index 543d1959ea1f..732737da148e 100644 --- a/server/mdm/maintainedapps/testdata/box-drive.json +++ b/server/mdm/maintainedapps/testdata/box-drive.json @@ -6,7 +6,7 @@ "name": [ "Box Drive" ], - "desc": "Client for the Box cloud storage service", + "desc": "Box Drive is the desktop client for Box Cloud, enabling seamless access to your files without taking up local storage.", "homepage": "https://www.box.com/drive", "url": "https://e3.boxcdn.net/desktop/releases/mac/BoxDrive-2.40.345.pkg", "url_specs": { diff --git a/server/mdm/maintainedapps/testdata/brave-browser.json b/server/mdm/maintainedapps/testdata/brave-browser.json index dfe62913e855..b7a713fe2393 100644 --- a/server/mdm/maintainedapps/testdata/brave-browser.json +++ b/server/mdm/maintainedapps/testdata/brave-browser.json @@ -6,7 +6,7 @@ "name": [ "Brave" ], - "desc": "Web browser focusing on privacy", + "desc": "Brave is a web browser designed with privacy, blocking ads and trackers by default while maintaining fast speed.", "homepage": "https://brave.com/", "url": "https://updates-cdn.bravesoftware.com/sparkle/Brave-Browser/stable-arm64/169.162/Brave-Browser-arm64.dmg", "url_specs": { diff --git a/server/mdm/maintainedapps/testdata/cloudflare-warp.json b/server/mdm/maintainedapps/testdata/cloudflare-warp.json index 2981f0bc8911..88ab4f797735 100644 --- a/server/mdm/maintainedapps/testdata/cloudflare-warp.json +++ b/server/mdm/maintainedapps/testdata/cloudflare-warp.json @@ -6,7 +6,7 @@ "name": [ "Cloudflare WARP" ], - "desc": "Free app that makes your Internet safer", + "desc": "Cloudflare WARP enhances internet safety and performance by encrypting your data and optimizing connections for privacy.", "homepage": "https://cloudflarewarp.com/", "url": "https://1111-releases.cloudflareclient.com/mac/Cloudflare_WARP_2024.6.474.0.pkg", "url_specs": { diff --git a/server/mdm/maintainedapps/testdata/docker.json b/server/mdm/maintainedapps/testdata/docker.json index 15aec7b0b8be..41d9cd262cc8 100644 --- a/server/mdm/maintainedapps/testdata/docker.json +++ b/server/mdm/maintainedapps/testdata/docker.json @@ -8,7 +8,7 @@ "Docker Community Edition", "Docker CE" ], - "desc": "App to build and share containerised applications and microservices", + "desc": "Docker Desktop provides a seamless environment for building, sharing, and running containerized applications and microservices.", "homepage": "https://www.docker.com/products/docker-desktop", "url": "https://desktop.docker.com/mac/main/arm64/165256/Docker.dmg", "url_specs": {}, diff --git a/server/mdm/maintainedapps/testdata/figma.json b/server/mdm/maintainedapps/testdata/figma.json index d8af9b3e4a8f..fbc5029e8760 100644 --- a/server/mdm/maintainedapps/testdata/figma.json +++ b/server/mdm/maintainedapps/testdata/figma.json @@ -6,7 +6,7 @@ "name": [ "Figma" ], - "desc": "Collaborative team software", + "desc": "Figma is a collaborative design tool for teams to create, prototype, and share designs in real time.", "homepage": "https://www.figma.com/", "url": "https://desktop.figma.com/mac-arm/Figma-124.3.2.zip", "url_specs": {}, diff --git a/server/mdm/maintainedapps/testdata/firefox.json b/server/mdm/maintainedapps/testdata/firefox.json index ed766c177114..724d7a92ce38 100644 --- a/server/mdm/maintainedapps/testdata/firefox.json +++ b/server/mdm/maintainedapps/testdata/firefox.json @@ -6,7 +6,7 @@ "name": [ "Mozilla Firefox" ], - "desc": "Web browser", + "desc": "Firefox is a powerful, open-source web browser built for speed, privacy, and customization.", "homepage": "https://www.mozilla.org/firefox/", "url": "https://download-installer.cdn.mozilla.net/pub/firefox/releases/130.0/mac/en-US/Firefox%20130.0.dmg", "url_specs": { diff --git a/server/mdm/maintainedapps/testdata/google-chrome.json b/server/mdm/maintainedapps/testdata/google-chrome.json index aaae6a12910b..9424d36113fb 100644 --- a/server/mdm/maintainedapps/testdata/google-chrome.json +++ b/server/mdm/maintainedapps/testdata/google-chrome.json @@ -6,7 +6,7 @@ "name": [ "Google Chrome" ], - "desc": "Web browser", + "desc": "Google Chrome is a fast, reliable web browser built for performance and compatibility across platforms.", "homepage": "https://www.google.com/chrome/", "url": "https://dl.google.com/chrome/mac/universal/stable/GGRO/googlechrome.dmg", "url_specs": {}, diff --git a/server/mdm/maintainedapps/testdata/microsoft-edge.json b/server/mdm/maintainedapps/testdata/microsoft-edge.json index e7b1827cc59f..5d566d1344c3 100644 --- a/server/mdm/maintainedapps/testdata/microsoft-edge.json +++ b/server/mdm/maintainedapps/testdata/microsoft-edge.json @@ -6,7 +6,7 @@ "name": [ "Microsoft Edge" ], - "desc": "Web browser", + "desc": "Microsoft Edge is a secure, fast, and modern web browser built for productivity and compatibility.", "homepage": "https://www.microsoft.com/en-us/edge?form=", "url": "https://msedge.sf.dl.delivery.mp.microsoft.com/filestreamingservice/files/be6d4c8f-ec75-405e-a5f7-fec66b2898a2/MicrosoftEdge-128.0.2739.67.pkg", "url_specs": {}, diff --git a/server/mdm/maintainedapps/testdata/microsoft-excel.json b/server/mdm/maintainedapps/testdata/microsoft-excel.json index d62356f284a8..6344a69a8f78 100644 --- a/server/mdm/maintainedapps/testdata/microsoft-excel.json +++ b/server/mdm/maintainedapps/testdata/microsoft-excel.json @@ -6,7 +6,7 @@ "name": [ "Microsoft Excel" ], - "desc": "Spreadsheet software", + "desc": "Microsoft Excel is the industry-standard spreadsheet software, perfect for data analysis, reporting, and visualization.", "homepage": "https://www.microsoft.com/en-US/microsoft-365/excel", "url": "https://officecdnmac.microsoft.com/pr/C1297A47-86C4-4C1F-97FA-950631F94777/MacAutoupdate/Microsoft_Excel_16.88.24081116_Installer.pkg", "url_specs": {}, diff --git a/server/mdm/maintainedapps/testdata/microsoft-teams.json b/server/mdm/maintainedapps/testdata/microsoft-teams.json index 07af90bdc742..6dd6d1e88853 100644 --- a/server/mdm/maintainedapps/testdata/microsoft-teams.json +++ b/server/mdm/maintainedapps/testdata/microsoft-teams.json @@ -6,7 +6,7 @@ "name": [ "Microsoft Teams" ], - "desc": "Meet, chat, call, and collaborate in just one place", + "desc": "Microsoft Teams is an all-in-one collaboration platform for meetings, chats, calls, and document sharing.", "homepage": "https://www.microsoft.com/en/microsoft-teams/group-chat-software/", "url": "https://statics.teams.cdn.office.net/production-osx/24215.1002.3039.5089/MicrosoftTeams.pkg", "url_specs": { diff --git a/server/mdm/maintainedapps/testdata/microsoft-word.json b/server/mdm/maintainedapps/testdata/microsoft-word.json index 9b639ed38a73..b361fe352e5b 100644 --- a/server/mdm/maintainedapps/testdata/microsoft-word.json +++ b/server/mdm/maintainedapps/testdata/microsoft-word.json @@ -6,7 +6,7 @@ "name": [ "Microsoft Word" ], - "desc": "Word processor", + "desc": "Microsoft Word is the industry-standard word processor for creating, editing, and sharing professional documents.", "homepage": "https://www.microsoft.com/en-US/microsoft-365/word", "url": "https://officecdnmac.microsoft.com/pr/C1297A47-86C4-4C1F-97FA-950631F94777/MacAutoupdate/Microsoft_Word_16.88.24081116_Installer.pkg", "url_specs": {}, diff --git a/server/mdm/maintainedapps/testdata/notion.json b/server/mdm/maintainedapps/testdata/notion.json index 3dcb88dff861..5cefa2b6a360 100644 --- a/server/mdm/maintainedapps/testdata/notion.json +++ b/server/mdm/maintainedapps/testdata/notion.json @@ -6,7 +6,7 @@ "name": [ "Notion" ], - "desc": "App to write, plan, collaborate, and get organised", + "desc": "Notion is an all-in-one workspace for writing, planning, collaborating, and organizing.", "homepage": "https://www.notion.so/", "url": "https://desktop-release.notion-static.com/Notion-3.14.0-arm64.dmg", "url_specs": { diff --git a/server/mdm/maintainedapps/testdata/postman.json b/server/mdm/maintainedapps/testdata/postman.json index e6ca9722ad81..36e266210046 100644 --- a/server/mdm/maintainedapps/testdata/postman.json +++ b/server/mdm/maintainedapps/testdata/postman.json @@ -6,7 +6,7 @@ "name": [ "Postman" ], - "desc": "Collaboration platform for API development", + "desc": "Postman is a collaboration platform for API development that simplifies building, testing, and sharing APIs.", "homepage": "https://www.postman.com/", "url": "https://dl.pstmn.io/download/version/11.12.0/osx_arm64", "url_specs": { diff --git a/server/mdm/maintainedapps/testdata/slack.json b/server/mdm/maintainedapps/testdata/slack.json index eaca79517a9c..7215c0842896 100644 --- a/server/mdm/maintainedapps/testdata/slack.json +++ b/server/mdm/maintainedapps/testdata/slack.json @@ -6,7 +6,7 @@ "name": [ "Slack" ], - "desc": "Team communication and collaboration software", + "desc": "Slack is a team communication and collaboration software for modern workplaces", "homepage": "https://slack.com/", "url": "https://downloads.slack-edge.com/desktop-releases/mac/arm64/4.40.126/Slack-4.40.126-macOS.dmg", "url_specs": { diff --git a/server/mdm/maintainedapps/testdata/teamviewer.json b/server/mdm/maintainedapps/testdata/teamviewer.json index a8e6a12dc99d..75dd9d777f43 100644 --- a/server/mdm/maintainedapps/testdata/teamviewer.json +++ b/server/mdm/maintainedapps/testdata/teamviewer.json @@ -6,7 +6,7 @@ "name": [ "TeamViewer" ], - "desc": "Remote access and connectivity software focused on security", + "desc": "TeamViewer is a versatile remote access and connectivity platform trusted for secure remote desktop control, support, and collaboration.", "homepage": "https://www.teamviewer.com/", "url": "https://dl.teamviewer.com/download/version_15x/update/15.57.5/TeamViewer.pkg", "url_specs": {}, diff --git a/server/mdm/maintainedapps/testdata/visual-studio-code.json b/server/mdm/maintainedapps/testdata/visual-studio-code.json index 398e6abb04d3..0e76eef10667 100644 --- a/server/mdm/maintainedapps/testdata/visual-studio-code.json +++ b/server/mdm/maintainedapps/testdata/visual-studio-code.json @@ -7,7 +7,7 @@ "Microsoft Visual Studio Code", "VS Code" ], - "desc": "Open-source code editor", + "desc": "Microsoft Visual Studio Code (VS Code) is an open-source, lightweight, and powerful code editor.", "homepage": "https://code.visualstudio.com/", "url": "https://update.code.visualstudio.com/1.93.0/darwin-arm64/stable", "url_specs": {}, diff --git a/server/mdm/maintainedapps/testdata/whatsapp.json b/server/mdm/maintainedapps/testdata/whatsapp.json index 97cbecbb810a..f12e4e286952 100644 --- a/server/mdm/maintainedapps/testdata/whatsapp.json +++ b/server/mdm/maintainedapps/testdata/whatsapp.json @@ -6,7 +6,7 @@ "name": [ "WhatsApp" ], - "desc": "Native desktop client for WhatsApp", + "desc": "WhatsApp's native desktop client for seamless messaging and calling on macOS.", "homepage": "https://www.whatsapp.com/", "url": "https://web.whatsapp.com/desktop/mac_native/release/?version=2.24.16.80&extension=zip&configuration=Release&branch=relbranch", "url_specs": {}, diff --git a/server/mdm/maintainedapps/testdata/zoom-for-it-admins.json b/server/mdm/maintainedapps/testdata/zoom-for-it-admins.json index 4f6953a08dfc..82c1096a99e1 100644 --- a/server/mdm/maintainedapps/testdata/zoom-for-it-admins.json +++ b/server/mdm/maintainedapps/testdata/zoom-for-it-admins.json @@ -4,7 +4,7 @@ "old_tokens": [], "tap": "homebrew/cask", "name": ["Zoom for IT Admins"], - "desc": "Video communication and virtual meeting platform", + "desc": "Zoom is a leading video communication platform for meetings, webinars, and collaboration.", "homepage": "https://www.zoom.us/", "url": "https://cdn.zoom.us/prod/6.2.11.43613/ZoomInstallerIT.pkg", "url_specs": {}, diff --git a/server/mdm/nanomdm/storage/allmulti/allmulti.go b/server/mdm/nanomdm/storage/allmulti/allmulti.go index 4ca7d6300d1c..9bc017202761 100644 --- a/server/mdm/nanomdm/storage/allmulti/allmulti.go +++ b/server/mdm/nanomdm/storage/allmulti/allmulti.go @@ -104,3 +104,10 @@ func (ms *MultiAllStorage) ExpandEmbeddedSecrets(ctx context.Context, document s }) return doc.(string), err } + +func (ms *MultiAllStorage) BulkDeleteHostUserCommandsWithoutResults(ctx context.Context, commandToIDs map[string][]string) error { + _, err := ms.execStores(ctx, func(s storage.AllStorage) (interface{}, error) { + return nil, s.BulkDeleteHostUserCommandsWithoutResults(ctx, commandToIDs) + }) + return err +} diff --git a/server/mdm/nanomdm/storage/file/file.go b/server/mdm/nanomdm/storage/file/file.go index c816a34e730f..00bc069f59c0 100644 --- a/server/mdm/nanomdm/storage/file/file.go +++ b/server/mdm/nanomdm/storage/file/file.go @@ -245,3 +245,8 @@ func (s *FileStorage) ExpandEmbeddedSecrets(_ context.Context, document string) // NOT IMPLEMENTED return document, nil } + +func (s *FileStorage) BulkDeleteHostUserCommandsWithoutResults(_ context.Context, _ map[string][]string) error { + // NOT IMPLEMENTED + return nil +} diff --git a/server/mdm/nanomdm/storage/mysql/queue.go b/server/mdm/nanomdm/storage/mysql/queue.go index 5e84b30bb940..ead83efb6c79 100644 --- a/server/mdm/nanomdm/storage/mysql/queue.go +++ b/server/mdm/nanomdm/storage/mysql/queue.go @@ -7,6 +7,7 @@ import ( "fmt" "strings" + "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/datastore/mysql/common_mysql" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" "github.com/google/uuid" @@ -260,3 +261,54 @@ WHERE ) return err } + +// BulkDeleteHostUserCommandsWithoutResults deletes all commands without results for the given host/user IDs. +// This is used to clean up the queue when a profile is deleted from Fleet. +func (m *MySQLStorage) BulkDeleteHostUserCommandsWithoutResults(ctx context.Context, commandToIDs map[string][]string) error { + if len(commandToIDs) == 0 { + return nil + } + return common_mysql.WithRetryTxx(ctx, sqlx.NewDb(m.db, ""), func(tx sqlx.ExtContext) error { + return m.bulkDeleteHostUserCommandsWithoutResults(ctx, tx, commandToIDs) + }, loggerWrapper{m.logger}) +} + +func (m *MySQLStorage) bulkDeleteHostUserCommandsWithoutResults(ctx context.Context, tx sqlx.ExtContext, + commandToIDs map[string][]string) error { + stmt := ` +DELETE + eq +FROM + nano_enrollment_queue AS eq + LEFT JOIN nano_command_results AS cr + ON cr.command_uuid = eq.command_uuid AND cr.id = eq.id +WHERE + cr.command_uuid IS NULL AND eq.command_uuid = ? AND eq.id IN (?);` + + // We process each commandUUID one at a time, in batches of hostUserIDs. + // This is because the number of hostUserIDs can be large, and number of unique commands is normally small. + // If we have a use case where each host has a unique command, we can create a separate method for that use case. + for commandUUID, hostUserIDs := range commandToIDs { + if len(hostUserIDs) == 0 { + continue + } + + batchSize := 10000 + err := common_mysql.BatchProcessSimple(hostUserIDs, batchSize, func(hostUserIDsToProcess []string) error { + expanded, args, err := sqlx.In(stmt, commandUUID, hostUserIDsToProcess) + if err != nil { + return ctxerr.Wrap(ctx, err, "expanding bulk delete nano commands") + } + _, err = tx.ExecContext(ctx, expanded, args...) + if err != nil { + return ctxerr.Wrap(ctx, err, "bulk delete nano commands") + } + return nil + }) + if err != nil { + return err + } + } + + return nil +} diff --git a/server/mdm/nanomdm/storage/storage.go b/server/mdm/nanomdm/storage/storage.go index 89efc1fb4b11..759c50b72827 100644 --- a/server/mdm/nanomdm/storage/storage.go +++ b/server/mdm/nanomdm/storage/storage.go @@ -27,6 +27,8 @@ type CommandAndReportResultsStore interface { StoreCommandReport(r *mdm.Request, report *mdm.CommandResults) error RetrieveNextCommand(r *mdm.Request, skipNotNow bool) (*mdm.CommandWithSubtype, error) ClearQueue(r *mdm.Request) error + // BulkDeleteHostUserCommandsWithoutResults deletes all commands without results for the given host/user IDs. + BulkDeleteHostUserCommandsWithoutResults(ctx context.Context, commandToId map[string][]string) error } type BootstrapTokenStore interface { diff --git a/server/mdm/scep/cmd/scepclient/csr.go b/server/mdm/scep/cmd/scepclient/csr.go index 0eadcac4e355..195c7c6f360a 100644 --- a/server/mdm/scep/cmd/scepclient/csr.go +++ b/server/mdm/scep/cmd/scepclient/csr.go @@ -10,7 +10,7 @@ import ( "io/ioutil" "os" - "github.com/fleetdm/fleet/v4/server/mdm/scep/cryptoutil/x509util" + "github.com/fleetdm/fleet/v4/server/mdm/scep/x509util" ) const ( diff --git a/server/mdm/scep/cryptoutil/cryptoutil.go b/server/mdm/scep/cryptoutil/cryptoutil.go deleted file mode 100644 index 6512c6154cc5..000000000000 --- a/server/mdm/scep/cryptoutil/cryptoutil.go +++ /dev/null @@ -1,36 +0,0 @@ -package cryptoutil - -import ( - "crypto" - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rsa" - "crypto/sha256" - "encoding/asn1" - "errors" -) - -// GenerateSubjectKeyID generates Subject Key Identifier (SKI) using SHA-256 -// hash of the public key bytes according to RFC 7093 section 2. -func GenerateSubjectKeyID(pub crypto.PublicKey) ([]byte, error) { - var pubBytes []byte - var err error - switch pub := pub.(type) { - case *rsa.PublicKey: - pubBytes, err = asn1.Marshal(*pub) - if err != nil { - return nil, err - } - case *ecdsa.PublicKey: - pubBytes = elliptic.Marshal(pub.Curve, pub.X, pub.Y) - default: - return nil, errors.New("only ECDSA and RSA public keys are supported") - } - - hash := sha256.Sum256(pubBytes) - - // According to RFC 7093, The keyIdentifier is composed of the leftmost - // 160-bits of the SHA-256 hash of the value of the BIT STRING - // subjectPublicKey (excluding the tag, length, and number of unused bits). - return hash[:20], nil -} diff --git a/server/mdm/scep/cryptoutil/cryptoutil_test.go b/server/mdm/scep/cryptoutil/cryptoutil_test.go deleted file mode 100644 index 53a73ee9b36f..000000000000 --- a/server/mdm/scep/cryptoutil/cryptoutil_test.go +++ /dev/null @@ -1,58 +0,0 @@ -package cryptoutil - -import ( - "crypto" - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "crypto/rsa" - "math/big" - "testing" -) - -func TestGenerateSubjectKeyID(t *testing.T) { - ecKey, err := ecdsa.GenerateKey(elliptic.P224(), rand.Reader) - if err != nil { - t.Fatal(err) - } - for _, test := range []struct { - testName string - pub crypto.PublicKey - }{ - {"RSA", &rsa.PublicKey{N: big.NewInt(123), E: 65537}}, - {"ECDSA", ecKey.Public()}, - } { - test := test - t.Run(test.testName, func(t *testing.T) { - t.Parallel() - ski, err := GenerateSubjectKeyID(test.pub) - if err != nil { - t.Fatal(err) - } - if len(ski) != 20 { - t.Fatalf("unexpected subject public key identifier length: %d", len(ski)) - } - ski2, err := GenerateSubjectKeyID(test.pub) - if err != nil { - t.Fatal(err) - } - if !testSKIEq(ski, ski2) { - t.Fatal("subject key identifier generation is not deterministic") - } - }) - } -} - -func testSKIEq(a, b []byte) bool { - if len(a) != len(b) { - return false - } - - for i := range a { - if a[i] != b[i] { - return false - } - } - - return true -} diff --git a/server/mdm/scep/depot/cacert.go b/server/mdm/scep/depot/cacert.go index 7aba250c3115..cf430459be84 100644 --- a/server/mdm/scep/depot/cacert.go +++ b/server/mdm/scep/depot/cacert.go @@ -8,7 +8,7 @@ import ( "math/big" "time" - "github.com/fleetdm/fleet/v4/server/mdm/scep/cryptoutil" + "github.com/fleetdm/fleet/v4/server/mdm/cryptoutil" ) // CACert represents a new self-signed CA certificate diff --git a/server/mdm/scep/depot/signer.go b/server/mdm/scep/depot/signer.go index d2f33ae45174..73476b1f19dc 100644 --- a/server/mdm/scep/depot/signer.go +++ b/server/mdm/scep/depot/signer.go @@ -5,7 +5,7 @@ import ( "crypto/x509" "time" - "github.com/fleetdm/fleet/v4/server/mdm/scep/cryptoutil" + "github.com/fleetdm/fleet/v4/server/mdm/cryptoutil" "github.com/smallstep/scep" ) diff --git a/server/mdm/scep/cryptoutil/x509util/doc.go b/server/mdm/scep/x509util/doc.go similarity index 100% rename from server/mdm/scep/cryptoutil/x509util/doc.go rename to server/mdm/scep/x509util/doc.go diff --git a/server/mdm/scep/cryptoutil/x509util/x509util.go b/server/mdm/scep/x509util/x509util.go similarity index 100% rename from server/mdm/scep/cryptoutil/x509util/x509util.go rename to server/mdm/scep/x509util/x509util.go diff --git a/server/mdm/scep/cryptoutil/x509util/x509util_test.go b/server/mdm/scep/x509util/x509util_test.go similarity index 100% rename from server/mdm/scep/cryptoutil/x509util/x509util_test.go rename to server/mdm/scep/x509util/x509util_test.go diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index f56e54e43081..b686b20370ad 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -1077,6 +1077,12 @@ type WipeHostViaWindowsMDMFunc func(ctx context.Context, host *fleet.Host, cmd * type UpdateHostLockWipeStatusFromAppleMDMResultFunc func(ctx context.Context, hostUUID string, cmdUUID string, requestType string, succeeded bool) error +type GetIncludedHostIDMapForSoftwareInstallerFunc func(ctx context.Context, installerID uint) (map[uint]struct{}, error) + +type GetExcludedHostIDMapForSoftwareInstallerFunc func(ctx context.Context, installerID uint) (map[uint]struct{}, error) + +type ClearAutoInstallPolicyStatusForHostsFunc func(ctx context.Context, installerID uint, hostIDs []uint) error + type GetSoftwareInstallDetailsFunc func(ctx context.Context, executionId string) (*fleet.SoftwareInstallDetails, error) type ListPendingSoftwareInstallsFunc func(ctx context.Context, hostID uint) ([]string, error) @@ -2772,6 +2778,15 @@ type DataStore struct { UpdateHostLockWipeStatusFromAppleMDMResultFunc UpdateHostLockWipeStatusFromAppleMDMResultFunc UpdateHostLockWipeStatusFromAppleMDMResultFuncInvoked bool + GetIncludedHostIDMapForSoftwareInstallerFunc GetIncludedHostIDMapForSoftwareInstallerFunc + GetIncludedHostIDMapForSoftwareInstallerFuncInvoked bool + + GetExcludedHostIDMapForSoftwareInstallerFunc GetExcludedHostIDMapForSoftwareInstallerFunc + GetExcludedHostIDMapForSoftwareInstallerFuncInvoked bool + + ClearAutoInstallPolicyStatusForHostsFunc ClearAutoInstallPolicyStatusForHostsFunc + ClearAutoInstallPolicyStatusForHostsFuncInvoked bool + GetSoftwareInstallDetailsFunc GetSoftwareInstallDetailsFunc GetSoftwareInstallDetailsFuncInvoked bool @@ -6636,6 +6651,27 @@ func (s *DataStore) UpdateHostLockWipeStatusFromAppleMDMResult(ctx context.Conte return s.UpdateHostLockWipeStatusFromAppleMDMResultFunc(ctx, hostUUID, cmdUUID, requestType, succeeded) } +func (s *DataStore) GetIncludedHostIDMapForSoftwareInstaller(ctx context.Context, installerID uint) (map[uint]struct{}, error) { + s.mu.Lock() + s.GetIncludedHostIDMapForSoftwareInstallerFuncInvoked = true + s.mu.Unlock() + return s.GetIncludedHostIDMapForSoftwareInstallerFunc(ctx, installerID) +} + +func (s *DataStore) GetExcludedHostIDMapForSoftwareInstaller(ctx context.Context, installerID uint) (map[uint]struct{}, error) { + s.mu.Lock() + s.GetExcludedHostIDMapForSoftwareInstallerFuncInvoked = true + s.mu.Unlock() + return s.GetExcludedHostIDMapForSoftwareInstallerFunc(ctx, installerID) +} + +func (s *DataStore) ClearAutoInstallPolicyStatusForHosts(ctx context.Context, installerID uint, hostIDs []uint) error { + s.mu.Lock() + s.ClearAutoInstallPolicyStatusForHostsFuncInvoked = true + s.mu.Unlock() + return s.ClearAutoInstallPolicyStatusForHostsFunc(ctx, installerID, hostIDs) +} + func (s *DataStore) GetSoftwareInstallDetails(ctx context.Context, executionId string) (*fleet.SoftwareInstallDetails, error) { s.mu.Lock() s.GetSoftwareInstallDetailsFuncInvoked = true diff --git a/server/mock/mdm/datastore_mdm_mock.go b/server/mock/mdm/datastore_mdm_mock.go index cbed04c82540..cd094f0f9b0e 100644 --- a/server/mock/mdm/datastore_mdm_mock.go +++ b/server/mock/mdm/datastore_mdm_mock.go @@ -29,6 +29,8 @@ type RetrieveNextCommandFunc func(r *mdm.Request, skipNotNow bool) (*mdm.Command type ClearQueueFunc func(r *mdm.Request) error +type BulkDeleteHostUserCommandsWithoutResultsFunc func(ctx context.Context, commandToId map[string][]string) error + type StoreBootstrapTokenFunc func(r *mdm.Request, msg *mdm.SetBootstrapToken) error type RetrieveBootstrapTokenFunc func(r *mdm.Request, msg *mdm.GetBootstrapToken) (*mdm.BootstrapToken, error) @@ -89,6 +91,9 @@ type MDMAppleStore struct { ClearQueueFunc ClearQueueFunc ClearQueueFuncInvoked bool + BulkDeleteHostUserCommandsWithoutResultsFunc BulkDeleteHostUserCommandsWithoutResultsFunc + BulkDeleteHostUserCommandsWithoutResultsFuncInvoked bool + StoreBootstrapTokenFunc StoreBootstrapTokenFunc StoreBootstrapTokenFuncInvoked bool @@ -198,6 +203,13 @@ func (fs *MDMAppleStore) ClearQueue(r *mdm.Request) error { return fs.ClearQueueFunc(r) } +func (fs *MDMAppleStore) BulkDeleteHostUserCommandsWithoutResults(ctx context.Context, commandToId map[string][]string) error { + fs.mu.Lock() + fs.BulkDeleteHostUserCommandsWithoutResultsFuncInvoked = true + fs.mu.Unlock() + return fs.BulkDeleteHostUserCommandsWithoutResultsFunc(ctx, commandToId) +} + func (fs *MDMAppleStore) StoreBootstrapToken(r *mdm.Request, msg *mdm.SetBootstrapToken) error { fs.mu.Lock() fs.StoreBootstrapTokenFuncInvoked = true diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go index 7394a82aeb93..50758be0cd87 100644 --- a/server/service/apple_mdm.go +++ b/server/service/apple_mdm.go @@ -3464,17 +3464,25 @@ func ReconcileAppleProfiles( } for _, p := range toRemove { + // Exclude profiles that are also marked for installation. if _, ok := profileIntersection.GetMatchingProfileInDesiredState(p); ok { hostProfilesToCleanup = append(hostProfilesToCleanup, p) continue } - if p.DidNotInstallOnHost() { - // then we shouldn't send an additional remove command since it wasn't installed on the - // host. + if p.FailedInstallOnHost() { + // then we shouldn't send an additional remove command since it failed to install on the host. hostProfilesToCleanup = append(hostProfilesToCleanup, p) continue } + if p.PendingInstallOnHost() { + // The profile most likely did not install on host. However, it is possible that the profile + // is currently being installed. So, we clean up the profile from the database, but also send + // a remove command to the host. + hostProfilesToCleanup = append(hostProfilesToCleanup, p) + // IgnoreError is set since the removal command is likely to fail. + p.IgnoreError = true + } target := removeTargets[p.ProfileUUID] if target == nil { @@ -3496,6 +3504,7 @@ func ReconcileAppleProfiles( ProfileName: p.ProfileName, Checksum: p.Checksum, SecretsUpdatedAt: p.SecretsUpdatedAt, + IgnoreError: p.IgnoreError, }) } @@ -3504,6 +3513,16 @@ func ReconcileAppleProfiles( // `InstallProfile` for the same identifier, which can cause race // conditions. It's better to "update" the profile by sending a single // `InstallProfile` command. + // + // Create a map of command UUIDs to host IDs + commandUUIDToHostIDsCleanupMap := make(map[string][]string) + for _, hp := range hostProfilesToCleanup { + commandUUIDToHostIDsCleanupMap[hp.CommandUUID] = append(commandUUIDToHostIDsCleanupMap[hp.CommandUUID], hp.HostUUID) + } + // We need to delete commands from the nano queue so they don't get sent to device. + if err := commander.BulkDeleteHostUserCommandsWithoutResults(ctx, commandUUIDToHostIDsCleanupMap); err != nil { + return ctxerr.Wrap(ctx, err, "deleting nano commands without results") + } if err := ds.BulkDeleteMDMAppleHostsConfigProfiles(ctx, hostProfilesToCleanup); err != nil { return ctxerr.Wrap(ctx, err, "deleting profiles that didn't change") } diff --git a/server/service/apple_mdm_test.go b/server/service/apple_mdm_test.go index ac9780d8863b..09e167d38cab 100644 --- a/server/service/apple_mdm_test.go +++ b/server/service/apple_mdm_test.go @@ -2293,6 +2293,10 @@ func TestMDMAppleReconcileAppleProfiles(t *testing.T) { require.Empty(t, payload) return nil } + mdmStorage.BulkDeleteHostUserCommandsWithoutResultsFunc = func(ctx context.Context, commandToIDs map[string][]string) error { + require.Empty(t, commandToIDs) + return nil + } var enqueueFailForOp fleet.MDMOperationType var mu sync.Mutex diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 76acce2cebd2..8d102a974687 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -12500,6 +12500,21 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerHostRequests() { "install_script_output": "ok" }`, *h.OrbitNodeKey, installUUID)), http.StatusNoContent) + // simulate a lock/unlock; this creates the host_mdm_actions table, which reproduces #25144 + s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/lock", h.ID), nil, http.StatusNoContent) + status, err := s.ds.GetHostLockWipeStatus(context.Background(), h) + require.NoError(t, err) + var orbitScriptResp orbitPostScriptResultResponse + s.DoJSON("POST", "/api/fleet/orbit/scripts/result", + json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q, "execution_id": %q, "exit_code": 0, "output": "ok"}`, *h.OrbitNodeKey, status.LockScript.ExecutionID)), + http.StatusOK, &orbitScriptResp) + s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/unlock", h.ID), nil, http.StatusNoContent) + status, err = s.ds.GetHostLockWipeStatus(context.Background(), h) + require.NoError(t, err) + s.DoJSON("POST", "/api/fleet/orbit/scripts/result", + json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q, "execution_id": %q, "exit_code": 0, "output": "ok"}`, *h.OrbitNodeKey, status.UnlockScript.ExecutionID)), + http.StatusOK, &orbitScriptResp) + // Do uninstall s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/uninstall", h.ID, titleID), nil, http.StatusAccepted, &resp) s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", h.ID), nil, http.StatusOK, &getHostSoftwareResp) @@ -15291,6 +15306,215 @@ func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsSoftwareInstallers require.NotNil(t, host1LastInstall) } +func (s *integrationEnterpriseTestSuite) TestPolicyAutomationLabelScopingRetrigger() { + t := s.T() + ctx := context.Background() + host, err := s.ds.NewHost(context.Background(), &fleet.Host{ + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now().Add(-1 * time.Minute), + OsqueryHostID: ptr.String(t.Name()), + NodeKey: ptr.String(t.Name()), + UUID: uuid.New().String(), + Hostname: fmt.Sprintf("%sfoo.local", t.Name()), + Platform: "linux", + }) + require.NoError(t, err) + orbitKey := setOrbitEnrollment(t, host, s.ds) + host.OrbitNodeKey = &orbitKey + + // Create a few labels + var newLabelResp createLabelResponse + s.DoJSON("POST", "/api/v1/fleet/labels", fleet.LabelPayload{ + Name: uuid.NewString(), + Query: "SELECT 1", + }, http.StatusOK, &newLabelResp) + lbl1 := newLabelResp.Label + + newLabelResp = createLabelResponse{} + s.DoJSON("POST", "/api/v1/fleet/labels", fleet.LabelPayload{ + Name: uuid.NewString(), + Query: "SELECT 2", + }, http.StatusOK, &newLabelResp) + lbl2 := newLabelResp.Label + + newLabelResp = createLabelResponse{} + s.DoJSON("POST", "/api/v1/fleet/labels", fleet.LabelPayload{ + Name: uuid.NewString(), + Query: "SELECT 3", + }, http.StatusOK, &newLabelResp) + lbl3 := newLabelResp.Label + + // Add label1 and label2 to the host + err = s.ds.RecordLabelQueryExecutions(context.Background(), host, map[uint]*bool{lbl1.ID: ptr.Bool(true), lbl2.ID: ptr.Bool(true)}, time.Now(), false) + require.NoError(t, err) + + // upload software. Add label1 and label3 as "exclude any" labels. + rubyPayload := &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "some deb install script", + Filename: "ruby.deb", + TeamID: nil, + LabelsIncludeAny: []string{lbl1.Name, lbl3.Name}, + Platform: "linux", + } + s.uploadSoftwareInstaller(t, rubyPayload, http.StatusOK, "") + + // Get software title ID of the uploaded installer. + resp := listSoftwareTitlesResponse{} + s.DoJSON( + "GET", "/api/latest/fleet/software/titles", + listSoftwareTitlesRequest{}, + http.StatusOK, &resp, + "query", "ruby", + "team_id", "0", + ) + require.Len(t, resp.SoftwareTitles, 1) + require.NotNil(t, resp.SoftwareTitles[0].SoftwarePackage) + rubyDebTitleID := resp.SoftwareTitles[0].ID + + var rubyDetail getSoftwareTitleResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", rubyDebTitleID), nil, http.StatusOK, &rubyDetail) + require.NotNil(t, rubyDetail.SoftwareTitle) + require.NotNil(t, rubyDetail.SoftwareTitle.SoftwarePackage) + rubyInstallerID := rubyDetail.SoftwareTitle.SoftwarePackage.InstallerID + + policy1, err := s.ds.NewTeamPolicy(ctx, 0, nil, fleet.PolicyPayload{ + Name: "policy1", + Query: "SELECT 1;", + Platform: "linux", + }) + require.NoError(t, err) + + mtplr := modifyTeamPolicyResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/0/policies/%d", policy1.ID), modifyTeamPolicyRequest{ + ModifyPolicyPayload: fleet.ModifyPolicyPayload{ + SoftwareTitleID: optjson.Any[uint]{Set: true, Valid: true, Value: rubyDebTitleID}, + }, + }, http.StatusOK, &mtplr) + + // No install attempt yet + host1LastInstall, err := s.ds.GetHostLastInstallData(ctx, host.ID, rubyInstallerID) + require.NoError(t, err) + require.Nil(t, host1LastInstall) + + // Send back a failed result for the policy. + distributedResp := submitDistributedQueryResultsResponse{} + s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + host, + map[uint]*bool{ + policy1.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + err = s.ds.UpdateHostPolicyCounts(ctx) + require.NoError(t, err) + policy1, err = s.ds.Policy(ctx, policy1.ID) + require.NoError(t, err) + + // Because the installer is in scope, the policy is failing + require.Equal(t, uint(0), policy1.PassingHostCount) + require.Equal(t, uint(1), policy1.FailingHostCount) + + // We've triggered an installation attempt + host1LastInstall, err = s.ds.GetHostLastInstallData(ctx, host.ID, rubyInstallerID) + require.NoError(t, err) + require.NotNil(t, host1LastInstall) + + // Update the installer's labels to "exclude any". This de-scopes the software. + s.updateSoftwareInstaller(t, &fleet.UpdateSoftwareInstallerPayload{ + InstallScript: ptr.String("some install script"), + PreInstallQuery: ptr.String("some pre install query"), + PostInstallScript: ptr.String("some post install script"), + Filename: "ruby.deb", + TitleID: rubyDebTitleID, + TeamID: nil, + LabelsExcludeAny: []string{lbl1.Name, lbl3.Name}, + }, http.StatusOK, "") + + // The update should clear out the installation attempt + host1LastInstall, err = s.ds.GetHostLastInstallData(ctx, host.ID, rubyInstallerID) + require.NoError(t, err) + require.Nil(t, host1LastInstall) + + // Update the installer's labels to be "include any" again. The software is now back in scope. + s.updateSoftwareInstaller(t, &fleet.UpdateSoftwareInstallerPayload{ + InstallScript: ptr.String("some install script"), + PreInstallQuery: ptr.String("some pre install query"), + PostInstallScript: ptr.String("some post install script"), + Filename: "ruby.deb", + TitleID: rubyDebTitleID, + TeamID: nil, + LabelsIncludeAny: []string{lbl1.Name, lbl3.Name}, + }, http.StatusOK, "") + + // Simulate a failure of the policy + s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + host, + map[uint]*bool{ + policy1.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + err = s.ds.UpdateHostPolicyCounts(ctx) + require.NoError(t, err) + policy1, err = s.ds.Policy(ctx, policy1.ID) + require.NoError(t, err) + // Because the installer is in scope, the policy should be failing again + require.Equal(t, uint(0), policy1.PassingHostCount) + require.Equal(t, uint(1), policy1.FailingHostCount) + + // We have an installation attempt again; the policy automation has been re-triggered + host1LastInstall, err = s.ds.GetHostLastInstallData(ctx, host.ID, rubyInstallerID) + require.NoError(t, err) + require.NotNil(t, host1LastInstall) + + // Update the include any labels. The host doesn't have label2, so this means that the software + // moved out of scope. + s.updateSoftwareInstaller(t, &fleet.UpdateSoftwareInstallerPayload{ + InstallScript: ptr.String("some install script"), + PreInstallQuery: ptr.String("some pre install query"), + PostInstallScript: ptr.String("some post install script"), + Filename: "ruby.deb", + TitleID: rubyDebTitleID, + TeamID: nil, + LabelsIncludeAny: []string{lbl2.Name}, + }, http.StatusOK, "") + + host1LastInstall, err = s.ds.GetHostLastInstallData(ctx, host.ID, rubyInstallerID) + require.NoError(t, err) + require.Nil(t, host1LastInstall) + + // Update to exclude any with label 2. This moves the software back into scope. The policy + // automation should re-trigger. + s.updateSoftwareInstaller(t, &fleet.UpdateSoftwareInstallerPayload{ + InstallScript: ptr.String("some install script"), + PreInstallQuery: ptr.String("some pre install query"), + PostInstallScript: ptr.String("some post install script"), + Filename: "ruby.deb", + TitleID: rubyDebTitleID, + TeamID: nil, + LabelsExcludeAny: []string{lbl2.Name}, + }, http.StatusOK, "") + + s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + host, + map[uint]*bool{ + policy1.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + err = s.ds.UpdateHostPolicyCounts(ctx) + require.NoError(t, err) + policy1, err = s.ds.Policy(ctx, policy1.ID) + require.NoError(t, err) + // Because the installer is in scope, the policy should be failing again. + require.Equal(t, uint(0), policy1.PassingHostCount) + require.Equal(t, uint(1), policy1.FailingHostCount) + + // We have an installation attempt again. + host1LastInstall, err = s.ds.GetHostLastInstallData(ctx, host.ID, rubyInstallerID) + require.NoError(t, err) + require.Nil(t, host1LastInstall) +} + func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsScripts() { t := s.T() ctx := context.Background() diff --git a/server/service/integration_mdm_profiles_test.go b/server/service/integration_mdm_profiles_test.go index 9fd9f0b3b368..9e9c0f0c6241 100644 --- a/server/service/integration_mdm_profiles_test.go +++ b/server/service/integration_mdm_profiles_test.go @@ -26,9 +26,11 @@ import ( microsoft_mdm "github.com/fleetdm/fleet/v4/server/mdm/microsoft" "github.com/fleetdm/fleet/v4/server/mdm/microsoft/syncml" "github.com/fleetdm/fleet/v4/server/mdm/nanodep/godep" + "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" "github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/test" "github.com/google/uuid" + "github.com/groob/plist" "github.com/jmoiron/sqlx" "github.com/smallstep/pkcs7" "github.com/stretchr/testify/assert" @@ -1347,6 +1349,10 @@ func (s *integrationMDMTestSuite) TestPuppetMatchPreassignProfiles() { {Identifier: "i4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, + // Profiles from previous team being deleted + {Identifier: "i2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, + {Identifier: mobileconfig.FleetFileVaultPayloadIdentifier, OperationType: fleet.MDMOperationTypeRemove, + Status: &fleet.MDMDeliveryPending}, }, }) @@ -5538,3 +5544,158 @@ func (s *integrationMDMTestSuite) TestWindowsConfigSecretVariablesUpload() { s.testSecretVariablesUpload(newProfileBytes, getProfileContents, "xml", "windows") } + +func (s *integrationMDMTestSuite) TestAppleProfileDeletion() { + t := s.T() + ctx := context.Background() + + err := s.ds.ApplyEnrollSecrets(ctx, nil, []*fleet.EnrollSecret{{Secret: t.Name()}}) + require.NoError(t, err) + + globalProfiles := [][]byte{ + mobileconfigForTest("N1", "I1"), + } + wantGlobalProfiles := globalProfiles + wantGlobalProfiles = append( + wantGlobalProfiles, + setupExpectedFleetdProfile(t, s.server.URL, t.Name(), nil), + ) + + // add global profiles + s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: globalProfiles}, http.StatusNoContent) + + // Create a host and then enroll to MDM. + host, mdmDevice := createHostThenEnrollMDM(s.ds, s.server.URL, t) + // Add IdP email to host + mysql.ExecAdhocSQL(t, s.ds, func(e sqlx.ExtContext) error { + _, err := e.ExecContext(ctx, `INSERT INTO host_emails (email, host_id, source) VALUES (?, ?, ?)`, "idp@example.com", host.ID, + fleet.DeviceMappingMDMIdpAccounts) + return err + }) + + // trigger a profile sync + s.awaitTriggerProfileSchedule(t) + installs, removes := checkNextPayloads(t, mdmDevice, false) + // verify that we received all profiles + s.signedProfilesMatch( + append(wantGlobalProfiles, setupExpectedCAProfile(t, s.ds)), + installs, + ) + require.Empty(t, removes) + + // Add a profile with a Fleet variable. We are also testing that removal of a profile with a Fleet variable works. + // A unique command is created for each host when this Fleet variable is used. + globalProfilesPlusOne := [][]byte{ + globalProfiles[0], + mobileconfigForTest("N2", "$FLEET_VAR_"+FleetVarHostEndUserEmailIDP), + } + s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: globalProfilesPlusOne}, + http.StatusNoContent) + // trigger a profile sync + s.awaitTriggerProfileSchedule(t) + + // Make sure profile was uploaded + profiles, err := s.ds.GetHostMDMAppleProfiles(ctx, host.UUID) + require.NoError(t, err) + assert.Len(t, profiles, 4) + + // Delete a profile before it is sent to device + s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: globalProfiles}, http.StatusNoContent) + // trigger a profile sync + s.awaitTriggerProfileSchedule(t) + sendErrorOnRemoveProfile := func(device *mdmtest.TestAppleMDMClient) { + // The host grabs the removal command from Fleet + cmd, err := device.Idle() + require.NoError(t, err) + assert.Equal(t, "RemoveProfile", cmd.Command.RequestType) + // Since profile is not on the device, it returns an error. + errChain := []mdm.ErrorChain{ + { + ErrorCode: 89, + ErrorDomain: "FooErrorDomain", + LocalizedDescription: "The profile not found", + }, + } + cmd, err = device.Err(cmd.CommandUUID, errChain) + require.NoError(t, err) + assert.Nil(t, cmd) + } + sendErrorOnRemoveProfile(mdmDevice) + + // Make sure deleted profile no longer shows up + profiles, err = s.ds.GetHostMDMAppleProfiles(ctx, host.UUID) + require.NoError(t, err) + assert.Len(t, profiles, 3) + + // Add a profile again + s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: globalProfilesPlusOne}, + http.StatusNoContent) + // trigger a profile sync + s.awaitTriggerProfileSchedule(t) + + // The host grabs the profile from Fleet + cmd, err := mdmDevice.Idle() + require.NoError(t, err) + assert.Equal(t, "InstallProfile", cmd.Command.RequestType) + // Verify that the Fleet variable was replaced with the IdP email + type Command struct { + Command struct { + Payload []byte + } + } + var p Command + err = plist.Unmarshal(cmd.Raw, &p) + require.NoError(t, err) + assert.NotContains(t, string(p.Command.Payload), "$FLEET_VAR_"+FleetVarHostEndUserEmailIDP) + assert.Contains(t, string(p.Command.Payload), "idp@example.com") + + // While the host is installing the profile, we delete it. + s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: globalProfiles}, http.StatusNoContent) + // trigger a profile sync + s.awaitTriggerProfileSchedule(t) + + // Host acknowledges installing the profile and grabs the remove command + cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + assert.Equal(t, "RemoveProfile", cmd.Command.RequestType) + cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + assert.Nil(t, cmd) + + // Add another device + host2, mdmDevice2 := createHostThenEnrollMDM(s.ds, s.server.URL, t) + // Add IdP email to host + mysql.ExecAdhocSQL(t, s.ds, func(e sqlx.ExtContext) error { + _, err := e.ExecContext(ctx, `INSERT INTO host_emails (email, host_id, source) VALUES (?, ?, ?)`, "idp2@example.com", host2.ID, + fleet.DeviceMappingMDMIdpAccounts) + return err + }) + + // trigger a profile sync + s.awaitTriggerProfileSchedule(t) + installs, removes = checkNextPayloads(t, mdmDevice2, false) + assert.Len(t, installs, 3) + assert.Empty(t, removes) + + // Add a profile again + s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: globalProfilesPlusOne}, + http.StatusNoContent) + // trigger a profile sync + s.awaitTriggerProfileSchedule(t) + // Delete a profile before it is sent to both devices + s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: globalProfiles}, http.StatusNoContent) + // trigger a profile sync + s.awaitTriggerProfileSchedule(t) + // The host grabs the removal command from Fleet + sendErrorOnRemoveProfile(mdmDevice) + sendErrorOnRemoveProfile(mdmDevice2) + + // Make sure deleted profile no longer shows up on either host + profiles, err = s.ds.GetHostMDMAppleProfiles(ctx, host.UUID) + require.NoError(t, err) + assert.Len(t, profiles, 3) + profiles, err = s.ds.GetHostMDMAppleProfiles(ctx, host2.UUID) + require.NoError(t, err) + assert.Len(t, profiles, 3) + +} diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index fc321093df8e..46c0e36406b3 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -9751,9 +9751,13 @@ func (s *integrationMDMTestSuite) TestRemoveFailedProfiles() { getHostResp = getHostResponse{} s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp) require.NotNil(t, getHostResp.Host.MDM.Profiles) - require.Len(t, *getHostResp.Host.MDM.Profiles, 2) + // Since Fleet doesn't know for sure whether profile was installed or not, it sends a remove command just in case. + require.Len(t, *getHostResp.Host.MDM.Profiles, 3) for _, hm := range *getHostResp.Host.MDM.Profiles { require.Equal(t, fleet.MDMDeliveryPending, *hm.Status) + if hm.Name == "N3" { + assert.Equal(t, fleet.MDMOperationTypeRemove, hm.OperationType) + } } } diff --git a/server/service/mdm.go b/server/service/mdm.go index 406c79441777..d0de896f6839 100644 --- a/server/service/mdm.go +++ b/server/service/mdm.go @@ -4,11 +4,7 @@ import ( "bytes" "context" "crypto" - "crypto/ecdsa" - "crypto/ed25519" - "crypto/rsa" "crypto/tls" - "crypto/x509" "encoding/json" "encoding/pem" "errors" @@ -34,6 +30,7 @@ import ( "github.com/fleetdm/fleet/v4/server/mdm" apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" "github.com/fleetdm/fleet/v4/server/mdm/assets" + "github.com/fleetdm/fleet/v4/server/mdm/cryptoutil" nanomdm "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" "github.com/fleetdm/fleet/v4/server/ptr" "github.com/go-kit/log/level" @@ -2496,10 +2493,9 @@ func (svc *Service) GetMDMAppleCSR(ctx context.Context) ([]byte, error) { } } else { rawApnsKey := savedAssets[fleet.MDMAssetAPNSKey] - block, _ := pem.Decode(rawApnsKey.Value) - apnsKey, err = parseAPNSPrivateKey(ctx, block) + apnsKey, err = cryptoutil.ParsePrivateKey(rawApnsKey.Value, "APNS private key") if err != nil { - return nil, err + return nil, ctxerr.Wrap(ctx, err, "parse APNS private key") } } @@ -2546,31 +2542,6 @@ func (svc *Service) GetMDMAppleCSR(ctx context.Context) ([]byte, error) { return signedCSRB64, nil } -func parseAPNSPrivateKey(ctx context.Context, block *pem.Block) (crypto.PrivateKey, error) { - if block == nil { - return nil, ctxerr.New(ctx, "failed to decode saved APNS key") - } - - // The code below is based on tls.parsePrivateKey - // https://cs.opensource.google/go/go/+/release-branch.go1.23:src/crypto/tls/tls.go;l=355-372 - if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil { - return key, nil - } - if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil { - switch key := key.(type) { - case *rsa.PrivateKey, *ecdsa.PrivateKey, ed25519.PrivateKey: - return key, nil - default: - return nil, errors.New("unmarshaled PKCS8 APNS key is not an RSA, ECDSA, or Ed25519 private key") - } - } - if key, err := x509.ParseECPrivateKey(block.Bytes); err == nil { - return key, nil - } - - return nil, ctxerr.New(ctx, fmt.Sprintf("failed to parse APNS private key of type %s", block.Type)) -} - //////////////////////////////////////////////////////////////////////////////// // POST /mdm/apple/apns_certificate //////////////////////////////////////////////////////////////////////////////// diff --git a/server/service/mdm_test.go b/server/service/mdm_test.go index d7cf53fca5d3..74a59f5a0d5f 100644 --- a/server/service/mdm_test.go +++ b/server/service/mdm_test.go @@ -8,7 +8,6 @@ import ( "crypto/x509" "crypto/x509/pkix" "database/sql" - "encoding/pem" "errors" "math/big" "net/http" @@ -29,7 +28,7 @@ import ( "github.com/fleetdm/fleet/v4/server/contexts/license" "github.com/fleetdm/fleet/v4/server/contexts/viewer" "github.com/fleetdm/fleet/v4/server/fleet" - "github.com/fleetdm/fleet/v4/server/mdm/scep/cryptoutil/x509util" + "github.com/fleetdm/fleet/v4/server/mdm/scep/x509util" "github.com/fleetdm/fleet/v4/server/mock" "github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/test" @@ -2185,44 +2184,3 @@ func TestBatchSetMDMProfilesLabels(t *testing.T) { assert.Equal(t, ProfileLabels{IncludeAny: true}, *profileLabels["DIncAny"]) assert.Equal(t, ProfileLabels{ExcludeAny: true}, *profileLabels["DExclAny"]) } - -func TestParseAPNSPrivateKey(t *testing.T) { - t.Parallel() - // nil block not allowed - ctx := context.Background() - _, err := parseAPNSPrivateKey(ctx, nil) - assert.ErrorContains(t, err, "failed to decode") - - // encrypted pkcs8 not supported - pkcs8Encrypted, err := os.ReadFile("testdata/pkcs8-encrypted.key") - require.NoError(t, err) - block, _ := pem.Decode(pkcs8Encrypted) - assert.NotNil(t, block) - _, err = parseAPNSPrivateKey(ctx, block) - assert.ErrorContains(t, err, "failed to parse APNS private key of type ENCRYPTED PRIVATE KEY") - - // X25519 pkcs8 not supported - pkcs8Encrypted, err = os.ReadFile("testdata/pkcs8-x25519.key") - require.NoError(t, err) - block, _ = pem.Decode(pkcs8Encrypted) - assert.NotNil(t, block) - _, err = parseAPNSPrivateKey(ctx, block) - assert.ErrorContains(t, err, "unmarshaled PKCS8 APNS key is not") - - // In this test, the pkcs1 key and pkcs8 keys are the same key, just different formats - pkcs1, err := os.ReadFile("testdata/pkcs1.key") - require.NoError(t, err) - block, _ = pem.Decode(pkcs1) - assert.NotNil(t, block) - pkcs1Key, err := parseAPNSPrivateKey(ctx, block) - require.NoError(t, err) - - pkcs8, err := os.ReadFile("testdata/pkcs8-rsa.key") - require.NoError(t, err) - block, _ = pem.Decode(pkcs8) - assert.NotNil(t, block) - pkcs8Key, err := parseAPNSPrivateKey(ctx, block) - require.NoError(t, err) - - assert.Equal(t, pkcs1Key, pkcs8Key) -} diff --git a/server/service/testing_client.go b/server/service/testing_client.go index b3e0c8966b5a..6734c061289c 100644 --- a/server/service/testing_client.go +++ b/server/service/testing_client.go @@ -692,6 +692,16 @@ func (ts *withServer) updateSoftwareInstaller( require.NoError(t, w.WriteField("self_service", "false")) } } + if payload.LabelsIncludeAny != nil { + for _, l := range payload.LabelsIncludeAny { + require.NoError(t, w.WriteField("labels_include_any", l)) + } + } + if payload.LabelsExcludeAny != nil { + for _, l := range payload.LabelsExcludeAny { + require.NoError(t, w.WriteField("labels_exclude_any", l)) + } + } w.Close() diff --git a/website/api/controllers/articles/view-articles.js b/website/api/controllers/articles/view-articles.js index b7f11213ebdd..a8396e18f2e1 100644 --- a/website/api/controllers/articles/view-articles.js +++ b/website/api/controllers/articles/view-articles.js @@ -26,12 +26,13 @@ module.exports = { }, - fn: async function ({category}) { + fn: async function () { if (!_.isObject(sails.config.builtStaticContent) || !_.isArray(sails.config.builtStaticContent.markdownPages) || !sails.config.builtStaticContent.compiledPagePartialsAppPath) { throw {badConfig: 'builtStaticContent.markdownPages'}; } let articles = []; + let category = this.req.path.split('/')[1]; if (category === 'articles') { // If the category is `/articles` we'll show all articles articles = sails.config.builtStaticContent.markdownPages.filter((page)=>{ @@ -39,8 +40,6 @@ module.exports = { return page; } }); - // setting the category to all - category = 'all'; } else { // if the user navigates to a URL for a specific category, we'll only display articles in that category articles = sails.config.builtStaticContent.markdownPages.filter((page)=>{ @@ -96,6 +95,10 @@ module.exports = { pageTitleForMeta = 'Podcasts'; pageDescriptionForMeta = 'Listen to the Future of Device Management podcast.'; break; + case 'articles': + pageTitleForMeta = 'Articles'; + pageDescriptionForMeta = 'Read the latest articles from the Fleet team and community.'; + break; } diff --git a/website/api/controllers/articles/view-basic-article.js b/website/api/controllers/articles/view-basic-article.js index 6648cebc580e..19f977bcd986 100644 --- a/website/api/controllers/articles/view-basic-article.js +++ b/website/api/controllers/articles/view-basic-article.js @@ -34,7 +34,7 @@ module.exports = { } // Serve appropriate page content. - let thisPage = _.find(sails.config.builtStaticContent.markdownPages, { url: '/' + pageUrlSuffix }); + let thisPage = _.find(sails.config.builtStaticContent.markdownPages, { url: this.req.path }); if (!thisPage) {// If there's no EXACTLY matching content page, try a revised version of the URL suffix that's lowercase, with all slashes deduped, and any leading or trailing slash removed (leading slashes are only possible if this is a regex, rather than "/*" route) let revisedPageUrlSuffix = pageUrlSuffix.toLowerCase().replace(/\/+/g, '/').replace(/^\/+/,'').replace(/\/+$/,''); thisPage = _.find(sails.config.builtStaticContent.markdownPages, { url: '/' + revisedPageUrlSuffix }); @@ -44,9 +44,6 @@ module.exports = { throw 'notFound'; } } - - let articleCategorySlug = pageUrlSuffix.split('/')[0]; - // Setting the pages meta title and description from the articles meta tags, as well as an article image, if provided. // Note: Every article page should have a 'articleTitle' and a 'authorFullName' meta tag. // Note: Leaving title and description as `undefined` in our view means we'll default to the generic title and description set in layout.ejs. @@ -61,13 +58,23 @@ module.exports = { pageDescriptionForMeta = _.trimRight(thisPage.meta.articleTitle, '.') + ' by ' + thisPage.meta.authorFullName; }//fi + let articleCategorySlug = this.req.path.split('/')[1]; + // console.log(articleCategorySlug); + let categoryFriendlyNamesByCategorySlug = { + 'success-stories': 'Success stories', + 'releases': 'Releases', + 'guides': 'Guides', + 'securing': 'Security articles', + 'engineering': 'Engineering articles', + 'announcements': 'Announcements', + 'podcasts': 'Podcasts', + 'report': 'Reports', + }; + let categoryFriendlyName = categoryFriendlyNamesByCategorySlug[articleCategorySlug]; // Set a currentSection variable for the website header based on how the articles category page is linked to in the header navigation dropdown menus. let currentSection; - if(articleCategorySlug === 'success-stories'){ - // If the article is in the 'device-management' category, highlight the "Platform" dropdown. - currentSection = 'platform'; - } else if(_.contains(['deploy','guides','releases'], articleCategorySlug)) { - // If the articleCategorySlug is deploy, guides, or release, highlight the "Documentation" dropdown. + if(['guides','releases'].includes(articleCategorySlug)) { + // If the articleCategorySlug is guides, or releases, highlight the "Documentation" dropdown. currentSection = 'documentation'; } else { // If the article is in any other category, highlight the "Community" dropdown. @@ -85,6 +92,7 @@ module.exports = { pageDescriptionForMeta, pageImageForMeta: thisPage.meta.articleImageUrl || undefined, articleCategorySlug, + categoryFriendlyName, currentSection, algoliaPublicKey: sails.config.custom.algoliaPublicKey, }; @@ -92,4 +100,5 @@ module.exports = { } + }; diff --git a/website/api/helpers/strings/to-html.js b/website/api/helpers/strings/to-html.js index 5182528b986d..60f8cd39ff35 100644 --- a/website/api/helpers/strings/to-html.js +++ b/website/api/helpers/strings/to-html.js @@ -107,13 +107,13 @@ module.exports = { if(infostring === 'mermaid') { return `${_.escape(code)}`; } else if(infostring === 'js') {// Interpret `js` as `javascript` - return `
${_.escape(code)}
`; + return `
${_.escape(code)}
`; } else if(infostring === 'bash' || infostring === 'sh') {// Interpret `sh` and `bash` as `bash` - return `
${_.escape(code)}
`; + return `
${_.escape(code)}
`; } else if(infostring !== '') {// leaving the code language as-is if the infoString is anything else. - return `
${_.escape(code)}
`; + return `
${_.escape(code)}
`; } else {// When unspecified, default to `text` - return `
${_.escape(code)}
`; + return `
${_.escape(code)}
`; } }; diff --git a/website/api/hooks/custom/index.js b/website/api/hooks/custom/index.js index 5a46166252cc..0e4f592f0537 100644 --- a/website/api/hooks/custom/index.js +++ b/website/api/hooks/custom/index.js @@ -70,11 +70,6 @@ will be disabled and/or hidden in the UI. // This will determine whether or not to enable various billing features. sails.config.custom.enableBillingFeatures = !isMissingStripeConfig; - - // Override the default sails.LOOKS_LIKE_ASSET_RX with a regex that does not match paths starting with '/release/'. - // Otherwise, our release blog posts are treated as assets because they contain periods in their URL (e.g., fleetdm.com/releases/fleet-4.29.0) - sails.LOOKS_LIKE_ASSET_RX = /^(?!\/releases\/|\/announcements\/|\/success-stories\/|\/securing\/|\/engineering\/|\/podcasts\/*$)[^?]*\/[^?\/]+\.[^?\/]+(\?.*)?$/; - // After "sails-hook-organics" finishes initializing, configure Stripe // and Sendgrid packs with any available credentials. sails.after('hook:organics:loaded', ()=>{ diff --git a/website/assets/js/pages/articles/articles.page.js b/website/assets/js/pages/articles/articles.page.js index cf15625714f4..a24be4a2d865 100644 --- a/website/assets/js/pages/articles/articles.page.js +++ b/website/assets/js/pages/articles/articles.page.js @@ -50,7 +50,7 @@ parasails.registerPage('articles', { this.articleCategory = 'Reports'; this.categoryDescription = ''; break; - case 'all': + case 'articles': this.articleCategory = 'Articles'; this.categoryDescription = 'Read the latest articles from the Fleet team and community.'; break; diff --git a/website/config/routes.js b/website/config/routes.js index 503f9f5c7f29..6293d097c4e3 100644 --- a/website/config/routes.js +++ b/website/config/routes.js @@ -73,15 +73,142 @@ module.exports.routes = { } }, - 'r|^/((success-stories|securing|releases|engineering|guides|announcements|podcasts|report|deploy)/(.+))$|': { + 'GET /articles': { + skipAssets: false, + action: 'articles/view-articles',// Meta title and description set in view action + locals: { + currentSection: 'community', + } + }, + + 'GET /success-stories': { + skipAssets: false, + action: 'articles/view-articles',// Meta title and description set in view action + locals: { + currentSection: 'community', + } + }, + + 'GET /success-stories/*': { + skipAssets: false, + action: 'articles/view-basic-article',// Meta title and description set in view action + locals: { + currentSection: 'community', + } + },// handles /success-stores/foo + + 'GET /securing': { + skipAssets: false, + action: 'articles/view-articles',// Meta title and description set in view action + locals: { + currentSection: 'community', + } + }, + + 'GET /securing/*': { skipAssets: false, action: 'articles/view-basic-article',// Meta title and description set in view action - },// Handles /device-management/foo, /securing/foo, /releases/foo, /engineering/foo, /guides/foo, /announcements/foo, /deploy/foo, /podcasts/foo, /report/foo + locals: { + currentSection: 'community', + } + },// handles /securing/foo - 'r|^/((success-stories|securing|releases|engineering|guides|announcements|articles|podcasts|report|deploy))/*$|category': { + 'GET /releases': { skipAssets: false, action: 'articles/view-articles',// Meta title and description set in view action - },// Handles the article landing page /articles, and the article cateogry pages (e.g. /device-management, /securing, /releases, etc) + locals: { + currentSection: 'documentation', + } + }, + + 'GET /releases/*': { + skipAssets: false, + action: 'articles/view-basic-article',// Meta title and description set in view action + locals: { + currentSection: 'documentation', + } + },// handles /releases/foo + + 'GET /guides': { + skipAssets: false, + action: 'articles/view-articles',// Meta title and description set in view action + locals: { + currentSection: 'documentation', + } + }, + + 'GET /guides/*': { + skipAssets: false, + action: 'articles/view-basic-article',// Meta title and description set in view action + locals: { + currentSection: 'documentation', + } + },// handles /guides/foo + + 'GET /announcements': { + skipAssets: false, + action: 'articles/view-articles',// Meta title and description set in view action + locals: { + currentSection: 'community', + } + }, + + 'GET /announcements/*': { + skipAssets: false, + action: 'articles/view-basic-article',// Meta title and description set in view action + locals: { + currentSection: 'community', + } + },// handles /announcements/foo + + 'GET /podcasts': { + skipAssets: false, + action: 'articles/view-articles',// Meta title and description set in view action + locals: { + currentSection: 'community', + } + }, + + 'GET /podcasts/*': { + skipAssets: false, + action: 'articles/view-basic-article',// Meta title and description set in view action + locals: { + currentSection: 'community', + } + },// handles /podcasts/foo + + 'GET /engineering': { + skipAssets: false, + action: 'articles/view-articles',// Meta title and description set in view action + locals: { + currentSection: 'community', + } + }, + + 'GET /engineering/*': { + skipAssets: false, + action: 'articles/view-basic-article',// Meta title and description set in view action + locals: { + currentSection: 'community', + } + },// handles /engineering/foo + + 'GET /report': { + skipAssets: false, + action: 'articles/view-articles',// Meta title and description set in view action + locals: { + currentSection: 'community', + } + }, + + 'GET /report/*': { + skipAssets: false, + action: 'articles/view-basic-article',// Meta title and description set in view action + locals: { + currentSection: 'community', + } + },// handles /engineering/foo + 'GET /docs/?*': { skipAssets: false, @@ -520,6 +647,81 @@ module.exports.routes = { 'GET /guides/how-to-uninstall-osquery': (req,res)=> { return res.redirect(301, '/guides/how-to-uninstall-fleetd');}, 'GET /guides/sysadmin-diaries-lost-device': (req,res)=> { return res.redirect(301, '/guides/lock-wipe-hosts');}, + // Release note article redirects. + 'GET /releases/fleet-3.10.0': '/releases/fleet-3-10-0', + 'GET /releases/fleet-3.12.0': '/releases/fleet-3-12-0', + 'GET /releases/fleet-3.13.0': '/releases/fleet-3-13-0', + 'GET /releases/fleet-3.5.0': '/releases/fleet-3-5-0', + 'GET /releases/fleet-3.6.0': '/releases/fleet-3-6-0', + 'GET /releases/fleet-3.7.1': '/releases/fleet-3-7-1', + 'GET /releases/fleet-3.8.0': '/releases/fleet-3-8-0', + 'GET /releases/fleet-3.9.0': '/releases/fleet-3-9-0', + 'GET /releases/fleet-4.0.0': '/releases/fleet-4-0-0', + 'GET /releases/fleet-4.1.0': '/releases/fleet-4-1-0', + 'GET /releases/fleet-4.10.0': '/releases/fleet-4-10-0', + 'GET /releases/fleet-4.12.0': '/releases/fleet-4-12-0', + 'GET /releases/fleet-4.11.0': '/releases/fleet-4-11-0', + 'GET /releases/fleet-4.13.0': '/releases/fleet-4-13-0', + 'GET /releases/fleet-4.15.0': '/releases/fleet-4-15-0', + 'GET /releases/fleet-3.11.0': '/releases/fleet-3-11-0', + 'GET /releases/fleet-4.16.0': '/releases/fleet-4-16-0', + 'GET /releases/fleet-4.17.0': '/releases/fleet-4-17-0', + 'GET /releases/fleet-4.18.0': '/releases/fleet-4-18-0', + 'GET /releases/fleet-4.19.0': '/releases/fleet-4-19-0', + 'GET /releases/fleet-4.2.0': '/releases/fleet-4-2-0', + 'GET /releases/fleet-4.21.0': '/releases/fleet-4-21-0', + 'GET /releases/fleet-4.14.0': '/releases/fleet-4-14-0', + 'GET /releases/fleet-4.22.0': '/releases/fleet-4-22-0', + 'GET /releases/fleet-4.20.0': '/releases/fleet-4-20-0', + 'GET /releases/fleet-4.23.0': '/releases/fleet-4-23-0', + 'GET /releases/fleet-4.24.0': '/releases/fleet-4-24-0', + 'GET /releases/fleet-4.25.0': '/releases/fleet-4-25-0', + 'GET /releases/fleet-4.27.0': '/releases/fleet-4-27-0', + 'GET /releases/fleet-4.26.0': '/releases/fleet-4-26-0', + 'GET /releases/fleet-4.28.0': '/releases/fleet-4-28-0', + 'GET /releases/fleet-4.29.0': '/releases/fleet-4-29-0', + 'GET /releases/fleet-4.30.0': '/releases/fleet-4-30-0', + 'GET /releases/fleet-4.31.0': '/releases/fleet-4-31-0', + 'GET /releases/fleet-4.3.0': '/releases/fleet-4-3-0', + 'GET /releases/fleet-4.32.0': '/releases/fleet-4-32-0', + 'GET /releases/fleet-4.33.0': '/releases/fleet-4-33-0', + 'GET /releases/fleet-4.34.0': '/releases/fleet-4-34-0', + 'GET /releases/fleet-4.36.0': '/releases/fleet-4-36-0', + 'GET /releases/fleet-4.38.0': '/releases/fleet-4-38-0', + 'GET /releases/fleet-4.39.0': '/releases/fleet-4-39-0', + 'GET /releases/fleet-4.35.0': '/releases/fleet-4-35-0', + 'GET /releases/fleet-4.4.0': '/releases/fleet-4-4-0', + 'GET /releases/fleet-4.37.0': '/releases/fleet-4-37-0', + 'GET /releases/fleet-4.40.0': '/releases/fleet-4-40-0', + 'GET /releases/fleet-4.42.0': '/releases/fleet-4-42-0', + 'GET /releases/fleet-4.43.0': '/releases/fleet-4-43-0', + 'GET /releases/fleet-4.44.0': '/releases/fleet-4-44-0', + 'GET /releases/fleet-4.41.0': '/releases/fleet-4-41-0', + 'GET /releases/fleet-4.45.0': '/releases/fleet-4-45-0', + 'GET /releases/fleet-4.46.0': '/releases/fleet-4-46-0', + 'GET /releases/fleet-4.47.0': '/releases/fleet-4-47-0', + 'GET /releases/fleet-4.49.0': '/releases/fleet-4-49-0', + 'GET /releases/fleet-4.5.0': '/releases/fleet-4-5-0', + 'GET /releases/fleet-4.50.0': '/releases/fleet-4-50-0', + 'GET /releases/fleet-4.51.0': '/releases/fleet-4-51-0', + 'GET /releases/fleet-4.48.0': '/releases/fleet-4-48-0', + 'GET /releases/fleet-4.53.0': '/releases/fleet-4-53-0', + 'GET /releases/fleet-4.55.0': '/releases/fleet-4-55-0', + 'GET /releases/fleet-4.56.0': '/releases/fleet-4-56-0', + 'GET /releases/fleet-4.54.0': '/releases/fleet-4-54-0', + 'GET /releases/fleet-4.58.0': '/releases/fleet-4-58-0', + 'GET /releases/fleet-4.59.0': '/releases/fleet-4-59-0', + 'GET /releases/fleet-4.57.0': '/releases/fleet-4-57-0', + 'GET /releases/fleet-4.6.0': '/releases/fleet-4-6-0', + 'GET /releases/fleet-4.60.0': '/releases/fleet-4-60-0', + 'GET /releases/fleet-4.7.0': '/releases/fleet-4-7-0', + 'GET /releases/fleet-4.8.0': '/releases/fleet-4-8-0', + 'GET /releases/fleet-4.61.0': '/releases/fleet-4-61-0', + 'GET /releases/fleet-4.9.0': '/releases/fleet-4-9-0', + 'GET /announcements/nvd-api-2.0': '/announcements/nvd-api-2-0', + 'GET /releases/osquery-5.11.0': '/releases/osquery-5-11-0', + 'GET /releases/osquery-5.8.1': '/releases/osquery-5-8-1', + // ╔╦╗╦╔═╗╔═╗ ╦═╗╔═╗╔╦╗╦╦═╗╔═╗╔═╗╔╦╗╔═╗ ┬ ╔╦╗╔═╗╦ ╦╔╗╔╦ ╔═╗╔═╗╔╦╗╔═╗ // ║║║║╚═╗║ ╠╦╝║╣ ║║║╠╦╝║╣ ║ ║ ╚═╗ ┌┼─ ║║║ ║║║║║║║║ ║ ║╠═╣ ║║╚═╗ // ╩ ╩╩╚═╝╚═╝ ╩╚═╚═╝═╩╝╩╩╚═╚═╝╚═╝ ╩ ╚═╝ └┘ ═╩╝╚═╝╚╩╝╝╚╝╩═╝╚═╝╩ ╩═╩╝╚═╝ diff --git a/website/scripts/build-static-content.js b/website/scripts/build-static-content.js index 104264662a4f..0c0c7dff2d3f 100644 --- a/website/scripts/build-static-content.js +++ b/website/scripts/build-static-content.js @@ -259,10 +259,11 @@ module.exports = { if(mdString.match(/(-|\d\.)\s.*\n\n+])\n+(>)/g, '$1$2'); // « Removes any newlines that might exist before the closing `>` when the compontent is added to markdown files. // [?] Looking for code that used to be here related to syntax highlighting? Please see https://github.com/fleetdm/fleet/pull/14124/files -mikermcneil, 2023-09-25 let htmlString = await sails.helpers.strings.toHtml(mdString); @@ -534,7 +535,7 @@ module.exports = { rootRelativeUrlPath = ( '/' + (encodeURIComponent(embeddedMetadata.category === 'success stories' ? 'success-stories' : embeddedMetadata.category === 'security' ? 'securing' : embeddedMetadata.category)) + '/' + - (pageUnextensionedUnwhitespacedLowercasedRelPath.split(/\//).map((fileOrFolderName) => encodeURIComponent(fileOrFolderName.replace(/^[0-9]+[\-]+/,''))).join('/')) + (pageUnextensionedUnwhitespacedLowercasedRelPath.split(/\//).map((fileOrFolderName) => encodeURIComponent(fileOrFolderName.replace(/^[0-9]+[\-]+/,'').replace(/\./g, '-'))).join('/')) ); } diff --git a/website/views/layouts/layout.ejs b/website/views/layouts/layout.ejs index 1c2409f267d8..c8185011a983 100644 --- a/website/views/layouts/layout.ejs +++ b/website/views/layouts/layout.ejs @@ -187,6 +187,7 @@ What people are saying News Ask around + Meetups COMPANY
Origins   (Fleet & osquery) @@ -249,6 +250,7 @@ News Ask around Take a tour + Meetups COMPANY Origins   (Fleet & osquery) The handbook diff --git a/website/views/pages/articles/basic-article.ejs b/website/views/pages/articles/basic-article.ejs index a469250b6353..c95199458083 100644 --- a/website/views/pages/articles/basic-article.ejs +++ b/website/views/pages/articles/basic-article.ejs @@ -3,7 +3,7 @@
{{thisPage.meta.articleTitle}} diff --git a/website/views/pages/meetups.ejs b/website/views/pages/meetups.ejs index 953c585d8838..2267285a4fb8 100644 --- a/website/views/pages/meetups.ejs +++ b/website/views/pages/meetups.ejs @@ -31,7 +31,7 @@

This group is intended for anyone who supports or manages a group of Macs or iOS devices of any size (from less than a dozen to ten of thousands) for an institution of any stripe (Small/Medium Business, Non-profit, Enterprise, Education) in the Greater Philadelphia area (South Eastern PA, South Jersey, Delaware) to swap ideas and solutions, network, and hang out.

- View details + View details