From 8c27e36c84edde14cb19bd090fcbf7ad9203e2f9 Mon Sep 17 00:00:00 2001 From: Eric Date: Sun, 8 Sep 2024 23:04:52 -0500 Subject: [PATCH 01/28] Website: Update login page layout. (#21735) Changes: - Updated the layout of the login page to match the /signup page. --- .../assets/styles/pages/entrance/login.less | 158 ++++++++++++++---- website/views/pages/entrance/login.ejs | 125 ++++++++++---- 2 files changed, 225 insertions(+), 58 deletions(-) diff --git a/website/assets/styles/pages/entrance/login.less b/website/assets/styles/pages/entrance/login.less index 42cadfb9be..46ffa53c30 100644 --- a/website/assets/styles/pages/entrance/login.less +++ b/website/assets/styles/pages/entrance/login.less @@ -1,45 +1,74 @@ #login { - - padding-top: 80px; - h1 { - font-size: 28px; - line-height: 38px; + font-size: 32px; + line-height: 120%; } a { + line-height: 150%; color: @core-fleet-black-75; text-decoration: underline; text-underline-offset: 2px; + } + p { line-height: 150%; } - [purpose='customer-login-container'] { - max-width: 560px; - } - [purpose='login-container'] { - max-width: 560px; - [purpose='customer-portal-form'] { - max-width: 560px; - } + [purpose='page-container'] { + padding: 64px 128px 64px 128px; + max-width: 1200px; } + [purpose='page-heading'] { - padding-left: 30px; - padding-right: 30px; - text-align: center; - margin-bottom: 40px; + text-align: left; + margin-bottom: 48px; } - [purpose='register-link'] { - margin-bottom: 8px; - a { - float: right; + + [purpose='quote-and-logos'] { + max-width: 310px; + } + [purpose='quote'] { + margin-top: 8px; + } + [purpose='quote-text'] { + font-size: 14px; + line-height: 150%; + font-style: italic; + } + [purpose='quote-author-info'] { + display: inline-flex; + padding: 4px 16px 4px 4px; + border-radius: 28px; + width: fit-content; + margin-top: 8px; + margin-bottom: 48px; + [purpose='job-title'] { color: @core-fleet-black-75; - text-decoration: underline; - font-size: 14px; + font-size: 12px; + font-weight: 400; + line-height: 18px; + margin-bottom: 0px; } + [purpose='name'] { + font-size: 12px; + font-weight: 700; + line-height: 18px; + margin-bottom: 0px; + } + [purpose='profile-picture'] { + margin-right: 16px; + img { + height: 32px; + width: 32px; + } + } + } + [purpose='logos'] { + max-width: 310px; + } + [purpose='login-form'] { + width: 528px; } [purpose='customer-portal-form'] { - max-width: 560px; border-radius: 16px; - margin-bottom: 40px; padding: 20px 32px 32px 32px; label { color: @core-fleet-black; @@ -50,8 +79,36 @@ height: 40px; border-radius: 6px; } - .card-body { - padding: 2em; + .selectbox { + + position: relative; + } + .selectbox::after { + content: url('/images/chevron-12x8@2x.png'); + right: 14px; + transform: scale(0.5); + top: 14px; + position: absolute; + pointer-events: none; + } + .selectbox select { + border-radius: 6px; + height: 48px; + appearance: none; + -webkit-appearance: none; + } + .small { + font-size: 12px; + } + } + + [purpose='register-link'] { + margin-bottom: 4px; + a { + float: right; + color: @core-fleet-black-75; + text-decoration: underline; + font-size: 14px; } } @@ -74,23 +131,64 @@ font-weight: 700; } } + @media (max-width: 1200px) { + [purpose='page-container'] { + padding: 64px; + } + } + @media (max-width: 991px) { + [purpose='signup-form'] { + max-width: 528px; + } + [purpose='quote-and-logos'] { + max-width: 528px; + margin-top: 48px; + } + [purpose='logos'] { + max-width: 528px; + } + [purpose='page-heading'] { + text-align: center; + margin-left: auto; + margin-right: auto; + margin-bottom: 48px; + max-width: 528px; + } + [purpose='page-container'] { + padding: 64px 32px; + } + + } @media (max-width: 768px) { - padding-top: 60px; [purpose='customer-portal-form'] { max-width: unset; } + [purpose='signup-form'] { + width: 100%; + } + [purpose='quote-and-logos'] { + width: 100%; + } + [purpose='logos'] { + width: 100%; + } } @media (max-width: 576px) { - padding-top: 40px; [purpose='page-heading'] { padding-left: 0px; padding-right: 0px; } + [purpose='page-container'] { + padding: 48px 24px; + } + [purpose='login-link'] { + margin-bottom: 12px; + } [purpose='customer-portal-form'] { .card-body { - padding: 1em; + padding: 1.5em 1em; } } } diff --git a/website/views/pages/entrance/login.ejs b/website/views/pages/entrance/login.ejs index 7ed6d7b471..4d55f0a281 100644 --- a/website/views/pages/entrance/login.ejs +++ b/website/views/pages/entrance/login.ejs @@ -1,38 +1,107 @@
-
-
+
+

Welcome to Fleet

We just need a few details in order to get started.

-
-

Welcome to Fleet

-

Sign in to your Fleet account.

-
-
-
- Create an account +
+
+
+ + + Forgot your password? +
-
<%- /* Expose locals as `window.SAILS_LOCALS` :: */ exposeLocalsToBrowser() %> From c5b3f450d67eb40ad4e79955a4bf22de7dd5600c Mon Sep 17 00:00:00 2001 From: Brock Walters <153771548+nonpunctual@users.noreply.github.com> Date: Mon, 9 Sep 2024 11:19:12 -0400 Subject: [PATCH 02/28] Update teams.md (#21910) fixed apostrophe... --- articles/teams.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/articles/teams.md b/articles/teams.md index 1b0c8f1cbb..ea688bc08b 100644 --- a/articles/teams.md +++ b/articles/teams.md @@ -2,7 +2,7 @@ _Available in Fleet Premium_ -In Fleet, you can group hosts together in a "team" in Fleet. This way, you can apply queries, policies, scripts, and more that are tailored to the hosts' risk/compliance needs. +In Fleet, you can group hosts together in a "team" in Fleet. This way, you can apply queries, policies, scripts, and more that are tailored to a host's risk/compliance needs. A host can only belong to one team. From e57876ae9c954b9775df9a6d55fdebebf34e19db Mon Sep 17 00:00:00 2001 From: Brock Walters <153771548+nonpunctual@users.noreply.github.com> Date: Mon, 9 Sep 2024 11:21:46 -0400 Subject: [PATCH 03/28] Update discovering-chrome-ai-using-fleet.md (#21897) Modified some text & removed my name from the file paths... --- articles/discovering-chrome-ai-using-fleet.md | 24 +++++++++---------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/articles/discovering-chrome-ai-using-fleet.md b/articles/discovering-chrome-ai-using-fleet.md index 55c90b4767..72b73627d6 100644 --- a/articles/discovering-chrome-ai-using-fleet.md +++ b/articles/discovering-chrome-ai-using-fleet.md @@ -4,15 +4,15 @@ # Discovering AI in Chrome with Fleet -Staying ahead of technological innovations is crucial for both individuals and organizations. Google Chrome, one of the most widely used web browsers, continually evolves to incorporate advanced features, including artificial intelligence (AI). This article will guide you through detecting if AI capabilities have been enabled in Chrome using Fleet. +Staying ahead of technological innovations is crucial for individuals and organizations. Google Chrome, one of the most widely used web browsers, continually evolves to incorporate new features, including artificial intelligence (AI). This article will guide you through detecting if AI capabilities have been enabled in Chrome using Fleet. ## Introduction to Chrome AI innovations -Google Chrome has integrated AI to enhance user experience by providing intelligent suggestions, improving search results, and offering in-browser assistance. To explore these innovations further, visit the [Chrome AI Innovations page](https://www.google.com/chrome/ai-innovations/). Detecting whether AI features are enabled in your Chrome browser can help you understand and leverage these advancements effectively. +Google Chrome has integrated AI to enhance user experience by providing intelligent suggestions, improving search results, and offering in-browser assistance. Visit the [Chrome AI Innovations page](https://www.google.com/chrome/ai-innovations/) for more infomration. ## Using Fleet to detect AI features in Chrome -Fleet, a comprehensive device management and security tool, allows you to monitor various aspects of your devices, including software configurations and enabled features. Using Fleet, you can detect whether AI features are enabled in Chrome by querying device settings, specifically in the "Preferences" JSON file. +Fleet, a comprehensive device management and security tool, allows you to monitor various aspects of your devices, including software configurations and enabled features. Using Fleet, you can detect whether AI features are enabled in Chrome by querying device settings, specifically in the Chrome "Preferences" JSON file. ### Step 1: Understanding Chrome's preferences JSON file @@ -24,33 +24,31 @@ Chrome stores user settings and configurations in a JSON file at the following p ### Step 2: Identifying AI-related settings -Within this file, AI-related features are stored in the `optimization_guide` section. The `tab_organization_setting_state` field will tell you if AI-based tab management features are enabled: +AI-related features are stored in the `optimization_guide` section of the preferences. The `tab_organization_setting_state` field will tell you if AI-based tab management features are enabled: -- Enabled: - The setting will return `1`. +`> jq` is a lightweight and powerful command-line tool for parsing, filtering, and manipulating JSON data. It allows you to extract specific information from JSON files efficiently. In this case, we use `jq` to locate and read the value of the `tab_organization_setting_state` key within Chrome's preference file which will help us understand how to craft our Fleet query for reporting the state of this setting. + +- If enabled, the setting will return `1`. ![Chrome settings UI with Chrome AI enabled](../website/assets/images/articles/discovering-chrome-ai-using-fleet-1-1472x370@2x.png) ``` -% jq '.optimization_guide.tab_organization_setting_state' /Users/brock/Library/Application\ Support/Google/Chrome/Default/Preferences +% jq '.optimization_guide.tab_organization_setting_state' /Users//Library/Application\ Support/Google/Chrome/Default/Preferences 1 ``` -- Disabled: - The setting will return `2`. +- If disabled, the setting will return `2`. ![Chrome settings UI with Chrome AI disabled](../website/assets/images/articles/discovering-chrome-ai-using-fleet-2-1474x276@2x.png) ``` -% jq '.optimization_guide.tab_organization_setting_state' /Users/brock/Library/Application\ Support/Google/Chrome/Default/Preferences +% jq '.optimization_guide.tab_organization_setting_state' /Users//Library/Application\ Support/Google/Chrome/Default/Preferences 2 ``` -`> jq` is a lightweight and powerful command-line tool for parsing, filtering, and manipulating JSON data. It allows you to extract specific information from JSON files efficiently. In this case, we use `jq` to locate and read the value of the `tab_organization_setting_state` key within Chrome's preference file. - ### Step 3: Query the JSON file with Fleet -To query the JSON file and detect AI features, you can use the following command: +To query the JSON file and detect AI features using Fleet, you can use the following SQL query: ``` SELECT fullkey,path FROM parse_json WHERE path LIKE '/Users/%/Library/Application Support/Google/Chrome/Default/Preferences' AND fullkey='optimization_guide/tab_organization_setting_state'; From 4fb0fd5ede2750d23ad5248f24131e90a9bfd745 Mon Sep 17 00:00:00 2001 From: Robert Fairburn <8029478+rfairburn@users.noreply.github.com> Date: Mon, 9 Sep 2024 10:35:41 -0500 Subject: [PATCH 04/28] Update ses addon module to have better SPF records (#21879) This helps prevent invite emails from Fleet going to spam instead of someone's inbox. --- terraform/addons/ses/main.tf | 18 +++++++++++++----- terraform/addons/ses/variables.tf | 2 +- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/terraform/addons/ses/main.tf b/terraform/addons/ses/main.tf index 6c7304f1ba..00aec19fe0 100644 --- a/terraform/addons/ses/main.tf +++ b/terraform/addons/ses/main.tf @@ -1,3 +1,10 @@ +locals { + spf_domains = [ + aws_ses_domain_identity.default.domain, + "_amazonses.${aws_ses_domain_identity.default.domain}" + ] +} + resource "aws_ses_domain_identity" "default" { domain = var.domain } @@ -19,11 +26,12 @@ resource "aws_route53_record" "amazonses_dkim_record" { resource "aws_route53_record" "spf_domain" { - zone_id = var.zone_id - name = "_amazonses.${aws_ses_domain_identity.default.domain}" - type = "TXT" - ttl = "600" - records = ["v=spf1 include:amazonses.com -all"] + for_each = toset(local.spf_domains) + zone_id = var.zone_id + name = each.key + type = "TXT" + ttl = "600" + records = ["v=spf1 include:amazonses.com -all"] } resource "aws_iam_policy" "main" { diff --git a/terraform/addons/ses/variables.tf b/terraform/addons/ses/variables.tf index be93f34ccf..a174e55f0a 100644 --- a/terraform/addons/ses/variables.tf +++ b/terraform/addons/ses/variables.tf @@ -4,6 +4,6 @@ variable "domain" { } variable "zone_id" { - type = string + type = string description = "Route53 Zone ID" } From 93131d3e2fa3edbd7229fb902dfa586b13912ed6 Mon Sep 17 00:00:00 2001 From: Eric Date: Mon, 9 Sep 2024 11:47:38 -0500 Subject: [PATCH 05/28] Website: fix typo in send-aggregated-metrics-to-datadog script (#21915) Changes: - Fixed a typo in the `send-aggregated-metrics-to-datadog` script --- website/scripts/send-aggregated-metrics-to-datadog.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/scripts/send-aggregated-metrics-to-datadog.js b/website/scripts/send-aggregated-metrics-to-datadog.js index af3cccceb6..f13826b866 100644 --- a/website/scripts/send-aggregated-metrics-to-datadog.js +++ b/website/scripts/send-aggregated-metrics-to-datadog.js @@ -450,7 +450,7 @@ module.exports = { }); // maintenanceWindowsConfigured let numberOfInstancesWithMaintenanceWindowsConfigured = _.where(latestStatisticsReportedByReleasedFleetVersions, {maintenanceWindowsEnabled: true}).length; - let numberOfInstancesWithoutMaintenanceWindowsConfigured = numberOfInstancesToReport - numberOfInstancesWithMaintenanceWindowsEnabled; + let numberOfInstancesWithoutMaintenanceWindowsConfigured = numberOfInstancesToReport - numberOfInstancesWithMaintenanceWindowsConfigured; metricsToReport.push({ metric: 'usage_statistics.maintenance_windows_configured', type: 3, From a1ccb3a38dd27d40863979c6f1660dd335a7cdfb Mon Sep 17 00:00:00 2001 From: Sam Pfluger <108141731+Sampfluger88@users.noreply.github.com> Date: Mon, 9 Sep 2024 12:36:04 -0500 Subject: [PATCH 06/28] Update why-this-way.md (#21919) --- handbook/company/why-this-way.md | 68 +++++++++++++++----------------- 1 file changed, 32 insertions(+), 36 deletions(-) diff --git a/handbook/company/why-this-way.md b/handbook/company/why-this-way.md index da8748cd09..189eb4aa39 100644 --- a/handbook/company/why-this-way.md +++ b/handbook/company/why-this-way.md @@ -12,6 +12,7 @@ Here are some of Fleet's decisions about the best way to work, and the reasoning ## Why open source? + Fleet's source code, website, documentation, company handbook, and internal tools are [public](https://github.com/fleetdm/fleet) and accessible to everyone, including engineers, executives, and end users. (Even [paid features](https://fleetdm.com/pricing) are source-available.) Meanwhile, the [company behind Fleet](https://twitter.com/fleetctl) is built on the [open-core](https://www.heavybit.com/library/video/commercial-open-source-business-strategies) business model. Openness is one of our core [values](https://fleetdm.com/handbook/company#values), and everything we do is [public by default](https://handbook.gitlab.com/handbook/values/#public-by-default). Even the [company handbook](https://fleetdm.com/handbook) is open to the world. @@ -31,6 +32,7 @@ Here are some of the reasons we build in the open: ## Why handbook-first strategy? + The Fleet handbook provides team members with up-to-date information about how to do things in the company. At Fleet, we make changes to the handbook first. That means, before any change to how we run the business is "live" or "official", it is first changed in the relevant [handbook pages](https://fleetdm.com/handbook) and [issue templates](https://github.com/fleetdm/confidential/tree/main/.github/ISSUE_TEMPLATE). @@ -43,6 +45,7 @@ To contribute to the handbook, click "Edit this page" and make your [edits in Ma ## Why read documentation? + There are three reasons for visiting [the docs](https://fleetdm.com/docs): - **Tire-kicking**: "I think this is cool, now is it something that I could ACTUALLY use? Does it ACTUALLY work? What all's in it? What links can I share with my colleagues to help them see what I'm seeing?" - **Committed learning**: "I've decided to learn this. I need a curriculum to get me there; with content that makes it as easy as possible, surface-level as possible. I want to learn how Fleet works and how to do all the things." @@ -61,6 +64,7 @@ Everyone [can contribute](https://fleetdm.com/handbook/company#openness) to Flee ## Why the emphasis on training? + Investing in people and providing generous, prioritized training, especially up front, helps contributors understand what is going on at Fleet. By making training a prerequisite at Fleet, we can: - help team members feel confident in the better decisions they make at work. - create a culture of helping others, which results in team members feeling more comfortable even if they aren’t familiar with the osquery, security, startup, or IT space. @@ -74,6 +78,7 @@ Here are a few examples of how Fleet prioritizes training: ## Why direct responsibility? + Like Apple and GitLab, Fleet uses the concept of [directly responsible individuals (DRIs)](https://about.gitlab.com/handbook/people-group/directly-responsible-individuals/) to know who is responsible for what. DRIs help us collaborate efficiently by knowing exactly who is responsible and can make decisions about the work they're doing. This saves time by eliminating a requirement for consensus decisions or political presenteeism, enables faster decision-making, and ensures a single individual is aware of what to do next. @@ -85,9 +90,8 @@ DRIs help us collaborate efficiently by knowing exactly who is responsible and c - **Multiple maintainers**: In some cases, multiple subject-matter experts called "maintainers" can merge changes to certain file paths, even though there is already a dedicated DRI configured as the "CODEOWNER". For examples of this, see the auto-approval flows configured as `sails.config.custom.githubRepoMaintainersByPath` and related configuration in [`website/config/custom.js`](https://github.com/fleetdm/fleet/blob/main/website/config/custom.js). - - ## Why do we use a wireframe-first approach? + Wireframing (usually as part of what Fleet calls ["drafting"](https://fleetdm.com/handbook/company/development-groups#making-changes)) provides a clear overview of page layout, information architecture, user flow, and functionality. The [wireframe-first approach](https://speakerdeck.com/mikermcneil/i-love-apis?slide=28) extends beyond what users see on their screens. Wireframe-first is also excellent for drafting APIs, config settings, CLI options, and even business processes. It's design thinking, applied to software development. @@ -109,8 +113,8 @@ Here's why Fleet uses a wireframe-first approach: - While the "wireframe first" practice is [still sometimes misunderstood](https://about.gitlab.com/handbook/product-development-flow/#but-wait-isnt-this-waterfall), today many modern high-performing teams now use a [wireframe-first methodology](https://speakerdeck.com/mikermcneil/i-love-apis), including [startups](https://www.forbes.com/sites/danwoods/2015/10/19/dont-get-ubered-apis-hold-key-to-digital-transformation/?sh=50112fea182c#:~:text=One%20recommendation%20that,deep%20experience) and [publicly-traded companies](https://about.gitlab.com/handbook/product-development-flow/#validation-phase-3-design). - ## Why do we use one repo? + At Fleet, we keep everything in one repo ([`fleetdm/fleet`](https://github.com/fleetdm/fleet)). Here's why: - One repo is easier to manage. It has less surface area for keeping content up to date and reduces the risk of things getting lost and forgotten. @@ -138,8 +142,8 @@ Besides the exceptions above, Fleet does not use any other repositories. Other > _**Tip:** In addition to the built-in search available for the public handbook on fleetdm.com, you can also [search any public AND non-public content, including issue templates, at the same time](https://github.com/search?q=org%3Afleetdm+path%3A.github%2FISSUE_TEMPLATE+path%3Ahandbook%2F+path%3Adocs%2F+foo&type=code)._ - ## Why not continuously generate REST API reference docs from javadoc-style code comments? + Here are a few of the drawbacks that we have experienced when generating docs via tools like Swagger or OpenAPI, and some of the advantages of doing it by hand with Markdown. - Markdown gives us more control over how the docs are compiled, what annotations we can include, and how we [present the information to the end-user](https://x.com/wesleytodd/status/1769810305448616185?s=46&t=4_cwTxqV5IXDLBvCm8KI6Q). @@ -153,12 +157,14 @@ Here are a few of the drawbacks that we have experienced when generating docs vi ## Why group Slack channels? + Groups (`g-*`) are organized around goals. Connecting people with the same goals helps them produce better results by fostering freer communication. Some groups align with teams in the org chart. Other groups, such as [product groups](https://fleetdm.com/handbook/company/development-groups), are cross-functional, with some group members who do not report to the same manager. Every group at Fleet maintains their own Slack channel, which all group members join and keep unmuted. Everyone else at Fleet is encouraged to mute these channels, using them only as needed. Each channel has a directly responsible individual responsible for keeping up with all new messages, even if they aren't explicitly mentioned (`@`). ## Why make work visible? + Work is tracked in [GitHub issues](https://github.com/issues?q=archived%3Afalse+org%3Afleetdm+is%3Aissue+is%3Aopen+). Every department organizes their work into [team-based kanban boards](https://app.zenhub.com/workspaces/-g-business-operations-63f3dc3cc931f6247fcf55a9/board?sprints=none). This provides a consistent framework for how every team works, plans, and requests things from each other. @@ -167,7 +173,9 @@ Every department organizes their work into [team-based kanban boards](https://ap 2. **Planning:** Give the team's manager and other team members a way to plan the [next three-week iteration](https://fleetdm.com/handbook/company/why-this-way#why-a-three-week-cadence) of what the team is working on. Provide a world (the kanban board) where the team has clarity, and the appropriate [DRI](https://fleetdm.com/handbook/company#why-direct-responsibility) can confidently [prioritize and plan changes](https://fleetdm.com/handbook/company/development-groups#planned-and-unplanned-changes) with enough context to make the right decisions. 3. **Shared to-do list:** What should I work on next? Who needs help? What important work is blocked? Is that bug fix merged yet? When will it be released? When will that new feature ship? What did I do yesterday? + ## Why agile? + Releasing software [🟢iteratively](https://fleetdm.com/handbook/company#results) gets changes and improvements into the hands of users faster and generally results in [🔵software that works](https://fleetdm.com/handbook/company#objectivity). This makes contributors fitter, happier, and more productive. We apply the [twelve principles of agile](https://agilemanifesto.org) to Fleet's [development process](https://fleetdm.com/handbook/company/product-groups#making-changes): @@ -187,6 +195,7 @@ We apply the [twelve principles of agile](https://agilemanifesto.org) to Fleet's ### Why scrum? + Scrum is an agile framework for software development that helps teams deliver high quality software faster. It emphasizes teamwork, collaboration, and continuous improvement to achieve business objectives. Here are some of the key reasons why [we use scrum at Fleet](https://fleetdm.com/handbook/engineering#scrum)): - Improved collaboration and communication: Scrum emphasizes teamwork and collaboration, which leads to better communication between team members and stakeholders. This helps ensure that everyone is aligned and working towards the same goals. - Flexibility and adaptability: Scrum allows teams to respond quickly to changing requirements and market conditions. By working in short sprints, teams can continuously adapt to new information and feedback, and adjust their approach as needed. @@ -194,7 +203,9 @@ Scrum is an agile framework for software development that helps teams deliver hi - Faster delivery of working software: Scrum helps teams deliver working software faster by breaking down the development process into manageable chunks that can be completed within a sprint. Stakeholders can see progress and provide feedback more quickly, which helps ensure the final product meets their needs. - Higher quality software: Scrum includes regular testing and quality assurance activities, which help ensure that the software being developed is of high quality and meets the required standards. + ### Why lean software development? + [Lean software development](https://en.wikipedia.org/wiki/Lean_software_development) is an iterative and incremental approach to software development that aims to eliminate waste and deliver value to customers quickly. It is based on the principles of [lean manufacturing](https://en.wikipedia.org/wiki/Lean_manufacturing) and emphasizes continuous improvement, collaboration, and customer focus. Lean development can be summarized by its seven principles: @@ -206,7 +217,9 @@ Lean development can be summarized by its seven principles: 6. Build integrity in: Build quality into the software by continuously testing, reviewing, and improving the code throughout the development process. 7. Optimize the whole: Optimize the entire process and focus on the system's overall performance rather than just individual parts to ensure the most efficient and effective use of resources. + ## Why a three-week cadence? + The Fleet product is released every three weeks. By syncing the whole company to this schedule, we can: - Keep all team members (especially those who aren't directly involved with the core product) aware of the current version of Fleet and when the next release is shipping. @@ -228,7 +241,9 @@ Why bother with all that? And why do it in this particular order? - **Better customer experience.** Understanding the impact of every production issue means we can reach out to affected users ASAP and acknowledge their challenge, showing them that Fleet takes quality and stability seriously. This kind of customer support is rare and memorable. - **It helps us prevent future outages.** By finding outages sooner, we incentivize ourselves to fix the root cause sooner. And by fixing bugs sooner, we prevent them from stacking and bleeding into one another, and we prevent ourselves from implementing future fixes and improvements on top of shaky foundations. This makes contributions less risky and reduces the number of outages. + ## Why make it obvious when stuff breaks? + At Fleet, we detect and fix bugs as quickly as possible. Breaking loudly means we can fix the break sooner and improve how fast and certain we are about making future changes. Especially in an all-remote environment, this provides contributors with discipline around quality and stability of the main branch. This is ["good annoying"](https://agilehope.blogspot.com/2014/12/diy-build-light-indicator.html). @@ -239,7 +254,9 @@ If that happens by mistake, first priority is merging a fix, then notifying the > Here is [an example of a deliberate decision to make broken images in Fleet fail more loudly](https://github.com/fleetdm/fleet/issues/12305#issuecomment-1671924257) so that they can't be overlooked, even though this might slow down short-term development. + ## Why keep issue templates simple? + At Fleet, we optimize for the person submitting the issue, not the person receiving it. We avoid making the submitter read anything. We prompt for as little information as possible. Why? @@ -251,6 +268,7 @@ For example, here is the [philosophy behind Fleet's bug report template](https:/ ## Why spend less? + - **Default to efficiency. Reward richly.** At Fleet, we celebrate success and reward hard work. But we do everyday things cheap. And that is very important, because it shapes the kind of people we hire, and the kind of expectations we set for the team about what "comfortable" feels like. - **Offsites are not rewards.** Day to day, Fleet does not look rich. Rich !== welcoming. The company is open, not closed. Work here means flexible collaboration, accessible people, and clear expectations. And a rich, exciting future worth working for. Not a rich, complacent baseline worth coasting for. - **Minimally viable comfort.** We stay at La Quintas by the train tracks every single time unless customers are coming into the room and we need more space. Even then, we accommodate in the spirit of _hospitality_, not to show off how well Fleet is doing. They'll know how well we're doing by how great the product is, how great the support is, and [how that makes them feel](https://fleetdm.com/handbook/company#purpose). They'll remember openness, flexibility, accessibility, and clarity in all of their interactions with the brand. Not the view from our hotel rooms. @@ -258,6 +276,7 @@ For example, here is the [philosophy behind Fleet's bug report template](https:/ ## Why don't we sell like everyone else? + Many companies encourage salespeople to ["spray and pray"](https://www.linkedin.com/posts/amstech_the-rampant-abuse-of-linkedin-connections-activity-7178412289413246978-Ci0I?utm_source=share&utm_medium=member_ios) email blasts, and to do whatever it takes to close deals. This can sometimes be temporarily effective. But Fleet takes a [🟠longer-term](https://fleetdm.com/handbook/company#ownership) approach: - **No spam.** Fleet is deliberate and thoughtful in the way we do outreach, whether that's for community-building, education, or [🧊 conversation-starting](https://github.com/fleetdm/confidential/blob/main/cold-outbound-strategy.md). - **Be a helper.** We focus on [🔴being helpers](https://fleetdm.com/handbook/company#empathy). Always be depositing value. This is how we create a virtuous cycle. (That doesn't mean sharing a random article; it means genuinely hearing, doing whatever it takes to fully understand, and offering only advice or links that we would actually want.) We are genuinely curious and desperate to help, because creating real value for people is the way we win. @@ -268,39 +287,8 @@ Many companies encourage salespeople to ["spray and pray"](https://www.linkedin. - **Step up.** We look at the [🟠big picture](https://fleetdm.com/handbook/company#ownership). The goal is for the organization using Fleet to be successful, as well as the individuals who decide to use or buy the product. There are multiple versions of Fleet, and so many ways to "do" open-source security and IT. It is in the company's best interest to help engineers pick the right one; even if that's Fleet Free, or another solution altogether. We think about our customer's needs like they are our own. -## Why don't we track leads differently? -There are about as many "MQL" definitions as there are sales orgs in the world. Exaggerating here, but only somewhat. - -Fleet documents all KPI's with clear definitions that are simple to evaluate, easy to track, and highly iterable. - -- **Lead** == A "Lead" row in Salesforce. - -- **MQL** == a human from an in-ICP organization that meets these parameters when the lead is created: - - Their organization is _not_ already a Fleet customer - - Their organization is _not_ already considering buying Fleet as part of a qualified, mutually beneficial opportunity - - Our friend, the human, has chosen to open or widen their line of communication with the company. This could come from an event or or sending a contact form message requesting a call. - - Attendees at events are considered MQLs if they have done any of the following: - - Had a 5+ minute conversation or a badge scan at a physical event. - - RSVPed yes to extracurricular in-person side event (e.g. dinner or activity) OR attended the in-person side event and a Fleetie was able to track their attendance. - - RSVPed yes to a virtual event OR indicated intent by sending a follow-up email. - - Multiple people from the same org each count as _separate_ MQLs. - - When an account converts to an opportunity, all subsequent new leads created for that account are ***NOT*** MQLs (i.e. do not count towards "MQLs created".). If an opportunity is marked "closed lost", then it is _no longer_ open, so subsequent new leads associated with the prospective customer are considered MQLs again. - -- **Open MQL** == An MQL whose lead status is neither _"disqualified" nor "converted"_. - -- **SQL** == An MQL whose lead status in Salesforce has exceeded a _certain threshold_, for **any** reason, from **any** source (threshold TBD: we aren't reporting these yet in KPIs) - -- **Lead source** == where a lead came from. To determine attribution, we will consider the lead source. (e.g. sales-sourced vs. marketing-sourced vs. misc-sourced leads can be determined by looking at the lead source. No need to establish any other *QL or change these.) - - - Instead of saying _"outbound lead"_ or _"inbound lead"_, you can say _"a lead from a badge scan at an event"_ or _"a lead from a customer referral"_ or _"a lead from the website"_. - -- **Opportunity** == A _"Opportunity"_ row in Salesforce. - -- **Open opportunity** == An opportunity whose stats is not _"closed lost"_ nor _"closed won"_. - - - ## Why does Fleet support query packs? + As originally envisioned by Zach Wasserman and the team when creating osquery, packs are a way to import and export queries into (and out of!) any platform that speaks osquery, whether that's Fleet, [Security Onion](https://securityonionsolutions.com/), an EDR, or even Rapid7. Queries [should be portable](https://github.com/fleetdm/fleet/blob/f711e60de47c69ab8be5bc13cf73fedf88adc338/README.md#lighter-than-air) to minimize lock-in to particular tools. The "Packs" section of the UI that began in `kolide/fleet` c. 2017 was an early attempt to segment and target formations of hosts that share certain characteristics. This came with some difficulties with debugging and collaboration, since it could be hard to tell which queries were running on which hosts. It also made it harder to understand what performance impact running all those queries might cause. @@ -311,7 +299,9 @@ The first step was to add a simpler way to schedule queries, and tuck away the l Packs will always be supported in Fleet. + ## Why does Fleet use sentence case? + Fleet uses sentence case capitalization for all headings, subheadings, button text in the Fleet product, fleetdm.com, the documentation, the handbook, marketing material, direct emails, in Slack, and in every other conceivable situation. In sentence case, we write and capitalize words as if they were in sentences: @@ -322,7 +312,9 @@ As we use sentence case, only the first word is capitalized. But, if a word woul 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. + ## Why not use superlatives? + A superlative is an adjective or adverb that expresses the degree of a quality, such as "best," "worst," or "most beautiful." A superlative is a judgment or evaluation, [which only the customer can decide](https://twitter.com/mikermcneil/status/1686837625187930112). @@ -341,6 +333,7 @@ Avoid using too many unnecessary words or superlatives, so your writing is short ## Why does Fleet use "MDM on/off" instead of "MDM enrolled/unenrolled"? + MDM should be a capability, not a product category. In Fleet, the word "enrolled" means "the host shows up in the dashboard and API". @@ -351,7 +344,9 @@ Since Fleet is more than MDM, you can collect logs and health data on any comput That means you can collect logs from Linux servers or Windows factory workstations without enabling remote script execution on those computers, even if you're using script execution on your Macs. + ## Why not mention the CEO in Slack threads? + Everyone else who works at Fleet is expected to read (and reply or acknowledge with an emoji reaction) every time they're mentioned in Slack, even deep inside long threads. Now that the company has grown, the CEO gets mentioned in threads [too often](https://docs.google.com/document/d/1vK-Dy2BVrw7doYUzabOPyCiN4RfolWFgOKMm23l91s0/edit) to keep up with thread replies, even for threads he participates in. @@ -377,6 +372,7 @@ Thank you so much!" 🙇 #### Stubs + The following stubs are included only so that old links continue to work (for backwards compatibility.) ##### Reporting structure From 05e249c951216906b0ddbef9c1df75714b0d1fe1 Mon Sep 17 00:00:00 2001 From: Dante Catalfamo <43040593+dantecatalfamo@users.noreply.github.com> Date: Mon, 9 Sep 2024 15:26:21 -0400 Subject: [PATCH 07/28] Clearer type error for software VPP app IDs (#21760) #20828 This is a fix specific to the VPP App ID field in gitops. It's a field where users are likely to get the type wrong, so I've addressed this separately before a larger effort. --- changes/20828-better-appid-error | 1 + cmd/fleetctl/gitops_test.go | 5 +++-- .../gitops/team_vpp_incorrect_type.yml | 17 ++++++++++++++++ pkg/spec/gitops.go | 4 ++++ server/service/integration_mdm_test.go | 20 +++++++++++++++++++ server/service/software_installers.go | 14 +++++++++++++ 6 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 changes/20828-better-appid-error create mode 100644 cmd/fleetctl/testdata/gitops/team_vpp_incorrect_type.yml diff --git a/changes/20828-better-appid-error b/changes/20828-better-appid-error new file mode 100644 index 0000000000..540c8fcbfa --- /dev/null +++ b/changes/20828-better-appid-error @@ -0,0 +1 @@ +- Improve clarity of gitops VPP app ID type errors diff --git a/cmd/fleetctl/gitops_test.go b/cmd/fleetctl/gitops_test.go index a512d3a6a5..58fb94b1c6 100644 --- a/cmd/fleetctl/gitops_test.go +++ b/cmd/fleetctl/gitops_test.go @@ -1262,7 +1262,7 @@ func TestGitOpsTeamSofwareInstallers(t *testing.T) { {"testdata/gitops/team_software_installer_install_not_found.yml", "no such file or directory"}, {"testdata/gitops/team_software_installer_post_install_not_found.yml", "no such file or directory"}, {"testdata/gitops/team_software_installer_no_url.yml", "software URL is required"}, - {"testdata/gitops/team_software_installer_invalid_self_service_value.yml", "cannot unmarshal string into Go struct field SoftwarePackage.packages.self_service of type bool"}, + {"testdata/gitops/team_software_installer_invalid_self_service_value.yml", "\"packages.self_service\" must be a bool, found string"}, } for _, c := range cases { t.Run(filepath.Base(c.file), func(t *testing.T) { @@ -1311,7 +1311,7 @@ func TestGitOpsNoTeamSoftwareInstallers(t *testing.T) { {"testdata/gitops/no_team_software_installer_install_not_found.yml", "no such file or directory"}, {"testdata/gitops/no_team_software_installer_post_install_not_found.yml", "no such file or directory"}, {"testdata/gitops/no_team_software_installer_no_url.yml", "software URL is required"}, - {"testdata/gitops/no_team_software_installer_invalid_self_service_value.yml", "cannot unmarshal string into Go struct field SoftwarePackage.packages.self_service of type bool"}, + {"testdata/gitops/no_team_software_installer_invalid_self_service_value.yml", "\"packages.self_service\" must be a bool, found string"}, } for _, c := range cases { t.Run(filepath.Base(c.file), func(t *testing.T) { @@ -1383,6 +1383,7 @@ func TestGitOpsTeamVPPApps(t *testing.T) { {"testdata/gitops/team_vpp_valid_empty.yml", "", time.Now().Add(-24 * time.Hour)}, {"testdata/gitops/team_vpp_valid_app.yml", "VPP token expired", time.Now().Add(-24 * time.Hour)}, {"testdata/gitops/team_vpp_invalid_app.yml", "app not available on vpp account", time.Now().Add(24 * time.Hour)}, + {"testdata/gitops/team_vpp_incorrect_type.yml", "\"app_store_apps.app_store_id\" must be a string, found number", time.Now().Add(24 * time.Hour)}, {"testdata/gitops/team_vpp_empty_adamid.yml", "software app store id required", time.Now().Add(24 * time.Hour)}, } diff --git a/cmd/fleetctl/testdata/gitops/team_vpp_incorrect_type.yml b/cmd/fleetctl/testdata/gitops/team_vpp_incorrect_type.yml new file mode 100644 index 0000000000..74e7b17806 --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/team_vpp_incorrect_type.yml @@ -0,0 +1,17 @@ +name: "${TEST_TEAM_NAME}" +team_settings: + secrets: + - secret: "ABC" + features: + enable_host_users: true + enable_software_inventory: true + host_expiry_settings: + host_expiry_enabled: true + host_expiry_window: 30 +agent_options: +controls: +policies: +queries: +software: + app_store_apps: + - app_store_id: 1 diff --git a/pkg/spec/gitops.go b/pkg/spec/gitops.go index ada6c8bb71..558d7a1f06 100644 --- a/pkg/spec/gitops.go +++ b/pkg/spec/gitops.go @@ -599,6 +599,10 @@ func parseSoftware(top map[string]json.RawMessage, result *GitOps, baseDir strin var software Software if len(softwareRaw) > 0 { if err := json.Unmarshal(softwareRaw, &software); err != nil { + var typeErr *json.UnmarshalTypeError + if errors.As(err, &typeErr) { + return multierror.Append(multiError, fmt.Errorf("Couldn't edit software. %q must be a %s, found %s", typeErr.Field, typeErr.Type.String(), typeErr.Value)) + } return multierror.Append(multiError, fmt.Errorf("failed to unmarshall softwarespec: %v", err)) } } diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index 3519a4c9af..63ab882da1 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -9836,6 +9836,26 @@ func (s *integrationMDMTestSuite) TestBatchAssociateAppStoreApps() { // Remove all vpp associations from team with no members s.Do("POST", batchURL, batchAssociateAppStoreAppsRequest{}, http.StatusNoContent, "team_name", tmGood.Name) + // Incorrect type check + incorrectTypes := struct { + Apps []struct { + AppStoreID int `json:"app_store_id"` + SelfService bool `json:"self_service"` + } `json:"app_store_apps"` + }{ + Apps: []struct { + AppStoreID int `json:"app_store_id"` + SelfService bool `json:"self_service"` + }{ + { + AppStoreID: 1, + }, + }, + } + badTypeReq := s.Do("POST", batchURL, incorrectTypes, http.StatusBadRequest, "team_name", tmGood.Name) + badTypeBody := extractServerErrorText(badTypeReq.Body) + assert.Contains(t, badTypeBody, "must be a string") + // Associating an app we don't own s.Do("POST", batchURL, batchAssociateAppStoreAppsRequest{Apps: []fleet.VPPBatchPayload{{AppStoreID: "fake-app"}}}, http.StatusUnprocessableEntity, "team_name", tmGood.Name) diff --git a/server/service/software_installers.go b/server/service/software_installers.go index e2b36a2de2..9862ade671 100644 --- a/server/service/software_installers.go +++ b/server/service/software_installers.go @@ -2,12 +2,15 @@ package service import ( "context" + "crypto/x509" + "encoding/json" "errors" "fmt" "io" "mime/multipart" "net" "net/http" + "net/url" "strconv" "github.com/docker/go-units" @@ -457,6 +460,17 @@ type batchAssociateAppStoreAppsRequest struct { Apps []fleet.VPPBatchPayload `json:"app_store_apps"` } +func (b *batchAssociateAppStoreAppsRequest) DecodeBody(ctx context.Context, r io.Reader, u url.Values, c []*x509.Certificate) error { + if err := json.NewDecoder(r).Decode(b); err != nil { + var typeErr *json.UnmarshalTypeError + if errors.As(err, &typeErr) { + return ctxerr.Wrap(ctx, fleet.NewUserMessageError(fmt.Errorf("Couldn't edit software. %q must be a %s, found %s", typeErr.Field, typeErr.Type.String(), typeErr.Value), http.StatusBadRequest)) + } + } + + return nil +} + type batchAssociateAppStoreAppsResponse struct { Err error `json:"error,omitempty"` } From 078c0ac3b70f4620ec0294c3a65817d4ce5558e8 Mon Sep 17 00:00:00 2001 From: Roberto Dip Date: Mon, 9 Sep 2024 17:03:20 -0300 Subject: [PATCH 08/28] document and use MDM SSO settings via gitops (#21869) for #21313 --- .github/workflows/dogfood-gitops.yml | 1 + docs/Configuration/yaml-files.md | 19 +++++++++++++++++-- it-and-security/default.yml | 4 ++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/.github/workflows/dogfood-gitops.yml b/.github/workflows/dogfood-gitops.yml index 8b2e3217c0..487cdac1ad 100644 --- a/.github/workflows/dogfood-gitops.yml +++ b/.github/workflows/dogfood-gitops.yml @@ -69,6 +69,7 @@ jobs: DOGFOOD_GLOBAL_ENROLL_SECRET: ${{ secrets.DOGFOOD_GLOBAL_ENROLL_SECRET }} DOGFOOD_SSO_ISSUER_URI: ${{ secrets.DOGFOOD_SSO_ISSUER_URI }} DOGFOOD_SSO_METADATA: ${{ secrets.DOGFOOD_SSO_METADATA }} + DOGFOOD_MDM_SSO_METADATA_URL: ${{ secrets.DOGFOOD_MDM_SSO_METADATA_URL }} DOGFOOD_FAILING_POLICIES_WEBHOOK_URL: ${{ secrets.DOGFOOD_FAILING_POLICIES_WEBHOOK_URL }} DOGFOOD_VULNERABILITIES_WEBHOOK_URL: ${{ secrets.DOGFOOD_VULNERABILITIES_WEBHOOK_URL }} DOGFOOD_WORKSTATIONS_ENROLL_SECRET: ${{ secrets.DOGFOOD_WORKSTATIONS_ENROLL_SECRET }} diff --git a/docs/Configuration/yaml-files.md b/docs/Configuration/yaml-files.md index a23d84b0b2..be73c1ff29 100644 --- a/docs/Configuration/yaml-files.md +++ b/docs/Configuration/yaml-files.md @@ -273,14 +273,16 @@ Use `labels_include_all` to only apply (scope) profiles to hosts that have all t #### macos_setup -The `macos_setup` section lets you control the [end user migration workflow](https://fleetdm.com/docs/using-fleet/mdm-migration-guide#end-user-workflow) for macOS hosts that automatically enrolled to your old MDM solution. +The `macos_setup` section lets you control the out-of-the-box macOS [setup experience](https://fleetdm.com/guides/macos-setup-experience) for hosts that use Automated Device Enrollment (ADE). - `bootstrap_package` is the URL to a bootstap package. Fleet will download the bootstrap package (default: `""`). - `enable_end_user_authentication` specifies whether or not to require end user authentication when the user first sets up their macOS host. -- `macos_setup_assistant` is a path to a custom automatic enrollment (DEP) profile (.json). +- `macos_setup_assistant` is a path to a custom automatic enrollment (ADE) profile (.json). #### macos_migration +The `macos_migration` section lets you control the [end user migration workflow](https://fleetdm.com/docs/using-fleet/mdm-migration-guide#end-user-workflow) for macOS hosts that enrolled to your old MDM solution. + - `enable` specifies whether or not to enable end user migration workflow (default: `false`) - `mode` specifies whether the end user initiates migration (`voluntary`) or they're nudged every 15-20 minutes to migrate (`forced`) (default: `""`). - `webhook_url` is the URL that Fleet sends a webhook to when the end user selects **Start**. Receive this webhook using your automation tool (ex. Tines) to unenroll your end users from your old MDM solution. @@ -585,6 +587,19 @@ org_settings: Can only be configured for all teams (`org_settings`). +##### end_user_authentication + +The `end_user_authentication` section lets you define the identity provider (IdP) settings used for end user authentication during Automated Device Enrollment (ADE). Learn more about end user authentication in Fleet [here](https://fleetdm.com/guides/macos-setup-experience#end-user-authentication-and-eula). + +Once the IdP settings are configured, you can use the [`controls.macos_setup.enable_end_user_authentication`](#macos_setup) key to control the end user experience during ADE. + +- `idp_name` is the human-friendly name for the identity provider that will provide single sign-on authentication (default: `""`). +- `entity_id` is the entity ID: a Uniform Resource Identifier (URI) that you use to identify Fleet when configuring the identity provider. It must exactly match the Entity ID field used in identity provider configuration (default: `""`). +- `metadata` is the metadata (in XML format) provided by the identity provider. (default: `""`) +- `metadata_url` is the URL that references the identity provider metadata. Only one of `metadata` or `metadata_url` is required (default: `""`). + +Can only be configured for all teams (`org_settings`). + diff --git a/it-and-security/default.yml b/it-and-security/default.yml index 9d1b126ebf..52baadb564 100644 --- a/it-and-security/default.yml +++ b/it-and-security/default.yml @@ -38,6 +38,10 @@ org_settings: zendesk: [ ] mdm: apple_bm_default_team: $DOGFOOD_APPLE_BM_DEFAULT_TEAM + end_user_authentication: + entity_id: dogfood-eula.fleetdm.com + idp_name: Google Workspace + metadata_url: $DOGFOOD_MDM_SSO_METADATA_URL org_info: contact_url: https://fleetdm.com/company/contact org_logo_url: "" From e6e509059612daf7009b4c485a4038718af8717f Mon Sep 17 00:00:00 2001 From: Isabell Reedy <113355639+ireedy@users.noreply.github.com> Date: Mon, 9 Sep 2024 16:09:39 -0400 Subject: [PATCH 09/28] Add a step to review comp for fleetie role changes (#21924) --- handbook/business-operations/README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/handbook/business-operations/README.md b/handbook/business-operations/README.md index 5635ad4bbd..11bc287b17 100644 --- a/handbook/business-operations/README.md +++ b/handbook/business-operations/README.md @@ -267,7 +267,9 @@ When BizOps receives notification of a Fleetie's job title changing, follow thes - Input the new job title in the Fleetie's row in the "Job title" cell. - Navigate to the "Org chart" tab of the spreadsheet, and verify that the Fleetie's title appears correctly in the org chart. 2. Update the departmental handbook page with the change of job title -3. Update the relevant payroll/HRIS system. +3. [Prepare salary benchmarking information](https://fleetdm.com/handbook/business-operations#prepare-salary-benchmarking-information) to determine whether the teammate's current compensation aligns with the benchmarks of the new role. + - If the benchmark is significantly different, take the steps to [update a team member's compensation](https://fleetdm.com/handbook/business-operations#prepare-salary-benchmarking-information). +4. Update the relevant payroll/HRIS system. - For updating Gusto (US-based Fleeties): - Login to Gusto and navigate to "People > Team members". - Find the Fleetie and select them to see their profile page. From fbbd3fdd2f365105c50ed72000cfd854b82382ee Mon Sep 17 00:00:00 2001 From: Jahziel Villasana-Espinoza Date: Mon, 9 Sep 2024 17:49:31 -0400 Subject: [PATCH 10/28] fix: add missing part of step (#21930) # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Manual QA for all new/changed functionality --- articles/macos-mdm-setup.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/articles/macos-mdm-setup.md b/articles/macos-mdm-setup.md index 28252b62d1..bc91ee6a72 100644 --- a/articles/macos-mdm-setup.md +++ b/articles/macos-mdm-setup.md @@ -31,7 +31,7 @@ banner at the top of page reminding you to renew your token. To renew an ABM token: 1. Navigate to the **Settings > Integrations > Mobile device management (MDM)** page. -2. Under "Automatic enrollment", click "Edit", and then fin +2. Under "Automatic enrollment", click "Edit", and then find the token that you want to renew. Token status is indicated in the "Renew date" column: tokens less than 30 days from expiring will have a yellow indicator, and expired tokens will have a red indicator. Click the "Actions" dropdown for the token and then click "Renew". Follow the instructions in the modal to download a new token from Apple Business Manager and then upload the new token to Fleet. After connecting Fleet to ABM, set Fleet to be the MDM for all Macs: From ca4d45f400d045e2cbe6b13b33264adda2654108 Mon Sep 17 00:00:00 2001 From: Luke Heath Date: Tue, 10 Sep 2024 10:04:37 -0500 Subject: [PATCH 11/28] Remove devices from demo policy (#21932) --- it-and-security/teams/workstations-canary.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/it-and-security/teams/workstations-canary.yml b/it-and-security/teams/workstations-canary.yml index 8e5cd6c48e..518b32d94e 100644 --- a/it-and-security/teams/workstations-canary.yml +++ b/it-and-security/teams/workstations-canary.yml @@ -131,7 +131,7 @@ policies: platform: darwin calendar_events_enabled: false - name: macOS - System maintenance complete - query: SELECT 1 AS result FROM system_info WHERE computer_name NOT IN ('Titanosauria', 'Drew’s MacBook Pro','fleetwoodmike','Anthony’s MacBook Pro','Patricia’s MacBook Pro','Paul’s MacBook Pro','Tom’s MacBook Air'); + query: SELECT 1 AS result FROM system_info WHERE computer_name NOT IN ('Drew’s MacBook Pro','Anthony’s MacBook Pro','Patricia’s MacBook Pro','Paul’s MacBook Pro','Tom’s MacBook Air'); critical: false description: Determines if the device has completed system maintenance. resolution: We will perform system maintenance on your device. From 299a9e100855e6962159fe786ad93f1c54796b5b Mon Sep 17 00:00:00 2001 From: Dante Catalfamo <43040593+dantecatalfamo@users.noreply.github.com> Date: Tue, 10 Sep 2024 11:25:11 -0400 Subject: [PATCH 12/28] Improve error message for `controls.windows_enable_and_configure` (#21944) #21402 --- changes/21402-improve-windows-mdm-enabled-error-message | 1 + server/service/appconfig.go | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 changes/21402-improve-windows-mdm-enabled-error-message diff --git a/changes/21402-improve-windows-mdm-enabled-error-message b/changes/21402-improve-windows-mdm-enabled-error-message new file mode 100644 index 0000000000..36dc6082f6 --- /dev/null +++ b/changes/21402-improve-windows-mdm-enabled-error-message @@ -0,0 +1 @@ +- Improve gitops error message about enabling windows MDM diff --git a/server/service/appconfig.go b/server/service/appconfig.go index 236564eff0..f384dec0d5 100644 --- a/server/service/appconfig.go +++ b/server/service/appconfig.go @@ -836,7 +836,7 @@ func (svc *Service) validateMDM( len(mdm.WindowsSettings.CustomSettings.Value) > 0 && !fleet.MDMProfileSpecsMatch(mdm.WindowsSettings.CustomSettings.Value, oldMdm.WindowsSettings.CustomSettings.Value) { invalid.Append("windows_settings.custom_settings", - `Couldn’t edit windows_settings.custom_settings. Windows MDM isn’t turned on. Visit https://fleetdm.com/docs/using-fleet to learn how to turn on MDM.`) + `Couldn’t edit windows_settings.custom_settings. Windows MDM isn’t turned on. This can be enabled by setting "controls.windows_enabled_and_configured: true" in the default configuration. Visit https://fleetdm.com/guides/windows-mdm-setup and https://fleetdm.com/docs/configuration/yaml-files#controls to learn more about enabling MDM.`) } } checkCustomSettings("windows", mdm.WindowsSettings.CustomSettings.Value) From 8277a80e3db3b52639d53cfbfac6396144ff1d8a Mon Sep 17 00:00:00 2001 From: Noah Talerman <47070608+noahtalerman@users.noreply.github.com> Date: Tue, 10 Sep 2024 12:14:57 -0400 Subject: [PATCH 13/28] Product Designer is responsible for adding product group label (#21927) - Product group labels: `#g-mdm`, `#g-endpoint-ops` - So we know which EM to assign it to - So the EM knows their product group will build it --- handbook/product-design/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/handbook/product-design/README.md b/handbook/product-design/README.md index 30d1dcece0..9ed61bbc9c 100644 --- a/handbook/product-design/README.md +++ b/handbook/product-design/README.md @@ -67,7 +67,7 @@ At Fleet, like [GitLab](https://about.gitlab.com/handbook/product-development-fl ### Ensure story drafting is complete -Once a story is approved in [design review](https://fleetdm.com/handbook/company/product-groups#design-reviews), the Product Designer is responsible for moving the user story to the "Ready to spec" column, assigning the appropriate Engineering Manager (EM), and changing the status on the cover page of the relevant Figma file to "Approved". +Once a story is approved in [design review](https://fleetdm.com/handbook/company/product-groups#design-reviews), the Product Designer is responsible for moving the user story to the "Ready to spec" column, assigning the appropriate Engineering Manager (EM), adding a product group label, and changing the status on the cover page of the relevant Figma file to "Approved". The EM is responsible for moving the user story to the "Specified" and "Estimated" columns. From 5df6db808a55e04212e7b0950687f1d15d695008 Mon Sep 17 00:00:00 2001 From: Roberto Dip Date: Tue, 10 Sep 2024 14:44:51 -0300 Subject: [PATCH 14/28] update loadtesting documentation for OTA (#21950) thiw new env var is being added in https://github.com/fleetdm/fleet/pull/21942 --- infrastructure/loadtesting/terraform/readme.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/infrastructure/loadtesting/terraform/readme.md b/infrastructure/loadtesting/terraform/readme.md index 378d9eaf35..bfa120bfa1 100644 --- a/infrastructure/loadtesting/terraform/readme.md +++ b/infrastructure/loadtesting/terraform/readme.md @@ -61,13 +61,14 @@ If you need to run a load test with MDM enabled and configured you will need to 2. Then set the `fleet_config` terraform var the following way (make sure to add any extra configuration you need to this JSON): ```sh -export TF_VAR_fleet_config='{"FLEET_DEV_MDM_APPLE_DISABLE_PUSH":"1","FLEET_MDM_APPLE_SCEP_CHALLENGE":"foobar","FLEET_MDM_APPLE_SCEP_CERT_BYTES":"'$(cat /Users/foobar/mdm/fleet-mdm-apple-scep.crt | gsed -z 's/\n/\\n/g')'","FLEET_MDM_APPLE_SCEP_KEY_BYTES":"'$(cat /Users/foobar/mdm/fleet-mdm-apple-scep.key | gsed -z 's/\n/\\n/g')'","FLEET_MDM_APPLE_APNS_CERT_BYTES":"'$(cat /Users/foobar/mdm/mdmcert.download.push.pem | gsed -z 's/\n/\\n/g')'","FLEET_MDM_APPLE_APNS_KEY_BYTES":"'$(cat /Users/foobar/mdm/mdmcert.download.push.key | gsed -z 's/\n/\\n/g')'","FLEET_MDM_APPLE_BM_SERVER_TOKEN_BYTES":"'$(cat /Users/foobar/mdm/downloadtoken.p7m | gsed -z 's/\n/\\n/g' | gsed 's/"smime\.p7m"/\\"smime.p7m\\"/g' | tr -d '\r\n')'","FLEET_MDM_APPLE_BM_CERT_BYTES":"'$(cat /Users/foobar/mdm/fleet-apple-mdm-bm-public-key.crt | gsed -z 's/\n/\\n/g')'","FLEET_MDM_APPLE_BM_KEY_BYTES":"'$(cat /Users/foobar/mdm/fleet-apple-mdm-bm-private.key | gsed -z 's/\n/\\n/g')'"}' +export TF_VAR_fleet_config='{"FLEET_DEV_MDM_APPLE_DISABLE_PUSH":"1","FLEET_DEV_MDM_APPLE_DISABLE_DEVICE_INFO_CERT_VERIFY":"1","FLEET_MDM_APPLE_SCEP_CHALLENGE":"foobar","FLEET_MDM_APPLE_SCEP_CERT_BYTES":"'$(cat /Users/foobar/mdm/fleet-mdm-apple-scep.crt | gsed -z 's/\n/\\n/g')'","FLEET_MDM_APPLE_SCEP_KEY_BYTES":"'$(cat /Users/foobar/mdm/fleet-mdm-apple-scep.key | gsed -z 's/\n/\\n/g')'","FLEET_MDM_APPLE_APNS_CERT_BYTES":"'$(cat /Users/foobar/mdm/mdmcert.download.push.pem | gsed -z 's/\n/\\n/g')'","FLEET_MDM_APPLE_APNS_KEY_BYTES":"'$(cat /Users/foobar/mdm/mdmcert.download.push.key | gsed -z 's/\n/\\n/g')'","FLEET_MDM_APPLE_BM_SERVER_TOKEN_BYTES":"'$(cat /Users/foobar/mdm/downloadtoken.p7m | gsed -z 's/\n/\\n/g' | gsed 's/"smime\.p7m"/\\"smime.p7m\\"/g' | tr -d '\r\n')'","FLEET_MDM_APPLE_BM_CERT_BYTES":"'$(cat /Users/foobar/mdm/fleet-apple-mdm-bm-public-key.crt | gsed -z 's/\n/\\n/g')'","FLEET_MDM_APPLE_BM_KEY_BYTES":"'$(cat /Users/foobar/mdm/fleet-apple-mdm-bm-private.key | gsed -z 's/\n/\\n/g')'"}' ``` - The above is needed because the newline characters in the certificate/key/token files. - The value set in `FLEET_MDM_APPLE_SCEP_CHALLENGE` must match whatever you set in `osquery-perf`'s `mdm_scep_challenge` argument. - The above `export TF_VAR_fleet_config=...` command was tested on `bash`. It did not work in `zsh`. - Note that we are also setting `FLEET_DEV_MDM_APPLE_DISABLE_PUSH=1`. We don't want to generate push notifications against fake UUIDs (otherwise it may cause Apple to rate limit due to invalid requests). +- Note that we are also setting `FLEET_DEV_MDM_APPLE_DISABLE_DEVICE_INFO_CERT_VERIFY=1` to skip verification of Apple certificates for OTA enrollments. This has an impact on real devices because they will not be notified of any command to execute (it may take a reboot for them to reach out to Fleet for more commands). 3. Add the following `osquery-perf` arguments to [loadtesting.tf](./loadtesting.tf) From 4eb1c60402645fd0945824ed8b5eaeb9be0a6506 Mon Sep 17 00:00:00 2001 From: Sam Pfluger <108141731+Sampfluger88@users.noreply.github.com> Date: Tue, 10 Sep 2024 13:52:48 -0500 Subject: [PATCH 15/28] Fix Slack channel naming to match temp channels format (#21953) --- handbook/company/leadership.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/handbook/company/leadership.md b/handbook/company/leadership.md index 4afb95d4fc..a567aeec99 100644 --- a/handbook/company/leadership.md +++ b/handbook/company/leadership.md @@ -255,7 +255,7 @@ When review is requested on a proposal to open a new position, the Apprentice to - _Update team database:_ Update the row in ["¶¶ 🥧 Equity plan"](https://docs.google.com/spreadsheets/d/1_GJlqnWWIQBiZFOoyl9YbTr72bg5qdSSp4O3kuKm1Jc/edit#gid=0) using the benchmarked compensation and share count. - _Salary:_ Enter the salary: If the role has variable compensation, use the role's OTE (on-target earning estimate) as the budgeted salary amount, and leave a note in the "Notes (¶¶)" cell clarifying the role's bonus or commission structure. - _Equity:_ Enter the equity as a number of shares, watching the percentage that is automatically calculated in the next cell. Keep guessing different numbers of shares until you get the derived percentage looking like what you want to see. - - _Create Slack channel:_ Create a private "#hiring-xxxxxx-YYYY" Slack channel (where "xxxxxx" is the job title and YYYY is the current year) for discussion and invite the hiring manager and Head of Business Operations. + - _Create Slack channel:_ Create a private "#YYYY-hiring-xxxxxx" Slack channel (where "xxxxxx" is the job title and YYYY is the current year) for discussion and invite the hiring manager and Head of Business Operations. - _Publish opening:_ Approve and merge the pull request. The job posting will go live within ≤10 minutes. - _Track as approved in "Fleeties":_ In the "Fleeties" spreadsheet, find the row for the new position and update the "Job description" column and replace the URL of the pull request that originally proposed this new position with the URL of the GitHub merge commit when that PR was merged. - _Reply to requestor:_ Post a comment on the pull request, being sure to include a direct link to their live job description on fleetdm.com. (This is the URL where candidates can go to read about the job and apply. For example: `fleetdm.com/handbook/company/product-designer`): From 0fb66e58affbb8f6ed7bc6ab7ce4740cc1c86c0a Mon Sep 17 00:00:00 2001 From: Eric Date: Tue, 10 Sep 2024 14:30:30 -0500 Subject: [PATCH 16/28] Schema: show iptables table documentation (#21956) Related to: https://github.com/fleetdm/fleet/issues/15651 Changes: - Removed `hidden: true` from the YAML override file for the iptables table. - Regenerated osquery_fleet_schema.json --- schema/osquery_fleet_schema.json | 1 - schema/tables/iptables.yml | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/schema/osquery_fleet_schema.json b/schema/osquery_fleet_schema.json index 097534dbd1..db3567e3dc 100644 --- a/schema/osquery_fleet_schema.json +++ b/schema/osquery_fleet_schema.json @@ -12799,7 +12799,6 @@ "index": false } ], - "hidden": true, "fleetRepoUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/iptables.yml" }, { diff --git a/schema/tables/iptables.yml b/schema/tables/iptables.yml index 8e560b85a0..564ecfeb86 100644 --- a/schema/tables/iptables.yml +++ b/schema/tables/iptables.yml @@ -1,2 +1 @@ -name: iptables -hidden: true +name: iptables \ No newline at end of file From 9566b7d320c87a14e76afbe1335cb32be3db5ead Mon Sep 17 00:00:00 2001 From: Eric Date: Tue, 10 Sep 2024 14:36:01 -0500 Subject: [PATCH 17/28] Website: requested changes from website backend code review (#21934) Related to: #21918 Changes: - Changed the name of the query string the endpoint ops page uses for personalization to be `purpose` (previously `pageMode`) - Updated `update-or-create-contact-and-account` to only allow automated psychological stage regressions caused by the get started questionnaire. - Updated deliver-talk-to-us-form-submission to always set a psychological stage. (That now may not be set depending on the contact records psychological stage) - Updated the `update-or-create-contact-and-account` helper to assert that the number of employees returned from the `get-enriched` helper is always a number. - Updated the version of `sails-hooks-organics` the website uses and updated the inputs to the sendgrid sendTemplateEmail helper. --- .../deliver-contact-form-message.js | 2 +- .../deliver-talk-to-us-form-submission.js | 16 +++------------- website/api/controllers/view-endpoint-ops.js | 6 +++--- .../update-or-create-contact-and-account.js | 19 +++++++++++++++++-- website/api/helpers/send-template-email.js | 4 ++-- website/package.json | 2 +- website/views/pages/homepage.ejs | 4 ++-- 7 files changed, 29 insertions(+), 24 deletions(-) diff --git a/website/api/controllers/deliver-contact-form-message.js b/website/api/controllers/deliver-contact-form-message.js index 28cca1438b..ea5d6251ec 100644 --- a/website/api/controllers/deliver-contact-form-message.js +++ b/website/api/controllers/deliver-contact-form-message.js @@ -75,7 +75,7 @@ module.exports = { to: sails.config.custom.fromEmailAddress, replyTo: { name: firstName + ' '+ lastName, - email: emailAddress, + emailAddress: emailAddress, }, subject: 'New contact form message', layout: false, diff --git a/website/api/controllers/deliver-talk-to-us-form-submission.js b/website/api/controllers/deliver-talk-to-us-form-submission.js index aae36db70f..a91c7202e3 100644 --- a/website/api/controllers/deliver-talk-to-us-form-submission.js +++ b/website/api/controllers/deliver-talk-to-us-form-submission.js @@ -73,18 +73,7 @@ module.exports = { if(_.includes(sails.config.custom.bannedEmailDomainsForWebsiteSubmissions, emailDomain.toLowerCase())){ throw 'invalidEmailDomain'; } - // Set a default psychological stage and change reason. - let psyStageAndChangeReason = { - psychologicalStage: '4 - Has use case', - psychologicalStageChangeReason: 'Website - Contact forms' - }; - if(this.req.me){ - // If this user is logged in, check their current psychological stage, and if it is higher than 4, we won't set a psystage. - // This way, if a user has a psytage >4, we won't regress their psystage because they submitted this form. - if(['4 - Has use case', '5 - Personally confident', '6 - Has team buy-in'].includes(this.req.me.psychologicalStage)) { - psyStageAndChangeReason = {}; - } - } + if(numberOfHosts >= 700){ sails.helpers.salesforce.updateOrCreateContactAndAccount.with({ emailAddress: emailAddress, @@ -94,7 +83,8 @@ module.exports = { primaryBuyingSituation: primaryBuyingSituation === 'eo-security' ? 'Endpoint operations - Security' : primaryBuyingSituation === 'eo-it' ? 'Endpoint operations - IT' : primaryBuyingSituation === 'mdm' ? 'Device management (MDM)' : primaryBuyingSituation === 'vm' ? 'Vulnerability management' : undefined, contactSource: 'Website - Contact forms', description: `Submitted the "Talk to us" form and was taken to the Calendly page for the "Talk to us" event.`, - ...psyStageAndChangeReason// Only (potentially) set psystage and change reason for >700 hosts. + psychologicalStage: '4 - Has use case', + psychologicalStageChangeReason: 'Website - Contact forms' }).exec((err)=>{ if(err) { sails.log.warn(`Background task failed: When a user submitted the "Talk to us" form, a lead/contact could not be updated in the CRM for this email address: ${emailAddress}.`, err); diff --git a/website/api/controllers/view-endpoint-ops.js b/website/api/controllers/view-endpoint-ops.js index fbeeda63ae..7e1f33c5ac 100644 --- a/website/api/controllers/view-endpoint-ops.js +++ b/website/api/controllers/view-endpoint-ops.js @@ -24,12 +24,12 @@ module.exports = { let testimonialsForScrollableTweets = _.clone(sails.config.builtStaticContent.testimonials); // Default the pagePersonalization to the user's primaryBuyingSituation. let pagePersonalization = this.req.session.primaryBuyingSituation; - // If a pageMode query parameter is set, update the pagePersonalization value. + // If a purpose query parameter is set, update the pagePersonalization value. // Note: This is the only page we're using this method instead of using the primaryBuyingSiutation value set in the users session. // This lets us link to the security and IT versions of the endpoint ops page from the unpersonalized homepage without changing the users primaryBuyingSituation. - if(this.req.param('pageMode') === 'it'){ + if(this.req.param('purpose') === 'it'){ pagePersonalization = 'eo-it'; - } else if(this.req.param('pageMode') === 'security'){ + } else if(this.req.param('purpose') === 'security'){ pagePersonalization = 'eo-security'; } diff --git a/website/api/helpers/salesforce/update-or-create-contact-and-account.js b/website/api/helpers/salesforce/update-or-create-contact-and-account.js index 051484e979..41ebd76efe 100644 --- a/website/api/helpers/salesforce/update-or-create-contact-and-account.js +++ b/website/api/helpers/salesforce/update-or-create-contact-and-account.js @@ -133,6 +133,21 @@ module.exports = { if(description && existingContactRecord.Description) { valuesToSet.Description = existingContactRecord.Description + '\n' + description; } + // Check the existing contact record's psychologicalStage. + if(psychologicalStage) { + let recordsCurrentPsyStage = existingContactRecord.Stage__c; + // Because each psychological stage starts with a number, we'll get the first character in the record's current psychological stage and the new psychological stage to make comparison easier. + let psyStageStageNumberToChangeTo = Number(psychologicalStage[0]); + let recordsCurrentPsyStageNumber = Number(recordsCurrentPsyStage[0]); + if(psyStageStageNumberToChangeTo < recordsCurrentPsyStageNumber) { + // If a psychological stage regression is caused by anything other than the start flow, remove the updated value. + // This is done to prevent automated psyStage regressions caused by users taking other action on the website. (e.g, Booking a meeting or requesting Fleet swag.) + if(psychologicalStageChangeReason && psychologicalStageChangeReason !== 'Website - Organic start flow') { + delete valuesToSet.Stage__c; + delete valuesToSet.Psystage_change_reason__c; + } + } + } // console.log(`Exisitng contact found! ${existingContactRecord.Id}`); // If we found an existing contact, we'll update it with the information provided. salesforceContactId = existingContactRecord.Id; @@ -193,7 +208,7 @@ module.exports = { // Create a timestamp to use for the new account's assigned date. let today = new Date(); let nowOn = today.toISOString().replace('Z', '+0000'); - + require('assert')(typeof enrichmentData.employer.numberOfEmployees === 'number'); let newAccountRecord = await salesforceConnection.sobject('Account') .create({ Account_Assigned_date__c: nowOn,// eslint-disable-line camelcase @@ -204,7 +219,7 @@ module.exports = { Name: enrichmentData.employer.organization,// IFWMIH: We know organization exists Website: enrichmentData.employer.emailDomain, LinkedIn_company_URL__c: enrichmentData.employer.linkedinCompanyPageUrl,// eslint-disable-line camelcase - NumberOfEmployees: Number(enrichmentData.employer.numberOfEmployees), + NumberOfEmployees: enrichmentData.employer.numberOfEmployees, OwnerId: salesforceAccountOwnerId }); salesforceAccountId = newAccountRecord.id; diff --git a/website/api/helpers/send-template-email.js b/website/api/helpers/send-template-email.js index ba5bac75b7..ab2f780002 100644 --- a/website/api/helpers/send-template-email.js +++ b/website/api/helpers/send-template-email.js @@ -58,12 +58,12 @@ module.exports = { replyTo: { description: 'The reply to email address.', example: { - email: 'anne.m.martin@example.com', + emailAddress: 'anne.m.martin@example.com', name: 'Anne M. Martin' }, type: { + emailAddress: 'string', name: 'string', - email: 'string', } }, diff --git a/website/package.json b/website/package.json index f478e37458..da6d9abee2 100644 --- a/website/package.json +++ b/website/package.json @@ -13,7 +13,7 @@ "moment": "2.29.4", "sails": "^1.5.11", "sails-hook-apianalytics": "^2.0.6", - "sails-hook-organics": "^2.2.3", + "sails-hook-organics": "^3.0.0", "sails-hook-orm": "^4.0.3", "sails-hook-sockets": "^3.0.0", "sails-postgresql": "^5.0.1" diff --git a/website/views/pages/homepage.ejs b/website/views/pages/homepage.ejs index d21fd43c39..7b113f4a13 100644 --- a/website/views/pages/homepage.ejs +++ b/website/views/pages/homepage.ejs @@ -62,7 +62,7 @@ Osquery on easy mode

Use "read-only" mode or enable remote scripting to automate anything on every operating system, including Linux.

@@ -80,7 +80,7 @@ Ship data to any platform

Ship logs to any platform like Splunk, Snowflake, or any streaming infrastructure like AWS Kinesis and Apache Kafka.

- Start with security engineering + Start with security engineering
From 05818902cd66cb8dcc6f2dd9973efcfefaabef20 Mon Sep 17 00:00:00 2001 From: Roberto Dip Date: Tue, 10 Sep 2024 16:52:17 -0300 Subject: [PATCH 18/28] implement OTA enrollment (#21942) for #21019 # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Committing-Changes.md#changes-files) for more information. - [x] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) - [x] Added/updated tests - [x] Manual QA for all new/changed functionality --- changes/21019-ota-enrollment | 1 + cmd/fleet/serve_test.go | 2 +- .../IosIpadosPanel/IosIpadosPanel.tsx | 4 +- go.mod | 3 +- go.sum | 2 + pkg/mdm/mdmtest/apple.go | 198 ++++++++++-- server/datastore/mysql/apple_mdm.go | 291 +++++++++++------- server/datastore/mysql/apple_mdm_test.go | 64 ++++ server/datastore/mysql/testing_utils.go | 2 +- server/fleet/apple_mdm_test.go | 2 +- server/fleet/datastore.go | 4 + server/fleet/service.go | 26 ++ server/mdm/apple/AppleIphoneDeviceCA.cer | Bin 0 -> 877 bytes server/mdm/apple/apple_mdm.go | 56 ++++ server/mdm/apple/commander_test.go | 2 +- server/mdm/apple/deviceinfo.go | 84 +++-- server/mdm/apple/gdmf/api.go | 3 +- server/mdm/apple/gdmf/api_test.go | 26 +- server/mdm/apple/mobileconfig/profiles.go | 2 +- server/mdm/assets/assets_test.go | 2 +- server/mdm/mdm.go | 2 +- server/mdm/microsoft/microsoft_mdm.go | 2 +- server/mdm/microsoft/wstep.go | 2 +- server/mdm/nanodep/tokenpki/parse.go | 2 +- server/mdm/nanomdm/cryptoutil/cryptoutil.go | 2 +- .../mdm/nanomdm/cryptoutil/cryptoutil_test.go | 2 +- server/mdm/scep/scep/scep.go | 2 +- server/mock/datastore_mock.go | 12 + server/service/apple_mdm.go | 185 ++++++++++- server/service/apple_mdm_test.go | 2 +- server/service/handler.go | 1 + server/service/hosts_test.go | 2 +- .../service/integration_mdm_lifecycle_test.go | 2 +- .../service/integration_mdm_profiles_test.go | 2 +- server/service/integration_mdm_test.go | 144 ++++++++- 35 files changed, 936 insertions(+), 202 deletions(-) create mode 100644 changes/21019-ota-enrollment create mode 100644 server/mdm/apple/AppleIphoneDeviceCA.cer diff --git a/changes/21019-ota-enrollment b/changes/21019-ota-enrollment new file mode 100644 index 0000000000..b43db060a7 --- /dev/null +++ b/changes/21019-ota-enrollment @@ -0,0 +1 @@ +* Implement protocol support for OTA enrollment and automatic team assignment for hosts. diff --git a/cmd/fleet/serve_test.go b/cmd/fleet/serve_test.go index 675f1242aa..e472566f3e 100644 --- a/cmd/fleet/serve_test.go +++ b/cmd/fleet/serve_test.go @@ -30,9 +30,9 @@ import ( "github.com/go-kit/log" kitlog "github.com/go-kit/log" "github.com/go-kit/log/level" + "github.com/smallstep/pkcs7" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "go.mozilla.org/pkcs7" ) // safeStore is a wrapper around mock.Store to allow for concurrent calling to diff --git a/frontend/components/AddHostsModal/PlatformWrapper/IosIpadosPanel/IosIpadosPanel.tsx b/frontend/components/AddHostsModal/PlatformWrapper/IosIpadosPanel/IosIpadosPanel.tsx index 40d991e2f8..7cfba0f4d2 100644 --- a/frontend/components/AddHostsModal/PlatformWrapper/IosIpadosPanel/IosIpadosPanel.tsx +++ b/frontend/components/AddHostsModal/PlatformWrapper/IosIpadosPanel/IosIpadosPanel.tsx @@ -6,7 +6,9 @@ import { AppContext } from "context/app"; import InputField from "components/forms/fields/InputField"; const generateUrl = (serverUrl: string, enrollSecret: string) => { - return `${serverUrl}/enroll?enroll_secret=${enrollSecret}`; + return `${serverUrl}/enroll?enroll_secret=${encodeURIComponent( + enrollSecret + )}`; }; const baseClass = "ios-ipados-panel"; diff --git a/go.mod b/go.mod index d1458f2471..82d9ea538b 100644 --- a/go.mod +++ b/go.mod @@ -95,6 +95,7 @@ require ( github.com/sethvargo/go-password v0.3.0 github.com/shirou/gopsutil/v3 v3.24.3 github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 + github.com/smallstep/pkcs7 v0.0.0-20240723090913-5e2c6a136dfa github.com/spf13/cast v1.4.1 github.com/spf13/cobra v1.8.0 github.com/spf13/viper v1.10.0 @@ -110,7 +111,6 @@ require ( go.elastic.co/apm/module/apmsql/v2 v2.4.3 go.elastic.co/apm/v2 v2.4.3 go.etcd.io/bbolt v1.3.9 - go.mozilla.org/pkcs7 v0.0.0-20210826202110-33d05740a352 go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux v0.44.0 go.opentelemetry.io/otel v1.28.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 @@ -308,6 +308,7 @@ require ( github.com/yusufpapurcu/wmi v1.2.4 // indirect go.elastic.co/apm/module/apmhttp/v2 v2.3.0 // indirect go.elastic.co/fastjson v1.1.0 // indirect + go.mozilla.org/pkcs7 v0.0.0-20210826202110-33d05740a352 // indirect go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect diff --git a/go.sum b/go.sum index 239c87b7d1..68586039a4 100644 --- a/go.sum +++ b/go.sum @@ -1056,6 +1056,8 @@ github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EE github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= github.com/slack-go/slack v0.9.4 h1:C+FC3zLxLxUTQjDy2RZeMHYon005zsCROiZNWVo+opQ= github.com/slack-go/slack v0.9.4/go.mod h1:wWL//kk0ho+FcQXcBTmEafUI5dz4qz5f4mMk8oIkioQ= +github.com/smallstep/pkcs7 v0.0.0-20240723090913-5e2c6a136dfa h1:FtxzVccOwaK+bK4bnWBPGua0FpCOhrVyeo6Fy9nxdlo= +github.com/smallstep/pkcs7 v0.0.0-20240723090913-5e2c6a136dfa/go.mod h1:SoUAr/4M46rZ3WaLstHxGhLEgoYIDRqxQEXLOmOEB0Y= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/assertions v1.0.0/go.mod h1:kHHU4qYBaI3q23Pp3VPrmWhuIUrLW/7eUrw0BU5VaoM= github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9/go.mod h1:SnhjPscd9TpLiy1LpzGSKh3bXCfxxXuqd9xmQJy3slM= diff --git a/pkg/mdm/mdmtest/apple.go b/pkg/mdm/mdmtest/apple.go index 326d40be60..f754b85620 100644 --- a/pkg/mdm/mdmtest/apple.go +++ b/pkg/mdm/mdmtest/apple.go @@ -33,7 +33,7 @@ import ( kitlog "github.com/go-kit/log" "github.com/google/uuid" "github.com/groob/plist" - "go.mozilla.org/pkcs7" + "github.com/smallstep/pkcs7" ) // TestAppleMDMClient simulates a macOS MDM client. @@ -65,6 +65,13 @@ type TestAppleMDMClient struct { // fetchEnrollmentProfileFromDEP indicates whether this simulated device will fetch // the enrollment profile from Fleet as if it were a device running the DEP flow. fetchEnrollmentProfileFromDEP bool + + // fetchEnrollmentProfileFromOTA indicates whether this simulated device will fetch + // the enrollment profile from Fleet as if it were a device running the OTA flow. + fetchEnrollmentProfileFromOTA bool + // otaEnrollSecret is the team enroll secret to be used during the OTA flow. + otaEnrollSecret string + // desktopURLToken is the token used to fetch the enrollment profile // from Fleet as if it were a device running the DEP flow. depURLToken string @@ -151,6 +158,24 @@ func NewTestMDMClientAppleDirect(enrollInfo AppleEnrollInfo, model string, opts return &c } +// NewTestMDMClientAppleOTA will create a simulated device that will fetch +// enrollment profile from Fleet as if it were a device running the Over The +// Air (OTA) flow. +func NewTestMDMClientAppleOTA(serverURL, enrollSecret, model string, opts ...TestMDMAppleClientOption) *TestAppleMDMClient { + c := TestAppleMDMClient{ + UUID: strings.ToUpper(uuid.New().String()), + SerialNumber: RandSerialNumber(), + Model: model, + fetchEnrollmentProfileFromOTA: true, + fleetServerURL: serverURL, + otaEnrollSecret: enrollSecret, + } + for _, fn := range opts { + fn(&c) + } + return &c +} + func (c *TestAppleMDMClient) SetDesktopToken(tok string) { c.desktopURLToken = tok } @@ -170,6 +195,10 @@ func (c *TestAppleMDMClient) Enroll() error { if err := c.fetchEnrollmentProfileFromDEPURL(); err != nil { return fmt.Errorf("get enrollment profile from DEP URL: %w", err) } + case c.fetchEnrollmentProfileFromOTA: + if err := c.fetchEnrollmentProfileFromOTAURL(); err != nil { + return fmt.Errorf("get enrollment profile from OTA URL: %w", err) + } default: if c.EnrollInfo.SCEPURL == "" || c.EnrollInfo.MDMURL == "" || c.EnrollInfo.SCEPChallenge == "" { return fmt.Errorf("missing info needed to perform enrollment: %+v", c.EnrollInfo) @@ -199,6 +228,120 @@ func (c *TestAppleMDMClient) fetchEnrollmentProfileFromDEPURL() error { ) } +func (c *TestAppleMDMClient) fetchEnrollmentProfileFromOTAURL() error { + rawDeviceInfo := []byte(fmt.Sprintf(` + + + + PRODUCT + %s + SERIAL + %s + UDID + %s + VERSION + 22A5316k + +`, c.Model, c.SerialNumber, c.UUID)) + + do := func(cert *x509.Certificate, key *rsa.PrivateKey) ([]byte, error) { + signedData, err := pkcs7.NewSignedData(rawDeviceInfo) + if err != nil { + return nil, fmt.Errorf("create signed data: %w", err) + } + err = signedData.AddSigner(cert, key, pkcs7.SignerInfoConfig{}) + if err != nil { + return nil, fmt.Errorf("add signer: %w", err) + } + sig, err := signedData.Finish() + if err != nil { + return nil, fmt.Errorf("finish signing: %w", err) + } + + request, err := http.NewRequest( + "POST", + c.fleetServerURL+"/api/latest/fleet/ota_enrollment?enroll_secret="+c.otaEnrollSecret, + bytes.NewReader(sig), + ) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + // #nosec (this client is used for testing only) + cc := fleethttp.NewClient(fleethttp.WithTLSClientConfig(&tls.Config{ + InsecureSkipVerify: true, + })) + response, err := cc.Do(request) + if err != nil { + return nil, fmt.Errorf("send request: %w", err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("request error: %d, %s", response.StatusCode, response.Status) + } + + body, err := io.ReadAll(response.Body) + if err != nil { + return nil, fmt.Errorf("read body: %w", err) + } + + return body, nil + } + + // TODO(roberto 09-10-2024): the first request in the OTA flow must be + // signed using a keypair that has a valid Apple certificate as root. I + // believe this could be done with a little bit of reverse + // engineering/cleverness but for now, we're signing the request with + // our mock certs and setting this env var to skip the verification. + os.Setenv("FLEET_DEV_MDM_APPLE_DISABLE_DEVICE_INFO_CERT_VERIFY", "1") + mockedCert, mockedKey, err := apple_mdm.NewSCEPCACertKey() + if err != nil { + return fmt.Errorf("creating mock certificates: %w", err) + } + body, err := do(mockedCert, mockedKey) + if err != nil { + return fmt.Errorf("first OTA request: %w", err) + } + os.Unsetenv("FLEET_DEV_MDM_APPLE_DISABLE_DEVICE_INFO_CERT_VERIFY") + + var scepInfo struct { + PayloadContent []struct { + PayloadContent struct { + Challenge string `plist:"Challenge"` + URL string `plist:"URL"` + } `plist:"PayloadContent"` + } `plist:"PayloadContent"` + } + + err = plist.Unmarshal(body, &scepInfo) + if err != nil { + return fmt.Errorf("unmarshaling SCEP response: %w", err) + } + + tmpCert, tmpKey, err := c.doSCEP(scepInfo.PayloadContent[0].PayloadContent.URL, scepInfo.PayloadContent[0].PayloadContent.Challenge) + if err != nil { + return fmt.Errorf("get SCEP certificate for OTA: %w", err) + } + + body, err = do(tmpCert, tmpKey) + if err != nil { + return fmt.Errorf("seconde OTA request: %w", err) + } + p7, err := pkcs7.Parse(body) + if err != nil { + return fmt.Errorf("enrollment profile is not XML nor PKCS7 parseable: %w", err) + } + err = p7.Verify() + if err != nil { + return fmt.Errorf("verifying enrollment profile: %w", err) + } + enrollInfo, err := ParseEnrollmentProfile(p7.Content) + if err != nil { + return fmt.Errorf("parse OTA SCEP profile: %w", err) + } + c.EnrollInfo = *enrollInfo + return nil +} + func (c *TestAppleMDMClient) fetchEnrollmentProfile(path string) error { request, err := http.NewRequest("GET", c.fleetServerURL+path, nil) if err != nil { @@ -212,6 +355,7 @@ func (c *TestAppleMDMClient) fetchEnrollmentProfile(path string) error { if err != nil { return fmt.Errorf("send request: %w", err) } + defer response.Body.Close() if response.StatusCode != http.StatusOK { return fmt.Errorf("request error: %d, %s", response.StatusCode, response.Status) } @@ -247,8 +391,7 @@ func (c *TestAppleMDMClient) fetchEnrollmentProfile(path string) error { return nil } -// SCEPEnroll runs the SCEP enroll protocol for the simulated device. -func (c *TestAppleMDMClient) SCEPEnroll() error { +func (c *TestAppleMDMClient) doSCEP(url, challenge string) (*x509.Certificate, *rsa.PrivateKey, error) { ctx := context.Background() var logger log.Logger @@ -257,25 +400,25 @@ func (c *TestAppleMDMClient) SCEPEnroll() error { } else { logger = kitlog.NewNopLogger() } - client, err := newSCEPClient(c.EnrollInfo.SCEPURL, logger) + client, err := newSCEPClient(url, logger) if err != nil { - return fmt.Errorf("scep client: %w", err) + return nil, nil, fmt.Errorf("scep client: %w", err) } // (1). Get the CA certificate from the SCEP server. resp, _, err := client.GetCACert(ctx, "") if err != nil { - return fmt.Errorf("get CA cert: %w", err) + return nil, nil, fmt.Errorf("get CA cert: %w", err) } caCert, err := x509.ParseCertificates(resp) if err != nil { - return fmt.Errorf("parse CA cert: %w", err) + return nil, nil, fmt.Errorf("parse CA cert: %w", err) } // (2). Generate RSA key pair. devicePrivateKey, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { - return fmt.Errorf("generate RSA private key: %w", err) + return nil, nil, fmt.Errorf("generate RSA private key: %w", err) } // (3). Generate CSR. @@ -288,15 +431,15 @@ func (c *TestAppleMDMClient) SCEPEnroll() error { }, SignatureAlgorithm: x509.SHA256WithRSA, }, - ChallengePassword: c.EnrollInfo.SCEPChallenge, + ChallengePassword: challenge, } csrDerBytes, err := x509util.CreateCertificateRequest(rand.Reader, &csrTemplate, devicePrivateKey) if err != nil { - return fmt.Errorf("create CSR: %w", err) + return nil, nil, fmt.Errorf("create CSR: %w", err) } csr, err := x509.ParseCertificateRequest(csrDerBytes) if err != nil { - return fmt.Errorf("parse CSR: %w", err) + return nil, nil, fmt.Errorf("parse CSR: %w", err) } // (4). SCEP requires a certificate for client authentication. We generate a new one @@ -312,7 +455,7 @@ func (c *TestAppleMDMClient) SCEPEnroll() error { serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) certSerialNumber, err := rand.Int(rand.Reader, serialNumberLimit) if err != nil { - return fmt.Errorf("generate cert serial number: %w", err) + return nil, nil, fmt.Errorf("generate cert serial number: %w", err) } deviceCertificateTemplate := x509.Certificate{ SerialNumber: certSerialNumber, @@ -334,11 +477,11 @@ func (c *TestAppleMDMClient) SCEPEnroll() error { devicePrivateKey, ) if err != nil { - return fmt.Errorf("create device certificate: %w", err) + return nil, nil, fmt.Errorf("create device certificate: %w", err) } deviceCertificateForRequest, err := x509.ParseCertificate(deviceCertificateDerBytes) if err != nil { - return fmt.Errorf("parse device certificate: %w", err) + return nil, nil, fmt.Errorf("parse device certificate: %w", err) } // (5). Send the PKCSReq message to the SCEP server. @@ -353,31 +496,40 @@ func (c *TestAppleMDMClient) SCEPEnroll() error { } msg, err := scep.NewCSRRequest(csr, pkiMsgReq, scep.WithLogger(logger)) if err != nil { - return fmt.Errorf("create CSR request: %w", err) + return nil, nil, fmt.Errorf("create CSR request: %w", err) } respBytes, err := client.PKIOperation(ctx, msg.Raw) if err != nil { - return fmt.Errorf("do CSR request: %w", err) + return nil, nil, fmt.Errorf("do CSR request: %w", err) } pkiMsgResp, err := scep.ParsePKIMessage(respBytes, scep.WithLogger(logger), scep.WithCACerts(msg.Recipients)) if err != nil { - return fmt.Errorf("parse PKIMessage response: %w", err) + return nil, nil, fmt.Errorf("parse PKIMessage response: %w", err) } if pkiMsgResp.PKIStatus != scep.SUCCESS { - return fmt.Errorf("PKIMessage CSR request failed with code: %s, fail info: %s", pkiMsgResp.PKIStatus, pkiMsgResp.FailInfo) + return nil, nil, fmt.Errorf("PKIMessage CSR request failed with code: %s, fail info: %s", pkiMsgResp.PKIStatus, pkiMsgResp.FailInfo) } if err := pkiMsgResp.DecryptPKIEnvelope(deviceCertificateForRequest, devicePrivateKey); err != nil { - return fmt.Errorf("decrypt PKI envelope: %w", err) + return nil, nil, fmt.Errorf("decrypt PKI envelope: %w", err) } - // (6). Finally, set the signed certificate returned from the server as the device certificate and key. - c.scepCert = pkiMsgResp.CertRepMessage.Certificate - c.scepKey = devicePrivateKey - if c.debug { fmt.Println("SCEP enrollment successful") } + // (6). return the signed certificate returned from the server as the device certificate and key. + return pkiMsgResp.CertRepMessage.Certificate, devicePrivateKey, nil +} + +// SCEPEnroll runs the SCEP enroll protocol for the simulated device. +func (c *TestAppleMDMClient) SCEPEnroll() error { + cert, key, err := c.doSCEP(c.EnrollInfo.SCEPURL, c.EnrollInfo.SCEPChallenge) + if err != nil { + return err + } + + c.scepCert = cert + c.scepKey = key return nil } diff --git a/server/datastore/mysql/apple_mdm.go b/server/datastore/mysql/apple_mdm.go index e93c2fe9c0..7943068687 100644 --- a/server/datastore/mysql/apple_mdm.go +++ b/server/datastore/mysql/apple_mdm.go @@ -896,61 +896,35 @@ func insertMDMAppleHostDB( return nil } -type hostWithEnrolled struct { - fleet.Host - Enrolled *bool `db:"enrolled"` +// hostToCreateFromMDM defines a common set of parameters required to create +// host records without a pre-existing osquery enrollment from MDM flows like +// ADE ingestion or OTA enrollments +type hostToCreateFromMDM struct { + // HardwareSerial should match the value for hosts.hardware_serial + HardwareSerial string + // HardwareModel should match the value for hosts.hardware_model + HardwareModel string + // PlatformHint is used to determine hosts.platform, if it: + // + // - contains "iphone" the platform is "ios" + // - contains "ipad" the platform is "ipados" + // - otherwise the platform is "darwin" + PlatformHint string } -func (ds *Datastore) IngestMDMAppleDevicesFromDEPSync( +func createHostFromMDMDB( ctx context.Context, - devices []godep.Device, - abmTokenID uint, - macOSTeam, iosTeam, ipadTeam *fleet.Team, -) (createdCount int64, err error) { - if len(devices) < 1 { - level.Debug(ds.logger).Log("msg", "ingesting devices from DEP received < 1 device, skipping", "len(devices)", len(devices)) - return 0, nil - } + tx sqlx.ExtContext, + logger log.Logger, + devices []hostToCreateFromMDM, + macOSTeam, iosTeam, ipadTeam *uint, +) (int64, []fleet.Host, error) { + // NOTE: order of arguments for teams is important, see statement. + args := []any{iosTeam, ipadTeam, macOSTeam} + us, unionArgs := unionSelectDevices(devices) + args = append(args, unionArgs...) - appCfg, err := ds.AppConfig(ctx) - if err != nil { - return 0, ctxerr.Wrap(ctx, err, "ingest mdm apple host get app config") - } - - var args []any - teams := []*fleet.Team{iosTeam, ipadTeam, macOSTeam} - for _, team := range teams { - if team == nil { - args = append(args, nil) - continue - } - - exists, err := ds.TeamExists(ctx, team.ID) - if err != nil { - return 0, ctxerr.Wrap(ctx, err, "ingest mdm apple host get team by name") - } - - if exists { - args = append(args, team.ID) - continue - } - - // If the team doesn't exist, we still ingest the device, but it won't - // belong to any team. - level.Debug(ds.logger).Log( - "msg", - "ingesting devices from ABM: unable to find default team assigned in config, the devices won't be assigned to a team", - "team_id", - team, - ) - args = append(args, nil) - } - - err = ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { - us, unionArgs := unionSelectDevices(devices) - args = append(args, unionArgs...) - - stmt := fmt.Sprintf(` + stmt := fmt.Sprintf(` INSERT INTO hosts ( hardware_serial, hardware_model, @@ -980,29 +954,28 @@ func (ds *Datastore) IngestMDMAppleDevicesFromDEPSync( h.id IS NULL GROUP BY us.hardware_serial, us.platform)`, - us, - ) + us, + ) - res, err := tx.ExecContext(ctx, stmt, args...) - if err != nil { - return ctxerr.Wrap(ctx, err, "ingest mdm apple hosts from dep sync insert") - } + res, err := tx.ExecContext(ctx, stmt, args...) + if err != nil { + return 0, nil, ctxerr.Wrap(ctx, err, "inserting new host in MDM ingestion") + } - n, err := res.RowsAffected() - if err != nil { - return ctxerr.Wrap(ctx, err, "ingest mdm apple hosts from dep sync rows affected") - } - createdCount = n + n, _ := res.RowsAffected() + // get new host ids + args = []any{} + parts := []string{} + for _, d := range devices { + args = append(args, d.HardwareSerial) + parts = append(parts, "?") + } - // get new host ids - args = []interface{}{} - parts := []string{} - for _, d := range devices { - args = append(args, d.SerialNumber) - parts = append(parts, "?") - } - var hostsWithEnrolled []hostWithEnrolled - err = sqlx.SelectContext(ctx, tx, &hostsWithEnrolled, fmt.Sprintf(` + var hostsWithEnrolled []struct { + fleet.Host + Enrolled *bool `db:"enrolled"` + } + err = sqlx.SelectContext(ctx, tx, &hostsWithEnrolled, fmt.Sprintf(` SELECT h.id, h.platform, @@ -1012,47 +985,135 @@ func (ds *Datastore) IngestMDMAppleDevicesFromDEPSync( FROM hosts h LEFT JOIN host_mdm hmdm ON hmdm.host_id = h.id WHERE h.hardware_serial IN(%s)`, - strings.Join(parts, ",")), - args...) - if err != nil { - return ctxerr.Wrap(ctx, err, "ingest mdm apple host get host ids") + strings.Join(parts, ",")), + args...) + if err != nil { + return 0, nil, ctxerr.Wrap(ctx, err, "ingest mdm apple host get host ids") + } + + var hosts []fleet.Host + var unmanagedHostIDs []uint + for _, h := range hostsWithEnrolled { + hosts = append(hosts, h.Host) + if h.Enrolled == nil || !*h.Enrolled { + unmanagedHostIDs = append(unmanagedHostIDs, h.ID) + } + } + + if err := upsertMDMAppleHostDisplayNamesDB(ctx, tx, hosts...); err != nil { + return 0, nil, ctxerr.Wrap(ctx, err, "ingest mdm apple host upsert display names") + } + + if err := upsertMDMAppleHostLabelMembershipDB(ctx, tx, logger, hosts...); err != nil { + return 0, nil, ctxerr.Wrap(ctx, err, "ingest mdm apple host upsert label membership") + } + + appCfg, err := appConfigDB(ctx, tx) + if err != nil { + return 0, nil, ctxerr.Wrap(ctx, err, "ingest mdm apple host get app config") + } + + // only upsert MDM info for hosts that are unmanaged. This + // prevents us from overriding valuable info with potentially + // incorrect data. For example: if a host is enrolled in a + // third-party MDM, but gets assigned in ABM to Fleet (during + // migration) we'll get an 'added' event. In that case, we + // expect that MDM info will be updated in due time as we ingest + // future osquery data from the host + if err := upsertMDMAppleHostMDMInfoDB( + ctx, + tx, + appCfg.ServerSettings, + true, + unmanagedHostIDs..., + ); err != nil { + return 0, nil, ctxerr.Wrap(ctx, err, "ingest mdm apple host upsert MDM info") + } + + return n, hosts, nil +} + +func (ds *Datastore) IngestMDMAppleDeviceFromOTAEnrollment( + ctx context.Context, + teamID *uint, + deviceInfo fleet.MDMAppleMachineInfo, +) error { + return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + toInsert := []hostToCreateFromMDM{ + { + HardwareSerial: deviceInfo.Serial, + PlatformHint: deviceInfo.Product, + HardwareModel: deviceInfo.Product, + }, + } + _, _, err := createHostFromMDMDB(ctx, tx, ds.logger, toInsert, teamID, teamID, teamID) + return ctxerr.Wrap(ctx, err, "creating host from OTA enrollment") + }) +} + +func (ds *Datastore) IngestMDMAppleDevicesFromDEPSync( + ctx context.Context, + devices []godep.Device, + abmTokenID uint, + macOSTeam, iosTeam, ipadTeam *fleet.Team, +) (createdCount int64, err error) { + if len(devices) < 1 { + level.Debug(ds.logger).Log("msg", "ingesting devices from DEP received < 1 device, skipping", "len(devices)", len(devices)) + return 0, nil + } + + var teamIDs []*uint + for _, team := range []*fleet.Team{macOSTeam, iosTeam, ipadTeam} { + if team == nil { + teamIDs = append(teamIDs, nil) + continue } - var hosts []fleet.Host - var unmanagedHostIDs []uint - for _, h := range hostsWithEnrolled { - hosts = append(hosts, h.Host) - if h.Enrolled == nil || !*h.Enrolled { - unmanagedHostIDs = append(unmanagedHostIDs, h.ID) + exists, err := ds.TeamExists(ctx, team.ID) + if err != nil { + return 0, ctxerr.Wrap(ctx, err, "ingest mdm apple host get team by name") + } + + if exists { + teamIDs = append(teamIDs, &team.ID) + continue + } + + // If the team doesn't exist, we still ingest the device, but it won't + // belong to any team. + level.Debug(ds.logger).Log( + "msg", + "ingesting devices from ABM: unable to find default team assigned in config, the devices won't be assigned to a team", + "team_id", + team, + ) + teamIDs = append(teamIDs, nil) + } + + err = ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + htc := make([]hostToCreateFromMDM, len(devices)) + for i, d := range devices { + htc[i] = hostToCreateFromMDM{ + HardwareSerial: d.SerialNumber, + HardwareModel: d.Model, + PlatformHint: d.DeviceFamily, } } - if err := upsertMDMAppleHostDisplayNamesDB(ctx, tx, hosts...); err != nil { - return ctxerr.Wrap(ctx, err, "ingest mdm apple host upsert display names") - } - - if err := upsertMDMAppleHostLabelMembershipDB(ctx, tx, ds.logger, hosts...); err != nil { - return ctxerr.Wrap(ctx, err, "ingest mdm apple host upsert label membership") - } - if err := upsertHostDEPAssignmentsDB(ctx, tx, hosts, abmTokenID); err != nil { - return ctxerr.Wrap(ctx, err, "ingest mdm apple host upsert DEP assignments") - } - - // only upsert MDM info for hosts that are unmanaged. This - // prevents us from overriding valuable info with potentially - // incorrect data. For example: if a host is enrolled in a - // third-party MDM, but gets assigned in ABM to Fleet (during - // migration) we'll get an 'added' event. In that case, we - // expect that MDM info will be updated in due time as we ingest - // future osquery data from the host - if err := upsertMDMAppleHostMDMInfoDB( + n, hosts, err := createHostFromMDMDB( ctx, tx, - appCfg.ServerSettings, - true, - unmanagedHostIDs..., - ); err != nil { - return ctxerr.Wrap(ctx, err, "ingest mdm apple host upsert MDM info") + ds.logger, + htc, + teamIDs[0], teamIDs[1], teamIDs[2], + ) + if err != nil { + return err + } + createdCount = n + + if err := upsertHostDEPAssignmentsDB(ctx, tx, hosts, abmTokenID); err != nil { + return ctxerr.Wrap(ctx, err, "ingest mdm apple host upsert DEP assignments") } return nil @@ -1310,22 +1371,24 @@ func (ds *Datastore) MDMTurnOff(ctx context.Context, uuid string) error { }) } -func unionSelectDevices(devices []godep.Device) (stmt string, args []interface{}) { +func unionSelectDevices(devices []hostToCreateFromMDM) (stmt string, args []interface{}) { for i, d := range devices { if i == 0 { stmt = "SELECT ? hardware_serial, ? hardware_model, ? platform" } else { stmt += " UNION SELECT ?, ?, ?" } - // Map Apple's device family to Fleet's hosts.platform field. - platform := "darwin" - switch d.DeviceFamily { - case "iPhone": - platform = "ios" - case "iPad": - platform = "ipados" + + // map the platform hint to Fleet's hosts.platform field. + normalizedHint := strings.ToLower(d.PlatformHint) + platform := string(fleet.MacOSPlatform) + switch { + case strings.Contains(normalizedHint, "iphone"): + platform = string(fleet.IOSPlatform) + case strings.Contains(normalizedHint, "ipad"): + platform = string(fleet.IPadOSPlatform) } - args = append(args, d.SerialNumber, d.Model, platform) + args = append(args, d.HardwareSerial, d.HardwareModel, platform) } return stmt, args diff --git a/server/datastore/mysql/apple_mdm_test.go b/server/datastore/mysql/apple_mdm_test.go index aba31a3c8e..3090bdb04d 100644 --- a/server/datastore/mysql/apple_mdm_test.go +++ b/server/datastore/mysql/apple_mdm_test.go @@ -88,6 +88,7 @@ func TestMDMApple(t *testing.T) { {"ABMTokensTermsExpired", testMDMAppleABMTokensTermsExpired}, {"TestMDMGetABMTokenOrgNamesAssociatedWithTeam", testMDMGetABMTokenOrgNamesAssociatedWithTeam}, {"HostMDMCommands", testHostMDMCommands}, + {"IngestMDMAppleDeviceFromOTAEnrollment", testIngestMDMAppleDeviceFromOTAEnrollment}, } for _, c := range cases { @@ -6793,6 +6794,7 @@ func testMDMGetABMTokenOrgNamesAssociatedWithTeam(t *testing.T, ds *Datastore) { require.Len(t, orgNames, 1) require.Equal(t, orgNames[0], "org3") } + func testHostMDMCommands(t *testing.T, ds *Datastore) { ctx := context.Background() @@ -6864,6 +6866,68 @@ func testHostMDMCommands(t *testing.T, ds *Datastore) { assert.ElementsMatch(t, hostCommands[1:], commands) } +func testIngestMDMAppleDeviceFromOTAEnrollment(t *testing.T, ds *Datastore) { + ctx := context.Background() + createBuiltinLabels(t, ds) + + for i := 0; i < 10; i++ { + _, err := ds.NewHost(ctx, &fleet.Host{ + Hostname: fmt.Sprintf("hostname_%d", i), + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now().Add(-time.Duration(i) * time.Minute), + OsqueryHostID: ptr.String(fmt.Sprintf("osquery-host-id_%d", i)), + NodeKey: ptr.String(fmt.Sprintf("node-key_%d", i)), + UUID: fmt.Sprintf("uuid_%d", i), + HardwareSerial: fmt.Sprintf("serial_%d", i), + }) + require.NoError(t, err) + } + + hosts := listHostsCheckCount(t, ds, fleet.TeamFilter{User: test.UserAdmin}, fleet.HostListOptions{}, 10) + wantSerials := []string{} + for _, h := range hosts { + wantSerials = append(wantSerials, h.HardwareSerial) + } + + // mock results incoming from OTA enrollments + otaDevices := []fleet.MDMAppleMachineInfo{ + {Serial: "abc", Product: "MacBook Pro"}, + {Serial: "abc", Product: "MacBook Pro"}, + {Serial: hosts[0].HardwareSerial, Product: "MacBook Pro"}, + {Serial: "ijk", Product: "iPad13,16"}, + {Serial: "tuv", Product: "iPhone14,6"}, + {Serial: hosts[1].HardwareSerial, Product: "MacBook Pro"}, + {Serial: "xyz", Product: "MacBook Pro"}, + {Serial: "xyz", Product: "MacBook Pro"}, + {Serial: "xyz", Product: "MacBook Pro"}, + } + wantSerials = append(wantSerials, "abc", "xyz", "ijk", "tuv") + + for _, d := range otaDevices { + err := ds.IngestMDMAppleDeviceFromOTAEnrollment(ctx, nil, d) + require.NoError(t, err) + } + + hosts = listHostsCheckCount(t, ds, fleet.TeamFilter{User: test.UserAdmin}, fleet.HostListOptions{}, len(wantSerials)) + gotSerials := []string{} + for _, h := range hosts { + gotSerials = append(gotSerials, h.HardwareSerial) + + switch h.HardwareSerial { + case "abc", "xyz": + checkMDMHostRelatedTables(t, ds, h.ID, h.HardwareSerial, "MacBook Pro") + case "ijk": + checkMDMHostRelatedTables(t, ds, h.ID, h.HardwareSerial, "iPad13,16") + case "tuv": + checkMDMHostRelatedTables(t, ds, h.ID, h.HardwareSerial, "iPhone14,6") + + } + } + require.ElementsMatch(t, wantSerials, gotSerials) +} + func TestGetMDMAppleOSUpdatesSettingsByHostSerial(t *testing.T) { ds := CreateMySQLDS(t) defer ds.Close() diff --git a/server/datastore/mysql/testing_utils.go b/server/datastore/mysql/testing_utils.go index 99b5989194..51b7491c0c 100644 --- a/server/datastore/mysql/testing_utils.go +++ b/server/datastore/mysql/testing_utils.go @@ -33,8 +33,8 @@ import ( "github.com/go-kit/log" "github.com/google/uuid" "github.com/jmoiron/sqlx" + "github.com/smallstep/pkcs7" "github.com/stretchr/testify/require" - "go.mozilla.org/pkcs7" ) const ( diff --git a/server/fleet/apple_mdm_test.go b/server/fleet/apple_mdm_test.go index f31796d4a4..240c37c9d7 100644 --- a/server/fleet/apple_mdm_test.go +++ b/server/fleet/apple_mdm_test.go @@ -18,9 +18,9 @@ import ( "github.com/fleetdm/fleet/v4/server/ptr" "github.com/google/go-cmp/cmp" "github.com/google/uuid" + "github.com/smallstep/pkcs7" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "go.mozilla.org/pkcs7" ) func TestMDMAppleConfigProfile(t *testing.T) { diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 2689b1114c..1c767c5148 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -1112,6 +1112,10 @@ type Datastore interface { // not already enrolled in Fleet. It returns the number of hosts created, and an error. IngestMDMAppleDevicesFromDEPSync(ctx context.Context, devices []godep.Device, abmTokenID uint, macOSTeam, iosTeam, ipadTeam *Team) (int64, error) + // IngestMDMAppleDeviceFromOTAEnrollment creates new host records for + // MDM-enrolled devices via OTA that are not already enrolled in Fleet. + IngestMDMAppleDeviceFromOTAEnrollment(ctx context.Context, teamID *uint, deviceInfo MDMAppleMachineInfo) error + // MDMAppleUpsertHost creates or matches a Fleet host record for an // MDM-enrolled device. MDMAppleUpsertHost(ctx context.Context, mdmHost *Host) error diff --git a/server/fleet/service.go b/server/fleet/service.go index ce3aa904a9..a7971cff02 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -655,6 +655,32 @@ type Service interface { AddAppStoreApp(ctx context.Context, teamID *uint, appTeam VPPAppTeam) error + // MDMAppleProcessOTAEnrollment handles OTA enrollment requests. + // + // Per the [spec][1] OTA enrollment is composed of two phases, each + // phase is a request sent by the host to the same endpoint, but it + // must be handled differently depending on the signatures of the + // request body: + // + // 1. First request has a certificate signed by Apple's CA as the root + // certificate. The server must return a SCEP payload that the device + // will use to get a keypair. Note that this keypair is _different_ + // from the "SCEP identity certificate" that will be generated during + // MDM enrollment, and only used for OTA. + // + // 2. Second request has the SCEP certificate generated in `1` as the + // root certificate, the server responds with a "classic" enrollment + // profile and the device starts the regular enrollment process from there. + // + // The extra steps allows us to grab device information like the serial + // number and hardware uuid to perform operations before the host even + // enrolls in MDM. Currently, this method creates a host records and + // assigns a pre-defined team (based on the enrollSecret provided) to + // the host. + // + // [1]: https://developer.apple.com/library/archive/documentation/NetworkingInternet/Conceptual/iPhoneOTAConfiguration/Introduction/Introduction.html#//apple_ref/doc/uid/TP40009505-CH1-SW1 + MDMAppleProcessOTAEnrollment(ctx context.Context, certificates []*x509.Certificate, rootSigner *x509.Certificate, enrollSecret string, deviceInfo MDMAppleMachineInfo) ([]byte, error) + // ///////////////////////////////////////////////////////////////////////////// // Vulnerabilities diff --git a/server/mdm/apple/AppleIphoneDeviceCA.cer b/server/mdm/apple/AppleIphoneDeviceCA.cer new file mode 100644 index 0000000000000000000000000000000000000000..fac79ff89daf7520aec9adafa728f34524663bac GIT binary patch literal 877 zcmXqLV$L*ZVhUWq%*4pV#K>sC%f_kI=F#?@mywZ`mBFCWklTQhjX9KsO_(V(*ihI& z5X9jU=5j13$VpZ3%uCiYR5MTk333a|LIj;ti%K%nGLsWaGV}8k97{_w@{2M{Dh+iF zv_a~ag;gNxG6OR5^HLRXC^wK3=QS`lFflYUGBPzWF^dxCH8eru8blG{G*JU#h|_qG ztu&N3kcGHJ3|UygCABOw8R#HKgU0#D0l~=1z}(o&V9?mf)Y!=Iaf;W;SG%Gl64stx zAfBAF>3$)0w4`irz#?(uHx(yO+56^Cp*&GK!p*$$pii>{Vt!=Tik#t)~L&(Zc# z+n{Ry(r8sRN6C~o2fZz1)BQMtrbKl|E{?fA`OAWTp{=`p+@{CnZ7O%wPmkHdEwMjx zXHMI9tk4Pgy<|F&2?c{}h#7+NNezUTswCD;Bu1+^4U^Kpv!CnMJ}ttU=_tiI$$yJFE~qgzumCBLXE8L;H_%<6y+E@~J)@+gz)D}gyu4g55tIh>lJj%*!K~y& z{mcSTlmc5(!)mX<-QC3UnUf-?~@TN|HN}3 zGyJ6A;Z`@XGP7;*e_r)(-S&WM=i7^1v&&zt7TOu37%JK4uIw(j@gna@nBbV-xyHaE?ZmLLTdhsPJs?pd^> z-Qu&M{<)SVnjvfKEEFY=&2|cXT(->Y?)KZ8yW@T?jI!Uopp0o#Lv-A`mxb(#7tUC| gyOUr0vNFiZ$F?%MnPa&Mf8KnJb)oluU9eFB0FE3n_5c6? literal 0 HcmV?d00001 diff --git a/server/mdm/apple/apple_mdm.go b/server/mdm/apple/apple_mdm.go index 4925202507..15b526b8c5 100644 --- a/server/mdm/apple/apple_mdm.go +++ b/server/mdm/apple/apple_mdm.go @@ -800,6 +800,62 @@ var funcMap = map[string]any{ "xml": mobileconfig.XMLEscapeString, } +var OTASCEPTemplate = template.Must(template.New("").Funcs(funcMap).Parse(` + + + + PayloadVersion + 1 + PayloadType + Configuration + PayloadIdentifier + Ignored + PayloadUUID + Ignored + PayloadContent + + + PayloadContent + + Key Type + RSA + Challenge + {{ .SCEPChallenge | xml }} + Key Usage + 5 + Keysize + 2048 + URL + {{ .SCEPURL }} + Subject + + + + O + Fleet + + + + + CN + Fleet Identity + + + + + PayloadIdentifier + com.fleetdm.fleet.mdm.apple.scep + PayloadType + com.apple.security.scep + PayloadUUID + BCA53F9D-5DD2-494D-98D3-0D0F20FF6BA1 + PayloadVersion + 1 + + + +`)) + // enrollmentProfileMobileconfigTemplate is the template Fleet uses to assemble a .mobileconfig enrollment profile to serve to devices. // // During a profile replacement, the system updates payloads with the same PayloadIdentifier and diff --git a/server/mdm/apple/commander_test.go b/server/mdm/apple/commander_test.go index 0d21c66ab5..29138179f4 100644 --- a/server/mdm/apple/commander_test.go +++ b/server/mdm/apple/commander_test.go @@ -19,8 +19,8 @@ import ( "github.com/google/uuid" "github.com/groob/plist" micromdm "github.com/micromdm/micromdm/mdm/mdm" + "github.com/smallstep/pkcs7" "github.com/stretchr/testify/require" - "go.mozilla.org/pkcs7" ) func TestMDMAppleCommander(t *testing.T) { diff --git a/server/mdm/apple/deviceinfo.go b/server/mdm/apple/deviceinfo.go index ebed273690..2f53fe3862 100644 --- a/server/mdm/apple/deviceinfo.go +++ b/server/mdm/apple/deviceinfo.go @@ -36,9 +36,11 @@ import ( "encoding/base64" "errors" "fmt" + "os" + "github.com/fleetdm/fleet/v4/server/fleet" "github.com/groob/plist" - "go.mozilla.org/pkcs7" + "github.com/smallstep/pkcs7" ) const DeviceInfoHeader = "x-apple-aspen-deviceinfo" @@ -48,36 +50,26 @@ const DeviceInfoHeader = "x-apple-aspen-deviceinfo" //go:embed AppleIncRootCertificate.cer var appleRootCert []byte -func newAppleRootCert() *x509.Certificate { - cert, err := x509.ParseCertificate(appleRootCert) +// appleRootCA is Apple's Root CA parsed to an *x509.Certificate +var appleRootCA = newAppleCert(appleRootCert) + +// appleIphoneDeviceCA is the PEM data defined here converted to DER: +// https://developer.apple.com/library/archive/documentation/NetworkingInternet/Conceptual/iPhoneOTAConfiguration/profile-service/profile-service.html#//apple_ref/doc/uid/TP40009505-CH2-SW24 +// +//go:embed AppleIphoneDeviceCA.cer +var appleIphoneDeviceCACert []byte + +// appleIphoneDeviceCA is Apple's Iphone Device CA parsed to an *x509.Certificate +var appleIphoneDeviceCA = newAppleCert(appleIphoneDeviceCACert) + +func newAppleCert(crt []byte) *x509.Certificate { + cert, err := x509.ParseCertificate(crt) if err != nil { panic(fmt.Errorf("could not parse cert: %w", err)) } return cert } -// appleRootCA is Apple's Root CA parsed to an *x509.Certificate -var appleRootCA = newAppleRootCert() - -// MachineInfo is a [device's information] sent as part of an MDM enrollment profile request -// -// [device's information]: https://developer.apple.com/documentation/devicemanagement/machineinfo -type MachineInfo struct { - IMEI string `plist:"IMEI,omitempty"` - Language string `plist:"LANGUAGE,omitempty"` - MDMCanRequestSoftwareUpdate bool `plist:"MDM_CAN_REQUEST_SOFTWARE_UPDATE"` - MEID string `plist:"MEID,omitempty"` - OSVersion string `plist:"OS_VERSION"` - PairingToken string `plist:"PAIRING_TOKEN,omitempty"` - Product string `plist:"PRODUCT"` - Serial string `plist:"SERIAL"` - SoftwareUpdateDeviceID string `plist:"SOFTWARE_UPDATE_DEVICE_ID,omitempty"` - SupplementalBuildVersion string `plist:"SUPPLEMENTAL_BUILD_VERSION,omitempty"` - SupplementalOSVersionExtra string `plist:"SUPPLEMENTAL_OS_VERSION_EXTRA,omitempty"` - UDID string `plist:"UDID"` - Version string `plist:"VERSION"` -} - // verifyPKCS7SHA1RSA performs a manual SHA1withRSA verification, since it's deprecated in Go 1.18. // If verifyChain is true, the signer certificate and its chain of certificates is verified against Apple's Root CA. // Also note that the certificate validity time window of the signing cert is not checked, since the cert is expired. @@ -142,7 +134,7 @@ outer: } // ParseDeviceinfo attempts to parse the provided string, assuming it to be the base64-encoded value -// of an x-apple-aspen-deviceinfo header. If successful, it returns the parsed *MachineInfo. If the +// of an x-apple-aspen-deviceinfo header. If successful, it returns the parsed *fleet.MDMAppleMachineInfo. If the // verify parameter is specified as true, the signature is also verified against Apple's Root CA and // an error will be returned if the signature is invalid. // @@ -152,7 +144,7 @@ outer: // // [documentation]: https://github.com/korylprince/dep-webview-oidc/blob/2dd846a54fed04c16dd227b8c6c31665b4d0ebd8/docs/Architecture.md#x-apple-aspen-deviceinfo-header // [article]: https://duo.com/labs/research/mdm-me-maybe -func ParseDeviceinfo(b64 string, verify bool) (*MachineInfo, error) { +func ParseDeviceinfo(b64 string, verify bool) (*fleet.MDMAppleMachineInfo, error) { buf, err := base64.StdEncoding.DecodeString(b64) if err != nil { return nil, fmt.Errorf("could not decode base64: %w", err) @@ -170,10 +162,46 @@ func ParseDeviceinfo(b64 string, verify bool) (*MachineInfo, error) { } } - info := new(MachineInfo) + info := new(fleet.MDMAppleMachineInfo) if err = plist.Unmarshal(p7.Content, info); err != nil { return nil, fmt.Errorf("could not decode plist: %w", err) } return info, nil } + +// VerifyFromAppleIphoneDeviceCA verifies a certificate was signed by Apple's iPhone Device CA. +// Manually verify the certificate since Go has deprecated verifying SHA1WithRSA x509 certificates. +// +// NOTE: most of this code was taken from micromdm. +func VerifyFromAppleIphoneDeviceCA(c *x509.Certificate) error { + if os.Getenv("FLEET_DEV_MDM_APPLE_DISABLE_DEVICE_INFO_CERT_VERIFY") == "1" { + return nil + } + + var hashType crypto.Hash + + switch c.SignatureAlgorithm { + case x509.SHA1WithRSA: + hashType = crypto.SHA1 + case x509.SHA256WithRSA: + hashType = crypto.SHA256 + default: + return fmt.Errorf("%w: %s", x509.ErrUnsupportedAlgorithm, c.SignatureAlgorithm) + } + + hasher := hashType.New() + hasher.Write(c.RawTBSCertificate) + hashed := hasher.Sum(nil) + + key, ok := appleIphoneDeviceCA.PublicKey.(*rsa.PublicKey) + if !ok { + panic("appleIphoneDeviceCA: invalid key type") + } + + if err := rsa.VerifyPKCS1v15(key, hashType, hashed, c.Signature); err != nil { + return fmt.Errorf("verifying signature: %w", err) + } + + return nil +} diff --git a/server/mdm/apple/gdmf/api.go b/server/mdm/apple/gdmf/api.go index 3fdea7ab51..ee8c671814 100644 --- a/server/mdm/apple/gdmf/api.go +++ b/server/mdm/apple/gdmf/api.go @@ -14,6 +14,7 @@ import ( "github.com/cenkalti/backoff" "github.com/fleetdm/fleet/v4/pkg/fleethttp" + "github.com/fleetdm/fleet/v4/server/fleet" apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" ) @@ -69,7 +70,7 @@ type APIResponse struct { // asset is found, an error is returned. // [1]: http://gdmf.apple.com/v2/pmv // [2]: https://support.apple.com/guide/deployment/use-mdm-to-deploy-software-updates-depafd2fad80/web -func GetLatestOSVersion(device apple_mdm.MachineInfo) (*Asset, error) { +func GetLatestOSVersion(device fleet.MDMAppleMachineInfo) (*Asset, error) { r, err := GetAssetMetadata() if err != nil { return nil, fmt.Errorf("retrieving asset metadata: %w", err) diff --git a/server/mdm/apple/gdmf/api_test.go b/server/mdm/apple/gdmf/api_test.go index 8b78420fb0..14bc7d8061 100644 --- a/server/mdm/apple/gdmf/api_test.go +++ b/server/mdm/apple/gdmf/api_test.go @@ -6,7 +6,7 @@ import ( "os" "testing" - apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" + "github.com/fleetdm/fleet/v4/server/fleet" "github.com/google/uuid" "github.com/stretchr/testify/require" ) @@ -27,7 +27,7 @@ func TestGetLatest(t *testing.T) { t.Setenv("FLEET_DEV_GDMF_URL", srv.URL) // test the function - d := apple_mdm.MachineInfo{ + d := fleet.MDMAppleMachineInfo{ MDMCanRequestSoftwareUpdate: true, OSVersion: "14.4.1", Product: "Mac15,7", @@ -53,14 +53,14 @@ func TestGetLatest(t *testing.T) { tests := []struct { name string - machineInfo apple_mdm.MachineInfo + machineInfo fleet.MDMAppleMachineInfo expectedVersion string expectedBuild string expectError bool }{ { name: "macOS matching software update device ID", - machineInfo: apple_mdm.MachineInfo{ + machineInfo: fleet.MDMAppleMachineInfo{ OSVersion: "14.4.1", Product: "Mac15,7", Serial: "TESTSERIAL", @@ -76,7 +76,7 @@ func TestGetLatest(t *testing.T) { { // macOS generally relies on the SoftwareUpdateDeviceID field and not the Product field name: "macOS non-matching software update device ID", - machineInfo: apple_mdm.MachineInfo{ + machineInfo: fleet.MDMAppleMachineInfo{ OSVersion: "14.4.1", Product: "Mac15,7", Serial: "TESTSERIAL", @@ -93,7 +93,7 @@ func TestGetLatest(t *testing.T) { // this should never happen in practice, but by default we still check macOS assets to // match the software update device ID name: "non-matching product but matching software update device ID", - machineInfo: apple_mdm.MachineInfo{ + machineInfo: fleet.MDMAppleMachineInfo{ OSVersion: "14.4.1", Product: "INVALID", Serial: "TESTSERIAL", @@ -108,7 +108,7 @@ func TestGetLatest(t *testing.T) { }, { name: "non-matching product and software update device ID", - machineInfo: apple_mdm.MachineInfo{ + machineInfo: fleet.MDMAppleMachineInfo{ OSVersion: "14.4.1", Product: "INVALID", Serial: "TESTSERIAL", @@ -125,7 +125,7 @@ func TestGetLatest(t *testing.T) { // missing other fields is not an error, this function always returns the latest // version and only depends on the Product and SoftwareUpdateDeviceID fields name: "missing other fields", - machineInfo: apple_mdm.MachineInfo{ + machineInfo: fleet.MDMAppleMachineInfo{ OSVersion: "", Product: "Mac15,7", SoftwareUpdateDeviceID: "J516sAP", @@ -136,7 +136,7 @@ func TestGetLatest(t *testing.T) { }, { name: "iphone matching product and software update device ID", - machineInfo: apple_mdm.MachineInfo{ + machineInfo: fleet.MDMAppleMachineInfo{ OSVersion: "17.5.1", Product: "iPhone14,6", Serial: "TESTSERIAL", @@ -153,7 +153,7 @@ func TestGetLatest(t *testing.T) { // iOS generally relies on the Product field and not the SoftwareUpdateDeviceID field so // this won't error even though the SoftwareUpdateDeviceID is invalid name: "iphone non-matching software update device ID", - machineInfo: apple_mdm.MachineInfo{ + machineInfo: fleet.MDMAppleMachineInfo{ OSVersion: "17.5.1", Product: "iPhone14,6", Serial: "TESTSERIAL", @@ -170,7 +170,7 @@ func TestGetLatest(t *testing.T) { // this should never happen in practice, but we'll still try to match iOS assets if the // software update device ID starts with "iPhone" or "iPad" name: "missing product but valid iphone software update device ID", - machineInfo: apple_mdm.MachineInfo{ + machineInfo: fleet.MDMAppleMachineInfo{ OSVersion: "17.5.1", Product: "", Serial: "TESTSERIAL", @@ -187,7 +187,7 @@ func TestGetLatest(t *testing.T) { // we don't support other Apple products yet, so this should always error // because we we default to the macOS asset set and we won't find a matching asset there name: "unsupported product", - machineInfo: apple_mdm.MachineInfo{ + machineInfo: fleet.MDMAppleMachineInfo{ OSVersion: "8.8.1", Product: "Watch3,1", Serial: "TESTSERIAL", @@ -230,7 +230,7 @@ func TestRetries(t *testing.T) { os.Unsetenv("FLEET_DEV_GDMF_URL") }) - latest, err := GetLatestOSVersion(apple_mdm.MachineInfo{ + latest, err := GetLatestOSVersion(fleet.MDMAppleMachineInfo{ OSVersion: "14.4.1", Product: "Mac15,7", Serial: "TESTSERIAL", diff --git a/server/mdm/apple/mobileconfig/profiles.go b/server/mdm/apple/mobileconfig/profiles.go index b71d2db5ab..75634aca98 100644 --- a/server/mdm/apple/mobileconfig/profiles.go +++ b/server/mdm/apple/mobileconfig/profiles.go @@ -138,7 +138,7 @@ var OTAMobileConfigTemplate = template.Must(template.New("").Funcs(template.Func UDID VERSION PRODUCT - SERIAL + SERIAL PayloadOrganization diff --git a/server/mdm/assets/assets_test.go b/server/mdm/assets/assets_test.go index 2b60ac4aa3..b6484fd4b0 100644 --- a/server/mdm/assets/assets_test.go +++ b/server/mdm/assets/assets_test.go @@ -18,8 +18,8 @@ import ( "github.com/fleetdm/fleet/v4/server/fleet" nanodep_client "github.com/fleetdm/fleet/v4/server/mdm/nanodep/client" "github.com/fleetdm/fleet/v4/server/mock" + "github.com/smallstep/pkcs7" "github.com/stretchr/testify/require" - "go.mozilla.org/pkcs7" ) // generateTestCert generates a test certificate and key. diff --git a/server/mdm/mdm.go b/server/mdm/mdm.go index 93af98eb3d..5aaae483d8 100644 --- a/server/mdm/mdm.go +++ b/server/mdm/mdm.go @@ -6,7 +6,7 @@ import ( "crypto/x509" "encoding/base64" - "go.mozilla.org/pkcs7" + "github.com/smallstep/pkcs7" ) // MaxProfileRetries is the maximum times an install profile command may be diff --git a/server/mdm/microsoft/microsoft_mdm.go b/server/mdm/microsoft/microsoft_mdm.go index a8a9254bd8..bc56572ee1 100644 --- a/server/mdm/microsoft/microsoft_mdm.go +++ b/server/mdm/microsoft/microsoft_mdm.go @@ -5,7 +5,7 @@ import ( "encoding/base64" "github.com/fleetdm/fleet/v4/server/mdm/internal/commonmdm" - "go.mozilla.org/pkcs7" + "github.com/smallstep/pkcs7" ) const ( diff --git a/server/mdm/microsoft/wstep.go b/server/mdm/microsoft/wstep.go index 68346cde4f..beb0c01ac2 100644 --- a/server/mdm/microsoft/wstep.go +++ b/server/mdm/microsoft/wstep.go @@ -21,7 +21,7 @@ import ( "github.com/fleetdm/fleet/v4/server/mdm/microsoft/syncml" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/cryptoutil" "github.com/golang-jwt/jwt/v4" - "go.mozilla.org/pkcs7" + "github.com/smallstep/pkcs7" ) // CertManager is an interface for certificate management tasks associated with Microsoft MDM (e.g., diff --git a/server/mdm/nanodep/tokenpki/parse.go b/server/mdm/nanodep/tokenpki/parse.go index 6d254403f1..296dc43edf 100644 --- a/server/mdm/nanodep/tokenpki/parse.go +++ b/server/mdm/nanodep/tokenpki/parse.go @@ -11,7 +11,7 @@ import ( "io" "net/textproto" - "go.mozilla.org/pkcs7" + "github.com/smallstep/pkcs7" ) // UnwrapSMIME removes the S/MIME-like header wrapper around the raw encrypted diff --git a/server/mdm/nanomdm/cryptoutil/cryptoutil.go b/server/mdm/nanomdm/cryptoutil/cryptoutil.go index d7e41fcf10..ad62b4587a 100644 --- a/server/mdm/nanomdm/cryptoutil/cryptoutil.go +++ b/server/mdm/nanomdm/cryptoutil/cryptoutil.go @@ -10,7 +10,7 @@ import ( "fmt" "strings" - "go.mozilla.org/pkcs7" + "github.com/smallstep/pkcs7" ) // OID for UID (User ID) attribute diff --git a/server/mdm/nanomdm/cryptoutil/cryptoutil_test.go b/server/mdm/nanomdm/cryptoutil/cryptoutil_test.go index 0c3eda3296..5b5291709c 100644 --- a/server/mdm/nanomdm/cryptoutil/cryptoutil_test.go +++ b/server/mdm/nanomdm/cryptoutil/cryptoutil_test.go @@ -4,7 +4,7 @@ import ( "encoding/base64" "testing" - "go.mozilla.org/pkcs7" + "github.com/smallstep/pkcs7" ) func TestPKCS7ParseTagLengthError(t *testing.T) { diff --git a/server/mdm/scep/scep/scep.go b/server/mdm/scep/scep/scep.go index 25fa1de349..dc2bb1fcc8 100644 --- a/server/mdm/scep/scep/scep.go +++ b/server/mdm/scep/scep/scep.go @@ -19,7 +19,7 @@ import ( "github.com/go-kit/log" "github.com/go-kit/log/level" - "go.mozilla.org/pkcs7" + "github.com/smallstep/pkcs7" ) // errors diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 99d55d1215..2b933b50ca 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -756,6 +756,8 @@ type UpsertMDMAppleHostDEPAssignmentsFunc func(ctx context.Context, hosts []flee type IngestMDMAppleDevicesFromDEPSyncFunc func(ctx context.Context, devices []godep.Device, abmTokenID uint, macOSTeam *fleet.Team, iosTeam *fleet.Team, ipadTeam *fleet.Team) (int64, error) +type IngestMDMAppleDeviceFromOTAEnrollmentFunc func(ctx context.Context, teamID *uint, deviceInfo fleet.MDMAppleMachineInfo) error + type MDMAppleUpsertHostFunc func(ctx context.Context, mdmHost *fleet.Host) error type RestoreMDMApplePendingDEPHostFunc func(ctx context.Context, host *fleet.Host) error @@ -2173,6 +2175,9 @@ type DataStore struct { IngestMDMAppleDevicesFromDEPSyncFunc IngestMDMAppleDevicesFromDEPSyncFunc IngestMDMAppleDevicesFromDEPSyncFuncInvoked bool + IngestMDMAppleDeviceFromOTAEnrollmentFunc IngestMDMAppleDeviceFromOTAEnrollmentFunc + IngestMDMAppleDeviceFromOTAEnrollmentFuncInvoked bool + MDMAppleUpsertHostFunc MDMAppleUpsertHostFunc MDMAppleUpsertHostFuncInvoked bool @@ -5220,6 +5225,13 @@ func (s *DataStore) IngestMDMAppleDevicesFromDEPSync(ctx context.Context, device return s.IngestMDMAppleDevicesFromDEPSyncFunc(ctx, devices, abmTokenID, macOSTeam, iosTeam, ipadTeam) } +func (s *DataStore) IngestMDMAppleDeviceFromOTAEnrollment(ctx context.Context, teamID *uint, deviceInfo fleet.MDMAppleMachineInfo) error { + s.mu.Lock() + s.IngestMDMAppleDeviceFromOTAEnrollmentFuncInvoked = true + s.mu.Unlock() + return s.IngestMDMAppleDeviceFromOTAEnrollmentFunc(ctx, teamID, deviceInfo) +} + func (s *DataStore) MDMAppleUpsertHost(ctx context.Context, mdmHost *fleet.Host) error { s.mu.Lock() s.MDMAppleUpsertHostFuncInvoked = true diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go index 3072cd710c..da2c75f68f 100644 --- a/server/service/apple_mdm.go +++ b/server/service/apple_mdm.go @@ -44,6 +44,7 @@ import ( "github.com/go-kit/log/level" "github.com/google/uuid" "github.com/groob/plist" + "go.mozilla.org/pkcs7" ) type getMDMAppleCommandResultsRequest struct { @@ -1276,8 +1277,7 @@ func (mdmAppleEnrollRequest) DecodeRequest(ctx context.Context, r *http.Request) InternalErr: err, } } - p := fleet.MDMAppleMachineInfo(*parsed) - decoded.MachineInfo = &p + decoded.MachineInfo = parsed } return &decoded, nil @@ -1456,7 +1456,7 @@ func (svc *Service) needsOSUpdateForDEPEnrollment(ctx context.Context, m fleet.M } func (svc *Service) getAppleSoftwareUpdateRequiredForDEPEnrollment(m fleet.MDMAppleMachineInfo) (*fleet.MDMAppleSoftwareUpdateRequired, error) { - latest, err := gdmf.GetLatestOSVersion(apple_mdm.MachineInfo(m)) + latest, err := gdmf.GetLatestOSVersion(m) if err != nil { return nil, err } @@ -4189,3 +4189,182 @@ func (svc *Service) GetOTAProfile(ctx context.Context, enrollSecret string) ([]b return signed, nil } + +//////////////////////////////////////////////////////////////////////////////// +// POST /ota_enrollment?enroll_secret=xyz +//////////////////////////////////////////////////////////////////////////////// + +type mdmAppleOTARequest struct { + EnrollSecret string `query:"enroll_secret"` + Certificates []*x509.Certificate + RootSigner *x509.Certificate + DeviceInfo fleet.MDMAppleMachineInfo +} + +func (mdmAppleOTARequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) { + enrollSecret := r.URL.Query().Get("enroll_secret") + if enrollSecret == "" { + return nil, &fleet.BadRequestError{ + Message: "enroll_secret query parameter is required", + } + } + + rawData, err := io.ReadAll(r.Body) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "reading body from request") + } + + p7, err := pkcs7.Parse(rawData) + if err != nil { + return nil, &fleet.BadRequestError{ + Message: "invalid request body", + InternalErr: err, + } + } + + var request mdmAppleOTARequest + err = plist.Unmarshal(p7.Content, &request.DeviceInfo) + if err != nil { + return nil, &fleet.BadRequestError{ + Message: "invalid request body", + InternalErr: err, + } + } + + if request.DeviceInfo.Serial == "" { + return nil, &fleet.BadRequestError{ + Message: "SERIAL is required", + } + } + + request.EnrollSecret = enrollSecret + request.Certificates = p7.Certificates + request.RootSigner = p7.GetOnlySigner() + return &request, nil +} + +type mdmAppleOTAResponse struct { + Err error `json:"error,omitempty"` + xml []byte +} + +func (r mdmAppleOTAResponse) error() error { return r.Err } + +func (r mdmAppleOTAResponse) hijackRender(ctx context.Context, w http.ResponseWriter) { + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(r.xml))) + w.Header().Set("Content-Type", "application/x-apple-aspen-config") + w.Header().Set("X-Content-Type-Options", "nosniff") + if _, err := w.Write(r.xml); err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } +} + +func mdmAppleOTAEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + req := request.(*mdmAppleOTARequest) + xml, err := svc.MDMAppleProcessOTAEnrollment(ctx, req.Certificates, req.RootSigner, req.EnrollSecret, req.DeviceInfo) + if err != nil { + return mdmAppleGetInstallerResponse{Err: err}, nil + } + return mdmAppleOTAResponse{xml: xml}, nil +} + +// NOTE: this method and how OTA works is documented in full in the interface definition. +func (svc *Service) MDMAppleProcessOTAEnrollment( + ctx context.Context, + certificates []*x509.Certificate, + rootSigner *x509.Certificate, + enrollSecret string, + deviceInfo fleet.MDMAppleMachineInfo, +) ([]byte, error) { + // authorization is performed via the enroll secret and the provided certificates + svc.authz.SkipAuthorization(ctx) + + if len(certificates) == 0 { + return nil, authz.ForbiddenWithInternal("no certificates provided", nil, nil, nil) + } + + // first check is for the enroll secret, we'll only let the host + // through if it has a valid secret. + enrollSecretInfo, err := svc.ds.VerifyEnrollSecret(ctx, enrollSecret) + if err != nil { + if fleet.IsNotFound(err) { + return nil, authz.ForbiddenWithInternal("invalid enroll secret provided", nil, nil, nil) + } + + return nil, ctxerr.Wrap(ctx, err, "validating enroll secret") + } + + assets, err := svc.ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{ + fleet.MDMAssetSCEPChallenge, + }) + if err != nil { + return nil, fmt.Errorf("loading SCEP challenge from the database: %w", err) + } + scepChallenge := string(assets[fleet.MDMAssetSCEPChallenge].Value) + + appCfg, err := svc.ds.AppConfig(ctx) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "reading app config") + } + fleetURL := appCfg.ServerSettings.ServerURL + + // if the root signer was issued by Apple's CA, it means we're in the + // first phase and we should return a SCEP payload. + if err := apple_mdm.VerifyFromAppleIphoneDeviceCA(rootSigner); err == nil { + scepURL, err := apple_mdm.ResolveAppleSCEPURL(fleetURL) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "resolve Apple SCEP url") + } + + var buf bytes.Buffer + if err := apple_mdm.OTASCEPTemplate.Execute(&buf, struct { + SCEPURL string + SCEPChallenge string + }{ + SCEPURL: scepURL, + SCEPChallenge: scepChallenge, + }); err != nil { + return nil, ctxerr.Wrap(ctx, err, "execute template") + } + return buf.Bytes(), nil + } + + // otherwise we might be in the second phase, check if the signing cert + // was issued by Fleet, only let the enrollment through if so. + certVerifier := mdmcrypto.NewSCEPVerifier(svc.ds) + if err := certVerifier.Verify(rootSigner); err != nil { + return nil, authz.ForbiddenWithInternal(fmt.Sprintf("payload signed with invalid certificate: %s", err), nil, nil, nil) + } + + topic, err := svc.mdmPushCertTopic(ctx) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "extracting topic from APNs cert") + } + + enrollmentProf, err := apple_mdm.GenerateEnrollmentProfileMobileconfig( + appCfg.OrgInfo.OrgName, + appCfg.ServerSettings.ServerURL, + string(assets[fleet.MDMAssetSCEPChallenge].Value), + topic, + ) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "generating manual enrollment profile") + } + + // before responding, create a host record, and assign the host to the + // team that matches the enroll secret provided. + err = svc.ds.IngestMDMAppleDeviceFromOTAEnrollment(ctx, enrollSecretInfo.TeamID, deviceInfo) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "creating new host record") + } + + // at this point we know the device can be enrolled, so we respond with + // a signed enrollment profile + signed, err := mdmcrypto.Sign(ctx, enrollmentProf, svc.ds) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "signing profile") + } + + return signed, nil +} diff --git a/server/service/apple_mdm_test.go b/server/service/apple_mdm_test.go index a8b20b04ec..3db0377126 100644 --- a/server/service/apple_mdm_test.go +++ b/server/service/apple_mdm_test.go @@ -49,9 +49,9 @@ import ( "github.com/google/uuid" "github.com/groob/plist" micromdm "github.com/micromdm/micromdm/mdm/mdm" + "github.com/smallstep/pkcs7" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "go.mozilla.org/pkcs7" ) type nopProfileMatcher struct{} diff --git a/server/service/handler.go b/server/service/handler.go index 23b30a3461..c7b8c11cb4 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -870,6 +870,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC neAppleMDM.GET(apple_mdm.EnrollPath, mdmAppleEnrollEndpoint, mdmAppleEnrollRequest{}) neAppleMDM.GET(apple_mdm.InstallerPath, mdmAppleGetInstallerEndpoint, mdmAppleGetInstallerRequest{}) neAppleMDM.HEAD(apple_mdm.InstallerPath, mdmAppleHeadInstallerEndpoint, mdmAppleHeadInstallerRequest{}) + neAppleMDM.POST("/api/_version_/fleet/ota_enrollment", mdmAppleOTAEndpoint, mdmAppleOTARequest{}) // Deprecated: GET /mdm/bootstrap is now deprecated, replaced by the // GET /bootstrap endpoint. diff --git a/server/service/hosts_test.go b/server/service/hosts_test.go index d44f9fcf4d..26611cb829 100644 --- a/server/service/hosts_test.go +++ b/server/service/hosts_test.go @@ -26,9 +26,9 @@ import ( "github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/test" kitlog "github.com/go-kit/log" + "github.com/smallstep/pkcs7" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "go.mozilla.org/pkcs7" ) func TestHostDetails(t *testing.T) { diff --git a/server/service/integration_mdm_lifecycle_test.go b/server/service/integration_mdm_lifecycle_test.go index b487cd1db3..27c1458c40 100644 --- a/server/service/integration_mdm_lifecycle_test.go +++ b/server/service/integration_mdm_lifecycle_test.go @@ -28,8 +28,8 @@ import ( "github.com/groob/plist" "github.com/jmoiron/sqlx" micromdm "github.com/micromdm/micromdm/mdm/mdm" + "github.com/smallstep/pkcs7" "github.com/stretchr/testify/require" - "go.mozilla.org/pkcs7" ) // NOTE: the mantra for lifecycle events is: diff --git a/server/service/integration_mdm_profiles_test.go b/server/service/integration_mdm_profiles_test.go index c60d0580e2..fdec8458e2 100644 --- a/server/service/integration_mdm_profiles_test.go +++ b/server/service/integration_mdm_profiles_test.go @@ -30,9 +30,9 @@ import ( "github.com/fleetdm/fleet/v4/server/test" "github.com/google/uuid" "github.com/jmoiron/sqlx" + "github.com/smallstep/pkcs7" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "go.mozilla.org/pkcs7" ) func (s *integrationMDMTestSuite) signedProfilesMatch(want, got [][]byte) { diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index 63ab882da1..9fcd7ec7a4 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -66,10 +66,10 @@ import ( "github.com/groob/plist" "github.com/jmoiron/sqlx" micromdm "github.com/micromdm/micromdm/mdm/mdm" + "github.com/smallstep/pkcs7" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" - "go.mozilla.org/pkcs7" ) func TestIntegrationsMDM(t *testing.T) { @@ -10993,3 +10993,145 @@ func (s *integrationMDMTestSuite) TestEnrollmentProfilesWithSpecialChars() { require.NoError(t, err) require.Equal(t, enrollSecretWithInvalidChars, parsedData.PayloadContent[0].EnrollSecret) } + +func (s *integrationMDMTestSuite) TestOTAEnrollment() { + t := s.T() + + // create a global enroll secret + globalSecret := "global_secret" + var applyResp applyEnrollSecretSpecResponse + s.DoJSON("POST", "/api/latest/fleet/spec/enroll_secret", applyEnrollSecretSpecRequest{ + Spec: &fleet.EnrollSecretSpec{ + Secrets: []*fleet.EnrollSecret{{Secret: globalSecret}}, + }, + }, http.StatusOK, &applyResp) + + reqBody := []byte(` + + + + PRODUCT + + SERIAL + foo + UDID + + VERSION + + +`) + + // request with no enroll secret + httpResp := s.DoRawNoAuth("POST", "/api/latest/fleet/ota_enrollment", reqBody, http.StatusBadRequest) + errMsg := extractServerErrorText(httpResp.Body) + require.Contains(t, errMsg, "enroll_secret query parameter is required") + require.NoError(t, httpResp.Body.Close()) + + // request with no body + httpResp = s.DoRawNoAuth("POST", "/api/latest/fleet/ota_enrollment?enroll_secret=foo", nil, http.StatusBadRequest) + errMsg = extractServerErrorText(httpResp.Body) + require.Contains(t, errMsg, "invalid request body") + require.NoError(t, httpResp.Body.Close()) + + // request with unsigned body + httpResp = s.DoRawNoAuth("POST", "/api/latest/fleet/ota_enrollment?enroll_secret=foo", reqBody, http.StatusBadRequest) + errMsg = extractServerErrorText(httpResp.Body) + require.Contains(t, errMsg, "invalid request body") + require.NoError(t, httpResp.Body.Close()) + + cert, key, err := apple_mdm.NewSCEPCACertKey() + require.NoError(t, err) + signedData, err := pkcs7.NewSignedData(reqBody) + require.NoError(t, err) + require.NoError(t, signedData.AddSigner(cert, key, pkcs7.SignerInfoConfig{})) + signedReqBody, err := signedData.Finish() + require.NoError(t, err) + + // request with invalid apple signature + httpResp = s.DoRawNoAuth("POST", "/api/latest/fleet/ota_enrollment?enroll_secret=foo", signedReqBody, http.StatusForbidden) + errMsg = extractServerErrorText(httpResp.Body) + require.Contains(t, errMsg, "forbidden") + require.NoError(t, httpResp.Body.Close()) + + // request with invalid device signature + os.Setenv("FLEET_DEV_MDM_APPLE_DISABLE_DEVICE_INFO_CERT_VERIFY", "1") + httpResp = s.DoRawNoAuth("POST", "/api/latest/fleet/ota_enrollment?enroll_secret=foo", signedReqBody, http.StatusForbidden) + errMsg = extractServerErrorText(httpResp.Body) + require.Contains(t, errMsg, "forbidden") + require.NoError(t, httpResp.Body.Close()) + + // request without serial number + signedData, err = pkcs7.NewSignedData([]byte(` + + + + SERIAL + + +`)) + require.NoError(t, err) + require.NoError(t, signedData.AddSigner(cert, key, pkcs7.SignerInfoConfig{})) + signedReqBody, err = signedData.Finish() + require.NoError(t, err) + httpResp = s.DoRawNoAuth("POST", "/api/latest/fleet/ota_enrollment?enroll_secret=foo", signedReqBody, http.StatusBadRequest) + errMsg = extractServerErrorText(httpResp.Body) + require.Contains(t, errMsg, "SERIAL is required") + require.NoError(t, httpResp.Body.Close()) + + checkInstallFleetdCommandSent := func(mdmDevice *mdmtest.TestAppleMDMClient, wantCommand bool) { + foundInstallFleetdCommand := false + cmd, err := mdmDevice.Idle() + require.NoError(t, err) + for cmd != nil { + var fullCmd micromdm.CommandPayload + require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd)) + if manifest := fullCmd.Command.InstallEnterpriseApplication.ManifestURL; manifest != nil { + foundInstallFleetdCommand = true + require.Equal(t, "InstallEnterpriseApplication", cmd.Command.RequestType) + require.Contains(t, *fullCmd.Command.InstallEnterpriseApplication.ManifestURL, fleetdbase.GetPKGManifestURL()) + } + cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + } + require.Equal(t, wantCommand, foundInstallFleetdCommand) + } + + hwModel := "MacBookPro16,1" + mdmDevice := mdmtest.NewTestMDMClientAppleOTA( + s.server.URL, + globalSecret, + hwModel, + ) + require.NoError(t, mdmDevice.Enroll()) + s.runWorker() + checkInstallFleetdCommandSent(mdmDevice, true) + + var hostByIdentifierResp getHostResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/identifier/%s", mdmDevice.UUID), nil, http.StatusOK, &hostByIdentifierResp) + require.Equal(t, hwModel, hostByIdentifierResp.Host.HardwareModel) + require.Equal(t, "darwin", hostByIdentifierResp.Host.Platform) + require.Nil(t, hostByIdentifierResp.Host.TeamID) + + // create a team with a different enroll secret + var specResp applyTeamSpecsResponse + teamSecret := "team_secret" + teamSpecs := applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{Name: "newteam", Secrets: &[]fleet.EnrollSecret{{Secret: teamSecret}}}}} + s.DoJSON("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK, &specResp) + + hwModel = "iPad13,16" + mdmDevice = mdmtest.NewTestMDMClientAppleOTA( + s.server.URL, + teamSecret, + hwModel, + ) + require.NoError(t, mdmDevice.Enroll()) + s.runWorker() + checkInstallFleetdCommandSent(mdmDevice, false) + + hostByIdentifierResp = getHostResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/identifier/%s", mdmDevice.UUID), nil, http.StatusOK, &hostByIdentifierResp) + require.Equal(t, hwModel, hostByIdentifierResp.Host.HardwareModel) + require.Equal(t, "ipados", hostByIdentifierResp.Host.Platform) + require.NotNil(t, hostByIdentifierResp.Host.TeamID) + require.Equal(t, specResp.TeamIDsByName["newteam"], *hostByIdentifierResp.Host.TeamID) +} From ff9cb683859b70d24b8c93f47a2ce8e6d0c50447 Mon Sep 17 00:00:00 2001 From: Roberto Dip Date: Tue, 10 Sep 2024 16:52:26 -0300 Subject: [PATCH 19/28] update UI OTA template with late copy changes based on device (#21957) for #19448 # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Manual QA for all new/changed functionality --- frontend/templates/enroll-ota.html | 50 ++++++++++++++++++++---------- 1 file changed, 33 insertions(+), 17 deletions(-) diff --git a/frontend/templates/enroll-ota.html b/frontend/templates/enroll-ota.html index 6aafd2870d..e17e7b317e 100644 --- a/frontend/templates/enroll-ota.html +++ b/frontend/templates/enroll-ota.html @@ -116,10 +116,15 @@
-

Enroll your device to Fleet

+

+ Enroll your + iPhone or iPad to + Fleet +

- Follow the instructions below to download and install the Fleet profile - on your device. + On your + iPhone or iPad, follow + the instructions below to download and install the Fleet profile.

  1. @@ -159,22 +164,33 @@
From 8d651b477c456df420f4862827cac3c418f6902f Mon Sep 17 00:00:00 2001 From: Sarah Gillespie <73313222+gillespi314@users.noreply.github.com> Date: Tue, 10 Sep 2024 15:55:22 -0500 Subject: [PATCH 20/28] Fix bug where Fleet cannot start with expired ABM cert (#21929) --- changes/21866-startup-expired-abm-cert | 2 + cmd/fleet/serve.go | 147 +++++++++++++++---------- server/config/config.go | 18 ++- 3 files changed, 95 insertions(+), 72 deletions(-) create mode 100644 changes/21866-startup-expired-abm-cert diff --git a/changes/21866-startup-expired-abm-cert b/changes/21866-startup-expired-abm-cert new file mode 100644 index 0000000000..f9e74bb641 --- /dev/null +++ b/changes/21866-startup-expired-abm-cert @@ -0,0 +1,2 @@ +- Fixed issue where Fleet server could start when expired ABM cerfificate was provided as server + config options. diff --git a/cmd/fleet/serve.go b/cmd/fleet/serve.go index c8773ba765..19dfd798aa 100644 --- a/cmd/fleet/serve.go +++ b/cmd/fleet/serve.go @@ -499,7 +499,18 @@ the way that the Fleet server works. mdmPushService = nanomdm_pushsvc.New(mdmStorage, mdmStorage, pushProviderFactory, nanoMDMLogger) } - // validate Apple APNs/SCEP config + checkMDMAssets := func(names []fleet.MDMAssetName) (bool, error) { + _, err = ds.GetAllMDMConfigAssetsByName(context.Background(), names) + if err != nil { + if fleet.IsNotFound(err) || errors.Is(err, mysql.ErrPartialResult) { + return false, nil + } + return false, err + } + return true, nil + } + + // reconcile Apple Business Manager configuration environment variables with the database if config.MDM.IsAppleAPNsSet() || config.MDM.IsAppleSCEPSet() { if !config.MDM.IsAppleAPNsSet() { initFatal(errors.New("Apple APNs MDM configuration must be provided when Apple SCEP is provided"), "validate Apple MDM") @@ -508,40 +519,63 @@ the way that the Fleet server works. } if len(config.Server.PrivateKey) == 0 { - initFatal(errors.New("inserting APNs and SCEP assets"), "missing required private key. Learn how to configure the private key here: https://fleetdm.com/learn-more-about/fleet-server-private-key") + initFatal(errors.New("inserting MDM APNs and SCEP assets"), "missing required private key. Learn how to configure the private key here: https://fleetdm.com/learn-more-about/fleet-server-private-key") } + // parse the APNs and SCEP assets from the config _, apnsCertPEM, apnsKeyPEM, err := config.MDM.AppleAPNs() if err != nil { - initFatal(err, "validate Apple APNs certificate and key") + initFatal(err, "parse Apple APNs certificate and key from config") } - _, appleSCEPCertPEM, appleSCEPKeyPEM, err := config.MDM.AppleSCEP() if err != nil { - initFatal(err, "validate Apple SCEP certificate and key") + initFatal(err, "load Apple SCEP certificate and key from config") } - err = ds.InsertMDMConfigAssets(context.Background(), []fleet.MDMConfigAsset{ - {Name: fleet.MDMAssetAPNSCert, Value: apnsCertPEM}, - {Name: fleet.MDMAssetAPNSKey, Value: apnsKeyPEM}, - {Name: fleet.MDMAssetCACert, Value: appleSCEPCertPEM}, - {Name: fleet.MDMAssetCAKey, Value: appleSCEPKeyPEM}, - }) - if err != nil { - // duplicate key errors mean that we already - // have a value for those keys in the - // database, fail to initalize on other - // cases. - if !mysql.IsDuplicate(err) { - initFatal(err, "inserting MDM APNs and SCEP assets") - } + // first we'll check if the APNs and SCEP assets are already in the database and + // only insert config values if they're not already present in the database + toInsert := make([]fleet.MDMConfigAsset, 0, 4) - level.Warn(logger).Log("msg", "Your server already has stored SCEP and APNs certificates. Fleet will ignore any certificates provided via environment variables when this happens.") + // check DB for APNs assets + found, err := checkMDMAssets([]fleet.MDMAssetName{fleet.MDMAssetAPNSCert, fleet.MDMAssetAPNSKey}) + switch { + case err != nil: + initFatal(err, "reading APNs assets from database") + case !found: + toInsert = append(toInsert, fleet.MDMConfigAsset{Name: fleet.MDMAssetAPNSCert, Value: apnsCertPEM}, fleet.MDMConfigAsset{Name: fleet.MDMAssetAPNSKey, Value: apnsKeyPEM}) + default: + level.Warn(logger).Log("msg", "Your server already has stored APNs certificates. Fleet will ignore any certificates provided via environment variables when this happens.") + } + + // check DB for SCEP assets + found, err = checkMDMAssets([]fleet.MDMAssetName{fleet.MDMAssetCACert, fleet.MDMAssetCAKey}) + switch { + case err != nil: + initFatal(err, "reading SCEP assets from database") + case !found: + toInsert = append(toInsert, fleet.MDMConfigAsset{Name: fleet.MDMAssetCACert, Value: appleSCEPCertPEM}, fleet.MDMConfigAsset{Name: fleet.MDMAssetCAKey, Value: appleSCEPKeyPEM}) + default: + level.Warn(logger).Log("msg", "Your server already has stored SCEP certificates. Fleet will ignore any certificates provided via environment variables when this happens.") + } + + if len(toInsert) > 0 { + if len(config.Server.PrivateKey) == 0 { + initFatal(errors.New("inserting APNs and SCEP assets"), "missing required private key. Learn how to configure the private key here: https://fleetdm.com/learn-more-about/fleet-server-private-key") + } + if err := ds.InsertMDMConfigAssets(context.Background(), toInsert); err != nil { + if mysql.IsDuplicate(err) { + // we already checked for existing assets so we should never have a duplicate key error here; we'll add a debug log just in case + level.Debug(logger).Log("msg", "unexpected duplicate key error inserting MDM APNs and SCEP assets") + } else { + initFatal(err, "inserting MDM APNs and SCEP assets") + } + } } } - // validate Apple BM config + // reconcile Apple Business Manager configuration environment variables with the database if config.MDM.IsAppleBMSet() { + // TODO: Confirm whether we should have any fatal license errors if !license.IsPremium() { initFatal(errors.New("Apple Business Manager configuration is only available in Fleet Premium"), "validate Apple BM") } @@ -552,36 +586,38 @@ the way that the Fleet server works. appleBM, err := config.MDM.AppleBM() if err != nil { - initFatal(err, "validate Apple BM token, certificate and key") + initFatal(err, "parse Apple BM token, certificate and key from config") } - err = ds.InsertMDMConfigAssets(context.Background(), []fleet.MDMConfigAsset{ - {Name: fleet.MDMAssetABMKey, Value: appleBM.KeyPEM}, - {Name: fleet.MDMAssetABMCert, Value: appleBM.CertPEM}, - }) - if err != nil { - // duplicate key errors mean that we already - // have a value for those keys in the - // database, fail to initalize on other - // cases. - if !mysql.IsDuplicate(err) { - initFatal(err, "inserting MDM ABM assets") - } + toInsert := make([]fleet.MDMConfigAsset, 0, 4) + found, err := checkMDMAssets([]fleet.MDMAssetName{fleet.MDMAssetABMKey, fleet.MDMAssetABMCert}) + switch { + case err != nil: + initFatal(err, "reading ABM assets from database") + case !found: + toInsert = append(toInsert, fleet.MDMConfigAsset{Name: fleet.MDMAssetABMKey, Value: appleBM.KeyPEM}, fleet.MDMConfigAsset{Name: fleet.MDMAssetABMCert, Value: appleBM.CertPEM}) + default: level.Warn(logger).Log("msg", "Your server already has stored ABM certificates and token. Fleet will ignore any certificates provided via environment variables when this happens.") - } else { - // insert the ABM token without any metdata, - // it'll be picked by the - // apple_mdm_dep_profile_assigner cron and - // backfilled - tok := &fleet.ABMToken{ - EncryptedToken: appleBM.EncryptedToken, - // 2000-01-01 is our "zero value" for time - RenewAt: time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC), - } - _, err = ds.InsertABMToken(context.Background(), tok) - if err != nil { - initFatal(err, "save ABM token") + } + + if len(toInsert) > 0 { + err := ds.InsertMDMConfigAssets(context.Background(), toInsert) + switch { + case err != nil && mysql.IsDuplicate(err): + // we already checked for existing assets so we should never have a duplicate key error here; we'll add a debug log just in case + level.Debug(logger).Log("msg", "unexpected duplicate key error inserting ABM assets") + case err != nil: + initFatal(err, "inserting ABM assets") + default: + // insert the ABM token without any metdata; it'll be picked by the + // apple_mdm_dep_profile_assigner cron and backfilled + if _, err := ds.InsertABMToken(context.Background(), &fleet.ABMToken{ + EncryptedToken: appleBM.EncryptedToken, + RenewAt: time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC), // 2000-01-01 is our "zero value" for time + }); err != nil { + initFatal(err, "save ABM token") + } } } } @@ -591,17 +627,6 @@ the way that the Fleet server works. initFatal(err, "loading app config") } - checkMDMAssets := func(names []fleet.MDMAssetName) (bool, error) { - _, err = ds.GetAllMDMConfigAssetsByName(context.Background(), names) - if err != nil { - if fleet.IsNotFound(err) || errors.Is(err, mysql.ErrPartialResult) { - return false, nil - } - return false, err - } - return true, nil - } - appCfg.MDM.EnabledAndConfigured = false appCfg.MDM.AppleBMEnabledAndConfigured = false if len(config.Server.PrivateKey) > 0 { @@ -612,7 +637,7 @@ the way that the Fleet server works. fleet.MDMAssetAPNSCert, }) if err != nil { - initFatal(err, "validating MDM assets from database") + initFatal(err, "loading MDM assets from database") } var appleBMCerts bool @@ -621,14 +646,14 @@ the way that the Fleet server works. fleet.MDMAssetABMKey, }) if err != nil { - initFatal(err, "validating MDM ABM assets from database") + initFatal(err, "loading MDM ABM assets from database") } if appleBMCerts { // the ABM certs are there, check if a token exists and if so, apple // BM is enabled and configured. count, err := ds.GetABMTokenCount(context.Background()) if err != nil { - initFatal(err, "validating MDM ABM token from database") + initFatal(err, "loading MDM ABM token from database") } appCfg.MDM.AppleBMEnabledAndConfigured = count > 0 } diff --git a/server/config/config.go b/server/config/config.go index 9e4cb6003a..3bc6a351fa 100644 --- a/server/config/config.go +++ b/server/config/config.go @@ -622,6 +622,7 @@ type CalendarConfig struct { func (c *CalendarConfig) AlwaysReloadEvent() bool { return c.alwaysReloadEvent } + func (c *CalendarConfig) SetAlwaysReloadEvent(value bool) { c.alwaysReloadEvent = value } @@ -714,8 +715,7 @@ func (m *MDMConfig) IsAppleBMSet() bool { return pair.IsSet() || m.AppleBMServerToken != "" || m.AppleBMServerTokenBytes != "" } -// AppleAPNs returns the parsed and validated TLS certificate for Apple APNs. -// It parses and validates it if it hasn't been done yet. +// AppleAPNs returns the parsed TLS certificate for Apple APNs. func (m *MDMConfig) AppleAPNs() (cert *tls.Certificate, pemCert, pemKey []byte, err error) { if m.appleAPNs == nil { pair := x509KeyPairConfig{ @@ -735,8 +735,7 @@ func (m *MDMConfig) AppleAPNs() (cert *tls.Certificate, pemCert, pemKey []byte, return m.appleAPNs, m.appleAPNsPEMCert, m.appleAPNsPEMKey, nil } -// AppleSCEP returns the parsed and validated TLS certificate for Apple SCEP. -// It parses and validates it if it hasn't been done yet. +// AppleSCEP returns the parsed TLS certificate for Apple SCEP. func (m *MDMConfig) AppleSCEP() (cert *tls.Certificate, pemCert, pemKey []byte, err error) { if m.appleSCEP == nil { pair := x509KeyPairConfig{ @@ -763,7 +762,7 @@ type ParsedAppleBM struct { Token *nanodep_client.OAuth1Tokens } -func decryptAndValidateABMToken(tokenBytes []byte, cert *x509.Certificate, keyPEM []byte) (*nanodep_client.OAuth1Tokens, error) { +func decryptABMToken(tokenBytes []byte, cert *x509.Certificate, keyPEM []byte) (*nanodep_client.OAuth1Tokens, error) { bmKey, err := tokenpki.RSAKeyFromPEM(keyPEM) if err != nil { return nil, fmt.Errorf("Apple BM configuration: parse private key: %w", err) @@ -776,14 +775,11 @@ func decryptAndValidateABMToken(tokenBytes []byte, cert *x509.Certificate, keyPE if err := json.Unmarshal(token, &jsonTok); err != nil { return nil, fmt.Errorf("Apple BM configuration: unmarshal JSON token: %w", err) } - if jsonTok.AccessTokenExpiry.Before(time.Now()) { - return nil, errors.New("Apple BM configuration: token is expired") - } return &jsonTok, nil } -// AppleBM returns the parsed, validated and decrypted server token for Apple -// Business Manager. It also parses and validates the Apple BM certificate and +// AppleBM returns the parsed and decrypted server token for Apple +// Business Manager. It also parses the Apple BM certificate and // private key in the process, in order to decrypt the token. func (m *MDMConfig) AppleBM() (*ParsedAppleBM, error) { if m.appleBMToken == nil { @@ -801,7 +797,7 @@ func (m *MDMConfig) AppleBM() (*ParsedAppleBM, error) { if err != nil { return nil, fmt.Errorf("Apple BM configuration: %w", err) } - jsonTok, err := decryptAndValidateABMToken(encToken, cert.Leaf, pair.keyBytes) + jsonTok, err := decryptABMToken(encToken, cert.Leaf, pair.keyBytes) if err != nil { return nil, err } From e1114d9356b65972878eabf97f30c27d72cb1a2b Mon Sep 17 00:00:00 2001 From: Noah Talerman <47070608+noahtalerman@users.noreply.github.com> Date: Tue, 10 Sep 2024 17:04:10 -0400 Subject: [PATCH 21/28] Update copy in Delete script modal (#21907) Script runs aren't canceled: #21889 --- .../components/DeleteScriptModal/DeleteScriptModal.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/frontend/pages/ManageControlsPage/Scripts/components/DeleteScriptModal/DeleteScriptModal.tsx b/frontend/pages/ManageControlsPage/Scripts/components/DeleteScriptModal/DeleteScriptModal.tsx index 4d67aea8b6..1935b38b11 100644 --- a/frontend/pages/ManageControlsPage/Scripts/components/DeleteScriptModal/DeleteScriptModal.tsx +++ b/frontend/pages/ManageControlsPage/Scripts/components/DeleteScriptModal/DeleteScriptModal.tsx @@ -42,9 +42,10 @@ const DeleteScriptModal = ({ > <>

- This action will cancel script{" "} - {scriptName} from - running on macOS hosts on which the script hasn't run yet. + The script{" "} + {scriptName} will + run on pending hosts. After the scripts runs, it's output and + exit code will appear in the activity feed.