diff --git a/.github/ISSUE_TEMPLATE/digital-experience-request.md b/.github/ISSUE_TEMPLATE/digital-experience-request.md deleted file mode 100644 index 97b697ec8037..000000000000 --- a/.github/ISSUE_TEMPLATE/digital-experience-request.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -name: 🌐 Digital Experience request [FKA Website request] -about: Propose a new feature or enhancement to fleetdm.com. -title: 'TODO: ' -labels: '#g-digital-experience' -assignees: '' - ---- - -## Goal - -| User story | -|:---------------------------------------------------------------------------| -| As a _________________________________________, -| I want to _________________________________________ -| so that I can _________________________________________. - ->For help creating a user story, see ["Writing a good user story"](https://fleetdm.com/handbook/company/development-groups#writing-a-good-user-story) in the website handbook. - - -## How? - -- [ ] TODO - -### Context - - - diff --git a/.github/workflows/test-db-changes.yml b/.github/workflows/test-db-changes.yml index 2bd89ab82b1e..10d1ec4ddea2 100644 --- a/.github/workflows/test-db-changes.yml +++ b/.github/workflows/test-db-changes.yml @@ -40,33 +40,6 @@ jobs: with: fetch-depth: 0 - - name: Install Go - uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 - with: - go-version-file: 'go.mod' - - - name: Start Infra Dependencies - # Use & to background this - run: docker compose up -d mysql_test & - - - name: Wait for mysql - run: | - echo "waiting for mysql..." - until docker compose exec -T mysql_test sh -c "mysql -uroot -p\"\${MYSQL_ROOT_PASSWORD}\" -e \"SELECT 1=1\" fleet" &> /dev/null; do - echo "." - sleep 1 - done - echo "mysql is ready" - - - name: Verify test schema changes - run: | - make dump-test-schema - if [[ $(git diff server/datastore/mysql/schema.sql) ]]; then - echo "❌ fail: uncommited changes in schema.sql" - echo "please run `make dump-test-schema` and commit the changes" - exit 1 - fi - # TODO: This doesn't cover all scenarios since other PRs might # be merged into `main` after this check has passed. # @@ -84,8 +57,8 @@ jobs: base_ref=$(git tag --list "fleet-v*" --sort=-creatordate | head -n 1) fi - all_migrations=($(ls server/datastore/mysql/migrations/tables/20*_*.go | sort -r)) - new_migrations=($(git diff --find-renames --name-only --diff-filter=A $base_ref -- server/datastore/mysql/migrations/tables/20\*_\*.go | sort -r)) + all_migrations=($(ls server/datastore/mysql/migrations/tables/20*_*.go | sort -r | grep -v '_test.go')) + new_migrations=($(git diff --find-renames --name-only --diff-filter=A $base_ref -- server/datastore/mysql/migrations/tables/20\*_\*.go ':(exclude,glob)server/datastore/mysql/migrations/tables/20*_*_test.go' | sort -r)) index=0 for migration in "${new_migrations[@]}"; do @@ -110,3 +83,31 @@ jobs: echo "Ref: https://github.com/fleetdm/fleet/blob/main/handbook/engineering/scaling-fleet.md#foreign-keys-and-locking" exit 1 fi + + + - name: Install Go + uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 + with: + go-version-file: 'go.mod' + + - name: Start Infra Dependencies + # Use & to background this + run: docker compose up -d mysql_test & + + - name: Wait for mysql + run: | + echo "waiting for mysql..." + until docker compose exec -T mysql_test sh -c "mysql -uroot -p\"\${MYSQL_ROOT_PASSWORD}\" -e \"SELECT 1=1\" fleet" &> /dev/null; do + echo "." + sleep 1 + done + echo "mysql is ready" + + - name: Verify test schema changes + run: | + make dump-test-schema + if [[ $(git diff server/datastore/mysql/schema.sql) ]]; then + echo "❌ fail: uncommited changes in schema.sql" + echo "please run `make dump-test-schema` and commit the changes" + exit 1 + fi \ No newline at end of file diff --git a/CODEOWNERS b/CODEOWNERS index 153161e7ee82..ab336be41f69 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -41,7 +41,7 @@ go.mod @fleetdm/go /cmd/ @fleetdm/go /server/ @fleetdm/go /ee/server/ @fleetdm/go -/orbit/ @lucasmrod @roperzh @lukeheath @georgekarrv @sharon-fdm +/orbit/ @fleetdm/go ############################################################################################## # 🚀 React files and other files related to the core product frontend. diff --git a/articles/automatic-software-install-in-fleet.md b/articles/automatic-software-install-in-fleet.md new file mode 100644 index 000000000000..2dd0c5db0e6c --- /dev/null +++ b/articles/automatic-software-install-in-fleet.md @@ -0,0 +1,79 @@ +# Automatic policy-based installation of software on hosts + +![Top Image](../website/assets/images/articles/automatic-software-install-top-image.png) + +Fleet [v4.57.0](https://github.com/fleetdm/fleet/releases/tag/fleet-v4.57.0) introduces the ability to automatically and remotely install software on hosts based on predefined policy failures. This guide will walk you through the process of configuring fleet for automatic installation of software on hosts using uploaded installation images and based on programmed policies. You'll learn how to configure and use this feature, as well as understand how the underlying mechanism works. + +Fleet allows its users to upload trusted software installation files to be installed and used on hosts. This installation could be conditioned on a failure of a specific Fleet Policy. + +## Prerequisites + +* Fleet premium with Admin permissions. +* Fleet [v4.57.0](https://github.com/fleetdm/fleet/releases/tag/fleet-v4.57.0) or greater. + +## Step-by-step instructions + +1. **Adding software**: Add any software to be available for installation. Follow the [deploying software](https://fleetdm.com/guides/deploy-security-agents) document with instructions how to do it. Note that all installation steps (pre-install query, install script, and post-install script) will be executed as configured, regardless of the policy that triggers the installation. + + +![Add software](../website/assets/images/articles/automatic-software-install-add-software.png) + +Current supported software deployment formats: +- macOS: .pkg +- Windows: .msi, .exe +- Linux: .deb + +Coming soon: +- VPP for iOS and iPadOS + +2. **Add a policy**: In Fleet, add a policy that failure to pass will trigger the required installation. Go to Policies tab --> Press the top right "Add policy" button. --> Click "create your own policy" --> Enter your policy SQL --> Save --> Fill in details in the Save modal and Save. + +``` +SELECT 1 FROM apps WHERE name = 'Adobe Acrobat Reader.app' AND version_compare(bundle_short_version, '23.001.20687') >= 0; +``` + +Note: In order to know the exact application name to put in the query (e.g. "Adobe Acrobat Reader.app" in the query above) you can manually install it on a canary/test host and then query SELECT * from apps; + + +3. **Manage automation**: Open Manage Automations: Policies Tab --> top right "Manage automations" --> "Install software". + +![Manage policies](../website/assets/images/articles/automatic-software-install-policies-manage.png) + +4. **Select policy**: Select (click the check box of) your newly created policy. To the right of it select from the + drop-down list the software you would like to be installed upon failure of this policy. + +![Install software modal](../website/assets/images/articles/automatic-software-install-install-software.png) + +Upon failure of the selected policy, the selected software installation will be triggered. + +## How does it work? + +* After configuring Fleet to auto-install a specific software the rest will be done automatically. +* The policy check mechanism runs on a typical 1 hour cadence on all online hosts. +* Fleet will send install requests to the hosts on the first policy failure (first "No" result for the host) or if a policy goes from "Yes" to "No". On this iteration it will not send a install request if a policy is already failing and continues to fail ("No" -> "No"). See the following flowchart for details. + +![Flowchart](../website/assets/images/articles/automatic-software-install-workflow.png) +*Detailed flowchart* + +## Using the REST API for self-service software packages + +Fleet provides a REST API for managing software packages, including self-service software packages. Learn more about Fleet's [REST API](https://fleetdm.com/docs/rest-api/rest-api#add-team-policy). + +## Managing self-service software packages with GitOps + +To manage self-service software packages using Fleet's best practice GitOps, check out the `software` key in the [GitOps reference documentation](https://fleetdm.com/docs/configuration/yaml-files#policies). + +## Conclusion + +Software deployment can be time-consuming and risky. This guide presents Fleet's ability to mass deploy software to your fleet in a simple and safe way. Starting with uploading a trusted installer and ending with deploying it to the proper set of machines answering the exact policy defined by you. + +Leveraging Fleet’s ability to install and upgrade software on your hosts, you can streamline the process of controlling your hosts, replacing old versions of software and having the up-to-date info on what's installed on your fleet. + +By automating software deployment, you can gain greater control over what's installed on your machines and have better oversight of version upgrades, ensuring old software with known issues is replaced. + + + + + + + diff --git a/articles/deploy-software-packages.md b/articles/deploy-software-packages.md index 92aa0901ccf1..41a26632f101 100644 --- a/articles/deploy-software-packages.md +++ b/articles/deploy-software-packages.md @@ -14,7 +14,7 @@ Fleet [v4.50.0](https://github.com/fleetdm/fleet/releases/tag/fleet-v4.50.0) int * An S3 bucket [configured](https://fleetdm.com/docs/configuration/fleet-server-configuration#s-3-software-installers-bucket) to store the installers. -* Increase any load balancer timeouts to at least 5 minutes for the [Add software](https://fleetdm.com/docs/rest-api/rest-api#add-software) endpoint. +* Increase any load balancer timeouts to at least 5 minutes for the [Add package](https://fleetdm.com/docs/rest-api/rest-api#add-package) and [Modify package](https://fleetdm.com/docs/rest-api/rest-api#modify-package) endpoints. ## Step-by-step instructions diff --git a/articles/enroll-hosts.md b/articles/enroll-hosts.md index 0635855596ab..f15f44236b29 100644 --- a/articles/enroll-hosts.md +++ b/articles/enroll-hosts.md @@ -320,7 +320,7 @@ Fleetd will send stdout/stderr logs to the following directories: - macOS: `/private/var/log/orbit/orbit.std{out|err}.log`. - Windows: `C:\Windows\system32\config\systemprofile\AppData\Local\FleetDM\Orbit\Logs\orbit-osquery.log` (the log file is rotated). - - Linux: Orbit and osqueryd stdout/stderr output is sent to syslog (`/var/log/syslog` on Debian systems and `/var/log/messages` on CentOS). + - Linux: Orbit and osqueryd stdout/stderr output is sent to syslog (`/var/log/syslog` on Debian systems, `/var/log/messages` on CentOS, and `journalctl -u orbit` on Fedora). If the `logger_path` agent configuration is set to `filesystem`, fleetd will send osquery's "result" and "status" logs to the following directories: - Windows: C:\Program Files\Orbit\osquery_log diff --git a/articles/exe-install-scripts.md b/articles/exe-install-scripts.md new file mode 100644 index 000000000000..3ed2f437191b --- /dev/null +++ b/articles/exe-install-scripts.md @@ -0,0 +1,334 @@ +# Windows EXE install scripts + +## What are EXE install scripts? + +EXE install scripts are a way to install software on Windows. EXE installers, such as `Figma-124.3.2.exe`, are self-contained packages with all the files and instructions needed to install software on a Windows device. EXE installers are fully customizable and do not follow the same installation process as MSI installers. + +For EXE installers, there is no unique script or command that will work for all installers. MSI installers are typically preferred over EXE installers because they provide a standardized installation process, easier silent deployment, and better integration with Windows Installer Service. If available, MSI installers offer more predictable results in enterprise environments. + +Some EXE installers and uninstallers require additional switches or flags to run silently. Common flags include `/S`, `/q`, `/quiet`, `/silent`, or `--silent`. + +## Device-scoped install scripts + +The recommended way to install software on Windows devices is to use device-scoped install scripts. These scripts install the software for all users on the device and run the installation process with administrator privileges. + +Fleet defaults to a device-scoped install script when you add software using an EXE installer. + +## User-scoped install scripts + +Some software can only be installed for a specific user. In this case, you can use user-scoped install scripts. The software is installed only for the user currently logged in, and the installation process is run with the user's privileges. + +### Example user-scoped install script + +The install script creates a scheduled task that will automatically be run as the current (logged-in) user. The EXE installer is copied to a public directory accessible by the user, ensuring that even non-administrator users can run the scheduled task to complete the installation. After the task finishes, the installer and the task are deleted. + +The use of scheduled tasks allows the installer to run with user-level permissions, which is especially useful when installing software for non-admin users without requiring administrator credentials at the time of execution. + +Since the installation is run by the current user, the script does not output the installer's messages to the console. If you need to see the output, you can modify the script to redirect it to a file and append it to the script output. + +```powershell +# Some installers require a flag to run silently. +# Each installer might use a different argument (usually it's "/S" or "/s") +$installArgs = "/S" + +$exeFilePath = "${env:INSTALLER_PATH}" + +$exitCode = 0 + +try { + +# Copy the installer to a public folder so that all can access it +# users +$exeFilename = Split-Path $exeFilePath -leaf +Copy-Item -Path $exeFilePath -Destination "${env:PUBLIC}" -Force +$exeFilePath = "${env:PUBLIC}\$exeFilename" + +# Task properties. The task will be started by the logged in user +$action = New-ScheduledTaskAction -Execute "$exeFilePath" ` + -Argument "$installArgs" +$trigger = New-ScheduledTaskTrigger -AtLogOn +$userName = Get-CimInstance -ClassName Win32_ComputerSystem | + Select-Object -expand UserName +$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries + +# Create a task object with the properties defined above +$task = New-ScheduledTask -Action $action -Trigger $trigger ` + -Settings $settings + +# Register the task +$taskName = "fleet-install-$exeFilename" +Register-ScheduledTask "$taskName" -InputObject $task -User "$userName" + +# keep track of the start time to cancel if taking too long to start +$startDate = Get-Date + +# Start the task now that it is ready +Start-ScheduledTask -TaskName "$taskName" -TaskPath "\" + +# Wait for the task to be running +$state = (Get-ScheduledTask -TaskName "$taskName").State +Write-Host "ScheduledTask is '$state'" + +while ($state -ne "Running") { + Write-Host "ScheduledTask is '$state'. Waiting to run .exe..." + + $endDate = Get-Date + $elapsedTime = New-Timespan -Start $startDate -End $endDate + if ($elapsedTime.TotalSeconds -gt 120) { + Throw "Timed-out waiting for scheduled task state." + } + + Start-Sleep -Seconds 1 + $state = (Get-ScheduledTask -TaskName "$taskName").State +} + +# Wait for the task to be done +$state = (Get-ScheduledTask -TaskName "$taskName").State +while ($state -eq "Running") { + Write-Host "ScheduledTask is '$state'. Waiting for .exe to complete..." + + $endDate = Get-Date + $elapsedTime = New-Timespan -Start $startDate -End $endDate + if ($elapsedTime.TotalSeconds -gt 120) { + Throw "Timed-out waiting for scheduled task state." + } + + Start-Sleep -Seconds 10 + $state = (Get-ScheduledTask -TaskName "$taskName").State +} + +# Remove task +Write-Host "Removing ScheduledTask: $taskName." +Unregister-ScheduledTask -TaskName "$taskName" -Confirm:$false + +} catch { + Write-Host "Error: $_" + $exitCode = 1 +} finally { + # Remove installer + Remove-Item -Path $exeFilePath -Force +} + +Exit $exitCode +``` + +### Example user-scoped uninstall script + +The uninstall script creates a scheduled task that will automatically be run as the current (logged-in) user. The uninstaller creates a separate PowerShell script for the user. After the task finishes, the script and the task are deleted. + +Since the uninstall script is run by the current user, it does not output its messages to the console. If you need to see the output, you can modify the main script to redirect it to a file and append it to the output. + +```powershell +# Fleet extracts the name from the installer (EXE) and saves it to PACKAGE_ID +# variable +$softwareName = $PACKAGE_ID + +# Script to uninstall software as the current logged-in user. +$userScript = @' +$softwareName = $PACKAGE_ID + +# Using the exact software name here is recommended to avoid +# uninstalling unintended software. +$softwareNameLike = "*$softwareName*" + +# Some uninstallers require additional flags to run silently. +# Each uninstaller might use a different argument (usually it's "/S" or "/s") +$uninstallArgs = "/S" + +$uninstallCommand = "" +$exitCode = 0 + +try { + +$userKey = ` + 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*' +[array]$uninstallKeys = Get-ChildItem ` + -Path @($userKey) ` + -ErrorAction SilentlyContinue | + ForEach-Object { Get-ItemProperty $_.PSPath } + +$foundUninstaller = $false +foreach ($key in $uninstallKeys) { + # If needed, add -notlike to the comparison to exclude certain similar + # software + if ($key.DisplayName -like $softwareNameLike) { + $foundUninstaller = $true + # Get the uninstall command. Some uninstallers do not include + # 'QuietUninstallString' and require a flag to run silently. + $uninstallCommand = if ($key.QuietUninstallString) { + $key.QuietUninstallString + } else { + $key.UninstallString + } + + # The uninstall command may contain command and args, like: + # "C:\Program Files\Software\uninstall.exe" --uninstall --silent + # Split the command and args + $splitArgs = $uninstallCommand.Split('"') + if ($splitArgs.Length -gt 1) { + if ($splitArgs.Length -eq 3) { + $uninstallArgs = "$( $splitArgs[2] ) $uninstallArgs".Trim() + } elseif ($splitArgs.Length -gt 3) { + Throw ` + "Uninstall command contains multiple quoted strings. " + + "Please update the uninstall script.`n" + + "Uninstall command: $uninstallCommand" + } + $uninstallCommand = $splitArgs[1] + } + Write-Host "Uninstall command: $uninstallCommand" + Write-Host "Uninstall args: $uninstallArgs" + + $processOptions = @{ + FilePath = $uninstallCommand + PassThru = $true + Wait = $true + } + if ($uninstallArgs -ne '') { + $processOptions.ArgumentList = "$uninstallArgs" + } + + # Start the process and track the exit code + $process = Start-Process @processOptions + $exitCode = $process.ExitCode + + # Prints the exit code + Write-Host "Uninstall exit code: $exitCode" + # Exit the loop once the software is found and uninstalled. + break + } +} + +if (-not $foundUninstaller) { + Write-Host "Uninstaller for '$softwareName' not found." + $exitCode = 1 +} + +} catch { + Write-Host "Error: $_" + $exitCode = 1 +} + +Exit $exitCode +'@ + +$exitCode = 0 + +# Create a script in a public folder so that it can be accessed by all users. +$uninstallScriptPath = "${env:PUBLIC}/uninstall-$softwareName.ps1" +$taskName = "fleet-uninstall-$softwareName" +try { + Set-Content -Path $uninstallScriptPath -Value $userScript -Force + + # Task properties. The task will be started by the logged in user + $action = New-ScheduledTaskAction -Execute "PowerShell.exe" ` + -Argument "$uninstallScriptPath" + $trigger = New-ScheduledTaskTrigger -AtLogOn + $userName = Get-CimInstance -ClassName Win32_ComputerSystem | + Select-Object -expand UserName + $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries + + # Create a task object with the properties defined above + $task = New-ScheduledTask -Action $action -Trigger $trigger ` + -Settings $settings + + # Register the task + Register-ScheduledTask "$taskName" -InputObject $task -User "$userName" + + # keep track of the start time to cancel if taking too long to start + $startDate = Get-Date + + # Start the task now that it is ready + Start-ScheduledTask -TaskName "$taskName" -TaskPath "\" + + # Wait for the task to be running + $state = (Get-ScheduledTask -TaskName "$taskName").State + Write-Host "ScheduledTask is '$state'" + + while ($state -ne "Running") { + Write-Host "ScheduledTask is '$state'. Waiting to uninstall..." + + $endDate = Get-Date + $elapsedTime = New-Timespan -Start $startDate -End $endDate + if ($elapsedTime.TotalSeconds -gt 120) { + Throw "Timed-out waiting for scheduled task state." + } + + Start-Sleep -Seconds 1 + $state = (Get-ScheduledTask -TaskName "$taskName").State + } + + # Wait for the task to be done + $state = (Get-ScheduledTask -TaskName "$taskName").State + while ($state -eq "Running") { + Write-Host "ScheduledTask is '$state'. Waiting for .exe to complete..." + + $endDate = Get-Date + $elapsedTime = New-Timespan -Start $startDate -End $endDate + if ($elapsedTime.TotalSeconds -gt 120) { + Throw "Timed-out waiting for scheduled task state." + } + + Start-Sleep -Seconds 10 + $state = (Get-ScheduledTask -TaskName "$taskName").State + } + +} catch { + Write-Host "Error: $_" + $exitCode = 1 +} finally { + # Remove task + Write-Host "Removing ScheduledTask: $taskName." + Unregister-ScheduledTask -TaskName "$taskName" -Confirm:$false + + # Remove user script + Remove-Item -Path $uninstallScriptPath -Force +} + +Exit $exitCode +``` + +## Install script for raw executables + +Raw executables without installers are less common but may be used in specific scenarios, such as when a vendor provides a standalone binary file for a lightweight application. In these cases, ensuring all necessary dependencies are in place is important. Additionally, consider cleaning up the source executable after installation to avoid leaving unnecessary files on the system. If you have a raw executable that does not come with an installer, you can use the following script to install it. This script copies the executable to Program Files, which are accessible by all users. + +```powershell +$exeFilePath = "${env:INSTALLER_PATH}" + +try { + +# extract the name of the executable to use as the sub-directory name +$exeName = [System.IO.Path]::GetFileName($exeFilePath) +$subDir = [System.IO.Path]::GetFileNameWithoutExtension($exeFilePath) + +$destinationPath = Join-Path -Path $env:ProgramFiles -ChildPath $subDir + +# check if the directory does not exist, and create it if necessary +if (-not (Test-Path -Path $destinationPath)) { + New-Item -ItemType Directory -Path $destinationPath +} + +# copy the .exe file to the new sub-directory +$destinationExePath = Join-Path -Path $destinationPath -ChildPath $exeName +Copy-Item -Path $exeFilePath -Destination $destinationExePath +Exit $LASTEXITCODE + +} catch { + Write-Host "Error: $_" + Exit 1 +} +``` +## Conclusion + +EXE install scripts provide a flexible solution for installing software on Windows devices when MSI installers are unavailable. By leveraging the power of PowerShell and scheduled tasks, IT administrators can easily automate both device-scoped and user-scoped installations. Whether you're deploying software for all users on a device or targeting a specific logged-in user, the provided scripts offer a robust starting point for handling EXE installations. + +Always verify the EXE installer's specific flags for silent installation for smoother operations, ensure proper permissions are in place, and consider implementing logging for troubleshooting. While MSI installers are generally preferred for their standardized behavior, these scripts allow you to manage even the most customized EXE installs in enterprise environments. + +Following this guide will enable you to manage software deployments using EXE install scripts, improving efficiency and ensuring a seamless installation experience across your Windows devices. + + + + + + + diff --git a/articles/fleet-now-supports-ios-and-ipados-software-deployment-and-automated-patch-management.md b/articles/fleet-now-supports-ios-and-ipados-software-deployment-and-automated-patch-management.md new file mode 100644 index 000000000000..4280852e116c --- /dev/null +++ b/articles/fleet-now-supports-ios-and-ipados-software-deployment-and-automated-patch-management.md @@ -0,0 +1,71 @@ +# Fleet now supports iOS and iPadOS, software deployment, and automated patch management + +Managing security, control, and flexibility across diverse devices can be challenging, especially when proprietary systems hold you back. That’s why we’re thrilled to announce that Fleet now supports iOS and iPadOS devices and enhanced patch management features to give your team more control and scalability than ever. + +**What does this mean for you?** Now, your team can manage iPhones, iPads, macOS, Windows, and Linux devices from a single platform. This latest release is designed to simplify mobile device management (MDM) while giving your team the control and flexibility they need to scale effectively. + +**Highlights**: + +* iPhone/iPad BYOD +* Self-service VPP apps +* Multiple ABM and VPP support +* Automatic installation of software on hosts + +### Enrolling BYOD iPad/iOS devices in Fleet + +[Enrolling BYOD iPhones and iPads in Fleet](https://fleetdm.com/guides/enroll-byod-ios-ipados-hosts) allows IT admins to manage software installations, enforce settings, and ensure devices comply with company policies—all without compromising user autonomy. This helps secure access to organizational resources while maintaining control over device configurations. + +![Fleet enrollment profile on an iPhone](../website/assets/images/articles/fleet-now-supports-ios-and-ipados-software-deployment-and-automated-patch-management-2-2000x1000@2x.png "Fleet enrollment profile on an iPhone.") + +*Fleet enrollment profile on an iPhone.* + +### Self-service Apple App Store apps + +Fleet enables organizations to assign and install Apple App Store apps purchased through the Volume Purchase Program (VPP) directly via Self-Service using Fleet Desktop. This feature lets IT administrators [make VPP-purchased apps available to end users](https://fleetdm.com/guides/install-vpp-apps-on-macos-using-fleet). + +By integrating VPP app distribution into the Fleet Desktop Self-Service portal, organizations can streamline the deployment of essential software across their macOS devices. This ensures that users have easy access to the tools they need while maintaining control over software distribution. This update enhances the overall user experience and operational efficiency, empowering end users to install approved applications with minimal IT intervention. + +### Multiple Apple Business Manager and VPP support + +Alongside initial Volume Purchase Program (VPP) support, now you can add and manage multiple Apple Business Manager (ABM) and VPP tokens within a single Fleet instance. This feature is designed for both Managed Service Providers (MSPs) and large enterprises. Whether an MSP or an enterprise with multiple divisions, admins can set up separate workflows to automatically enroll devices and distribute apps through the App Store. This update simplifies the process of handling macOS, iOS, and iPadOS devices, providing a scalable solution for both MSPs and enterprises looking to centralize control while maintaining flexibility for different user groups. + +![Add software modal](../website/assets/images/articles/fleet-now-supports-ios-and-ipados-software-deployment-and-automated-patch-management-1-2000x1000@2x.png "Add software modal.") +*Add software modal.* + +### Automatic installation of software on hosts + +Fleet [v4.50.0](https://github.com/fleetdm/fleet/releases/tag/fleet-v4.50.0) introduced software deployment capabilities for your hosts. With the recent [v4.57.0](https://fleetdm.com/releases/fleet-4.57.0) update, admins can automatically install software when a policy fails. This proactive approach helps maintain compliance and security without manual intervention. + +This feature is handy when a device is found to have a vulnerable or outdated version of software installed. When a policy detects this, Fleet can automatically install a secure, updated version of the software to remediate the issue and bring the host back into compliance. This automation helps IT teams address vulnerabilities quickly and efficiently. + +**Current supported software deployment formats:** + +* macOS: .pkg +* Windows: .msi, .exe +* Linux: .deb + +**Learn more about automatic software installation:** [https://fleetdm.com/guides/automatic-software-install-in-fleet](https://fleetdm.com/guides/automatic-software-install-in-fleet) + + +### A smoother path for MDM migrations + +Switching MDM platforms has often been seen as a daunting task. Fleet makes the process of migrating easier and more efficient than ever before. With support for iOS and iPadOS, your team can seamlessly manage all your organization’s devices with a single tool that integrates with your existing GitHub repo and workflow. + +By embracing open-source flexibility and cross-platform support, Fleet empowers teams to manage their devices in a way that suits their organization’s unique needs. + +For more information on migrating to Fleet, check out: [https://fleetdm.com/guides/mdm-migration](https://fleetdm.com/guides/mdm-migration) + +### The future of device management is here + +With our newly added support for iOS, iPadOS, and patch management tools, you can finally consolidate your device management across multiple platforms, streamlining your processes and reducing tool bloat. + +As Mike McNeil, Fleet’s CEO, says: *“Our vision is to empower teams to manage their devices their way—without being locked into proprietary tools or vendor restrictions.”* With Fleet’s open-source approach, IT teams can take back control and build a future that fits their needs. + +To learn more about how Fleet can support your organization, visit [fleetdm.com/mdm](https://fleetdm.com/mdm). + + + + + + + diff --git a/articles/fleet-supports-macos-15-sequoia-ios-18-and-ipados-18.md b/articles/fleet-supports-macos-15-sequoia-ios-18-and-ipados-18.md new file mode 100644 index 000000000000..afde4d97339e --- /dev/null +++ b/articles/fleet-supports-macos-15-sequoia-ios-18-and-ipados-18.md @@ -0,0 +1,29 @@ +# Fleet supports Apple’s latest operating systems: macOS 15 Sequoia, iOS 18, and iPadOS 18 + +![Fleet supports Apple’s latest operating systems: macOS 15 Sequoia, iOS 18, and iPadOS 18](../website/assets/images/articles/fleet-supports-macos-15-sequoia-ios-18-and-ipados-18-1600x900@2x.jpg) + +_Photo by [aditya bhatia](https://www.pexels.com/photo/people-walking-on-a-bridge-between-trees-13809734/)_ + +Fleet is pleased to announce full support for Apple’s newest operating systems, including macOS 15 Sequoia, iOS 18, and iPadOS 18. With these updates, Fleet ensures seamless management and security capabilities for organizations adopting the latest Apple technology across their device fleet. This release enables IT administrators to confidently manage devices running these new operating systems, leveraging Fleet's robust tools for device monitoring, policy enforcement, and configuration management. + +## Noteworthy changes and known issues for macOS 15 Sequoia + +macOS 15 Sequoia brings significant changes that may impact device management workflows. Notably, firewall settings are no longer contained in a `.plist` file, resulting in potential reporting issues with osquery. Fleet is working with the osquery community to address this issue and update the `alf` table to correctly report firewall status under Sequoia. For more details on this issue, [follow the progress here](https://github.com/fleetdm/fleet/issues/21802). Additionally, manual installation of unsigned `fleet-osquery.pkg` packages now require extra steps due to changes in Apple's security settings, reflecting a heightened focus on device security. + +## Expanding Support for Declarative Device Management (DDM) + +As Apple expands the Declarative Device Management (DDM) capabilities with macOS 15 Sequoia, Fleet looks forward to implementing these new functionalities to enhance device management capabilities further. Today, administrators can send DDM JSON payloads directly to hosts, enabling more responsive and granular control over device configurations and settings. Fleet's support for DDM allows organizations to leverage this robust framework to manage devices efficiently without constant communication with the server. For more information on DDM, visit Apple’s [guide to Declarative Device Management](https://support.apple.com/guide/deployment/intro-to-declarative-device-management-depb1bab77f8/1/web/1.0). + +## Updates Across macOS, iOS, and iPadOS + +Apple has renamed "Profiles" to "Device Management" in all the new operating systems within System Settings. This change affects how administrators and users interact with device management settings on all Apple platforms. For more details on navigating these changes, visit Apple’s [support page](https://it-training.apple.com/tutorials/support/sup530/#Determining-Whether-a-Device-Is-Managed). + +Fleet remains committed to supporting Apple's latest advancements, ensuring that your organization can seamlessly integrate and manage devices running macOS 15 Sequoia, iOS 18, and iPadOS 18 with the same reliability and security you expect. Beginning with macOS 16, [Fleet will provide release-day support for major macOS releases](https://fleetdm.com/handbook/engineering#provide-0-day-support-for-major-version-macos-releases), ensuring your fleet is always prepared for the latest updates. Stay tuned for updates as we refine and enhance our support for these new platforms. [Engineering | Fleet handbook](https://fleetdm.com/handbook/engineering#provide-0-day-support-for-major-version-macos-releases) + + + + + + + + diff --git a/articles/fleetctl.md b/articles/fleetctl.md index caa234f845a8..a7e2a245af27 100644 --- a/articles/fleetctl.md +++ b/articles/fleetctl.md @@ -10,15 +10,19 @@ fleetctl also provides a quick way to work with all the data exposed by Fleet wi ## Installing fleetctl -Install fleetctl with npm or download the binary from [GitHub](https://github.com/fleetdm/fleet/releases). +Download and install [Node.js](https://nodejs.org/en). + +Install fleetctl with npm (included in Node.js). ```sh -npm install -g fleetctl +sudo npm install -g fleetctl ``` +To install fleetctl on Windows or Linux, download the fleectl binary here on [GitHub](https://github.com/fleetdm/fleet/releases). + ### Upgrading fleetctl -The easiest way to update fleetctl is by running the installation command again. +The easiest way to update fleetctl is by rerunning the installation command. ```sh npm install -g fleetctl@latest @@ -30,7 +34,7 @@ npm install -g fleetctl@latest ### Available commands -Much of the functionality available in the Fleet UI is also available in `fleetctl`. You can run queries, add and remove users, generate Fleet's agent (fleetd) to add new hosts, get information about existing hosts, and more! +Much of the functionality available in the Fleet UI is also available in fleetctl. You can run queries, add and remove users, generate Fleet's agent (fleetd) to add new hosts, get information about existing hosts, and more! > Note: Unless a logging infrastructure is configured on your Fleet server, osquery-related logs will be stored locally on each device. Read more [here](https://fleetdm.com/guides/log-destinations) @@ -74,7 +78,7 @@ This section walks you through authentication, assuming you already have a runni ### Login -To log in to your Fleet instance, run following commands: +To log in to your Fleet instance, run the following commands: 1. Set the Fleet instance address @@ -93,11 +97,11 @@ Password: [+] Fleet login successful and context configured! ``` -Once your local context is configured, you can use `fleetctl` normally. +Once your local context is configured, you can use fleetctl normally. ### Log in with SAML (SSO) authentication -Users that authenticate to Fleet via SSO should retrieve their API token from the UI and set it manually in their `fleetctl` configuration (instead of logging in via `fleetctl login`). +Users that authenticate to Fleet via SSO should retrieve their API token from the UI and manually set it in their fleetctl configuration (instead of logging in via `fleetctl login`). **Fleet UI:** 1. Go to the **My account** page (https://fleet.example.com/profile) @@ -116,13 +120,13 @@ The token can also be set with `fleetctl config set --token`, but this may leak ## Using fleetctl with an API-only user -When running automated workflows using the Fleet API, we recommend an API-only user's API key rather than the API key of a regular user. A regular user's API key expires frequently for security purposes, requiring routine updates. Meanwhile, an API-only user's key does not expire. +When running automated workflows using the Fleet API, we recommend using an API-only user's API key rather than a regular user's API key. A regular user's API key expires frequently for security purposes, requiring routine updates. Meanwhile, an API-only user's key does not expire. An API-only user does not have access to the Fleet UI. Instead, it's only purpose is to interact with the API programmatically or from fleetctl. ### Create API-only user -Before creating the API-only user, log in to `fleetctl` as an admin. See [authentication](https://#authentication) above for details. +Before creating the API-only user, log in to fleetctl as an admin. See [authentication](https://#authentication) above for details. To create your new API-only user, use `fleetctl user create`: @@ -154,12 +158,12 @@ fleetctl user create --name "API User" --email api@example.com --password temp@p #### Changing permissions -To change roles of a current user, log into the Fleet UI as an admin and navigate to **Settings > Users**. -> Suggestion: To disable/enable a user's access to the UI (converting a regular user to an API-only user or vice versa), create a new user. +To change the role of a current user, log into the Fleet UI as an admin and navigate to Settings > Users. +> Suggestion: Create a new user to disable/enable a user's access to the UI (converting a regular user to an API-only user or vice versa). ### Switching users -To use `fleetctl` with your regular user account but occasionally use your API-only user for specific cases, you can set up your `fleetctl` config with a new `context` to hold the credentials of your API-only user: +To use fleetctl with your regular user account but occasionally use your API-only user for specific cases, you can set up your fleetctl config with a new `context` to hold the credentials of your API-only user: ```sh fleetctl config set --address https://dogfood.fleetdm.com --context api @@ -181,7 +185,7 @@ Running a command with no context will use the default profile. ## Debugging Fleet -`fleetctl` provides debugging capabilities about the running Fleet server via the `debug` command. To see a complete list of all the options run: +fleetctl provides debugging capabilities about the running Fleet server via the `debug` command. To see a complete list of all the options, run: ```sh fleetctl debug --help @@ -204,4 +208,4 @@ This will generate a `tar.gz` file with: - + diff --git a/articles/how-to-uninstall-fleetd.md b/articles/how-to-uninstall-fleetd.md new file mode 100644 index 000000000000..1b7a524cd7d9 --- /dev/null +++ b/articles/how-to-uninstall-fleetd.md @@ -0,0 +1,34 @@ +# How to uninstall Fleet's agent (fleetd) + +This guide walks you through the steps to remove fleetd from your device. After performing these steps, the device will display as an offline host in the Fleet UI until you manually remove it. + +## On macOS: +Run the [cleanup script](https://github.com/fleetdm/fleet/blob/main/orbit/tools/cleanup/cleanup_macos.sh) found in Fleet's GitHub + +--- + +## On Windows: +Use the "Add or remove programs" dialog to remove Fleet osquery. + +![windows_uninstall](https://github.com/user-attachments/assets/4140e62b-f67a-4df6-85b0-430c2c624881) + +--- + +## On Linux: + +Using Debian package manager (Debian, Ubuntu, etc.) : + +Run ```sudo apt remove fleet-osquery -y``` + +Using yum Package Manager (RHEL, CentOS, etc.) : + +Run ```sudo rpm -e fleet-osquery-X.Y.Z.x86_64``` + +Are you having trouble uninstalling Fleetd on macOS, Windows, or Linux? Get help on Slack in the [#fleet channel](https://fleetdm.com/slack). + + + + + + + diff --git a/articles/how-to-uninstall-osquery.md b/articles/how-to-uninstall-osquery.md deleted file mode 100644 index c5c54359ab47..000000000000 --- a/articles/how-to-uninstall-osquery.md +++ /dev/null @@ -1,60 +0,0 @@ -# How to uninstall osquery - -This article walks you through the steps to remove osquery from your device. Remember that if you enrolled this device in a Fleet instance, it would display as an offline host in the Fleet UI until you manually remove it. - -## On macOS: -Open up your terminal and paste the following commands; note that `sudo` is required, and you’ll need administrator privileges to complete this process. - -``` -sudo launchctl unload /Library/LaunchDaemons/io.osquery.agent.plist -sudo rm /Library/LaunchDaemons/io.osquery.agent.plist -sudo rm -rf /private/var/log/osquery /private/var/osquery -sudo rm /usr/local/bin/osquery* -sudo pkgutil --forget io.osquery.agent -``` - -These commands stop the running osquery daemon, remove it from your device, and delete the files created by osquery. - -And that’s it; you have now removed osquery from your macOS device. - ---- - -## On Windows: -Removing osquery on Windows 10 is a simple process. To get started, open Windows settings and go to Apps. Then find “osquery” and click Uninstall. - -![Uninstall osquery](../website/assets/images/articles/how-to-uninstall-osquery-1-607x188@2x.png) - -Click Uninstall again to confirm, and osquery will be removed from your Windows device. You might need to restart your computer to complete the uninstall process fully. - ---- - -## On Linux: - -1. Open your terminal and paste the following commands to stop the running osquery service, uninstall osquery, and clean up files created by osquery. - -2. Note that `sudo` is required, and you’ll need administrative privileges to complete this process. - -3. Using Debian package manager (Debian, Ubuntu, etc.) : - -``` -sudo systemctl stop osqueryd.service -sudo apt remove osquery -rm -rf /var/osquery /var/log/osquery /etc/osquery -``` - -Using yum Package Manager (RHEL, CentOS, etc.) : - -``` -sudo systemctl stop osqueryd.service -sudo yum remove osquery -rm -rf /var/osquery /var/log/osquery /etc/osquery -``` - -Are you running into trouble uninstalling osquery on macOS, Windows, or Linux? Get help on Slack in the [#fleet channel](https://fleetdm.com/slack). - - - - - - - diff --git a/articles/macos-mdm-setup.md b/articles/macos-mdm-setup.md index bc91ee6a7206..0f32ccb09033 100644 --- a/articles/macos-mdm-setup.md +++ b/articles/macos-mdm-setup.md @@ -25,8 +25,7 @@ To connect Fleet to ABM, you have to add an ABM token to Fleet. To add an ABM to 1. Navigate to the **Settings > Integrations > Mobile device management (MDM)** page. 2. Under "Automatic enrollment", click "Add ABM", and then click "Add ABM" again on the next page. Follow the instructions in the modal and upload an ABM token to Fleet. -When one of your uploaded ABM tokens has expired or is within 30 days of expiring, you will see a warning -banner at the top of page reminding you to renew your token. +When one of your uploaded ABM tokens has expired or is within 30 days of expiring, you will see a warning banner at the top of page reminding you to renew your token. To renew an ABM token: @@ -53,7 +52,50 @@ If no default team is set for a host platform (macOS, iOS, or iPadOS), then newl > A host can be transferred to a new (not default) team before it enrolls. In the Fleet UI, you can do this under **Settings** > **Teams**. -### Simple Certificate Enrollment Protocol (SCEP) +## Volume Purchasing Program (VPP) + +> Available in Fleet Premium + +To connect Fleet to Apple's VPP, head to the guide [here](https://fleetdm.com/guides/install-vpp-apps-on-macos-using-fleet). + +## Best practice + +Most organizations only need one ABM token and one VPP token to manage their macOS, iOS, and iPadOS hosts. + +These organizations may need multiple ABM and VPP tokens: + +- Managed Service Providers (MSPs) +- Enterprises that acquire new businesses and as a result inherit new hosts +- Umbrella organizations that preside over entities with separated purchasing authority (i.e. a hospital or university) + +For **MSPs**, the best practice is to have one ABM and VPP connection per client. + +The default teams in Fleet for each client's ABM token in Fleet will look like this: +- macOS: 💻 Client A - Workstations +- iOS: 📱🏢 Client A - Company-owned iPhones +- iPadOS:🔳🏢 Client A - Company-owned iPads + +Client A's VPP token will be assigned to the above teams. + +For **enterprises that acquire**, the best practice is to add a new ABM and VPP connection for each acquisition. + +These will default teams in Fleet: + +Enterprise ABM token: +- macOS: 💻 Enterprise - Workstations +- iOS: 📱🏢 Enterprise - Company-owned iPhones +- iPadOS:🔳🏢 Enterprise - Company-owned iPads + +The enterprises's VPP token will be assigned to the above teams. + +Acquisition ABM token: +- macOS: 💻 Acquisition - Workstations +- iOS: 📱🏢 Acquisition - Company-owned iPhones +- iPadOS:🔳🏢 Acquisition - Company-owned iPads + +The acquisitions's VPP token will be assigned to the above teams. + +## Simple Certificate Enrollment Protocol (SCEP) Fleet uses SCEP certificates (1 year expiry) to authenticate the requests hosts make to Fleet. Fleet renews each host's SCEP certificates automatically every 180 days. diff --git a/articles/role-based-access.md b/articles/role-based-access.md index 95fc712c5252..d147796cdb58 100644 --- a/articles/role-based-access.md +++ b/articles/role-based-access.md @@ -46,9 +46,9 @@ GitOps is an API-only and write-only role that can be used on CI/CD pipelines. | Transfer hosts between teams\* | | | ✅ | ✅ | ✅ | | Create, edit, and delete labels | | | ✅ | ✅ | ✅ | | View all software | ✅ | ✅ | ✅ | ✅ | | -| Add and delete software | | | ✅ | ✅ | ✅ | +| Add, edit, and delete software | | | ✅ | ✅ | ✅ | | Download added software | | | ✅ | ✅ | | -| Install software on hosts | | | ✅ | ✅ | | +| Install/uninstall software on hosts | | | ✅ | ✅ | | | Filter software by [vulnerabilities](https://fleetdm.com/docs/using-fleet/vulnerability-processing#vulnerability-processing) | ✅ | ✅ | ✅ | ✅ | | | Filter hosts by software | ✅ | ✅ | ✅ | ✅ | | | Filter software by team\* | ✅ | ✅ | ✅ | ✅ | | @@ -79,9 +79,10 @@ GitOps is an API-only and write-only role that can be used on CI/CD pipelines. | Edit agent options for hosts assigned to teams\* | | | | ✅ | ✅ | | Initiate [file carving](https://fleetdm.com/docs/using-fleet/rest-api#file-carving) | | | ✅ | ✅ | | | Retrieve contents from file carving | | | | ✅ | | -| View Apple mobile device management (MDM) certificate information | | | | ✅ | | -| View Apple business manager (BM) information | | | | ✅ | | -| Generate Apple mobile device management (MDM) certificate signing request (CSR) | | | | ✅ | | +| Create Apple Push Certificates service (APNs) certificate signing request (CSR) | | | | ✅ | | +| View, edit, and delete APNs certificate | | | | ✅ | | +| View, edit, and delete Apple Business Manager (ABM) connections | | | | ✅ | | +| View, edit, and delete Volume Purchasing Program (VPP) connections | | | | ✅ | | | View disk encryption key for macOS and Windows hosts | ✅ | ✅ | ✅ | ✅ | | | Edit OS updates for macOS, Windows, iOS, and iPadOS hosts | | | ✅ | ✅ | ✅ | | Create, edit, resend and delete configuration profiles for macOS and Windows hosts | | | ✅ | ✅ | ✅ | @@ -129,7 +130,7 @@ Users with access to multiple teams can be assigned different roles for each tea | View software | ✅ | ✅ | ✅ | ✅ | | | Add and delete software | | | ✅ | ✅ | ✅ | | Download added software | | | ✅ | ✅ | | -| Install software on hosts | | | ✅ | ✅ | | +| Install/uninstall software on hosts | | | ✅ | ✅ | | | Filter software by [vulnerabilities](https://fleetdm.com/docs/using-fleet/vulnerability-processing#vulnerability-processing) | ✅ | ✅ | ✅ | ✅ | | | Filter hosts by software | ✅ | ✅ | ✅ | ✅ | | | Filter software | ✅ | ✅ | ✅ | ✅ | | diff --git a/articles/sysadmin-diaries-gitops-a-strategic-advantage.md b/articles/sysadmin-diaries-gitops-a-strategic-advantage.md new file mode 100644 index 000000000000..51d087a410df --- /dev/null +++ b/articles/sysadmin-diaries-gitops-a-strategic-advantage.md @@ -0,0 +1,63 @@ +# Sysadmin diaries: GitOps: A strategic advantage for automation, collaboration, and cost savings + +![Sysadmin diaries: GitOps: A strategic advantage for automation, collaboration, and cost savings](../website/assets/images/articles/sysadmin-diaries-1600x900@2x.png) + +_This diary entry was originally published on [Allen's LinkedIn](https://www.linkedin.com/pulse/gitops-strategic-advantage-automation-collaboration-cost-houchins-a4luf/)._ + +![GitHub logo and GitHub Actions logo](../website/assets/images/articles/sysadmin-diaries-gitops-a-strategic-advantage-1-312x207@2x.jpg) + +For many IT professionals, the idea of GitOps might seem daunting, if not completely foreign. I can relate to this. I’ve spent over two decades contributing at every level of IT, from individual contributor to executive leadership, and until recently, GitOps was something I had never implemented. It felt like a complex, high-barrier concept that no one adequately explained in terms I could quantify. + +As an IT leader, I often prioritized immediate business needs over initiatives that seemed more technical or abstract, especially those I didn’t fully understand. GitOps, at first glance, fell into that category. While the term GitOps might be newer, the principles behind it aren’t. Its roots lie in familiar DevOps and Engineering methodologies. GitOps has helped these groups become efficiency wizards orchestrating complex testing, builds, and deployments through centralized code repositories and automations. + +Imagine applying those same principles to your entire IT infrastructure and applications. GitOps is something you can approach gradually, finding small wins to build upon, even with the smallest steps. But before you invest any time, why should you care? + +GitOps is no longer just for DevOps teams. IT professionals, Information Security and compliance experts, people leaders, and Executives alike should consider it a cornerstone of our operations. It’s time to start thinking about GitOps capabilities as a critical factor in decision-making, whether you’re evaluating new tools or renewing existing ones. + +## Information technology + +According to *[Okta’s Businesses at Work 2024 report](https://www.okta.com/resources/whitepaper-businesses-at-work/)*, IT teams manage an average of 93 apps globally—some more, some fewer. That’s potentially 93 different systems to context-switch between, each with its own UI, change management process, and configuration quirks. To complicate matters, these systems aren’t usually managed by a single IT team. For instance, network systems might be handled by one group and productivity applications by another, while IT support often requires access across the board. + +GitOps changes that dynamic. By centralizing configuration management, GitOps offers full visibility, allowing every team to access the information they need to be more effective. Better yet, it standardizes how contributors interact with systems by using a common “code language,” empowering them to collaborate on areas they wouldn’t usually have administrator rights over. This is one of the most effective ways to break down silos and foster collaboration across traditionally separate groups. + +GitOps enables IT teams to unlock greater value through automation, allowing them to focus on more engaging, creative challenges that require human ingenuity—while leaving the repetitive, mundane tasks to the machines. For example, I want to ensure my end users’ devices are running the latest version of Google Chrome. Without GitOps, I’d need to manually monitor for updates and adjust compliance criteria whenever a new version is released. + +With GitOps, however, I can automate both the monitoring of Chrome updates and the adjustment of compliance criteria, creating a true “set it and forget it” workflow. This not only saves time but also ensures that compliance is maintained seamlessly, without manual intervention. + +![Screenshot of GitHub Actions workflow progress](../website/assets/images/articles/sysadmin-diaries-gitops-a-strategic-advantage-2-1999x680@2x.png) + +## Information security & compliance + +The best Information Security and Compliance teams aim to protect the company’s assets and customers’ data without negatively impacting user productivity or privacy. However, achieving this balance can be tricky, as security needs often conflict with user autonomy. These teams are also responsible for accessing corporate systems to verify and audit configurations, which can sometimes create friction. + +A GitOps-centric environment can help ease this tension by fostering a culture of trust and transparency. Instead of multiple touchpoints, both security teams and end users can access a centralized repository, a single source of truth, for all configuration data. This level of visibility streamlines audits and builds trust, ensuring that security doesn’t feel like “big brother” hovering over end users. It creates an environment where operational teams and users feel empowered, with clear, mutual access to critical information. + +![GitOps workflow diagram](../website/assets/images/articles/sysadmin-diaries-gitops-a-strategic-advantage-3-566x415@2x.png) + +Most systems offer little or no change management features. At best, they might say, “An admin made a change to this setting” and display what was updated. At worst, they might just say, “An admin made a change” without further details. Not only that, but information security and compliance teams don’t want to hunt for this data across various systems, which leads to the creation of centralized logging systems. These shortcomings also result in teams creating complex ticketing systems to propose, approve, and track changes, inevitably leading to the dreaded change management meeting—a time-consuming and often uninspiring process. They’re typically boring and take us away from more engaging, skill-appropriate tasks. However, With GitOps, every change across every system is tracked in one centralized location. This allows full visibility into each proposed change and its outcome and space for questions and comments. More importantly, anyone can propose changes or solutions, ensuring that the entire team feels involved and invested in decisions that impact them. GitOps doesn’t just streamline the process; it fosters collaboration and ownership in a way that traditional methods can’t. + +## Leadership & executives + +Let’s face it: you’re juggling competing priorities with constant pressure to increase efficiency and justify spending. While the pitch for GitOps sounds appealing, you’re a realist and know it will require an investment. It may not be strictly financial, but it will demand time and focus from your team. You’ll need to reprioritize commitments to ensure a meaningful return on the effort spent improving efficiency and meeting business demands. This is why GitOps should be introduced as a core strategy from the top down. It requires a shift in mindset and workflow, which might spark a cultural transformation within your organization. + +Beyond the tangible benefits, GitOps offers a host of unrealized gains that could have an even bigger impact on your business. Yes, it may be challenging at first, but you’ll be upskilling your team along the way. Learning something new is rarely easy, but support and investing in training will pay off. As your team implements greater automation, you’ll uncover opportunities to streamline processes and eliminate redundant systems, ultimately driving down costs. While licensing reductions are easy to quantify, it’s just as critical for leaders to communicate the story of time and efficiency savings. + +Perhaps most importantly, you’ll see a morale boost. Your teams will engage in more meaningful, stimulating work, and the skills they develop in one system will transfer across many others, multiplying their value to the business. This kind of transformation drives productivity and enables your team to become true force multipliers across the entire organization. + +## Where do you go from here? + +Some of you may already be well along your GitOps journey. If that’s the case, I encourage you to share your insights through conference presentations or by open-sourcing your solutions. By doing so, you can help others navigate similar challenges and showcase the real-world benefits of GitOps. + +For those yet to begin, [Fleet](https://fleetdm.com/) is a great place to start. Not only will you be setting up a system that provides comprehensive visibility and insights into all your devices, Fleet also provides an [easy-to-understand GitOps repository to help you get started](https://github.com/fleetdm/fleet-gitops). + +I hope this discussion has piqued your interest and given you the confidence to explore GitOps further. It should be at the top of your decision-making criteria during purchasing and renewal cycles for any software or systems you manage. For IT professionals, imagine becoming the hero of work automation and enhancing the services you provide to your customers. For InfoSec and compliance teams, picture gaining faster and deeper visibility into how devices in your environment are configured, whether on-prem or in the cloud, and having one location to run queries and enforce policies. For executives and leadership, envision a more engaged, motivated, and productive team that consistently helps you meet operational goals while driving down costs. + +Good luck on your GitOps journey, and don’t hesitate to [reach out if you have questions or want to learn more](https://fleetdm.com/support)! + + + + + + + + diff --git a/changes/18354-update-success-messages b/changes/18354-update-success-messages new file mode 100644 index 000000000000..98f582dad033 --- /dev/null +++ b/changes/18354-update-success-messages @@ -0,0 +1 @@ +* Update success messages for lock, unlock, and wipe commands in the UI. diff --git a/changes/19619-win-battery b/changes/19619-win-battery new file mode 100644 index 000000000000..124e58114048 --- /dev/null +++ b/changes/19619-win-battery @@ -0,0 +1 @@ +- Windows host details now include battery status \ No newline at end of file diff --git a/changes/21276-select-live-query-targets-improvements b/changes/21276-select-live-query-targets-improvements new file mode 100644 index 000000000000..75b2086beb03 --- /dev/null +++ b/changes/21276-select-live-query-targets-improvements @@ -0,0 +1 @@ +- UI Improvements to selecting live query targets (e.g. styling, closing behavior) diff --git a/changes/21343-hide-redundant-built-in-label-pills b/changes/21343-hide-redundant-built-in-label-pills new file mode 100644 index 000000000000..92baea5ba52f --- /dev/null +++ b/changes/21343-hide-redundant-built-in-label-pills @@ -0,0 +1 @@ +- UI: Remove redundant built in label filter pills diff --git a/changes/21370-bundle-id-quickfix b/changes/21370-bundle-id-quickfix new file mode 100644 index 000000000000..1f27bedc40af --- /dev/null +++ b/changes/21370-bundle-id-quickfix @@ -0,0 +1 @@ +- Fix "no rows" error when adding a software installer that matches an existing title's name and source but not its bundle ID diff --git a/changes/21409-fedora-label b/changes/21409-fedora-label new file mode 100644 index 000000000000..2f0ca7cfd61e --- /dev/null +++ b/changes/21409-fedora-label @@ -0,0 +1 @@ +- added builtin label for Fedora Linux. Warning: migrations will fail if a pre-existing 'Fedora Linux' label exists. To resolve, delete the existing 'Fedora Linux' label. \ No newline at end of file diff --git a/changes/21594-host-software-filter-bug b/changes/21594-host-software-filter-bug new file mode 100644 index 000000000000..fb7ff11dda66 --- /dev/null +++ b/changes/21594-host-software-filter-bug @@ -0,0 +1 @@ +- Fleet UI: Fix host software filter bug that resets dropdown filter on table changes (pagination, order by column, etc) diff --git a/changes/21875-duplicate-label-name b/changes/21875-duplicate-label-name new file mode 100644 index 000000000000..80ee9d9e6925 --- /dev/null +++ b/changes/21875-duplicate-label-name @@ -0,0 +1 @@ +- Fleet UI: Surface duplicate label name error to user diff --git a/changes/21923-switch-exact-search-focus-bug b/changes/21923-switch-exact-search-focus-bug new file mode 100644 index 000000000000..0ae8b45da921 --- /dev/null +++ b/changes/21923-switch-exact-search-focus-bug @@ -0,0 +1 @@ +- UI fix: Switching vulnerability search types does not cause page re-render diff --git a/changes/22094-cleanup-queries b/changes/22094-cleanup-queries new file mode 100644 index 000000000000..af8e9ab77da8 --- /dev/null +++ b/changes/22094-cleanup-queries @@ -0,0 +1 @@ +- updated activity cleanup job to remove all expired live queries to improve API performance in environment using large volumes of live queries. To note, the cleanup cron may take longer on the first run after upgrade. \ No newline at end of file diff --git a/changes/22094-query-optimization b/changes/22094-query-optimization new file mode 100644 index 000000000000..bd779b451346 --- /dev/null +++ b/changes/22094-query-optimization @@ -0,0 +1 @@ +- Increased performance for Host details and Fleet Desktop, particularly in environments using high volumes of live queries \ No newline at end of file diff --git a/changes/22122-mdm-apple-status-queries b/changes/22122-mdm-apple-status-queries new file mode 100644 index 000000000000..2ea893d31ff5 --- /dev/null +++ b/changes/22122-mdm-apple-status-queries @@ -0,0 +1 @@ +- Improved performance of SQL queries used to determine MDM profile status for Apple hosts. \ No newline at end of file diff --git a/changes/22159-hide-severity-fleet-free b/changes/22159-hide-severity-fleet-free new file mode 100644 index 000000000000..ddb47088fa36 --- /dev/null +++ b/changes/22159-hide-severity-fleet-free @@ -0,0 +1 @@ +- Hide CVSS severity column from Fleet Free software details > vulnerabilities sections diff --git a/changes/22197-policy-auto-software-truncation b/changes/22197-policy-auto-software-truncation new file mode 100644 index 000000000000..4b4740622e6c --- /dev/null +++ b/changes/22197-policy-auto-software-truncation @@ -0,0 +1 @@ +- Fleet UI: Fix policy automation truncation when selecting software to auto-install \ No newline at end of file diff --git a/changes/22198-defaults b/changes/22198-defaults new file mode 100644 index 000000000000..ec243e9a48e4 --- /dev/null +++ b/changes/22198-defaults @@ -0,0 +1,2 @@ +- Fixes a bug where removing a VPP or ABM token from a GitOps YAML file would leave the team + assignments unchanged. \ No newline at end of file diff --git a/changes/22207-close-team-modal b/changes/22207-close-team-modal new file mode 100644 index 000000000000..cad918366754 --- /dev/null +++ b/changes/22207-close-team-modal @@ -0,0 +1 @@ +- Fix UI bug: Edit team name closes modal diff --git a/changes/22415-fix-vpp-migration b/changes/22415-fix-vpp-migration new file mode 100644 index 000000000000..eed21b93dc12 --- /dev/null +++ b/changes/22415-fix-vpp-migration @@ -0,0 +1 @@ +* Fixed an issue with the migration adding support for multiple VPP tokens that would happen if a token is removed prior to upgrading Fleet. diff --git a/changes/22492-msrc-fix b/changes/22492-msrc-fix new file mode 100644 index 000000000000..4bd6035faaed --- /dev/null +++ b/changes/22492-msrc-fix @@ -0,0 +1 @@ +* Fix MSRC feed pulls (for NVD release builds) in environments where GitHub access is authenticated diff --git a/changes/software-edit-request-deadline b/changes/software-edit-request-deadline new file mode 100644 index 000000000000..f3576256f5ed --- /dev/null +++ b/changes/software-edit-request-deadline @@ -0,0 +1 @@ +* Ensure request timeouts for software installer edits are just as high as for initial software installer uploads diff --git a/cmd/fleet/serve.go b/cmd/fleet/serve.go index eda0660a731d..02353ffe3183 100644 --- a/cmd/fleet/serve.go +++ b/cmd/fleet/serve.go @@ -1145,7 +1145,8 @@ the way that the Fleet server works. } } - if req.Method == http.MethodPost && strings.HasSuffix(req.URL.Path, "/fleet/software/package") { + if (req.Method == http.MethodPost && strings.HasSuffix(req.URL.Path, "/fleet/software/package")) || + (req.Method == http.MethodPatch && strings.HasSuffix(req.URL.Path, "/package") && strings.Contains(req.URL.Path, "/fleet/software/titles/")) { // when uploading a software installer, the file might be large so // the read timeout (to read the full request body) must be extended. rc := http.NewResponseController(rw) diff --git a/cmd/fleetctl/apply_test.go b/cmd/fleetctl/apply_test.go index f35b39dc84f1..bd80bb84347c 100644 --- a/cmd/fleetctl/apply_test.go +++ b/cmd/fleetctl/apply_test.go @@ -657,6 +657,18 @@ func TestApplyAppConfig(t *testing.T) { return []*fleet.TeamSummary{{Name: "team1", ID: 1}}, nil } + ds.SaveABMTokenFunc = func(ctx context.Context, tok *fleet.ABMToken) error { + return nil + } + + ds.ListVPPTokensFunc = func(ctx context.Context) ([]*fleet.VPPTokenDB, error) { + return []*fleet.VPPTokenDB{}, nil + } + + ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { + return []*fleet.ABMToken{{OrganizationName: t.Name()}}, nil + } + name := writeTmpYml(t, `--- apiVersion: v1 kind: config @@ -782,6 +794,18 @@ func TestApplyAppConfigDryRunIssue(t *testing.T) { return nil } + ds.SaveABMTokenFunc = func(ctx context.Context, tok *fleet.ABMToken) error { + return nil + } + + ds.ListVPPTokensFunc = func(ctx context.Context) ([]*fleet.VPPTokenDB, error) { + return []*fleet.VPPTokenDB{}, nil + } + + ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { + return []*fleet.ABMToken{}, nil + } + // first, set the default app config's agent options as set after fleetctl setup name := writeTmpYml(t, `--- apiVersion: v1 @@ -914,6 +938,18 @@ func TestApplyAppConfigDeprecatedFields(t *testing.T) { return nil } + ds.SaveABMTokenFunc = func(ctx context.Context, tok *fleet.ABMToken) error { + return nil + } + + ds.ListVPPTokensFunc = func(ctx context.Context) ([]*fleet.VPPTokenDB, error) { + return []*fleet.VPPTokenDB{}, nil + } + + ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { + return []*fleet.ABMToken{}, nil + } + name := writeTmpYml(t, `--- apiVersion: v1 kind: config @@ -1316,6 +1352,14 @@ func TestApplyAsGitOps(t *testing.T) { return []*fleet.ABMToken{{ID: 1}}, nil } + ds.SaveABMTokenFunc = func(ctx context.Context, tok *fleet.ABMToken) error { + return nil + } + + ds.ListVPPTokensFunc = func(ctx context.Context) ([]*fleet.VPPTokenDB, error) { + return []*fleet.VPPTokenDB{}, nil + } + // Apply global config. name := writeTmpYml(t, `--- apiVersion: v1 @@ -1873,6 +1917,18 @@ func TestCanApplyIntervalsInNanoseconds(t *testing.T) { return nil } + ds.SaveABMTokenFunc = func(ctx context.Context, tok *fleet.ABMToken) error { + return nil + } + + ds.ListVPPTokensFunc = func(ctx context.Context) ([]*fleet.VPPTokenDB, error) { + return []*fleet.VPPTokenDB{}, nil + } + + ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { + return []*fleet.ABMToken{}, nil + } + name := writeTmpYml(t, `--- apiVersion: v1 kind: config @@ -1908,6 +1964,18 @@ func TestCanApplyIntervalsUsingDurations(t *testing.T) { return nil } + ds.SaveABMTokenFunc = func(ctx context.Context, tok *fleet.ABMToken) error { + return nil + } + + ds.ListVPPTokensFunc = func(ctx context.Context) ([]*fleet.VPPTokenDB, error) { + return []*fleet.VPPTokenDB{}, nil + } + + ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { + return []*fleet.ABMToken{}, nil + } + name := writeTmpYml(t, `--- apiVersion: v1 kind: config @@ -2091,6 +2159,18 @@ func TestApplyMacosSetup(t *testing.T) { return []*fleet.ABMToken{{ID: 1}}, nil } + ds.SaveABMTokenFunc = func(ctx context.Context, tok *fleet.ABMToken) error { + return nil + } + + ds.ListVPPTokensFunc = func(ctx context.Context) ([]*fleet.VPPTokenDB, error) { + return []*fleet.VPPTokenDB{}, nil + } + + ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { + return []*fleet.ABMToken{}, nil + } + return ds } @@ -2764,6 +2844,17 @@ func TestApplySpecs(t *testing.T) { ds.DeleteMDMWindowsConfigProfileByTeamAndNameFunc = func(ctx context.Context, teamID *uint, profileName string) error { return nil } + + // VPP/AMB + ds.SaveABMTokenFunc = func(ctx context.Context, tok *fleet.ABMToken) error { + return nil + } + ds.ListVPPTokensFunc = func(ctx context.Context) ([]*fleet.VPPTokenDB, error) { + return []*fleet.VPPTokenDB{}, nil + } + ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { + return []*fleet.ABMToken{}, nil + } } cases := []struct { diff --git a/cmd/fleetctl/gitops_test.go b/cmd/fleetctl/gitops_test.go index 1a054482f618..5a688b2fd91b 100644 --- a/cmd/fleetctl/gitops_test.go +++ b/cmd/fleetctl/gitops_test.go @@ -83,6 +83,18 @@ func TestGitOpsBasicGlobalFree(t *testing.T) { return nil } + ds.SaveABMTokenFunc = func(ctx context.Context, tok *fleet.ABMToken) error { + return nil + } + + ds.ListVPPTokensFunc = func(ctx context.Context) ([]*fleet.VPPTokenDB, error) { + return []*fleet.VPPTokenDB{}, nil + } + + ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { + return []*fleet.ABMToken{}, nil + } + tmpFile, err := os.CreateTemp(t.TempDir(), "*.yml") require.NoError(t, err) @@ -238,6 +250,18 @@ func TestGitOpsBasicGlobalPremium(t *testing.T) { return nil, nil } + ds.SaveABMTokenFunc = func(ctx context.Context, tok *fleet.ABMToken) error { + return nil + } + + ds.ListVPPTokensFunc = func(ctx context.Context) ([]*fleet.VPPTokenDB, error) { + return []*fleet.VPPTokenDB{}, nil + } + + ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { + return []*fleet.ABMToken{}, nil + } + tmpFile, err := os.CreateTemp(t.TempDir(), "*.yml") require.NoError(t, err) @@ -591,6 +615,17 @@ func TestGitOpsFullGlobal(t *testing.T) { return nil } + // Needed for checking tokens + ds.SaveABMTokenFunc = func(ctx context.Context, tok *fleet.ABMToken) error { + return nil + } + ds.ListVPPTokensFunc = func(ctx context.Context) ([]*fleet.VPPTokenDB, error) { + return []*fleet.VPPTokenDB{}, nil + } + ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { + return []*fleet.ABMToken{}, nil + } + const ( fleetServerURL = "https://fleet.example.com" orgName = "GitOps Test" @@ -1079,6 +1114,18 @@ func TestGitOpsBasicGlobalAndTeam(t *testing.T) { return nil, 0, nil, nil } + ds.SaveABMTokenFunc = func(ctx context.Context, tok *fleet.ABMToken) error { + return nil + } + + ds.ListVPPTokensFunc = func(ctx context.Context) ([]*fleet.VPPTokenDB, error) { + return []*fleet.VPPTokenDB{}, nil + } + + ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { + return []*fleet.ABMToken{}, nil + } + globalFile, err := os.CreateTemp(t.TempDir(), "*.yml") require.NoError(t, err) @@ -1345,6 +1392,18 @@ func TestGitOpsBasicGlobalAndNoTeam(t *testing.T) { return nil, 0, nil, nil } + ds.SaveABMTokenFunc = func(ctx context.Context, tok *fleet.ABMToken) error { + return nil + } + + ds.ListVPPTokensFunc = func(ctx context.Context) ([]*fleet.VPPTokenDB, error) { + return []*fleet.VPPTokenDB{}, nil + } + + ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { + return []*fleet.ABMToken{}, nil + } + globalFileBasic, err := os.CreateTemp(t.TempDir(), "*.yml") require.NoError(t, err) @@ -1604,6 +1663,18 @@ func TestGitOpsFullGlobalAndTeam(t *testing.T) { return team, nil } + ds.SaveABMTokenFunc = func(ctx context.Context, tok *fleet.ABMToken) error { + return nil + } + + ds.ListVPPTokensFunc = func(ctx context.Context) ([]*fleet.VPPTokenDB, error) { + return []*fleet.VPPTokenDB{}, nil + } + + ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { + return []*fleet.ABMToken{}, nil + } + apnsCert, apnsKey, err := mysql.GenerateTestCertBytes() require.NoError(t, err) crt, key, err := apple_mdm.NewSCEPCACertKey() @@ -2234,6 +2305,15 @@ func setupFullGitOpsPremiumServer(t *testing.T) (*mock.Store, **fleet.AppConfig, ds.ListSoftwareTitlesFunc = func(ctx context.Context, opt fleet.SoftwareTitleListOptions, tmFilter fleet.TeamFilter) ([]fleet.SoftwareTitleListResult, int, *fleet.PaginationMetadata, error) { return nil, 0, nil, nil } + ds.SaveABMTokenFunc = func(ctx context.Context, tok *fleet.ABMToken) error { + return nil + } + ds.ListVPPTokensFunc = func(ctx context.Context) ([]*fleet.VPPTokenDB, error) { + return []*fleet.VPPTokenDB{}, nil + } + ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { + return []*fleet.ABMToken{}, nil + } t.Setenv("FLEET_SERVER_URL", fleetServerURL) t.Setenv("ORG_NAME", orgName) diff --git a/cmd/msrc/generate.go b/cmd/msrc/generate.go index 75909ac4fc27..9af759eb2a72 100644 --- a/cmd/msrc/generate.go +++ b/cmd/msrc/generate.go @@ -3,6 +3,7 @@ package main import ( "context" "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -34,13 +35,16 @@ func main() { panicif(err) now := time.Now() - httpC := fleethttp.NewGithubClient() ctx := context.Background() - ghAPI := io.NewGitHubClient(httpC, github.NewClient(httpC).Repositories, wd) - msrcAPI := msrc.NewMSRCClient(httpC, inPath, msrc.MSRCBaseURL) - fmt.Println("Downloading existing bulletins...") + githubHttp := fleethttp.NewGithubClient() + ghAPI := io.NewGitHubClient(githubHttp, github.NewClient(githubHttp).Repositories, wd) + + msrcHttp := fleethttp.NewClient() // don't reuse the GitHub client as it has an OAuth token baked in + msrcAPI := msrc.NewMSRCClient(msrcHttp, inPath, msrc.MSRCBaseURL) + + fmt.Println("Downloading existing MSRC bulletins...") eBulletins, err := ghAPI.MSRCBulletins(ctx) panicif(err) @@ -61,7 +65,16 @@ func main() { panicif(err) } - fmt.Println("Done.") + fmt.Println("Done processing MSRC feed.") +} + +// windowsBulletinGracePeriod returns whether we are within the grace period for a MSRC monthly feed to exist. +// +// E.g. September 2024 bulletin was released on the 2nd, thus we add some grace period (5 days) +// for Microsoft to publish the current month bulletin. +func windowsBulletinGracePeriod(month time.Month, year int) bool { + now := time.Now() + return month == now.Month() && year == now.Year() && now.Day() <= 5 } func update( @@ -72,15 +85,22 @@ func update( ghClient io.GitHubAPI, ) ([]*parsed.SecurityBulletin, error) { fmt.Println("Downloading current feed...") - f, err := msrcClient.GetFeed(m, y) + currentFeed, err := msrcClient.GetFeed(m, y) if err != nil { - return nil, err + if errors.Is(err, msrc.FeedNotFound) && windowsBulletinGracePeriod(m, y) { + fmt.Printf("Current month feed %d-%d was not found, skipping...\n", y, m) + } else { + return nil, err + } } - fmt.Println("Parsing current feed...") - nBulletins, err := msrc.ParseFeed(f) - if err != nil { - return nil, err + var nBulletins map[string]*parsed.SecurityBulletin + if currentFeed != "" { + fmt.Println("Parsing current feed...") + nBulletins, err = msrc.ParseFeed(currentFeed) + if err != nil { + return nil, err + } } var bulletins []*parsed.SecurityBulletin @@ -118,6 +138,10 @@ func backfill(upToM time.Month, upToY int, client msrc.MSRCAPI) ([]*parsed.Secur fmt.Printf("Downloading feed for %d-%d...\n", d.Year(), d.Month()) f, err := client.GetFeed(d.Month(), d.Year()) if err != nil { + if errors.Is(err, msrc.FeedNotFound) && windowsBulletinGracePeriod(d.Month(), d.Year()) { + fmt.Printf("Current month feed %d-%d was not found, skipping...\n", d.Year(), d.Month()) + continue + } return nil, err } diff --git a/docs/Contributing/API-for-contributors.md b/docs/Contributing/API-for-contributors.md index 2ba361b2c3a7..074c3338b4c2 100644 --- a/docs/Contributing/API-for-contributors.md +++ b/docs/Contributing/API-for-contributors.md @@ -885,7 +885,20 @@ Content-Type: application/octet-stream "location": "https://example.com/mdm/apple/mdm", "renew_date": "2024-10-20T00:00:00Z", "terms_expired": false, - "teams": [1, 2, 3] + "teams": [ + { + "team_id": 1, + "name": "Team 1" + }, + { + "team_id": 2, + "name": "Team 2" + }, + { + "team_id": 2, + "name": "Team 3" + }, + ] } ``` diff --git a/docs/Contributing/Building-Fleet.md b/docs/Contributing/Building-Fleet.md index 2ab34243a45a..368b68d23b46 100644 --- a/docs/Contributing/Building-Fleet.md +++ b/docs/Contributing/Building-Fleet.md @@ -38,7 +38,7 @@ sudo npm install -g yarn # Install nvm to manage node versions (apt very out of date) https://github.com/nvm-sh/nvm#install--update-script curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.5/install.sh | bash # refresh your session before continuing -nvm install v19.7.0 +nvm install v20.11.1 ``` #### Windows @@ -149,7 +149,7 @@ The following assumes that you already installed [Docker](https://docs.docker.c To set up a canonical development environment via Docker, run the following from the root of the repository: ```sh -docker-compose up +docker compose up ``` > Note: you can customize the DB Docker image via the environment variables FLEET_MYSQL_IMAGE and FLEET_MYSQL_PLATFORM. For example: @@ -161,12 +161,12 @@ docker-compose up If you'd like to shut down the virtual infrastructure created by Docker, run the following from the root of the repository: ```sh -docker-compose down +docker compose down ``` ### Setting up the database tables -Once you `docker-compose up` and are running the databases, you can build the code and run the following command to create the database tables: +Once you `docker compose up` and are running the databases, you can build the code and run the following command to create the database tables: ```sh ./build/fleet prepare db --dev diff --git a/docs/Contributing/Understanding-host-vitals.md b/docs/Contributing/Understanding-host-vitals.md index 3cdabf2fde06..3aa2078574f1 100644 --- a/docs/Contributing/Understanding-host-vitals.md +++ b/docs/Contributing/Understanding-host-vitals.md @@ -3,7 +3,7 @@ Following is a summary of the detail queries hardcoded in Fleet used to populate the device details: -## battery +## battery_macos - Platforms: darwin @@ -12,6 +12,20 @@ Following is a summary of the detail queries hardcoded in Fleet used to populate SELECT serial_number, cycle_count, health FROM battery; ``` +## battery_windows + +- Platforms: windows + +- Discovery query: +```sql +SELECT 1 FROM osquery_registry WHERE active = true AND registry = 'table' AND name = 'battery' +``` + +- Query: +```sql +SELECT serial_number, cycle_count, designed_capacity, max_capacity FROM battery +``` + ## chromeos_profile_user_info - Platforms: chrome diff --git a/docs/Get started/tutorials-and-guides.md b/docs/Get started/tutorials-and-guides.md index f83de79f27db..aff2e5a6087c 100644 --- a/docs/Get started/tutorials-and-guides.md +++ b/docs/Get started/tutorials-and-guides.md @@ -6,7 +6,7 @@ A collection of guides to help you with Fleet. - [How to install osquery and enroll Linux devices into Fleet](https://fleetdm.com/guides/how-to-install-osquery-and-enroll-linux-devices-into-fleet) - [How to install osquery and enroll Windows devices into Fleet](https://fleetdm.com/guides/how-to-install-osquery-and-enroll-windows-devices-into-fleet) - [Sysadmin diaries: restoring fleetd](https://fleetdm.com/guides/sysadmin-diaries-restoring-fleetd) -- [How to uninstall osquery](https://fleetdm.com/guides/how-to-uninstall-osquery) +- [How to uninstall Fleet's agent (fleetd)](https://fleetdm.com/guides/how-to-uninstall-fleetd) - [Sysadmin diaries: device enrollment](https://fleetdm.com/guides/sysadmin-diaries-device-enrollment) - [Sysadmin diaries: passcode profiles](https://fleetdm.com/guides/sysadmin-diaries-passcode-profiles) - [Sysadmin diaries: lost device](https://fleetdm.com/guides/sysadmin-diaries-lost-device) diff --git a/docs/Get started/why-fleet.md b/docs/Get started/why-fleet.md index 0918248ac085..e0db75d68759 100644 --- a/docs/Get started/why-fleet.md +++ b/docs/Get started/why-fleet.md @@ -1,70 +1,26 @@ # Why Fleet -## What's it for? - -In an industry that views security and IT as a shopping spree rather than a process, it's little wonder that IT and security teams get stuck with bolt-on solutions, misconfigured tools, and disparate workflows. - -Fleet helps organizations like Fastly and Gusto reimagine the "easy button." - -By simplifying how they do device health, FIM, HIDS, posture assessment, malware detection, vulnerability management, MDM, and the rest, Fleet's API enables teams with thousands of computers to build an IT and security program that works. - -### Explore data -To see what kind of data you can use Fleet to gather, you can check out the [table reference documentation](https://fleetdm.com/tables). - -### Out-of-the-box policies -Fleet includes out-of-the-box support for all [CIS benchmarks for macOS and Windows](https://fleetdm.com/pricing), as well as many [simpler queries](https://fleetdm.com/queries). - -Take as much or as little as you need for your organization. - -### Supported platforms -Here are the platforms Fleet currently supports: +Fleet is an open-source device management platform for Linux, macOS, Windows, Chromebooks, and iOS devices (BYOD or corporate owned.) -- Linux (all distros) -- macOS -- Windows -- Chromebooks -- Amazon Web Services (AWS) -- Google Cloud (GCP) -- Azure (Microsoft cloud) -- Data centers -- Containers (kube, etc) -- Linux-based IoT devices - -## Lighter than air -Fleet is lightweight and modular. You can use it for security without using it for MDM, and vice versa. You can turn off features you are not using. - -### Open by design -Fleet is dedicated to flexibility, accessibility, and clarity. We think [everyone can contribute](https://fleetdm.com/handbook/company#openness) and that tools should be as easy as possible for everyone to understand. - -### Good neighbors -Ready-to-use, enterprise-friendly [integrations](https://fleetdm.com/integrations) exist for Snowflake, Splunk, GitHub Actions, Vanta, Elastic Jira, Zendesk, and more. - -Fleet plays well with Munki, Chef, Puppet, and Ansible, as well as with security tools like Crowdstrike and SentinelOne. For example, you can use the free version of Fleet to quickly report on what hosts are _actually_ running your EDR agent. - - -### Free as in free -The free version of Fleet will [always be free](https://fleetdm.com/pricing). Fleet is [independently backed](https://linkedin.com/company/fleetdm) and actively maintained with the help of many amazing [contributors](https://github.com/fleetdm/fleet/graphs/contributors). +## What's it for? -### Longevity -The [company behind Fleet](https://fleetdm.com/handbook/company) is founded (and majority-owned) by [true believers in open source](https://fleetdm.com/handbook/company/why-this-way#why-open-source). The company's business model is influenced by GitLab (NYSE: GTLB), with great investors, happy customers, and the capacity to become profitable at any time. +Managing computers today is getting harder. You have to juggle a mix of operating systems and devices, with a whole bunch of middleman vendors in between. -In keeping with Fleet's value of openness, [Fleet Device Management's company handbook](https://fleetdm.com/handbook/company) is public and open source. You can read about the [history of Fleet and osquery](https://fleetdm.com/handbook/company#history) and our commitment to improving the product. +Fleet makes things easier by giving you a single system to manage and secure all your computing devices. You can do MDM, patch stuff, and verify anything—all from one dashboard. It's like having a universal remote control for all your organization's computers. - +Fleet is open source, and free features will always be free. ## Is it any good? -Fleet is used in production by IT and security teams with thousands of laptops and servers. Many deployments support tens of thousands of hosts, and a few large organizations manage deployments as large as 400,000+ hosts. -## Chat -Please join us in [MacAdmins Slack](https://www.macadmins.org/) or [osquery Slack](https://fleetdm.com/slack). +Fleet is used in production by IT and security teams with thousands of laptops and servers. Many deployments support tens of thousands of hosts, and a few large organizations manage deployments as large as 400,000+ hosts. -The Fleet community is full of [kind and helpful people](https://fleetdm.com/handbook/company#empathy). Whether or not you are a paying customer, if you need help, just ask. +- **Get what you need:** Fleet lets you work directly with [data](https://fleetdm.com/integrations) and events from the native operating system. It lets you go all the way down to the bare metal. It’s also modular. (You can turn off features you are not using.) +- **Out of the box:** Ready-to-use integrations exist for the [most common tools](https://fleetdm.com/integrations). You can also build custom workflows with the REST API, webhook events, and the fleetctl command line tool. Or go all in and manage computers [with GitOps](https://fleetdm.com/handbook/company#history). +- **Good neighbors:** We think tools should be as easy as possible for everyone to understand. We helped [create osquery](https://fleetdm.com/handbook/company#history), and we are committed to improving it. +- **Free as in free:** The free version of Fleet will [always be free](https://fleetdm.com/pricing). Fleet is independently backed and actively maintained with the help of many amazing contributors. - +## Ready to get started? -## Questions? -We have answers to the most [common questions](https://fleetdm.com/docs/get-started/faq). +Deploying Fleet is easy. [Install it](https://fleetdm.com/docs/deploy/deploy-fleet) on a server, or [we can host it](https://fleetdm.com/register) for you. Enroll your devices, and you're set. diff --git a/docs/REST API/rest-api.md b/docs/REST API/rest-api.md index 638728015b9d..0a760c32d5fc 100644 --- a/docs/REST API/rest-api.md +++ b/docs/REST API/rest-api.md @@ -8644,6 +8644,7 @@ Deletes the session specified by ID. When the user associated with the session n - [Get software version](#get-software-version) - [Get operating system version](#get-operating-system-version) - [Add package](#add-package) +- [Modify package](#modify-package) - [List App Store apps](#list-app-store-apps) - [Add App Store app](#add-app-store-app) - [Add Fleet library app](#add-fleet-library-app) @@ -8677,6 +8678,7 @@ Get a list of all software. | vulnerable | boolean | query | If true or 1, only list software that has detected vulnerabilities. Default is `false`. | | available_for_install | boolean | query | If `true` or `1`, only list software that is available for install (added by the user). Default is `false`. | | self_service | boolean | query | If `true` or `1`, only lists self-service software. Default is `false`. | +| packages_only | boolean | query | If `true` or `1`, only lists packages available for install (without App Store apps). | | min_cvss_score | integer | query | _Available in Fleet Premium_. Filters to include only software with vulnerabilities that have a CVSS version 3.x base score higher than the specified value. | | max_cvss_score | integer | query | _Available in Fleet Premium_. Filters to only include software with vulnerabilities that have a CVSS version 3.x base score lower than what's specified. | | exploit | boolean | query | _Available in Fleet Premium_. If `true`, filters to only include software with vulnerabilities that have been actively exploited in the wild (`cisa_known_exploit: true`). Default is `false`. | @@ -9229,6 +9231,8 @@ Content-Type: application/octet-stream ### Modify package +> **Experimental feature**. This feature is undergoing rapid improvement, which may result in breaking changes to the API or configuration surface. It is not recommended for use in automated workflows. + _Available in Fleet Premium._ Update a package to install on macOS, Windows, or Linux (Ubuntu) hosts. diff --git a/frontend/components/AddHostsModal/PlatformWrapper/PlatformWrapper.tsx b/frontend/components/AddHostsModal/PlatformWrapper/PlatformWrapper.tsx index a2a8ccb1d1c3..7f520ae6440c 100644 --- a/frontend/components/AddHostsModal/PlatformWrapper/PlatformWrapper.tsx +++ b/frontend/components/AddHostsModal/PlatformWrapper/PlatformWrapper.tsx @@ -257,7 +257,7 @@ const PlatformWrapper = ({ Run this command with the{" "} diff --git a/frontend/components/LiveQuery/SelectTargets.tsx b/frontend/components/LiveQuery/SelectTargets.tsx index ad46545983c0..5302301a08fd 100644 --- a/frontend/components/LiveQuery/SelectTargets.tsx +++ b/frontend/components/LiveQuery/SelectTargets.tsx @@ -430,17 +430,6 @@ const SelectTargets = ({ ); }; - if (isLoadingLabels || (isPremiumTier && isLoadingTeams)) { - return ( -
-

Select targets

-
- -
-
- ); - } - if (errorLabels || errorTeams) { return (
diff --git a/frontend/components/LiveQuery/TargetsInput/TargetsInput.tsx b/frontend/components/LiveQuery/TargetsInput/TargetsInput.tsx index dcfc2074d8b6..e54830081ed1 100644 --- a/frontend/components/LiveQuery/TargetsInput/TargetsInput.tsx +++ b/frontend/components/LiveQuery/TargetsInput/TargetsInput.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useRef, useEffect, useState } from "react"; import { Row } from "react-table"; import { isEmpty, pullAllBy } from "lodash"; @@ -9,7 +9,6 @@ import DataError from "components/DataError"; // @ts-ignore import InputFieldWithIcon from "components/forms/fields/InputFieldWithIcon/InputFieldWithIcon"; import TableContainer from "components/TableContainer"; -import Spinner from "components/Spinner"; import { ITargestInputHostTableConfig } from "./TargetsInputHostsTableConfig"; interface ITargetsInputProps { @@ -51,12 +50,39 @@ const TargetsInput = ({ handleRowSelect, setSearchText, }: ITargetsInputProps): JSX.Element => { + const dropdownRef = useRef(null); const dropdownHosts = searchResults && pullAllBy(searchResults, targetedHosts, "display_name"); - const isActiveSearch = - !isEmpty(searchText) && (!hasFetchError || isTargetsLoading); + + const [isActiveSearch, setIsActiveSearch] = useState(false); + const isSearchError = !isEmpty(searchText) && hasFetchError; + // Closes target search results when clicking outside of results + // But not during API loading state as it will reopen on API return + useEffect(() => { + if (!isTargetsLoading) { + const handleClickOutside = (event: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) + ) { + setIsActiveSearch(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + } + }, [isTargetsLoading]); + + useEffect(() => { + setIsActiveSearch( + !isEmpty(searchText) && (!hasFetchError || isTargetsLoading) + ); + }, [searchText, hasFetchError, isTargetsLoading]); return (
@@ -71,35 +97,35 @@ const TargetsInput = ({ placeholder={placeholder} onChange={setSearchText} /> - {isActiveSearch && - (isTargetsLoading ? ( - - ) : ( -
- > - columnConfigs={searchResultsTableConfig} - data={dropdownHosts} - isLoading={false} - emptyComponent={() => ( -
-
-

No hosts match the current search criteria.

-

- Expecting to see hosts? Try again in a few seconds as - the system catches up. -

-
+ {isActiveSearch && ( +
+ > + columnConfigs={searchResultsTableConfig} + data={dropdownHosts} + isLoading={isTargetsLoading} + emptyComponent={() => ( +
+
+

No hosts match the current search criteria.

+

+ Expecting to see hosts? Try again in a few seconds as the + system catches up. +

- )} - showMarkAllPages={false} - isAllPagesSelected={false} - disableCount - disablePagination - disableMultiRowSelect - onClickRow={handleRowSelect} - /> -
- ))} +
+ )} + showMarkAllPages={false} + isAllPagesSelected={false} + disableCount + disablePagination + disableMultiRowSelect + onClickRow={handleRowSelect} + /> +
+ )} {isSearchError && (
diff --git a/frontend/components/LiveQuery/TargetsInput/_styles.scss b/frontend/components/LiveQuery/TargetsInput/_styles.scss index e219d2660d18..a3fac64150b3 100644 --- a/frontend/components/LiveQuery/TargetsInput/_styles.scss +++ b/frontend/components/LiveQuery/TargetsInput/_styles.scss @@ -17,10 +17,6 @@ overflow: auto; } - &__data-table-block > div { - min-height: 89px; - } - // Properly vertically aligns host issue icon .display_name__cell { display: inline-flex; @@ -39,7 +35,7 @@ } .empty-search, - .error-search { + .data-error { padding-top: 72px; padding-bottom: 72px; min-height: 225px; @@ -48,16 +44,14 @@ box-shadow: 0px 4px 10px rgba(52, 59, 96, 0.15); box-sizing: border-box; - &__inner { - h4 { - margin: 0; - margin-bottom: 16px; - font-size: $small; - } - p { - margin: 0; - font-size: $x-small; - } + h4 { + margin: 0; + margin-bottom: 16px; + font-size: $small; + } + p { + margin: 0; + font-size: $x-small; } } } @@ -99,9 +93,15 @@ } } - // override the default styles for the spinner. - // TODO: set better default styles for the spinner + .data-table-block .data-table__no-rows { + min-height: 225px; // Match empty and error state + } + + .loading-overlay { + height: 100%; // Match container height + } + .loading-spinner.centered { - margin: 1rem auto; + margin: auto; } } diff --git a/frontend/pages/ManageControlsPage/OSUpdates/components/AppleOSTargetForm/AppleOSTargetForm.tsx b/frontend/pages/ManageControlsPage/OSUpdates/components/AppleOSTargetForm/AppleOSTargetForm.tsx index 755b59d093a0..fdf534328259 100644 --- a/frontend/pages/ManageControlsPage/OSUpdates/components/AppleOSTargetForm/AppleOSTargetForm.tsx +++ b/frontend/pages/ManageControlsPage/OSUpdates/components/AppleOSTargetForm/AppleOSTargetForm.tsx @@ -5,12 +5,13 @@ import { APP_CONTEXT_NO_TEAM_ID } from "interfaces/team"; import { NotificationContext } from "context/notification"; import configAPI from "services/entities/config"; import teamsAPI from "services/entities/teams"; +import { ApplePlatform } from "interfaces/platform"; // @ts-ignore import InputField from "components/forms/fields/InputField"; import Button from "components/buttons/Button"; import validatePresence from "components/forms/validators/validate_presence"; -import { ApplePlatform } from "interfaces/platform"; +import CustomLink from "components/CustomLink"; const baseClass = "apple-os-target-form"; @@ -197,7 +198,16 @@ const AppleOSTargetForm = ({ + Use only versions available from Apple.{" "} + + + } value={minOsVersion} error={minOsVersionError} onChange={handleMinVersionChange} diff --git a/frontend/pages/ManageControlsPage/Scripts/components/DeleteScriptModal/DeleteScriptModal.tsx b/frontend/pages/ManageControlsPage/Scripts/components/DeleteScriptModal/DeleteScriptModal.tsx index 1935b38b1133..8b5fec069426 100644 --- a/frontend/pages/ManageControlsPage/Scripts/components/DeleteScriptModal/DeleteScriptModal.tsx +++ b/frontend/pages/ManageControlsPage/Scripts/components/DeleteScriptModal/DeleteScriptModal.tsx @@ -44,8 +44,8 @@ const DeleteScriptModal = ({

The script{" "} {scriptName} will - run on pending hosts. After the scripts runs, it's output and - exit code will appear in the activity feed. + run on pending hosts. After the script runs, its output and exit code + will appear in the activity feed.

diff --git a/frontend/pages/hosts/details/HostDetailsPage/modals/LockModal/LockModal.tsx b/frontend/pages/hosts/details/HostDetailsPage/modals/LockModal/LockModal.tsx index 9877e4729ac0..a527de72b338 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/modals/LockModal/LockModal.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/modals/LockModal/LockModal.tsx @@ -34,7 +34,7 @@ const LockModal = ({ try { await hostAPI.lockHost(id); onSuccess(); - renderFlash("success", "Host is locking!"); + renderFlash("success", "Locking host or will lock when it comes online."); } catch (e) { renderFlash("error", getErrorReason(e)); } diff --git a/frontend/pages/hosts/details/HostDetailsPage/modals/UnlockModal/UnlockModal.tsx b/frontend/pages/hosts/details/HostDetailsPage/modals/UnlockModal/UnlockModal.tsx index 178f21a1b8f7..5d40e09886d2 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/modals/UnlockModal/UnlockModal.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/modals/UnlockModal/UnlockModal.tsx @@ -51,7 +51,10 @@ const UnlockModal = ({ try { await hostAPI.unlockHost(id); onSuccess(); - renderFlash("success", "Host is unlocking!"); + renderFlash( + "success", + "Unlocking host or will unlock when it comes online." + ); } catch (e) { renderFlash("error", getErrorReason(e)); } diff --git a/frontend/pages/hosts/details/HostDetailsPage/modals/WipeModal/WipeModal.tsx b/frontend/pages/hosts/details/HostDetailsPage/modals/WipeModal/WipeModal.tsx index 7df40b2adb97..9e7754ac55cb 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/modals/WipeModal/WipeModal.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/modals/WipeModal/WipeModal.tsx @@ -27,7 +27,10 @@ const WipeModal = ({ id, hostName, onSuccess, onClose }: IWipeModalProps) => { try { await hostAPI.wipeHost(id); onSuccess(); - renderFlash("success", "Success! Host is wiping."); + renderFlash( + "success", + "Wiping host or will wipe when the host comes online." + ); } catch (e) { renderFlash("error", getErrorReason(e)); } diff --git a/frontend/pages/hosts/details/cards/Software/HostSoftwareTable/HostSoftwareTable.tsx b/frontend/pages/hosts/details/cards/Software/HostSoftwareTable/HostSoftwareTable.tsx index be7a476d781a..99275f7751cd 100644 --- a/frontend/pages/hosts/details/cards/Software/HostSoftwareTable/HostSoftwareTable.tsx +++ b/frontend/pages/hosts/details/cards/Software/HostSoftwareTable/HostSoftwareTable.tsx @@ -118,6 +118,7 @@ const HostSoftwareTable = ({ /> ); }, [handleFilterDropdownChange, hostSoftwareFilter]); + const determineQueryParamChange = useCallback( (newTableQuery: ITableQueryData) => { const changedEntry = Object.entries(newTableQuery).find(([key, val]) => { @@ -148,9 +149,15 @@ const HostSoftwareTable = ({ page: changedParam === "pageIndex" ? newTableQuery.pageIndex : 0, }; + if (hostSoftwareFilter === "vulnerableSoftware") { + newQueryParam.vulnerable = "true"; + } else if (hostSoftwareFilter === "installableSoftware") { + newQueryParam.available_for_install = "true"; + } + return newQueryParam; }, - [] + [hostSoftwareFilter] ); // TODO: Look into useDebounceCallback with dependencies diff --git a/frontend/pages/hosts/details/cards/Software/HostSoftwareTableConfig.tests.tsx b/frontend/pages/hosts/details/cards/Software/HostSoftwareTableConfig.tests.tsx index aa14172c86f8..68c7a854d960 100644 --- a/frontend/pages/hosts/details/cards/Software/HostSoftwareTableConfig.tests.tsx +++ b/frontend/pages/hosts/details/cards/Software/HostSoftwareTableConfig.tests.tsx @@ -80,4 +80,21 @@ describe("generateActions", () => { const actions = generateActions(props); expect(actions.find((a) => a.value === "uninstall")).toBeUndefined(); }); + + it("allows to install VPP apps even if scripts are disabled", () => { + const props: generateActionsProps = { + ...defaultProps, + hostScriptsEnabled: false, + app_store_app: { + app_store_id: "1", + self_service: false, + icon_url: "", + version: "", + last_install: { command_uuid: "", installed_at: "" }, + }, + }; + const actions = generateActions(props); + expect(actions.find((a) => a.value === "install")?.disabled).toBe(false); + expect(actions.find((a) => a.value === "uninstall")).toBeUndefined(); + }); }); diff --git a/frontend/pages/hosts/details/cards/Software/HostSoftwareTableConfig.tsx b/frontend/pages/hosts/details/cards/Software/HostSoftwareTableConfig.tsx index 53256f50bffb..c6a07ce7fa18 100644 --- a/frontend/pages/hosts/details/cards/Software/HostSoftwareTableConfig.tsx +++ b/frontend/pages/hosts/details/cards/Software/HostSoftwareTableConfig.tsx @@ -94,8 +94,9 @@ export const generateActions = ({ actions.splice(indexUninstallAction, 1); actions.splice(indexInstallAction, 1); } else { - // if host's scripts are disabled, disable install/uninstall with tooltip - if (!hostScriptsEnabled) { + // if host's scripts are disabled, and this isn't a VPP app, disable + // install/uninstall with tooltip + if (!hostScriptsEnabled && !app_store_app) { actions[indexInstallAction].disabled = true; actions[indexUninstallAction].disabled = true; diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/_styles.scss b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/_styles.scss index eb21c7d118a8..9b4ae26e5ec3 100644 --- a/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/_styles.scss +++ b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/_styles.scss @@ -13,7 +13,7 @@ &__item-topline { display: flex; flex-direction: row; - height: 64px; + height: 66px; align-items: center; gap: 16px; overflow: hidden; diff --git a/frontend/pages/hosts/details/cards/Software/_styles.scss b/frontend/pages/hosts/details/cards/Software/_styles.scss index a87305760ad1..331c9861e943 100644 --- a/frontend/pages/hosts/details/cards/Software/_styles.scss +++ b/frontend/pages/hosts/details/cards/Software/_styles.scss @@ -9,7 +9,7 @@ .Select { .Select-menu-outer { width: 364px; - max-height: 310px; + max-height: min-content; .Select-menu { max-height: none; diff --git a/frontend/pages/labels/NewLabelPage/DynamicLabel/DynamicLabel.tsx b/frontend/pages/labels/NewLabelPage/DynamicLabel/DynamicLabel.tsx index 8f3237d27820..b11deb64e5de 100644 --- a/frontend/pages/labels/NewLabelPage/DynamicLabel/DynamicLabel.tsx +++ b/frontend/pages/labels/NewLabelPage/DynamicLabel/DynamicLabel.tsx @@ -1,12 +1,14 @@ -import React, { useContext } from "react"; +import React, { useContext, useCallback } from "react"; import { RouteComponentProps } from "react-router"; import PATHS from "router/paths"; import labelsAPI from "services/entities/labels"; import { NotificationContext } from "context/notification"; +import { IApiError } from "interfaces/errors"; import DynamicLabelForm from "pages/labels/components/DynamicLabelForm"; import { IDynamicLabelFormData } from "pages/labels/components/DynamicLabelForm/DynamicLabelForm"; +import { DUPLICATE_ENTRY_ERROR } from "../ManualLabel/ManualLabel"; const baseClass = "dynamic-label"; @@ -26,15 +28,22 @@ const DynamicLabel = ({ }: IDynamicLabelProps) => { const { renderFlash } = useContext(NotificationContext); - const onSaveNewLabel = async (formData: IDynamicLabelFormData) => { - try { - const res = await labelsAPI.create(formData); - router.push(PATHS.MANAGE_HOSTS_LABEL(res.label.id)); - renderFlash("success", "Label added successfully."); - } catch { - renderFlash("error", "Couldn't add label. Please try again."); - } - }; + const onSaveNewLabel = useCallback( + (formData: IDynamicLabelFormData) => { + labelsAPI + .create(formData) + .then((res) => { + router.push(PATHS.MANAGE_HOSTS_LABEL(res.label.id)); + renderFlash("success", "Label added successfully."); + }) + .catch((error: { data: IApiError }) => { + if (error.data.errors[0].reason.includes("Duplicate entry")) { + renderFlash("error", DUPLICATE_ENTRY_ERROR); + } else renderFlash("error", "Couldn't add label. Please try again."); + }); + }, + [renderFlash, router] + ); const onCancelLabel = () => { router.goBack(); diff --git a/frontend/pages/labels/NewLabelPage/ManualLabel/ManualLabel.tsx b/frontend/pages/labels/NewLabelPage/ManualLabel/ManualLabel.tsx index 1dd4553f7abe..a1ed3ac2b964 100644 --- a/frontend/pages/labels/NewLabelPage/ManualLabel/ManualLabel.tsx +++ b/frontend/pages/labels/NewLabelPage/ManualLabel/ManualLabel.tsx @@ -1,29 +1,40 @@ -import React, { useContext } from "react"; +import React, { useCallback, useContext } from "react"; import { RouteComponentProps } from "react-router"; import PATHS from "router/paths"; import labelsAPI from "services/entities/labels"; import { NotificationContext } from "context/notification"; +import { IApiError } from "interfaces/errors"; import ManualLabelForm from "pages/labels/components/ManualLabelForm"; import { IManualLabelFormData } from "pages/labels/components/ManualLabelForm/ManualLabelForm"; const baseClass = "manual-label"; +export const DUPLICATE_ENTRY_ERROR = + "Couldn't add. A label with this name already exists."; + type IManualLabelProps = RouteComponentProps; const ManualLabel = ({ router }: IManualLabelProps) => { const { renderFlash } = useContext(NotificationContext); - const onSaveNewLabel = async (formData: IManualLabelFormData) => { - try { - const res = await labelsAPI.create(formData); - router.push(PATHS.MANAGE_HOSTS_LABEL(res.label.id)); - renderFlash("success", "Label added successfully."); - } catch { - renderFlash("error", "Couldn't add label. Please try again."); - } - }; + const onSaveNewLabel = useCallback( + (formData: IManualLabelFormData) => { + labelsAPI + .create(formData) + .then((res) => { + router.push(PATHS.MANAGE_HOSTS_LABEL(res.label.id)); + renderFlash("success", "Label added successfully."); + }) + .catch((error: { data: IApiError }) => { + if (error.data.errors[0].reason.includes("Duplicate entry")) { + renderFlash("error", DUPLICATE_ENTRY_ERROR); + } else renderFlash("error", "Couldn't add label. Please try again."); + }); + }, + [renderFlash, router] + ); const onCancelLabel = () => { router.goBack(); diff --git a/frontend/pages/policies/ManagePoliciesPage/components/InstallSoftwareModal/InstallSoftwareModal.tsx b/frontend/pages/policies/ManagePoliciesPage/components/InstallSoftwareModal/InstallSoftwareModal.tsx index 7c29b4979f3b..84b8c01ffa42 100644 --- a/frontend/pages/policies/ManagePoliciesPage/components/InstallSoftwareModal/InstallSoftwareModal.tsx +++ b/frontend/pages/policies/ManagePoliciesPage/components/InstallSoftwareModal/InstallSoftwareModal.tsx @@ -174,7 +174,7 @@ const InstallSoftwareModal = ({
  • = '12.5.1';", + query: "SELECT 1 FROM os_version WHERE version >= '14.6.1' OR version >= '15.0';", name: "Operating system up to date (macOS)", description: "Using an outdated macOS version risks exposure to security vulnerabilities and potential system instability.", resolution: diff --git a/frontend/pages/queries/ManageQueriesPage/_styles.scss b/frontend/pages/queries/ManageQueriesPage/_styles.scss index 3f7cd2515089..ea724a00c250 100644 --- a/frontend/pages/queries/ManageQueriesPage/_styles.scss +++ b/frontend/pages/queries/ManageQueriesPage/_styles.scss @@ -63,7 +63,7 @@ &__platform-dropdown { .Select-menu-outer { width: 364px; - max-height: 380px; + max-height: min-content; .Select-menu { max-height: none; diff --git a/go.mod b/go.mod index 0dd27931fb28..056fe1b18413 100644 --- a/go.mod +++ b/go.mod @@ -96,6 +96,7 @@ require ( 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/smallstep/scep v0.0.0-20240214080410-892e41795b99 github.com/spf13/cast v1.4.1 github.com/spf13/cobra v1.8.0 github.com/spf13/viper v1.10.0 @@ -111,6 +112,7 @@ 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 @@ -310,7 +312,6 @@ 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 f6cb680a8051..beec8ba3e049 100644 --- a/go.sum +++ b/go.sum @@ -490,6 +490,7 @@ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2 github.com/go-ini/ini v1.25.4/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= +github.com/go-kit/kit v0.4.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.7.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.12.0 h1:e4o3o3IsBfAKQh5Qbbiqyfu97Ku7jrO/JbohvztANh4= @@ -519,6 +520,7 @@ github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI= github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/go-stack/stack v1.6.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-stack/stack v1.7.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible h1:2cauKuaELYAEARXRkq2LrJ0yDDv1rW7+wrTEdVL3uaU= @@ -1060,8 +1062,11 @@ 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-20231024181729-3b98ecc1ca81/go.mod h1:SoUAr/4M46rZ3WaLstHxGhLEgoYIDRqxQEXLOmOEB0Y= 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/smallstep/scep v0.0.0-20240214080410-892e41795b99 h1:e85HuLX5/MW15yJ7yWb/PMNFW1Kx1N+DeQtpQnlMUbw= +github.com/smallstep/scep v0.0.0-20240214080410-892e41795b99/go.mod h1:4d0ub42ut1mMtvGyMensjuHYEUpRrASvkzLEJvoRQcU= 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/handbook/company/communications.md b/handbook/company/communications.md index c83524ee6693..5b36e34f5191 100644 --- a/handbook/company/communications.md +++ b/handbook/company/communications.md @@ -1126,6 +1126,7 @@ As we use sentence case, only the first word is capitalized. But, if a word woul - **Group of devices or virtual servers:** Use "fleet" or "fleets" (lowercase). - **Osquery:** Osquery should always be written in lowercase unless used to start a sentence or heading. - **Fleetd:** Fleetd should always be written in lowercase unless used to start a sentence or heading. +- **Fleetctl:** Fleetctl should always be written in lowercase unless used to start a sentence or heading. Fleetctl should always be in plain text and not inside codeblocks text unless used in a command (ex. `fleetctl -help`). #### Device vs endpoint diff --git a/handbook/company/open-positions.yml b/handbook/company/open-positions.yml index 71bcb2af6b3d..0ef2713d0ac8 100644 --- a/handbook/company/open-positions.yml +++ b/handbook/company/open-positions.yml @@ -61,3 +61,31 @@ - 🟣 Openness: You are flexible and open to new ideas and ways of working. - ➕ Bonus: Cybersecurity or IT background. +- jobTitle: 🚀 Quality Assurance Engineer + department: Engineering + hiringManagerName: Luke Heath + hiringManagerGithubUsername: lukeheath + hiringManagerLinkedInUrl: https://www.linkedin.com/in/lukeheath/ + responsibilities: | + - ⏫ Work closely with CTO to continually improve overall quality assurance efficiency and effectiveness throughout the product design and engineering process. + - 🐶 Own using Fleet the product at Fleet the business by ensuring all newly released features are leverged in Fleet's dogfood environment. + - 🤝 Collaborate with the engineering managers and quality assurance engineers in the product groups, actively participating in some engineering scrum meetings, sprint planning, daily standups, sprint demos, sprint retrospectives, and estimation sessions. + - 🌟 Contribute to the overall success of both the [MDM](https://fleetdm.com/handbook/company/product-groups#mdm-group) and [Endpoint Ops](https://fleetdm.com/handbook/company/product-groups#endpoint-ops-group) product groups by ensuring users receive valuable new features that work as intended. + - 🧪 Develop and execute testing plans based on feature specifications, outlining step-by-step actions for each user role to confirm that features function as intended. + - 🚀 Perform manual testing of newly developed features on all supported devices, platforms, and browsers, ensuring a seamless user experience. + - 🐞 Identify, document, and report any bugs or unusual behavior, creating and assigning bug tickets to the appropriate engineering manager for resolution. + - 🔧 Verify that bugs have been resolved after engineers have addressed them, repeating the testing process as needed. + experience: | + - 💭 3-5 years' of experience in a product quality, QA, or testing role. + - 💖 Proficient in creating comprehensive testing plans. + - ✍️ Experience working with engineering and product teams in an agile environment. + - 🎯 Strong attention to detail and ability to identify inconsistencies or deviations from specifications. + - 💡 Excellent communication and collaboration skills, with the ability to work closely with engineering and product teams. + - 🌐 Experience in manual testing across various devices, platforms, and browsers. + - 🏃‍♂️ Familiarity with agile development processes and scrum methodologies. + - 👥 A customer-centric mindset, focusing on delivering value and a positive user experience. + - 🤝 Collaboration: You work best in a participatory, team-based environment. + - 🛠️ Technical: You understand the software development processes. + - 🟣 Openness: You are flexible and open to new ideas and ways of working + - ➕ Bonus: Cybersecurity or IT background + diff --git a/handbook/company/pricing-features-table.yml b/handbook/company/pricing-features-table.yml index 13e19b938880..3070d8d19c7a 100644 --- a/handbook/company/pricing-features-table.yml +++ b/handbook/company/pricing-features-table.yml @@ -130,7 +130,7 @@ - industryName: GitOps friendlyName: Manage endpoints in git documentationUrl: https://github.com/fleetdm/fleet-gitops - description: Fork the best practices GitHub repo and use the included GitHub Actions to quickly automate Fleet console and configuration workflow management. + description: Fork the best practices GitHub repo and use the included GitHub Actions or GitLab CI/CD pipelines to quickly automate Fleet console and configuration workflow management. productCategories: [Endpoint operations,Device management,Vulnerability management] pricingTableCategories: [Configuration] usualDepartment: IT @@ -260,7 +260,7 @@ # ╠╩╗╚╦╝║ ║ ║║ ║╣ ║║║╠╦╝║ ║║ ║ ║║║║╣ ║║║ ║ # ╚═╝ ╩ ╚═╝═╩╝ ╚═╝╝╚╝╩╚═╚═╝╩═╝╩═╝╩ ╩╚═╝╝╚╝ ╩ - industryName: Bring your own device (BYOD) enrollment - description: BYOD enrollment for macOS, iOS/iPadOS (coming soon), Windows, and Android (coming soon) devices. + description: BYOD enrollment for macOS, iOS/iPadOS, Windows, and Android (coming soon) devices. documentationUrl: https://fleetdm.com/guides/sysadmin-diaries-device-enrollment#byod-enrollment tier: Free jamfProHasFeature: yes diff --git a/handbook/customer-success/README.md b/handbook/customer-success/README.md index 0aeba57401dc..22c4606e96d0 100644 --- a/handbook/customer-success/README.md +++ b/handbook/customer-success/README.md @@ -145,7 +145,7 @@ The acting developer on-call rotation is reflected in the [📈KPIs spreadsheet ### Generate a trial license key 1. Fleet's self-service license key creator is the best way to generate a proof of concept (POC) or renewal/expansion Fleet Premium license key. - - [Here is a tutorial on using the self-service method](https://www.loom.com/share/b519e6a42a7d479fa628e394ee1d1517) (internal video) + - [Here is a tutorial on using the self-service method](https://www.loom.com/share/048474d7199048e1bf0c4fc106632129) (internal video) - Pre-sales license key DRI is the Director of Solutions Consulting - Post-sales license key DRI is the VP of Customer Success diff --git a/handbook/demand/README.md b/handbook/demand/README.md index 69dd7a2d843f..9d3f6eb5d561 100644 --- a/handbook/demand/README.md +++ b/handbook/demand/README.md @@ -31,20 +31,6 @@ Fleet's public relations firm is directly responsible for the accuracy of event 2. Update the workbook with the latest location, dates, and CFP deadlines from the website. -### Respond to a "Contact us" submission - -1. Check the [_from-prospective-customers](https://fleetdm.slack.com/archives/C01HE9GQW6B) Slack channel for "Contact us" submissions. -2. Mark submission as seen with the "👀" emoji. -3. Within 4 business hours, use the [best practices template (private Google doc)](https://docs.google.com/document/d/1D02k0tc5v-sEJ4uahAouuqnvZ6phxA_gP-IqmkBdMTE/edit) to respond to general asks. -4. Answer any technical questions to the best of your ability. If you are unable to answer a technical/product question, ask a Solutions Consultant in `#help-solutions-consulting`. If an SC is unavailable, post in `#g-mdm`or `#g-endpoint-ops`and notify @on-call. -5. log in to [Salesforce](https://fleetdm.lightning.force.com/lightning/o/Lead/list?filterName=00B4x00000DtaRDEAZ) and search the lead list by first name and match the corresponding email to find the right lead. -6. Enrich each lead with company information and buying situation. -7. If a lead is completed or out of ICP, update the lead status in Salesforce to "Closed" or "Disqualified". If within ICP at-mention the [Head of Digital Experience](https://fleetdm.com/handbook/digital-experience#team) in the [#g-digital-experience](https://fleetdm.slack.com/archives/C058S8PFSK0) Slack channel and move lead to their name in SFDC. -8. Mark the Slack message as complete with the "✅" emoji. - -> For any support-related questions, forward the submission to [Fleet's support team](https://docs.google.com/document/d/1tE-NpNfw1icmU2MjYuBRib0VWBPVAdmq4NiCrpuI0F0/edit#heading=h.wqalwz1je6rq). - - ### Begin or modify an advertising campaign Any new ads or changes to current running ads are approved in ["🦢🗣 Design review (#g-digital-experience)"](https://app.zenhub.com/workspaces/-g-digital-experience-6451748b4eb15200131d4bab/board?sprints=none). diff --git a/handbook/digital-experience/README.md b/handbook/digital-experience/README.md index 34f77f2b9fe3..b634a973ec1f 100644 --- a/handbook/digital-experience/README.md +++ b/handbook/digital-experience/README.md @@ -765,6 +765,20 @@ Fleet has several brand fronts that need to be updated from time to time. Check - The current [brand imagery](https://www.figma.com/design/1J2yxqH8Q7u8V7YTtA1iej/Social-media-(logos%2C-covers%2C-banners)?node-id=3962-65895). Check this [Loom video](https://www.loom.com/share/4432646cc9614046aaa4a74da1c0adb5?sid=2f84779f-f0bd-4055-be69-282c5a16f5c5) for more info. +### Respond to a "Contact us" submission + +1. Check the [_from-prospective-customers](https://fleetdm.slack.com/archives/C01HE9GQW6B) Slack channel for "Contact us" submissions. +2. Mark submission as seen with the "👀" emoji. +3. Within 4 business hours, use the [best practices template (private Google doc)](https://docs.google.com/document/d/1D02k0tc5v-sEJ4uahAouuqnvZ6phxA_gP-IqmkBdMTE/edit) to respond to general asks. +4. Answer any technical questions to the best of your ability. If you are unable to answer a technical/product question, ask a Solutions Consultant in `#help-solutions-consulting`. If an SC is unavailable, post in `#g-mdm`or `#g-endpoint-ops`and notify @on-call. +5. log in to [Salesforce](https://fleetdm.lightning.force.com/lightning/o/Lead/list?filterName=00B4x00000DtaRDEAZ) and search the lead list by first name and match the corresponding email to find the right lead. +6. Enrich each lead with company information and buying situation. +7. If a lead is completed or out of ICP, update the lead status in Salesforce to "Closed" or "Disqualified". If within ICP at-mention the [Head of Digital Experience](https://fleetdm.com/handbook/digital-experience#team) in the [#g-digital-experience](https://fleetdm.slack.com/archives/C058S8PFSK0) Slack channel and move lead to their name in SFDC. +8. Mark the Slack message as complete with the "✅" emoji. + +> For any support-related questions, forward the submission to [Fleet's support team](https://docs.google.com/document/d/1tE-NpNfw1icmU2MjYuBRib0VWBPVAdmq4NiCrpuI0F0/edit#heading=h.wqalwz1je6rq). + + ## Rituals - Note: Some rituals (⏰) are especially time-sensitive and require attention multiple times (3+) per day. Set reminders for the following times (CT): diff --git a/handbook/digital-experience/digital-experience.rituals.yml b/handbook/digital-experience/digital-experience.rituals.yml index c36e75db31ec..f7f1f8d5aedc 100644 --- a/handbook/digital-experience/digital-experience.rituals.yml +++ b/handbook/digital-experience/digital-experience.rituals.yml @@ -9,6 +9,16 @@ autoIssue: labels: [ "#g-digital-experience" ] repo: "fleet" +- + task: "Confirm consultant hours" + startedOn: "2024-09-30" + frequency: "Weekly" + description: "Perform step three in “Inform managers about hours worked” responsibility" + moreInfoUrl: "https://fleetdm.com/handbook/digital-experience#inform-managers-about-hours-worked" + dri: "SFriendLee" + autoIssue: + labels: [ "#g-digital-experience" ] + repo: "fleet" - task: "Prep 1:1s for OKR planning" startedOn: "2024-09-09" diff --git a/handbook/product-design/README.md b/handbook/product-design/README.md index 2044bde60c80..a81f4376f9e7 100644 --- a/handbook/product-design/README.md +++ b/handbook/product-design/README.md @@ -102,15 +102,6 @@ What happens during expedited drafting? Product Managers [write user stories](https://fleetdm.com/handbook/company/product-groups#writing-a-good-user-story) in the [drafting board](https://app.zenhub.com/workspaces/-product-backlog-coming-soon-6192dd66ea2562000faea25c/board). The drafting board is shared by every [product group](https://fleetdm.com/handbook/company/development-groups). -### Draft a user story - -Product Designers [draft user stories](https://fleetdm.com/handbook/company/product-groups#drafting) that have been prioritized by PMs. If the estimated user stories for a product group exceed [that group's capacity](https://fleetdm.com/handbook/company/product-groups#current-product-groups), all new design work for that group is paused, and the designer will contribute in other ways (documentation & handbook work, Figma maintenance, QA, etc.) until the PM deprioritizes estimated stories to make room, or until the next sprint begins. (If the designer has existing work-in-progress, they will continue to review and iterate on those designs and see the stories through to estimation.) - -If an issue's title or user story summary (_"as a…I want to…so that"_) does not match the intended change being discussed, the designer will move the issue to the "Needs clarity" column of the drafting board and assign the group product manager. The group product manager will revisit ASAP and edit the issue title and user story summary, then reassign the designer and move the issue back to the "Prioritized" column. - -Engineering Managers estimate user stories. They are responsible for delivering planned work in the current sprint (0-3 weeks) while quickly getting user stories estimated for the next sprint (3-6 weeks). Only work that is slated to be released into the hands of users within ≤six weeks will be estimated. Estimation is run by each group's Engineering Manager and occurs on the [drafting board](https://app.zenhub.com/workspaces/-product-backlog-coming-soon-6192dd66ea2562000faea25c/board). - - ### Consider a feature eligible to be flagged At Fleet, features are placed behind feature flags if the changes could affect Fleet's availability of existing functionalities. The following highlights should be considered when deciding if we should leverage feature flags: @@ -136,6 +127,30 @@ available in Google Drive. Some of the data is forwarded to [Datadog](https://us5.datadoghq.com/dashboard/7pb-63g-xty/usage-statistics?from_ts=1682952132131&to_ts=1685630532131&live=true) and is available to Fleeties. +### Prepare reference docs for release + +Every change to how Fleet is used is reflected live on the website in reference documentation **at release day** (REST API, config surface, tables, and other already-existing docs under /docs/using-fleet). + +To make sure this happens, first, the [DRI for what goes in a release](https://fleetdm.com/handbook/company/communications#directly-responsible-individuals-dris) @ mentions the [API design DRI](https://fleetdm.com/handbook/company/communications#directly-responsible-individuals-dris) in a message in [#help-engineering Slack channel](https://fleetdm.slack.com/archives/C019WG4GH0A) when we cut the release candidate (RC). + +Next, the API design DRI reviews all user stories and bugs with the release milestone to check that all reference doc PRs are merged into the reference docs release branch. To see which stories were pushed to the next release, and thus which reference doc changes need to be removed from the branch, the API design DRI filters issues by the `~pushed` label and the next release's milestone. + +To signal that the reference docs branch is ready for release, the API design DRI opens a PR to `main`, adds the DRI for what goes in a release as the reviewer, and adds the release milestone. + +### Interview a Product Designer candidate + +Ensure the interview process follows these steps in order. This process must follow [creating a new position](https://fleetdm.com/handbook/company/leadership#creating-a-new-position) through [receiving job applications](https://fleetdm.com/handbook/company/leadership#receiving-job-applications). + +1. **Reach out**: Send an email or LinkedIn message introducing yourself. Include the URL for the position, your Calendly URL, and invite the candidate to schedule a 30 minute introduction call. +2. **Conduct screening call**: Discuss the requirements of the position with the candidate, and answer any questions they have about Fleet. Look for alignment with [Fleet's values](https://fleetdm.com/handbook/company#values) and technical expertise necessary to meet the requirements of the role. +2. **Deliver design challenge**: Share the [design challenge](https://docs.google.com/document/d/1S4fD5fPUU9YUjlKy2YAbRZPb_IK4EPkmmO7j09iPWR8/edit) and ask them to complete and send their project back within 5 business days. +5. **Schedule design challenge interview**: Send the candidate a Calendly link for 1 hour call to review the candidate's project. The goal is to understand the design capabilities of the candidate. An additional Product Designer can optionally join if available. +6. **Schedule EM interview**: Send the candidate a calendly link for 30m talk with the Engineering Manager (EM) of the [product group](https://fleetdm.com/handbook/company/product-groups#current-product-groups) the candidate will be working with. +7. **Schedule CTO interview**: Send the candidate a calendly link for 30m talk with our CTO @lukeheath. + +If the candidate passes all of these steps then continue with [hiring a new team member](https://fleetdm.com/handbook/company/leadership#hiring-a-new-team-member). + + ## Rituals diff --git a/handbook/product-design/product-design.rituals.yml b/handbook/product-design/product-design.rituals.yml index b32f5406fe06..fb6af51f8d50 100644 --- a/handbook/product-design/product-design.rituals.yml +++ b/handbook/product-design/product-design.rituals.yml @@ -52,7 +52,7 @@ startedOn: "2024-03-01" frequency: "Weekly" description: "Head of Product Design checks the latest versions of relevant platforms, updates the maintenance tracker, and notifies the #g-mdm and #g-endpoint-ops Slack channel." - moreInfoUrl: + moreInfoUrl: "https://docs.google.com/spreadsheets/d/1IWfQtSkOQgm_JIQZ0i2y3A8aaK5vQW1ayWRk6-4FOp0/edit?gid=0#gid=0" dri: "noahtalerman" - task: "Product confirm and celebrate" # 2024-03-06 TODO: Link to responsibility or corresponding "how to" info e.g. https://fleetdm.com/handbook/company/product-groups#making-changes diff --git a/handbook/sales/README.md b/handbook/sales/README.md index 46688864e581..be6df24ea636 100644 --- a/handbook/sales/README.md +++ b/handbook/sales/README.md @@ -10,8 +10,7 @@ This handbook page details processes specific to working [with](#contact-us) and | Chief Revenue Officer (CRO) | [Alex Mitchell](https://www.linkedin.com/in/alexandercmitchell/) _([@alexmitchelliii](https://github.com/alexmitchelliii))_ | Solutions Consulting (SC) | [Dave Herder](https://www.linkedin.com/in/daveherder/) _([@dherder](https://github.com/dherder))_
    [Zach Wasserman](https://www.linkedin.com/in/zacharywasserman/) _([@zwass](https://github.com/zwass))_
    [Allen Houchins](https://www.linkedin.com/in/allenhouchins/) _([@allenhouchins](https://github.com/allenhouchins))_
    [Harrison Ravazzolo](https://www.linkedin.com/in/harrison-ravazzolo/) _([@harrisonravazzolo](https://github.com/harrisonravazzolo))_ | Channel Sales | [Tom Ostertag](https://www.linkedin.com/in/tom-ostertag-77212791/) _([@tomostertag](https://github.com/TomOstertag))_ -| Sr. Account Executive | [Kendra McKeever](https://www.linkedin.com/in/kendramckeever/) _([@KendraAtFleet](https://github.com/KendraAtFleet))_ -| Account Executive (AE) | [Patricia Ambrus](https://www.linkedin.com/in/pambrus/) _([@ambrusps](https://github.com/ambrusps))_
    [Anthony Snyder](https://www.linkedin.com/in/anthonysnyder8/) _([@anthonysnyder8](https://github.com/AnthonySnyder8))_
    [Paul Tardif](https://www.linkedin.com/in/paul-t-750833/) _([@phtardif1](https://github.com/phtardif1))_ +| Account Executive (AE) | [Patricia Ambrus](https://www.linkedin.com/in/pambrus/) _([@ambrusps](https://github.com/ambrusps))_
    [Anthony Snyder](https://www.linkedin.com/in/anthonysnyder8/) _([@anthonysnyder8](https://github.com/AnthonySnyder8))_
    [Paul Tardif](https://www.linkedin.com/in/paul-t-750833/) _([@phtardif1](https://github.com/phtardif1))_
    [Kendra McKeever](https://www.linkedin.com/in/kendramckeever/) _([@KendraAtFleet](https://github.com/KendraAtFleet))_ ## Contact us @@ -146,14 +145,19 @@ To schedule an [ad hoc meeting](https://www.vocabulary.com/dictionary/ad%20hoc) ### Conduct a POV -We use the "tech eval test plan" as a guide when conducting a "POV" (Proof of Value) with a prospect. This planning helps us avoid costly detours that can take a long time, and result in folks getting lost. The tech eval test plan is the main document that will track success criteria for the tech eval. Before the Solutions Consultant (SC) creates a [tech eval issue](https://github.com/fleetdm/confidential/issues/new?assignees=dherder&labels=%23g-sales&projects=&template=technical-evaluation.md&title=Technical+evaluation%3A+___________________), the AE and SC will ask each other, at minimum, the following questions in order to enter the "Stage 3 - Requested POV" phase for the tech eval: -1. Do we have a well-defined set of technical criteria to test? +We use the "tech eval test plan" as a guide when conducting a "POV" (Proof of Value) with a prospect. This planning helps us avoid costly detours that can take a long time, and result in folks getting lost. The tech eval test plan is the main document that will track success criteria for the tech eval. + +When we have had sufficient meetings and demos, including an overview demo and a customized demo, and we have qualified the prospect, when the prospect asks to "kick the tires/do a POC/do a technical evaluation", the AE moves the opportuity to "Stage 3 - Requested POV" phase in Salesforce. Automation will generate the tech eval test plan. This doc will exist in Google Drive> Sales> Opportunities> "Account Name". + +The AE and SC will work together to scope the POV with the prospect in this stage. The AE and SC will work together to answer the following questions: + +1. Do we have a well-defined set of technical criteria to test and are we confident that Fleet can meet this criteria to achieve a technical win? 2. Do we have a timeline agreed upon? 3. What are the key business outcomes that will be verified as a result of completing the tech eval? -If the above questions cannot be answered, the opportunity should not progress to tech eval. Once the opportunity moves to the "Stage 3 - Requested POV" phase in Salesforce, automation will generate the tech eval test plan. This doc will exist in Google Drive> Sales> Opportunities> "Account Name". +If the above questions are answered successfully, the opportunity should progress to tech eval. If we cannot answer the questions above successfully, then the POV should not start unless approved by the CRO. -Once there is agreement to proceed with the tech eval and success criteria have been defined and documented, follow this process: +During Stage 4, follow this process: 1. SC creates a [tech eval issue](https://github.com/fleetdm/confidential/issues/new?assignees=dherder&labels=%23g-sales&projects=&template=technical-evaluation.md&title=Technical+evaluation%3A+___________________). 2. SC updates the issue labels to include: "~sc, :tech-eval" and the obfuscated "prospect-codename" label. See [Assign a customer a codename](https://fleetdm.com/handbook/customer-success#assign-a-customer-codename). Instead of "customer-codename", prospects are labeled "prospect-codename". When a prospect purchases Fleet, the SC will edit this label from "prospect-codename" to "customer-codename". diff --git a/it-and-security/default.yml b/it-and-security/default.yml index 52baadb564d1..9b60ffb92cd7 100644 --- a/it-and-security/default.yml +++ b/it-and-security/default.yml @@ -1,27 +1,5 @@ agent_options: path: ./lib/agent-options.yml -controls: - enable_disk_encryption: true - macos_migration: - enable: true - mode: voluntary - webhook_url: $DOGFOOD_MACOS_MIGRATION_WEBHOOK_URL - macos_settings: - custom_settings: null - macos_setup: - bootstrap_package: "" - enable_end_user_authentication: false - macos_setup_assistant: null - macos_updates: - deadline: "2023-06-13" - minimum_version: 13.4.1 - windows_enabled_and_configured: true - windows_settings: - custom_settings: [] - windows_updates: - deadline_days: 3 - grace_period_days: 2 - scripts: [] org_settings: features: enable_host_users: true @@ -90,4 +68,3 @@ org_settings: policies: queries: - path: ./lib/collect-fleetd-update-channels.queries.yml -software: diff --git a/it-and-security/lib/configuration-profiles/macos-passcode-settings.json b/it-and-security/lib/configuration-profiles/macos-passcode-settings.json new file mode 100644 index 000000000000..d433a91826fe --- /dev/null +++ b/it-and-security/lib/configuration-profiles/macos-passcode-settings.json @@ -0,0 +1,12 @@ +{ + "Type": "com.apple.configuration.passcode.settings", + "Identifier": "com.fleetdm.config.passcode.settings", + "Payload": { + "RequireAlphanumericPasscode": true, + "MinimumLength": 10, + "MinimumComplexCharacters": 1, + "MaximumFailedAttempts": 11, + "MaximumGracePeriodInMinutes": 1, + "MaximumInactivityInMinutes": 15 + } +} diff --git a/it-and-security/teams/no-team.yml b/it-and-security/teams/no-team.yml new file mode 100644 index 000000000000..ef6baf9e40fb --- /dev/null +++ b/it-and-security/teams/no-team.yml @@ -0,0 +1,25 @@ +name: No team +policies: +controls: + enable_disk_encryption: true + macos_migration: + enable: true + mode: voluntary + webhook_url: $DOGFOOD_MACOS_MIGRATION_WEBHOOK_URL + macos_settings: + custom_settings: null + macos_setup: + bootstrap_package: "" + enable_end_user_authentication: false + macos_setup_assistant: null + macos_updates: + deadline: "2023-06-13" + minimum_version: 13.4.1 + windows_enabled_and_configured: true + windows_settings: + custom_settings: [] + windows_updates: + deadline_days: 3 + grace_period_days: 2 + scripts: [] +software: diff --git a/it-and-security/teams/workstations.yml b/it-and-security/teams/workstations.yml index 0ad266f905d9..f586b2b5d724 100644 --- a/it-and-security/teams/workstations.yml +++ b/it-and-security/teams/workstations.yml @@ -38,6 +38,7 @@ controls: - path: ../lib/configuration-profiles/macos-password.mobileconfig - path: ../lib/configuration-profiles/macos-prevent-autologon.mobileconfig - path: ../lib/configuration-profiles/macos-secure-terminal-keyboard.mobileconfig + - path: ../lib/configuration-profiles/macos-passcode-settings.json macos_setup: bootstrap_package: "" enable_end_user_authentication: true diff --git a/orbit/pkg/update/escrow_buddy.go b/orbit/pkg/update/escrow_buddy.go index e1f6fdf1160d..b226da9a24ce 100644 --- a/orbit/pkg/update/escrow_buddy.go +++ b/orbit/pkg/update/escrow_buddy.go @@ -5,8 +5,9 @@ import ( "sync" "time" - "github.com/fleetdm/fleet/v4/server/fleet" "github.com/rs/zerolog/log" + + "github.com/fleetdm/fleet/v4/server/fleet" ) // EscrowBuddyRunner sets up [Escrow Buddy][1] to rotate FileVault keys on @@ -86,6 +87,13 @@ func (e *EscrowBuddyRunner) Run(cfg *fleet.OrbitConfig) error { } } + // Some macOS updates and upgrades reset the authorization database to its default state + // which will deactivate Escrow Buddy and prevent FileVault key generation upon next login. + log.Debug().Msg("EscrowBuddyRunner: re-enable Escrow Buddy in the authorization database") + if err := e.setAuthDBSetup(); err != nil { + return fmt.Errorf("failed to re-enable Escrow Buddy in the authorization database, err: %w", err) + } + log.Debug().Msg("EscrowBuddyRunner: enabling disk encryption rotation") if err := e.setGenerateNewKeyTo(true); err != nil { return fmt.Errorf("enabling disk encryption rotation: %w", err) @@ -118,3 +126,13 @@ func (e *EscrowBuddyRunner) setGenerateNewKeyTo(enabled bool) error { } return fn("sh", "-c", cmd) } + +func (e *EscrowBuddyRunner) setAuthDBSetup() error { + log.Debug().Msg("ready to re-enable Escrow Buddy in the authorization database") + cmd := "/Library/Security/SecurityAgentPlugins/Escrow\\ Buddy.bundle/Contents/Resources/AuthDBSetup.sh" + fn := e.runCmdFunc + if fn == nil { + fn = runCmdCollectErr + } + return fn("sh", "-c", cmd) +} diff --git a/orbit/pkg/update/escrow_buddy_test.go b/orbit/pkg/update/escrow_buddy_test.go index 0ed61883b03f..ccd30938341d 100644 --- a/orbit/pkg/update/escrow_buddy_test.go +++ b/orbit/pkg/update/escrow_buddy_test.go @@ -65,9 +65,11 @@ func (s *escrowBuddyTestSuite) TestEscrowBuddyRotatesKey() { err = r.Run(cfg) require.NoError(t, err) - require.Len(t, cmdCalls, 1) + require.Len(t, cmdCalls, 2) require.Equal(t, cmdCalls[0]["cmd"], "sh") - require.Equal(t, cmdCalls[0]["args"], []string{"-c", "defaults write /Library/Preferences/com.netflix.Escrow-Buddy.plist GenerateNewKey -bool true"}) + require.Equal(t, cmdCalls[0]["args"], []string{"-c", "/Library/Security/SecurityAgentPlugins/Escrow\\ Buddy.bundle/Contents/Resources/AuthDBSetup.sh"}) + require.Equal(t, cmdCalls[1]["cmd"], "sh") + require.Equal(t, cmdCalls[1]["args"], []string{"-c", "defaults write /Library/Preferences/com.netflix.Escrow-Buddy.plist GenerateNewKey -bool true"}) targets = runner.updater.opt.Targets require.Len(t, targets, 1) @@ -77,10 +79,12 @@ func (s *escrowBuddyTestSuite) TestEscrowBuddyRotatesKey() { time.Sleep(3 * time.Millisecond) cfg.Notifications.RotateDiskEncryptionKey = false + cmdCalls = []map[string]any{} err = r.Run(cfg) require.NoError(t, err) - require.Len(t, cmdCalls, 2) - require.Equal(t, cmdCalls[1]["cmd"], "sh") - require.Equal(t, cmdCalls[1]["args"], []string{"-c", "defaults write /Library/Preferences/com.netflix.Escrow-Buddy.plist GenerateNewKey -bool false"}) + // only one call to set the GenerateNewKey to false + require.Len(t, cmdCalls, 1) + require.Equal(t, cmdCalls[0]["cmd"], "sh") + require.Equal(t, cmdCalls[0]["args"], []string{"-c", "defaults write /Library/Preferences/com.netflix.Escrow-Buddy.plist GenerateNewKey -bool false"}) } diff --git a/pkg/download/download.go b/pkg/download/download.go index 23c0f5c152ea..b15a2cd75b79 100644 --- a/pkg/download/download.go +++ b/pkg/download/download.go @@ -4,6 +4,7 @@ package download import ( "compress/bzip2" "compress/gzip" + "errors" "fmt" "io" "net/http" @@ -20,16 +21,24 @@ import ( const backoffMaxElapsedTime = 5 * time.Minute // Download downloads a file from a URL and writes it to path. +// +// It will retry requests until it succeeds. If the server returns a 404 +// then it will not retry and return a NotFound error. func Download(client *http.Client, u *url.URL, path string) error { return download(client, u, path, false) } // DownloadAndExtract downloads and extracts a file from a URL and writes it to path. // The compression method is determined using extension from the url path. Only .gz, .bz2, or .xz extensions are supported. +// +// It will retry requests until it succeeds. If the server returns a 404 +// then it will not retry and return a NotFound error. func DownloadAndExtract(client *http.Client, u *url.URL, path string) error { return download(client, u, path, true) } +var NotFound = errors.New("resource not found") + func download(client *http.Client, u *url.URL, path string, extract bool) error { // atomically write to file dir, file := filepath.Split(path) @@ -79,7 +88,12 @@ func download(client *http.Client, u *url.URL, path string, extract bool) error } defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { + switch { + case resp.StatusCode == http.StatusOK: + // OK + case resp.StatusCode == http.StatusNotFound: + return &backoff.PermanentError{Err: NotFound} + default: return fmt.Errorf("unexpected status code %d", resp.StatusCode) } diff --git a/pkg/download/download_test.go b/pkg/download/download_test.go new file mode 100644 index 000000000000..9b5bf1901488 --- /dev/null +++ b/pkg/download/download_test.go @@ -0,0 +1,24 @@ +package download + +import ( + "net/url" + "path/filepath" + "testing" + "time" + + "github.com/fleetdm/fleet/v4/pkg/fleethttp" + "github.com/stretchr/testify/require" +) + +func TestDownloadNotFoundNoRetries(t *testing.T) { + c := fleethttp.NewClient() + tmpDir := t.TempDir() + outputFile := filepath.Join(tmpDir) + url, err := url.Parse("https://github.com/fleetdm/non-existent") + require.NoError(t, err) + start := time.Now() + err = Download(c, url, outputFile) + require.Error(t, err) + require.ErrorIs(t, err, NotFound) + require.True(t, time.Since(start) < backoffMaxElapsedTime) +} diff --git a/pkg/mdm/mdmtest/apple.go b/pkg/mdm/mdmtest/apple.go index 601fe313b6ab..b455b4eddd19 100644 --- a/pkg/mdm/mdmtest/apple.go +++ b/pkg/mdm/mdmtest/apple.go @@ -26,7 +26,6 @@ import ( apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" "github.com/fleetdm/fleet/v4/server/mdm/scep/cryptoutil/x509util" - "github.com/fleetdm/fleet/v4/server/mdm/scep/scep" scepserver "github.com/fleetdm/fleet/v4/server/mdm/scep/server" httptransport "github.com/go-kit/kit/transport/http" "github.com/go-kit/log" @@ -34,6 +33,7 @@ import ( "github.com/google/uuid" "github.com/groob/plist" "github.com/smallstep/pkcs7" + "github.com/smallstep/scep" ) // TestAppleMDMClient simulates a macOS MDM client. diff --git a/proposals/Fleet-Installers-4-Sandbox.md b/proposals/Fleet-Installers-4-Sandbox.md deleted file mode 100644 index 7043c05fe987..000000000000 --- a/proposals/Fleet-Installers-4-Sandbox.md +++ /dev/null @@ -1,50 +0,0 @@ -# Fleet Sandbox & Pre-Packaged Fleet-osquery Installers - -## Goals - -1. Improve UX on Fleet Sandbox by offering pre-packaged Fleet-osquery installers. -2. Add the "Pre-Packaged installers" feature to "Fleet Sandbox" as soon as possible (i.e. not block on having a fully functional "Fleet Packager" service). - -## Fleet Sandbox Assumptions - -- We will limit number of teams to T. -- Sandbox has good root CA trusted certificates -- Users won't be allowed to change enroll secrets. - -## Pre-Packaged Installers Plan - -We will need some changes to fleetctl, the pre-provisioner, the Fleet server and UI. - -### fleetctl - -Make all functionality in `fleetctl package` to run in linux. (This change will be needed for the Packager service anyways.) - -PS: Abstract in its own package so that it can be used by Packager service in a next iteration. - -### Pre-provisioner - -Following are the pre-provisioner steps to generate the pre-packaged installers: - -1. Generate T+1 random enroll secrets. - -2. Run `fleetctl package --type={pkg|rpm|deb|msi}` with T+1 enroll secrets (i.e. one for Global and one for each team). -PS: There's some complexity in storing/handling credentials for macOS Signing and Notarization of the packages. - -3. The generated packages will be stored in a S3 bucket accessible by the Fleet server with the following object name format -`$INSTALLERS_DIR/$ENROLL_SECRET/fleet-osquery.$TYPE`, e.g. `/fleet-installers/FzRCZWTlEY2kqzIwk1BE9fru5KuhrlYP/fleet-osquery.pkg`. -We propose using S3 to support multiple Fleet instances serving the requests. - -4. Set comma-separated `FLEET_ENROLL_POOL` environment variable to Fleet server config (Fleet would use those secrets instead of randomly generating one). -The Fleet server will only serve the installers with enroll secret listed in this variable (security check). - -### Fleet Server and UI - -- Fleet server new configuration and new functionality: - - `FLEET_MAX_TEAMS`: Maximum number of teams to allow in the deployment (default 0, disabled). - - `FLEET_DISABLE_ENROLL_CHANGE`: Disallow users from changing enroll secrets (default false). - - `FLEET_PACKAGES_S3_*`: S3 configuration for the retrieval of the pre-packaged installers (default empty). - - `FLEET_ENROLL_POOL`: comma-separated enrolls to use when needed (default empty), must have equals to FLEET_MAX_TEAMS+1 items (default empty). - -- Fleet will serve a new authenticated API (for Sandbox-only): `{GET|HEAD} /api/v1/fleet/download_installer/{enroll}/{type}`, e.g. `GET /api/v1/fleet/download_installer/FzRCZWTlEY2kqzIwk1BE9fru5KuhrlYP/rpm`. - - The UI can make a `HEAD` request to check if an installer exists, if so, then it can display a download button for it, (if not, "show the current UI"? TBD with UI team) - - The API looks for the installer corresponding to the Global/Team the user is looking at, and returns it for download. diff --git a/proposals/Fleet-Installers.md b/proposals/Fleet-Installers.md deleted file mode 100644 index f6604e36c431..000000000000 --- a/proposals/Fleet-Installers.md +++ /dev/null @@ -1,469 +0,0 @@ -# Fleet Installers - -## Goal - -[#5757](https://github.com/fleetdm/fleet/issues/5757) - -``` -As a user, I want to be able to download a Fleet-osquery installer (aka Orbit) in the Fleet UI -so that I can add hosts to Fleet without having to know how to successfully generate an -installer with the `fleetctl package` command. - -Figma wireframes: https://www.figma.com/file/hdALBDsrti77QuDNSzLdkx/?node-id=6740%3A267448 -``` - -## Command `fleetctl package` - -Currently users use the `fleetctl package` command to generate Fleet-osquery packages. - -The `fleetctl package` command has the following *required* configuration that is specific to a "Fleet Deployment": -- `--fleet-url`: The URL that the hosts will use to connect to the Fleet server. -- `--enroll-secret`: Global or team enroll secret to use when enrolling the host to Fleet. - -As the goal states, we would like to provide functionality in Fleet to automatically generate and download such packages from the UI. - -## How - -We can implement such functionality in two ways: - -- Option A. Fleet Server to generate such packages itself. -- Option B. Separate "Packager" service. - -There's a lot of platform specific logic and tooling involved in packaging, and one of Fleet's primary goals is to keep deployment/infrastructure simple for On-Prem deployments. -To that end, we believe the best option is Option B, implementing the functionality as a separate service. - -## Packager Service - -The "Fleet Packager" service will implement all the package generation logic. Think of it like offering the `fleetctl package` command functionality but as a REST service. - -```mermaid -graph LR; - A[User-Agent/
    Browser]-- Fleet API -->B[Fleet
    UI/Server] - A-- Packager
    API -->C - subgraph FleetDM Hosted Infrastructure - direction LR - subgraph
    pkg.fleetctl.com - direction TB; - C[Packager
    Service] - packages[(Generated
    packages)] - end - direction TB; - D[tuf.fleetctl.com] - C-- Fetch
    Targets -->D; - end - E[Apple's
    Notary
    Server] - C-- Notary
    API ---->E -``` - -Configurations: -- The `Fleet UI/Server` will allow configuring the "Fleet Packager" URL (default value being FleetDM Hosted Packager service, something like `pkg.fleetctl.com`). -- The `Packager Service` will allow configuring an alternative TUF server URL and TUF roots via an environmental variable (default value being FleetDM Hosted TUF, `tuf.fleetctl.com`). - -Both of these configurations will allow users to deploy their own `Packager Service`. - -### Storage - -The generated packages should be stored on encrypted disk and will expire (will be deleted) after a configurable amount of time (default 30m). -There are two reasons we want to expire generated packages soon: -1. To not store user credentials for too long (URL and enroll secret). -2. To free up space. - -We can use "Storage Optimized" instances (see https://aws.amazon.com/ec2/instance-types/). - -#### Notes - -- As a possible future optimization, we could use "Memory Optimized" instances and store packages in RAM instead of using hard disk. -- S3 could also be used to store such packages, but disk storage is needed to generate the packages in the first place. -So hard-disk will be a dependency anyways (and ideally we would like these packages with sensitive credentials to be stored in one location). -- From Roberto: "sounds like the main bottleneck here will be transferring the data over the network to the user doing the request.". -In other words, we should apply all optimizations on the network (like caching, reducing package size, etc.), that will be our main bottleneck -(not CPU or hard-disk access). - -#### Back of the Envelope - -- `.pkg`s use ~70MB of storage. -- `.msi`s use ~20MB of storage. -- `.deb`s and `.rpm`s use ~75MB of storage. - -Assuming the worst case of ~75 MB for each package: -If we have a ~30TB hard disk, it would allow storing ~400_000 packages simultaneously. - -### Network & Credentials - -The service will require network access to (URLs provided via config): - -- TUF server. -- Apple's Notary Server (for generating `.pkg`). - -The packaging service will need the following credentials (provided via config): - -- Apple credentials: - - Codesign identity. - - Username and Password for notarization. -- TUF server update roots (default will be the hardcoded one for FleetDM's hosted TUF server, tuf.fleetctl.com). - -### Packager REST API - -For the MVF (Minimum Viable Feature) we'll need three APIs: one for creation/submission, one for checking status and another one for the actual download of the package. -All the APIs must be rate-limited to prevent abuse of the system. - -#### 1. Package creation - -`POST /create` - -This endpoint will perform the following operations: -1. Check if a `package_id` already exists (and hasn't been expired) with the exact same arguments, if so, return HTTP 200 with the `package_id`. -2. Generate a [random](https://en.wikipedia.org/wiki/Universally_unique_identifier#Version_4_(random)) `package_id`. -3. Dispatch the creation of a package with ID set to `package_id` and the given request parameters. -4. Return HTTP 200 with the `package_id`. - -This endpoint, which is the entrypoint, should be rate-limited by IP. - -##### Request Fields - -| Name | Type | In | Description | -| ----------------- | ------- | ---- | -------------------------------------------------------------------------------------- | -| type | string | body | **Required.** One of the following values "pkg", "msi", "deb", "rpm" | -| fleet_url | string | body | **Required.** The URL that the hosts will use to connect to the Fleet server | -| enroll_secret | string | body | **Required.** Global or team enroll secret to use when enrolling the host to Fleet | -| retry | boolean | body | Retry a failed package generation (default: false) | -| fleet-certificate | string | body | Server certificate chain | -| insecure | boolean | body | Disable TLS certificate verification (default: false) | -| osqueryd-channel | string | body | Update channel of osqueryd to use (default: "stable") | -| desktop-channel | string | body | Update channel of desktop to use (default: "stable") | -| orbit-channel | string | body | Update channel of Orbit to use (default: "stable") | -| disable-updates | boolean | body | Disable auto updates on the generated package (default: false) | -| debug | boolean | body | Enable debug logging in orbit (default: false) | -| fleet-desktop | boolean | body | Include the Fleet Desktop Application in the package (default: false) | -| update-interval | string | body | Interval that Orbit will use to check for new updates (10s, 1h, etc.) (default: 15m0s) | -| osquery-flagfile | string | body | Flagfile to package and provide to osquery (default: empty) | -| service | boolean | body | Install with a persistence service (launchd, systemd, etc.) (default: true) | - -##### Error in Package Generation - -When the set of arguments correspond to a `package_id` that failed to generate, then: -- If `retry` is `false` (default) it will return such `package_id`. -- If `retry` is set to `true`, the service will dispatch a new package build and return a new `package_id`. - -##### Response Fields - -| Name | Type | In | Description | -| ----------------- | ------- | ---- | -------------------------------- | -| package_id | string | body | ID of the package being created. | - -#### 2. Package Status Check - -`GET /status` - -This endpoint allows checking the status of a package being created. -Clients can poll for the status of a package using this API. - -This endpoint should be rate-limited by `package_id`. - -##### Request Fields - -| Name | Type | In | Description | -| ----------------- | ------- | ----- | ------------------------------------------------------------------------ | -| package_id | string | query | **Required.** ID of the package created with `POST /create` | - -##### Response Fields - -| Name | Type | In | Description | -| ----------------- | ------- | ---- | ---------------------------------------------------------------------------- | -| status | string | body | One of the following values "success", "fail", "in-progress", "expired" | -| download_url | string | body | Set to the download URL if status field is "success" | -| stage | string | body | Set to a "stage" string if status field is "in-progress" (e.g. "notarizing") | -| logs | string | body | Contains logs if status is "failed" | - -#### 3. Package Download - -`GET /download/{package_id}` - -This is the API to download the generated package. - -This endpoint should be rate-limited by `package_id`. - -## Sequence Diagram - -Following is the sequence diagram for the happy-path. - -```mermaid -sequenceDiagram - User-Agent/Browser->>Fleet: GET /api/v1/fleet/config - Fleet-->>User-Agent/Browser: packager_url - User-Agent/Browser->>Packager: POST /create - Packager-->>User-Agent/Browser: package_id - Packager-->>TUF Server: Fetch targets - loop - User-Agent/Browser->>Packager: GET /status - Packager-->>User-Agent/Browser: status == "in-progress" - Note over User-Agent/Browser: Retry every ~10 seconds,
    until status is "success" - TUF Server->>Packager: Targets - Note over Packager: Build package - Note over Packager: Sign package - rect rgb(128, 128, 128) - Note right of Packager: (When generating macOS pkgs) - Packager-->>Apple's Notary Server: New SubmissionRequest (upload) - Apple's Notary Server->>Packager: NewSubmissionResponse - Note over Packager: Upload to S3
    (see Apple docs) - loop Every ~30 seconds, until status is "Accepted" - Packager->>Apple's Notary Server: Get submission status - Apple's Notary Server-->>Packager: status - end - end - end - User-Agent/Browser->>Packager: GET /download/{package_id} - Packager-->>User-Agent/Browser: Package File -``` - -## Package Generation Time - -Some back of the envelope calculations for the time the user clicks Download to the time the installer is downloaded fully. - -### macOS - -Test running `time fleetctl package --type=pkg --fleet-url=... --enroll-secret=... --fleet-desktop` (from Argentina). - -1. Download packages from TUF and generate raw `.pkg` (16 seconds). -2. Signing (assuming this is negligible). -3. Notarization (~3 minutes, from Zach's tests mentioned below). -4. Download from Packager service (~15 seconds to download a 100 MB file). - -Total: ~4 minutes - -### Windows - -Test running `time fleetctl package --type=msi --fleet-url=... --enroll-secret=... --fleet-desktop` (from Argentina). - -1. Download packages from TUF and generate raw `.msi` (~18 seconds). -2. Download from Packager service (~15 seconds to download a 100 MB file). - -Total: ~30 seconds. - -### Linux - -Test running `time fleetctl package --type={deb|rpm} --fleet-url=... --enroll-secret=... --fleet-desktop` (from Argentina). - -1. Download packages from TUF and generate raw `.{deb|rpm}` (~30 seconds). -2. Download from Packager service (~30 seconds to download a 200 MB file). - -Total: ~1 minute. - -### Possible Download Time Optimization - -- The Packager service could cache the TUF targets for a couple of minutes (given that they usually change ~ every three weeks). -This would reduce 15s-30s the time it takes to generate the packages. - -## Platform Specifics - -### macOS - -There are two operations **required** to have a proper macOS installer package (`.pkg`): - -1. Code signing: Used by Gatekeeper to verify the author of the package (identified by Developer ID) -2. Notarization: "Gives users more confidence that the Developer ID-signed software you distribute has been checked by Apple for malicious components." - -The packages are composed by three TUF targets: osquery, Orbit and Fleet Desktop. - -All the TUF targets served by FleetDM's TUF server are signed and notarized: - -- osquery: signed and notarized .app (by Osquery) -- Orbit: signed and notarized executable (by Fleet DM) -- fleet-desktop: signed and notarized .app (by Fleet DM) - -Even if all targets are signed and notarized we must still sign and notarize the `.pkg` installer as a whole, see [#122045](https://developer.apple.com/forums/thread/122045). - -#### Notarization - -The Notarization process can be summarized to the following steps: - -1. Submit/Upload the **signed** package to the Notary Server. -2. Notary server performs automated security checks. -3. Notary generates a ticket, publishes ticket online (so that Gatekeeper can find it) and returns the ticket. -4. Ticket can be stapled to your software to let Gatekeeper know it has been notarized. - -##### Notarization Limitations - -- Notarization completes for most software within 5 minutes, and for 98 percent of software within 15 minutes. Thus our workflow must support users leaving the "Add hosts" page and returning later. -- To help avoid long response times they suggest: to "limit notarizations to 75 per day." So it doesn't look like a hard limit, just something that would help reduce upload response times. -Source: https://developer.apple.com/documentation/security/notarizing_macos_software_before_distribution/customizing_the_notarization_workflow -Though Zach has ran the following test with no issues: -> I was able to Notarize ~200 packages in under 12 hours yesterday with no apparent limiting. -> Note this was using the older Notarization API (slower, about 3 mins per notarization). - -#### Future Optimizations for Notarization - -This is the current structure of a `.pkg` generated with `fleetctl package` (unsigned and unnotarized): -```sh -pkg_expanded -├── Distribution -└── base - ├── Bom - ├── Library - │   └── LaunchDaemons - │   └── com.fleetdm.orbit.plist - ├── PackageInfo - ├── Payload - ├── Scripts - │   └── postinstall - └── opt - └── orbit - ├── bin - │   ├── desktop - │   │   └── macos - │   │   └── stable - │   │   ├── Fleet Desktop.app - │   │   │   └── Contents - │   │   │   ├── CodeResources - │   │   │   ├── Info.plist - │   │   │   ├── MacOS - │   │   │   │   └── fleet-desktop - │   │   │   └── _CodeSignature - │   │   │   └── CodeResources - │   │   └── desktop.app.tar.gz - │   ├── orbit - │   │   └── macos - │   │   └── stable - │   │   └── orbit - │   └── osqueryd - │   └── macos-app - │   └── stable - │   ├── osquery.app - │   │   └── Contents - │   │   ├── Info.plist - │   │   ├── MacOS - │   │   │   └── osqueryd - │   │   ├── PkgInfo - │   │   ├── Resources - │   │   │   └── osqueryctl - │   │   ├── _CodeSignature - │   │   │   └── CodeResources - │   │   └── embedded.provisionprofile - │   └── osqueryd.app.tar.gz - ├── certs.pem - ├── osquery.flags - ├── secret.txt - ├── staging - └── tuf-metadata.json -``` - -##### Remove Unnecessary Files - -The `osqueryd.app.tar.gz` and `desktop.app.tar.gz` files are used by Orbit to compare with remote targets when checking for updates. -A future optimization could replace those `.app.tar.gz` files with a txt file with the hash of such file -(would reduce ~30MB of uncompressed size reduction for the `.pkg`). - -##### Notarized Package without Configs - -The only files that really change when users generate a pkg are: -- Library/LaunchDaemons/com.fleetdm.orbit.plist (contains the configuration, like URLs, update channels, etc.) -- opt/orbit/secret.txt (enroll secret) - -Another idea to consider could be packaging and notarizing code and scripts, but leave specific configs out of the `.pkg`. -Problem: We would need to solve how to apply the additional configuration (the two files above) to installed packages. - -From [#122512](https://developer.apple.com/forums/thread/122512): - -> I thought there might be some apple approved way of adding extra information to a package. - ->> No. The notarisation ticket covers the code signature of the package, and the code signature of the package covers the ->> effective contents of that package. This is pretty much required. For example, one of your goals is to tweak the install scripts, ->> but such scripts execute with enormous privileges and thus must be covered by the code signature, and thus covered by the notarisation ticket. - ---- - -> Is there a Apple recommended method to provide custom packages for different customers? -> Some way to add additional parameters to a package without breaking the notarizartion/signing of it? - ->> Option 2) Change your distribution strategy to distribute a static executable. ->> Download any customer-specific resources and store them in /Library/Application Support. - -### Windows - -Currently we don't support signing of the MSI installers in `fleetctl package`. But this functionality could be added both to the command and the service. -From Zach: -> When we decide to support this (which we should do soon), we can use https://github.com/mtrojnar/osslsigncode. - -We should also research https://github.com/sassoftware/relic (it mentions Windows signature support) - -## Service Implementation - -It looks like we will be able to implement the first iteration of the Packager Service as a Go service running on a Linux server. -The only limitation will be macOS stapling (see below). - -### Scale - -MVP of the service will support running one instance of the service. Probably ok as the majority of the load will be IO, network and disk. - -Nothing prevent us from horizontal scaling by sharding requests by `package_id` (or a `client_id`) in the future. - -### State storing - -For the first iteration, we should pick one of the following simple options: -1. Store state in-memory. Simplest, but not resilient to crashes/restarts. -2. Store state in an embedded disk database, such as: - - https://github.com/dgraph-io/badger (Pure Go) - - https://github.com/etcd-io/bbolt (Pure Go) - - Sqlite3 (Cgo 🙈), see https://www.youtube.com/watch?v=XcAYkriuQ1o - -- We already need disk storage for storing the packages, so option (2) is feasible to do from the get-go. - -### macOS requirements - -- Code Signing: Looks like https://github.com/sassoftware/relic would allow us to sign a `.pkg` in pure Go. -- Notarization: We can implement a Go package that uses the new [Notary API](https://developer.apple.com/documentation/notaryapi). -Limitation: Such API does not offer a way to "staple" the package, thus we would depend on the `stapler` tool for this last step (such tool is only available on macOS). -So if we run the Packager service on a Linux server, we won't be able to support stapling. However, it seems stapling is recommended but not a must, see [#116812](https://developer.apple.com/forums/thread/116812). -> If Gatekeeper can’t find a notarisation ticket stapled to the item, it attempts to get that ticket from the Apple notarisation servers. -> Assuming the Mac is online, this typically works and thus the Gatekeeper check succeeds. - -### Windows requirements - -We need Docker to generate the MSI for Windows (the `fleetctl package` command uses the `fleetdm/wix:latest` docker image to generate them). -We will need to pre-fetch such Docker Image during initialization (to not delay the first request indefinitely). - -PS: We could alternatively try a PoC that uses Wine+WiX on Linux without Docker? - -### Lambda? - -Depending on dependencies we could implement the service as a Lambda Function. Though we could make this a full service to not be tied to a specific provider. - -## Security / Threat model - -What could go wrong? - -### Fleet Credentials - -The MVP of the service will have access to: -- Fleet's Developer Signing Key (for `.pkg` signing). -- Fleet's Apple Connect Username and Password (for `.pkg` notarization). - -From Guillaume: -> If someone manages to compromise this service, they could potentially sign AND notarize malware under Fleet's identity. - -Remediation: Fleet credentials should be stored on a secrets manager, e.g. [AWS Secrets Manager](https://docs.aws.amazon.com/secretsmanager/latest/userguide/intro.html). - -### User Credentials - -The Fleet-URL and enroll secret are stored within the generated packages. -If the unexpired installers are leaked, users' Fleet URLs and enroll secrets would be compromised. -Attackers could enroll their devices to users' Fleet deployment. - -An attacker with access to an enroll secret can perform the following attacks: -- Feed a Fleet server with fake data (possibly DoS the service). -- Leak the queries configured in Fleet. - -Remediation: All generated packages should be securely deleted when they expire. - -## Sandbox/Demo & Cloud - -The design supports the following deployments: - -- Fleet On-Prem running with FleetDM's Packager and TUF. -- Fleet On-Prem running with On-Prem Packager with FleetDM's TUF server. -- Fleet On-Prem running with On-Prem Packager with On-Prem TUF server. -- Fleet Sandbox/Demo & Cloud (basically all hosted by FleetDM). - -## New Fleetctl Command - -We could add new flags (e.g. `--remote`) to `fleetctl package` to generate the packages using a Packager Service instead of building locally. \ No newline at end of file diff --git a/server/datastore/mysql/activities.go b/server/datastore/mysql/activities.go index 650c097cb985..ccaeb4f16ae0 100644 --- a/server/datastore/mysql/activities.go +++ b/server/datastore/mysql/activities.go @@ -539,35 +539,65 @@ func (ds *Datastore) CleanupActivitiesAndAssociatedData(ctx context.Context, max // `activities` and `queries` are not tied because the activity itself holds // the query SQL so they don't need to be executed on the same transaction. // - if err := ds.withTx(ctx, func(tx sqlx.ExtContext) error { - // Delete temporary queries (aka "not saved"). - if _, err := tx.ExecContext(ctx, - `DELETE FROM queries + // All expired live queries are deleted in batch sizes of `maxCount` to ensure + // the table size is kept in check with high volumes of live queries (zero-trust workflows). + // This differs from the `activities` cleanup which uses maxCount as a limit to + // the number of activities to delete. + // + for { + if ctx.Err() != nil { + return ctx.Err() + } + + var rowsAffected int64 + + // Start a new transaction for each batch of deletions. + err := ds.withTx(ctx, func(tx sqlx.ExtContext) error { + // Delete expired live queries (aka "not saved") + result, err := tx.ExecContext(ctx, + `DELETE FROM queries WHERE NOT saved AND created_at < DATE_SUB(NOW(), INTERVAL ? DAY) LIMIT ?`, - expiredWindowDays, maxCount, - ); err != nil { - return ctxerr.Wrap(ctx, err, "delete expired non-saved queries") - } - // Delete distributed campaigns that reference unexisting query (removed in the previous query). - if _, err := tx.ExecContext(ctx, - `DELETE distributed_query_campaigns FROM distributed_query_campaigns + expiredWindowDays, maxCount, + ) + if err != nil { + return ctxerr.Wrap(ctx, err, "delete expired non-saved queries") + } + + rowsAffected, err = result.RowsAffected() + if err != nil { + return ctxerr.Wrap(ctx, err, "retrieving rows affected from delete query") + } + + // Cleanup orphaned distributed campaigns that reference non-existing queries. + if _, err := tx.ExecContext(ctx, + `DELETE distributed_query_campaigns FROM distributed_query_campaigns LEFT JOIN queries ON (distributed_query_campaigns.query_id=queries.id) WHERE queries.id IS NULL`, - ); err != nil { - return ctxerr.Wrap(ctx, err, "delete expired orphaned distributed_query_campaigns") - } - // Delete distributed campaign targets that reference unexisting distributed campaign (removed in the previous query). - if _, err := tx.ExecContext(ctx, - `DELETE distributed_query_campaign_targets FROM distributed_query_campaign_targets + ); err != nil { + return ctxerr.Wrap(ctx, err, "delete expired orphaned distributed_query_campaigns") + } + + // Cleanup orphaned distributed campaign targets that reference non-existing distributed campaigns. + if _, err := tx.ExecContext(ctx, + `DELETE distributed_query_campaign_targets FROM distributed_query_campaign_targets LEFT JOIN distributed_query_campaigns ON (distributed_query_campaign_targets.distributed_query_campaign_id=distributed_query_campaigns.id) WHERE distributed_query_campaigns.id IS NULL`, - ); err != nil { - return ctxerr.Wrap(ctx, err, "delete expired orphaned distributed_query_campaign_targets") + ); err != nil { + return ctxerr.Wrap(ctx, err, "delete expired orphaned distributed_query_campaign_targets") + } + + return nil + }) + if err != nil { + return ctxerr.Wrap(ctx, err, "delete expired queries in batch") + } + + // Break the loop if no rows were deleted in the current batch. + if rowsAffected == 0 { + break } - return nil - }); err != nil { - return ctxerr.Wrap(ctx, err, "delete expired distributed queries") } + return nil } diff --git a/server/datastore/mysql/activities_test.go b/server/datastore/mysql/activities_test.go index 17bd72e6ee8c..669680127633 100644 --- a/server/datastore/mysql/activities_test.go +++ b/server/datastore/mysql/activities_test.go @@ -991,7 +991,7 @@ func testCleanupActivitiesAndAssociatedDataBatch(t *testing.T, ds *Datastore) { ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { return sqlx.GetContext(ctx, q, &queriesLen, `SELECT COUNT(*) FROM queries WHERE NOT saved;`) }) - require.Equal(t, 1000, queriesLen) + require.Equal(t, 250, queriesLen) // All expired queries should be cleaned up. err = ds.CleanupActivitiesAndAssociatedData(ctx, maxCount, 1) require.NoError(t, err) @@ -1002,7 +1002,7 @@ func testCleanupActivitiesAndAssociatedDataBatch(t *testing.T, ds *Datastore) { ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { return sqlx.GetContext(ctx, q, &queriesLen, `SELECT COUNT(*) FROM queries WHERE NOT saved;`) }) - require.Equal(t, 500, queriesLen) + require.Equal(t, 250, queriesLen) err = ds.CleanupActivitiesAndAssociatedData(ctx, maxCount, 1) require.NoError(t, err) diff --git a/server/datastore/mysql/apple_mdm.go b/server/datastore/mysql/apple_mdm.go index a64a99dc9a56..ad18242eb754 100644 --- a/server/datastore/mysql/apple_mdm.go +++ b/server/datastore/mysql/apple_mdm.go @@ -2556,377 +2556,150 @@ func (ds *Datastore) UpdateOrDeleteHostMDMAppleProfile(ctx context.Context, prof return err } -const ( - appleMDMFailedProfilesStmt = ` - h.uuid = hmap.host_uuid AND - hmap.status = :failed` - - appleMDMPendingProfilesStmt = ` - h.uuid = hmap.host_uuid AND - ( - hmap.status IS NULL OR - hmap.status = :pending OR +// sqlCaseMDMAppleStatus returns a SQL snippet that can be used to determine the status of a host +// based on the status of its profiles and declarations and filevault status. It should be used in +// conjunction with sqlJoinMDMAppleProfilesStatus and sqlJoinMDMAppleDeclarationsStatus. It assumes the +// hosts table to be aliased as 'h' and the host_disk_encryption_keys table to be aliased as 'hdek'. +func sqlCaseMDMAppleStatus() string { + // NOTE: To make this snippet reusable, we're not using sqlx.Named here because it would + // complicate usage in other queries (e.g., list hosts). + var ( + failed = fmt.Sprintf("'%s'", string(fleet.MDMDeliveryFailed)) + pending = fmt.Sprintf("'%s'", string(fleet.MDMDeliveryPending)) + verifying = fmt.Sprintf("'%s'", string(fleet.MDMDeliveryVerifying)) + verified = fmt.Sprintf("'%s'", string(fleet.MDMDeliveryVerified)) + ) + return ` + CASE WHEN (prof_failed + OR decl_failed + OR fv_failed) THEN + ` + failed + ` + WHEN (prof_pending + OR decl_pending -- special case for filevault, it's pending if the profile is -- pending OR the profile is verified or verifying but we still -- don't have an encryption key. - ( - hmap.profile_identifier = :filevault AND - hmap.status IN (:verifying, :verified) AND - hmap.operation_type = :install AND - NOT EXISTS ( - SELECT 1 - FROM host_disk_encryption_keys hdek - WHERE h.id = hdek.host_id AND - (hdek.decryptable = 1 OR hdek.decryptable IS NULL) - ) - ) - )` - - appleMDMVerifyingProfilesStmt = ` - h.uuid = hmap.host_uuid AND - hmap.operation_type = :install AND - ( - -- all profiles except filevault that are 'verifying' - ( - hmap.profile_identifier != :filevault AND - hmap.status = :verifying - ) - OR - -- special cases for filevault - ( - hmap.profile_identifier = :filevault AND - ( - -- filevault profile is verified, but we didn't verify the encryption key - ( - hmap.status = :verified AND - EXISTS ( - SELECT 1 - FROM host_disk_encryption_keys AS hdek - WHERE h.id = hdek.host_id AND - hdek.decryptable IS NULL - ) - ) - OR - -- filevault profile is verifying, and we already have an encryption key, in any state - ( - hmap.status = :verifying AND - EXISTS ( - SELECT 1 - FROM host_disk_encryption_keys AS hdek - WHERE h.id = hdek.host_id AND - hdek.decryptable = 1 OR hdek.decryptable IS NULL - ) - ) - ) - ) - )` - - appleVerifiedProfilesStmt = ` - h.uuid = hmap.host_uuid AND - hmap.operation_type = :install AND - hmap.status = :verified AND - ( - hmap.profile_identifier != :filevault OR - EXISTS ( - SELECT 1 - FROM host_disk_encryption_keys hdek - WHERE h.id = hdek.host_id AND - hdek.decryptable = 1 - ) - )` -) - -// subqueryAppleProfileStatus builds the right subquery that can be used to -// filter hosts based on their profile status. -// -// The subquery mechanism works by finding profiles for hosts that: -// - match with the provided status -// - match any status that supercedes the provided status (eg: failed supercedes verifying) -// -// Hosts will be considered to be in the given status only if the profiles -// match the given status and zero profiles match any superceding status. -func subqueryAppleProfileStatus(status fleet.MDMDeliveryStatus) (string, []any, error) { - var condition string - var excludeConditions string - switch status { - case fleet.MDMDeliveryFailed: - condition = appleMDMFailedProfilesStmt - excludeConditions = "FALSE" - case fleet.MDMDeliveryPending: - condition = appleMDMPendingProfilesStmt - excludeConditions = appleMDMFailedProfilesStmt - case fleet.MDMDeliveryVerifying: - condition = appleMDMVerifyingProfilesStmt - excludeConditions = fmt.Sprintf("(%s) OR (%s)", appleMDMPendingProfilesStmt, appleMDMFailedProfilesStmt) - case fleet.MDMDeliveryVerified: - condition = appleVerifiedProfilesStmt - excludeConditions = fmt.Sprintf("(%s) OR (%s) OR (%s)", appleMDMPendingProfilesStmt, appleMDMFailedProfilesStmt, appleMDMVerifyingProfilesStmt) - default: - return "", nil, fmt.Errorf("invalid status: %s", status) - } - - sql := fmt.Sprintf(` - SELECT 1 - FROM host_mdm_apple_profiles hmap - WHERE %s AND - NOT EXISTS ( - SELECT 1 - FROM host_mdm_apple_profiles hmap - WHERE %s - )`, condition, excludeConditions) - - arg := map[string]any{ - "install": fleet.MDMOperationTypeInstall, - "verifying": fleet.MDMDeliveryVerifying, - "failed": fleet.MDMDeliveryFailed, - "verified": fleet.MDMDeliveryVerified, - "pending": fleet.MDMDeliveryPending, - "filevault": mobileconfig.FleetFileVaultPayloadIdentifier, - } - query, args, err := sqlx.Named(sql, arg) - if err != nil { - return "", nil, fmt.Errorf("subqueryAppleProfileStatus %s: %w", status, err) - } - - return query, args, nil + OR(fv_pending + OR((fv_verifying + OR fv_verified) + AND (hdek.base64_encrypted IS NULL OR (hdek.decryptable IS NOT NULL AND hdek.decryptable != 1))))) THEN + ` + pending + ` + WHEN (prof_verifying + OR decl_verifying + -- special case when fv profile is verifying, and we already have an encryption key, in any state, we treat as verifying + OR(fv_verifying + AND hdek.base64_encrypted IS NOT NULL AND (hdek.decryptable IS NULL OR hdek.decryptable = 1)) + -- special case when fv profile is verified, but we didn't verify the encryption key, we treat as verifying + OR(fv_verified + AND hdek.base64_encrypted IS NOT NULL AND hdek.decryptable IS NULL)) THEN + ` + verifying + ` + WHEN (prof_verified + OR decl_verified + OR(fv_verified + AND hdek.base64_encrypted IS NOT NULL AND hdek.decryptable = 1)) THEN + ` + verified + ` + END +` } -// subqueryAppleDeclarationStatus builds out the subquery for declaration status -func subqueryAppleDeclarationStatus() (string, []any, error) { - const declNamedStmt = ` - CASE WHEN EXISTS ( - SELECT - 1 - FROM - host_mdm_apple_declarations d1 - WHERE - h.uuid = d1.host_uuid - AND d1.operation_type = :install - AND d1.status = :failed - AND d1.declaration_name NOT IN (:reserved_names)) THEN - 'declarations_failed' - WHEN EXISTS ( - SELECT - 1 - FROM - host_mdm_apple_declarations d2 - WHERE - h.uuid = d2.host_uuid - AND d2.operation_type = :install - AND(d2.status IS NULL - OR d2.status = :pending) - AND d2.declaration_name NOT IN (:reserved_names) - AND NOT EXISTS ( - SELECT - 1 - FROM - host_mdm_apple_declarations d3 - WHERE - h.uuid = d3.host_uuid - AND d3.operation_type = :install - AND d3.status = :failed - AND d3.declaration_name NOT IN (:reserved_names))) THEN - 'declarations_pending' - WHEN EXISTS ( - SELECT - 1 - FROM - host_mdm_apple_declarations d4 - WHERE - h.uuid = d4.host_uuid - AND d4.operation_type = :install - AND d4.status = :verifying - AND d4.declaration_name NOT IN (:reserved_names) - AND NOT EXISTS ( - SELECT - 1 - FROM - host_mdm_apple_declarations d5 - WHERE (h.uuid = d5.host_uuid - AND d5.operation_type = :install - AND d5.declaration_name NOT IN (:reserved_names) - AND(d5.status IS NULL - OR d5.status IN(:pending, :failed))))) THEN - 'declarations_verifying' - WHEN EXISTS ( - SELECT - 1 - FROM - host_mdm_apple_declarations d6 - WHERE - h.uuid = d6.host_uuid - AND d6.operation_type = :install - AND d6.status = :verified - AND d6.declaration_name NOT IN (:reserved_names) - AND NOT EXISTS ( - SELECT - 1 - FROM - host_mdm_apple_declarations d7 - WHERE (h.uuid = d7.host_uuid - AND d7.operation_type = :install - AND d7.declaration_name NOT IN (:reserved_names) - AND(d7.status IS NULL - OR d7.status IN(:pending, :failed, :verifying))))) THEN - 'declarations_verified' - ELSE - '' - END` - - arg := map[string]any{ - "install": fleet.MDMOperationTypeInstall, - "verifying": fleet.MDMDeliveryVerifying, - "failed": fleet.MDMDeliveryFailed, - "verified": fleet.MDMDeliveryVerified, - "pending": fleet.MDMDeliveryPending, - "reserved_names": fleetmdm.ListFleetReservedMacOSDeclarationNames(), - } - query, args, err := sqlx.Named(declNamedStmt, arg) - if err != nil { - return "", nil, fmt.Errorf("subqueryAppleDeclarationStatus: %w", err) - } - query, args, err = sqlx.In(query, args...) - if err != nil { - return "", nil, fmt.Errorf("subqueryAppleDeclarationStatus resolve IN: %w", err) - } - - return query, args, nil +// sqlJoinMDMAppleProfilesStatus returns a SQL snippet that can be used to join a table derived from +// host_mdm_apple_profiles (grouped by host_uuid and status) and the hosts table. For each host_uuid, +// it derives a boolean value for each status category. The value will be 1 if the host has any +// profile in the given status category. Separate columns are used for status of the filevault profile +// vs. all other profiles. The snippet assumes the hosts table to be aliased as 'h'. +func sqlJoinMDMAppleProfilesStatus() string { + // NOTE: To make this snippet reusable, we're not using sqlx.Named here because it would + // complicate usage in other queries (e.g., list hosts). + var ( + failed = fmt.Sprintf("'%s'", string(fleet.MDMDeliveryFailed)) + pending = fmt.Sprintf("'%s'", string(fleet.MDMDeliveryPending)) + verifying = fmt.Sprintf("'%s'", string(fleet.MDMDeliveryVerifying)) + verified = fmt.Sprintf("'%s'", string(fleet.MDMDeliveryVerified)) + install = fmt.Sprintf("'%s'", string(fleet.MDMOperationTypeInstall)) + filevault = fmt.Sprintf("'%s'", mobileconfig.FleetFileVaultPayloadIdentifier) + ) + return ` + LEFT JOIN ( + -- profile statuses grouped by host uuid, boolean value will be 1 if host has any profile with the given status + -- filevault profiles are treated separately + SELECT + host_uuid, + MAX( IF((status IS NULL OR status = ` + pending + `) AND profile_identifier != ` + filevault + `, 1, 0)) AS prof_pending, + MAX( IF(status = ` + failed + ` AND profile_identifier != ` + filevault + `, 1, 0)) AS prof_failed, + MAX( IF(status = ` + verifying + ` AND profile_identifier != ` + filevault + ` AND operation_type = ` + install + `, 1, 0)) AS prof_verifying, + MAX( IF(status = ` + verified + ` AND profile_identifier != ` + filevault + ` AND operation_type = ` + install + `, 1, 0)) AS prof_verified, + MAX( IF((status IS NULL OR status = ` + pending + `) AND profile_identifier = ` + filevault + `, 1, 0)) AS fv_pending, + MAX( IF(status = ` + failed + ` AND profile_identifier = ` + filevault + `, 1, 0)) AS fv_failed, + MAX( IF(status = ` + verifying + ` AND profile_identifier = ` + filevault + ` AND operation_type = ` + install + `, 1, 0)) AS fv_verifying, + MAX( IF(status = ` + verified + ` AND profile_identifier = ` + filevault + ` AND operation_type = ` + install + `, 1, 0)) AS fv_verified + FROM + host_mdm_apple_profiles + GROUP BY + host_uuid) hmap ON h.uuid = hmap.host_uuid +` } -func subqueryOSSettingsStatusMac() (string, []any, error) { - var profArgs []any - profFailed, profFailedArgs, err := subqueryAppleProfileStatus(fleet.MDMDeliveryFailed) - if err != nil { - return "", nil, err - } - profArgs = append(profArgs, profFailedArgs...) - - profPending, profPendingArgs, err := subqueryAppleProfileStatus(fleet.MDMDeliveryPending) - if err != nil { - return "", nil, err - } - profArgs = append(profArgs, profPendingArgs...) - - profVerifying, profVerifyingArgs, err := subqueryAppleProfileStatus(fleet.MDMDeliveryVerifying) - if err != nil { - return "", nil, err - } - profArgs = append(profArgs, profVerifyingArgs...) - - profVerified, profVerifiedArgs, err := subqueryAppleProfileStatus(fleet.MDMDeliveryVerified) - if err != nil { - return "", nil, err - } - profArgs = append(profArgs, profVerifiedArgs...) - - profStmt := fmt.Sprintf(` - CASE WHEN EXISTS (%s) THEN - 'profiles_failed' - WHEN EXISTS (%s) THEN - 'profiles_pending' - WHEN EXISTS (%s) THEN - 'profiles_verifying' - WHEN EXISTS (%s) THEN - 'profiles_verified' - ELSE - '' - END`, - profFailed, - profPending, - profVerifying, - profVerified, +// sqlJoinMDMAppleDeclarationsStatus returns a SQL snippet that can be used to join a table derived from +// host_mdm_apple_declarations (grouped by host_uuid and status) and the hosts table. For each host_uuid, +// it derives a boolean value for each status category. The value will be 1 if the host has any +// declaration in the given status category. The snippet assumes the hosts table to be aliased as 'h'. +func sqlJoinMDMAppleDeclarationsStatus() string { + // NOTE: To make this snippet reusable, we're not using sqlx.Named here because it would + // complicate usage in other queries (e.g., list hosts). + var ( + failed = fmt.Sprintf("'%s'", string(fleet.MDMDeliveryFailed)) + pending = fmt.Sprintf("'%s'", string(fleet.MDMDeliveryPending)) + verifying = fmt.Sprintf("'%s'", string(fleet.MDMDeliveryVerifying)) + verified = fmt.Sprintf("'%s'", string(fleet.MDMDeliveryVerified)) + install = fmt.Sprintf("'%s'", string(fleet.MDMOperationTypeInstall)) + reservedDeclNames = fmt.Sprintf("'%s', '%s', '%s'", fleetmdm.FleetMacOSUpdatesProfileName, fleetmdm.FleetIOSUpdatesProfileName, fleetmdm.FleetIPadOSUpdatesProfileName) ) - - declStmt, declArgs, err := subqueryAppleDeclarationStatus() - if err != nil { - return "", nil, err - } - - stmt := fmt.Sprintf(` - CASE (%s) - WHEN 'profiles_failed' THEN - 'failed' - WHEN 'profiles_pending' THEN ( - CASE (%s) - WHEN 'declarations_failed' THEN - 'failed' - ELSE - 'pending' - END) - WHEN 'profiles_verifying' THEN ( - CASE (%s) - WHEN 'declarations_failed' THEN - 'failed' - WHEN 'declarations_pending' THEN - 'pending' - ELSE - 'verifying' - END) - WHEN 'profiles_verified' THEN ( - CASE (%s) - WHEN 'declarations_failed' THEN - 'failed' - WHEN 'declarations_pending' THEN - 'pending' - WHEN 'declarations_verifying' THEN - 'verifying' - ELSE - 'verified' - END) - ELSE - REPLACE((%s), 'declarations_', '') - END`, profStmt, declStmt, declStmt, declStmt, declStmt) - - args := append(profArgs, declArgs...) - args = append(args, declArgs...) - args = append(args, declArgs...) - args = append(args, declArgs...) - - // FIXME(roberto): we found issues in MySQL 5.7.17 (only that version, - // which we must support for now) with prepared statements on this - // query. The results returned by the DB were always different what - // expected unless the arguments are inlined in the query. - // - // We decided to do this given: - // - // - The time constraints we were given to develop DDM - // - The fact that all the variables in this query are really strings managed by us - // - The imminent deprecation of MySQL 5.7 - return fmt.Sprintf(strings.Replace(stmt, "?", "'%s'", -1), args...), []any{}, nil + return ` + LEFT JOIN ( + -- declaration statuses grouped by host uuid, boolean value will be 1 if host has any declaration with the given status + SELECT + host_uuid, + MAX( IF((status IS NULL OR status = ` + pending + `), 1, 0)) AS decl_pending, + MAX( IF(status = ` + failed + `, 1, 0)) AS decl_failed, + MAX( IF(status = ` + verifying + ` , 1, 0)) AS decl_verifying, + MAX( IF(status = ` + verified + ` , 1, 0)) AS decl_verified + FROM + host_mdm_apple_declarations + WHERE + operation_type = ` + install + ` AND declaration_name NOT IN(` + reservedDeclNames + `) + GROUP BY + host_uuid) hmad ON h.uuid = hmad.host_uuid +` } func (ds *Datastore) GetMDMAppleProfilesSummary(ctx context.Context, teamID *uint) (*fleet.MDMProfilesSummary, error) { - subquery, args, err := subqueryOSSettingsStatusMac() - if err != nil { - return nil, ctxerr.Wrap(ctx, err, "building os settings subquery") - } - - sqlFmt := ` + stmt := ` SELECT - %s as status, - COUNT(id) as count + COUNT(id) AS count, + %s AS status FROM - hosts h -WHERE platform = 'darwin' OR platform = 'ios' OR platform = 'ipados' -GROUP BY status, team_id HAVING status IN (?, ?, ?, ?) AND %s` - - args = append(args, fleet.MDMDeliveryFailed, fleet.MDMDeliveryPending, fleet.MDMDeliveryVerifying, fleet.MDMDeliveryVerified) + hosts h + %s + %s + LEFT JOIN host_disk_encryption_keys hdek ON h.id = hdek.host_id +WHERE + platform IN('darwin', 'ios', 'ipad_os') AND %s +GROUP BY + status HAVING status IS NOT NULL` teamFilter := "team_id IS NULL" if teamID != nil && *teamID > 0 { - teamFilter = "team_id = ?" - args = append(args, *teamID) + teamFilter = fmt.Sprintf("team_id = %d", *teamID) } - stmt := fmt.Sprintf(sqlFmt, subquery, teamFilter) + stmt = fmt.Sprintf(stmt, sqlCaseMDMAppleStatus(), sqlJoinMDMAppleProfilesStatus(), sqlJoinMDMAppleDeclarationsStatus(), teamFilter) var dest []struct { Count uint `db:"count"` Status string `db:"status"` } - err = sqlx.SelectContext(ctx, ds.reader(ctx), &dest, stmt, args...) - if err != nil { + if err := sqlx.SelectContext(ctx, ds.reader(ctx), &dest, stmt); err != nil { return nil, err } diff --git a/server/datastore/mysql/hosts.go b/server/datastore/mysql/hosts.go index 0b3a0e498362..6026dcd9b140 100644 --- a/server/datastore/mysql/hosts.go +++ b/server/datastore/mysql/hosts.go @@ -403,13 +403,17 @@ func loadHostPackStatsDB(ctx context.Context, db sqlx.QueryerContext, hid uint, return ps, nil } +// loadHostScheduledQueryStatsDB will load all the scheduled query stats for the given host. +// The filter is split into two statements joined by a UNION ALL to take advantage of indexes. +// Using an OR in the WHERE clause causes a full table scan which causes issues with a large +// queries table due to the high volume of live queries (created by zero trust workflows) func loadHostScheduledQueryStatsDB(ctx context.Context, db sqlx.QueryerContext, hid uint, hostPlatform string, teamID *uint) ([]fleet.QueryStats, error) { var teamID_ uint if teamID != nil { teamID_ = *teamID } - sqlQuery := ` + baseQuery := ` SELECT q.id, q.name, @@ -442,12 +446,19 @@ func loadHostScheduledQueryStatsDB(ctx context.Context, db sqlx.QueryerContext, SUM(stats.wall_time) AS wall_time FROM scheduled_query_stats stats WHERE stats.host_id = ? GROUP BY stats.scheduled_query_id) as sqs ON (q.id = sqs.scheduled_query_id) LEFT JOIN query_results qr ON (q.id = qr.query_id AND qr.host_id = ?) + ` + + filter1 := ` WHERE (q.platform = '' OR q.platform IS NULL OR FIND_IN_SET(?, q.platform) != 0) - AND q.schedule_interval > 0 + AND q.is_scheduled = 1 AND (q.automations_enabled IS TRUE OR (q.discard_data IS FALSE AND q.logging_type = ?)) AND (q.team_id IS NULL OR q.team_id = ?) - OR EXISTS ( + GROUP BY q.id + ` + + filter2 := ` + WHERE EXISTS ( SELECT 1 FROM query_results WHERE query_results.query_id = q.id AND query_results.host_id = ? @@ -455,6 +466,8 @@ func loadHostScheduledQueryStatsDB(ctx context.Context, db sqlx.QueryerContext, GROUP BY q.id ` + sqlQuery := baseQuery + filter1 + " UNION ALL " + baseQuery + filter2 + args := []interface{}{ pastDate, hid, @@ -462,8 +475,12 @@ func loadHostScheduledQueryStatsDB(ctx context.Context, db sqlx.QueryerContext, fleet.PlatformFromHost(hostPlatform), fleet.LoggingSnapshot, teamID_, + pastDate, + hid, + hid, hid, } + var stats []fleet.QueryStats if err := sqlx.SelectContext(ctx, db, &stats, sqlQuery, args...); err != nil { return nil, ctxerr.Wrap(ctx, err, "load query stats") @@ -1114,6 +1131,14 @@ func (ds *Datastore) applyHostFilters( whereParams = append(whereParams, microsoft_mdm.MDMDeviceStateEnrolled) } + mdmAppleProfilesStatusJoin := "" + mdmAppleDeclarationsStatusJoin := "" + if opt.OSSettingsFilter.IsValid() || + opt.MacOSSettingsFilter.IsValid() { + mdmAppleProfilesStatusJoin = sqlJoinMDMAppleProfilesStatus() + mdmAppleDeclarationsStatusJoin = sqlJoinMDMAppleDeclarationsStatus() + } + sqlStmt += fmt.Sprintf( `FROM hosts h LEFT JOIN host_seen_times hst ON (h.id = hst.host_id) @@ -1128,6 +1153,8 @@ func (ds *Datastore) applyHostFilters( %s %s %s + %s + %s %s WHERE TRUE AND %s AND %s AND %s AND %s `, @@ -1142,6 +1169,8 @@ func (ds *Datastore) applyHostFilters( munkiJoin, displayNameJoin, connectedToFleetJoin, + mdmAppleProfilesStatusJoin, + mdmAppleDeclarationsStatusJoin, // Conditions ds.whereFilterHostsByTeams(filter, "h"), @@ -1304,15 +1333,9 @@ func filterHostsByMacOSSettingsStatus(sql string, opt fleet.HostListOptions, par whereStatus += ` AND h.team_id IS NULL` } - subqueryStatus, paramsStatus, err := subqueryOSSettingsStatusMac() - if err != nil { - return "", nil, err - } + whereStatus += fmt.Sprintf(` AND %s = ?`, sqlCaseMDMAppleStatus()) - whereStatus += fmt.Sprintf(` AND %s = ?`, subqueryStatus) - paramsStatus = append(paramsStatus, opt.MacOSSettingsFilter) - - return sql + whereStatus, append(params, paramsStatus...), nil + return sql + whereStatus, append(params, opt.MacOSSettingsFilter), nil } func filterHostsByMacOSDiskEncryptionStatus(sql string, opt fleet.HostListOptions, params []interface{}) (string, []interface{}) { @@ -1364,13 +1387,9 @@ func (ds *Datastore) filterHostsByOSSettingsStatus(sql string, opt fleet.HostLis AND ((h.platform = 'windows' AND (%s)) OR ((h.platform = 'darwin' OR h.platform = 'ios' OR h.platform = 'ipados') AND (%s)))` - whereMacOS, paramsMacOS, err := subqueryOSSettingsStatusMac() - if err != nil { - return "", nil, err - } - whereMacOS += ` = ?` - // ensure the host has MDM turned on - paramsMacOS = append(paramsMacOS, opt.OSSettingsFilter) + // construct the WHERE for macOS + whereMacOS = fmt.Sprintf(`(%s) = ?`, sqlCaseMDMAppleStatus()) + paramsMacOS := []any{opt.OSSettingsFilter} // construct the WHERE for windows whereWindows = `hmdm.is_server = 0` diff --git a/server/datastore/mysql/labels.go b/server/datastore/mysql/labels.go index d604b286a4aa..5ac777be3939 100644 --- a/server/datastore/mysql/labels.go +++ b/server/datastore/mysql/labels.go @@ -638,6 +638,12 @@ func (ds *Datastore) applyHostLabelFilters(ctx context.Context, filter fleet.Tea joinParams = append(joinParams, microsoft_mdm.MDMDeviceStateEnrolled) } + if opt.OSSettingsFilter.IsValid() || + opt.MacOSSettingsFilter.IsValid() { + query += sqlJoinMDMAppleProfilesStatus() + query += sqlJoinMDMAppleDeclarationsStatus() + } + query += fmt.Sprintf(` WHERE lm.label_id = ? AND %s `, ds.whereFilterHostsByTeams(filter, "h")) whereParams = append(whereParams, lid) diff --git a/server/datastore/mysql/migrations/tables/20240829170033_AddVPPTokenIDToVppAppsTeams.go b/server/datastore/mysql/migrations/tables/20240829170033_AddVPPTokenIDToVppAppsTeams.go index ea367f59ea69..b8ef59e25ffd 100644 --- a/server/datastore/mysql/migrations/tables/20240829170033_AddVPPTokenIDToVppAppsTeams.go +++ b/server/datastore/mysql/migrations/tables/20240829170033_AddVPPTokenIDToVppAppsTeams.go @@ -3,6 +3,8 @@ package tables import ( "database/sql" "fmt" + + "github.com/pkg/errors" ) func init() { @@ -14,7 +16,11 @@ func Up_20240829170033(tx *sql.Tx) error { ALTER TABLE vpp_apps_teams ADD COLUMN vpp_token_id int(10) UNSIGNED NOT NULL` - stmtAssociate := `UPDATE vpp_apps_teams SET vpp_token_id = (SELECT id FROM vpp_tokens LIMIT 1)` + stmtFindToken := `SELECT id FROM vpp_tokens LIMIT 1` //nolint:gosec + + stmtCleanAssociations := `DELETE FROM vpp_apps_teams` + + stmtAssociate := `UPDATE vpp_apps_teams SET vpp_token_id = ?` stmtAddConstraint := ` ALTER TABLE vpp_apps_teams @@ -27,8 +33,22 @@ ALTER TABLE vpp_apps_teams // Associate apps with the first token available. If we're // migrating from single-token VPP this should be correct. - if _, err := tx.Exec(stmtAssociate); err != nil { - return fmt.Errorf("failed to associate vpp apps with first token: %w", err) + var vppTokenID uint + err := tx.QueryRow(stmtFindToken).Scan(&vppTokenID) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("get existing VPP token ID: %w", err) + } + } + + if vppTokenID > 0 { + if _, err := tx.Exec(stmtAssociate, vppTokenID); err != nil { + return fmt.Errorf("failed to associate vpp apps with first token: %w", err) + } + } else { + if _, err := tx.Exec(stmtCleanAssociations); err != nil { + return fmt.Errorf("failed clean orphaned VPP team associations: %w", err) + } } if _, err := tx.Exec(stmtAddConstraint); err != nil { diff --git a/server/datastore/mysql/migrations/tables/20240829170033_AddVPPTokenIDToVppAppsTeams_test.go b/server/datastore/mysql/migrations/tables/20240829170033_AddVPPTokenIDToVppAppsTeams_test.go new file mode 100644 index 000000000000..7329d628de93 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240829170033_AddVPPTokenIDToVppAppsTeams_test.go @@ -0,0 +1,70 @@ +package tables + +import ( + "testing" + "time" + + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/require" +) + +func TestUp_20240829170033_Existing(t *testing.T) { + db := applyUpToPrev(t) + + // insert a vpp token + vppTokenID := execNoErrLastID(t, db, "INSERT INTO vpp_tokens (organization_name, location, renew_at, token) VALUES (?, ?, ?, ?)", "org", "location", time.Now(), "token") + + // create a couple teams + tm1 := execNoErrLastID(t, db, "INSERT INTO teams (name) VALUES ('team1')") + tm2 := execNoErrLastID(t, db, "INSERT INTO teams (name) VALUES ('team2')") + + // create a couple of vpp apps + adamID1 := "123" + execNoErr(t, db, `INSERT INTO vpp_apps (adam_id, platform) VALUES (?, "iOS")`, adamID1) + adamID2 := "456" + execNoErr(t, db, `INSERT INTO vpp_apps (adam_id, platform) VALUES (?, "iOS")`, adamID2) + + // insert some teams with vpp apps + execNoErr(t, db, `INSERT INTO vpp_apps_teams (adam_id, team_id, global_or_team_id, platform, self_service) VALUES (?, ?, ?, ?, ?)`, adamID1, tm1, 0, "iOS", 0) + execNoErr(t, db, `INSERT INTO vpp_apps_teams (adam_id, team_id, global_or_team_id, platform, self_service) VALUES (?, ?, ?, ?, ?)`, adamID2, tm2, 0, "iOS", 0) + + // apply current migration + applyNext(t, db) + + // ensure vpp_token_id is set for all teams + var vppTokenIDs []int + err := sqlx.Select(db, &vppTokenIDs, `SELECT vpp_token_id FROM vpp_apps_teams`) + require.NoError(t, err) + require.Len(t, vppTokenIDs, 2) + for _, tokenID := range vppTokenIDs { + require.Equal(t, int(vppTokenID), tokenID) + } +} + +func TestUp_20240829170033_NoTokens(t *testing.T) { + db := applyUpToPrev(t) + + // create a couple teams + tm1 := execNoErrLastID(t, db, "INSERT INTO teams (name) VALUES ('team1')") + tm2 := execNoErrLastID(t, db, "INSERT INTO teams (name) VALUES ('team2')") + + // create a couple of vpp apps + adamID1 := "123" + execNoErr(t, db, `INSERT INTO vpp_apps (adam_id, platform) VALUES (?, "iOS")`, adamID1) + adamID2 := "456" + execNoErr(t, db, `INSERT INTO vpp_apps (adam_id, platform) VALUES (?, "iOS")`, adamID2) + + // insert some teams with vpp apps + execNoErr(t, db, `INSERT INTO vpp_apps_teams (adam_id, team_id, global_or_team_id, platform, self_service) VALUES (?, ?, ?, ?, ?)`, adamID1, tm1, 0, "iOS", 0) + execNoErr(t, db, `INSERT INTO vpp_apps_teams (adam_id, team_id, global_or_team_id, platform, self_service) VALUES (?, ?, ?, ?, ?)`, adamID2, tm2, 0, "iOS", 0) + + // apply current migration + applyNext(t, db) + + // ensure no rows are left in vpp_apps_teams (since there are no tokens) + var count int + err := sqlx.Get(db, &count, `SELECT COUNT(*) FROM vpp_apps_teams`) + require.NoError(t, err) + require.Zero(t, count) + +} diff --git a/server/datastore/mysql/migrations/tables/20240927081858_CreateFedoraBuiltinLabel.go b/server/datastore/mysql/migrations/tables/20240927081858_CreateFedoraBuiltinLabel.go new file mode 100644 index 000000000000..f69faa0a7b8f --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240927081858_CreateFedoraBuiltinLabel.go @@ -0,0 +1,57 @@ +package tables + +import ( + "database/sql" + "fmt" + "time" + + "github.com/VividCortex/mysqlerr" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/go-sql-driver/mysql" +) + +func init() { + MigrationClient.AddMigration(Up_20240927081858, Down_20240927081858) +} + +func Up_20240927081858(tx *sql.Tx) error { + const stmt = ` + INSERT INTO labels ( + name, + description, + query, + platform, + label_type, + label_membership_type, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) +` + + // hard-coded timestamps are used so that schema.sql is stable + ts := time.Date(2024, 9, 27, 0, 0, 0, 0, time.UTC) + _, err := tx.Exec( + stmt, + fleet.BuiltinLabelFedoraLinux, + "All Fedora hosts", + `select 1 from os_version where name = 'Fedora Linux';`, + "rhel", + fleet.LabelTypeBuiltIn, + fleet.LabelMembershipTypeDynamic, + ts, + ts, + ) + if err != nil { + if driverErr, ok := err.(*mysql.MySQLError); ok { + if driverErr.Number == mysqlerr.ER_DUP_ENTRY { + return fmt.Errorf("a label with the name %q already exists, please rename it before applying this migration: %w", fleet.BuiltinLabelFedoraLinux, err) + } + } + return err + } + return nil +} + +func Down_20240927081858(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20240927081858_CreateFedoraBuiltinLabel_test.go b/server/datastore/mysql/migrations/tables/20240927081858_CreateFedoraBuiltinLabel_test.go new file mode 100644 index 000000000000..1610b0f0b149 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240927081858_CreateFedoraBuiltinLabel_test.go @@ -0,0 +1,19 @@ +package tables + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUp_20240927081858(t *testing.T) { + db := applyUpToPrev(t) + + applyNext(t, db) + + var names []string + err := db.Select(&names, `SELECT name FROM labels`) + require.NoError(t, err) + + require.Contains(t, names, "Fedora Linux") +} diff --git a/server/datastore/mysql/migrations/tables/20240930171917_AddScheduleAutomationsIndex.go b/server/datastore/mysql/migrations/tables/20240930171917_AddScheduleAutomationsIndex.go new file mode 100644 index 000000000000..592e8349df32 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240930171917_AddScheduleAutomationsIndex.go @@ -0,0 +1,33 @@ +package tables + +import ( + "database/sql" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20240930171917, Down_20240930171917) +} + +func Up_20240930171917(tx *sql.Tx) error { + _, err := tx.Exec(` + ALTER TABLE queries + ADD COLUMN is_scheduled BOOLEAN GENERATED ALWAYS AS (schedule_interval > 0) STORED NOT NULL + `) + if err != nil { + return fmt.Errorf("error creating generated column is_scheduled: %w", err) + } + + _, err = tx.Exec(` + CREATE INDEX idx_queries_schedule_automations ON queries (is_scheduled, automations_enabled) + `) + if err != nil { + return fmt.Errorf("error creating index idx_queries_schedule_automations: %w", err) + } + + return nil +} + +func Down_20240930171917(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20240930171917_AddScheduleAutomationsIndex_test.go b/server/datastore/mysql/migrations/tables/20240930171917_AddScheduleAutomationsIndex_test.go new file mode 100644 index 000000000000..27f7d814c52b --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240930171917_AddScheduleAutomationsIndex_test.go @@ -0,0 +1,104 @@ +package tables + +import ( + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUp_20240930171917(t *testing.T) { + db := applyUpToPrev(t) + + // + // Insert data to test the migration + // + // ... + + // Apply current migration. + applyNext(t, db) + + // Assert the index was created. + rows, err := db.Query("SHOW INDEX FROM queries WHERE Key_name = 'idx_queries_schedule_automations'") + require.NoError(t, err) + defer rows.Close() + + var indexCount int + for rows.Next() { + indexCount++ + } + + require.NoError(t, rows.Err()) + require.Greater(t, indexCount, 0) + + // + // Assert the index is used when there are rows in the queries table + // (wrong index is used when there are no rows in the queries table) + // + + stmtPrefix := "INSERT INTO `queries` (`saved`, `name`, `description`, `query`, `author_id`, `observer_can_run`, `team_id`, `team_id_char`, `platform`, `min_osquery_version`, `schedule_interval`, `automations_enabled`, `logging_type`, `discard_data`) VALUES " + stmtSuffix := ";" + + var valueStrings []string + var valueArgs []interface{} + + // Generate 10 records + for i := 0; i < 10; i++ { + queryID := i + 1 + valueStrings = append(valueStrings, "(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)") + valueArgs = append(valueArgs, 0, fmt.Sprintf("query_%d", queryID), "", "SELECT * FROM processes;", 1, 0, nil, "", "", "", 0, 0, "snapshot", 0) + } + + // Disable foreign key checks to improve performance + _, err = db.Exec("SET FOREIGN_KEY_CHECKS=0") + require.NoError(t, err) + + // Construct and execute the batch insert + stmt := stmtPrefix + strings.Join(valueStrings, ",") + stmtSuffix + _, err = db.Exec(stmt, valueArgs...) + require.NoError(t, err) + + // Re-enable foreign key checks + _, err = db.Exec(`SET FOREIGN_KEY_CHECKS=1`) + require.NoError(t, err) + + result := struct { + ID int `db:"id"` + SelectType string `db:"select_type"` + Table string `db:"table"` + Type string `db:"type"` + PossibleKeys *string `db:"possible_keys"` + Key *string `db:"key"` + KeyLen *int `db:"key_len"` + Ref *string `db:"ref"` + Rows int `db:"rows"` + Filtered float64 `db:"filtered"` + Extra *string `db:"Extra"` + Partitions *string `db:"partitions"` + }{} + + // Query based on loadHostScheduledQueryStatsDB in server/datastore/mysql/hosts.go + err = db.Get(&result, ` + EXPLAIN + SELECT + q.id + FROM + queries q + WHERE (q.platform = '' + OR q.platform IS NULL + OR FIND_IN_SET('darwin', q.platform) != 0) + AND q.is_scheduled = 1 + AND(q.automations_enabled IS TRUE + OR(q.discard_data IS FALSE + AND q.logging_type = 'snapshot')) + AND(q.team_id IS NULL + OR q.team_id = 0) + GROUP BY + q.id +`) + require.NoError(t, err) + + // Assert the correct index is used + require.Equal(t, *result.Key, "idx_queries_schedule_automations") +} diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index 8fb66675fb14..58a5ec1eb4d3 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -746,9 +746,9 @@ CREATE TABLE `labels` ( PRIMARY KEY (`id`), UNIQUE KEY `idx_label_unique_name` (`name`), FULLTEXT KEY `labels_search` (`name`) -) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -INSERT INTO `labels` VALUES (1,'2024-04-03 00:00:00','2024-04-03 00:00:00','macOS 14+ (Sonoma+)','macOS hosts with version 14 and above','select 1 from os_version where platform = \'darwin\' and major >= 14;','darwin',1,0),(2,'2024-06-28 00:00:00','2024-06-28 00:00:00','iOS','All iOS hosts','','ios',1,1),(3,'2024-06-28 00:00:00','2024-06-28 00:00:00','iPadOS','All iPadOS hosts','','ipados',1,1); +INSERT INTO `labels` VALUES (1,'2024-04-03 00:00:00','2024-04-03 00:00:00','macOS 14+ (Sonoma+)','macOS hosts with version 14 and above','select 1 from os_version where platform = \'darwin\' and major >= 14;','darwin',1,0),(2,'2024-06-28 00:00:00','2024-06-28 00:00:00','iOS','All iOS hosts','','ios',1,1),(3,'2024-06-28 00:00:00','2024-06-28 00:00:00','iPadOS','All iPadOS hosts','','ipados',1,1),(4,'2024-09-27 00:00:00','2024-09-27 00:00:00','Fedora Linux','All Fedora hosts','select 1 from os_version where name = \'Fedora Linux\';','rhel',1,0); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `locks` ( @@ -1038,9 +1038,9 @@ CREATE TABLE `migration_status_tables` ( `tstamp` timestamp NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `id` (`id`) -) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=313 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=315 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'),(276,20240702123921,1,'2020-01-01 01:01:01'),(277,20240703154849,1,'2020-01-01 01:01:01'),(278,20240707134035,1,'2020-01-01 01:01:01'),(279,20240707134036,1,'2020-01-01 01:01:01'),(280,20240709124958,1,'2020-01-01 01:01:01'),(281,20240709132642,1,'2020-01-01 01:01:01'),(282,20240709183940,1,'2020-01-01 01:01:01'),(283,20240710155623,1,'2020-01-01 01:01:01'),(284,20240723102712,1,'2020-01-01 01:01:01'),(285,20240725152735,1,'2020-01-01 01:01:01'),(286,20240725182118,1,'2020-01-01 01:01:01'),(287,20240726100517,1,'2020-01-01 01:01:01'),(288,20240730171504,1,'2020-01-01 01:01:01'),(289,20240730174056,1,'2020-01-01 01:01:01'),(290,20240730215453,1,'2020-01-01 01:01:01'),(291,20240730374423,1,'2020-01-01 01:01:01'),(292,20240801115359,1,'2020-01-01 01:01:01'),(293,20240802101043,1,'2020-01-01 01:01:01'),(294,20240802113716,1,'2020-01-01 01:01:01'),(295,20240814135330,1,'2020-01-01 01:01:01'),(296,20240815000000,1,'2020-01-01 01:01:01'),(297,20240815000001,1,'2020-01-01 01:01:01'),(298,20240816103247,1,'2020-01-01 01:01:01'),(299,20240820091218,1,'2020-01-01 01:01:01'),(300,20240826111228,1,'2020-01-01 01:01:01'),(301,20240826160025,1,'2020-01-01 01:01:01'),(302,20240829165448,1,'2020-01-01 01:01:01'),(303,20240829165605,1,'2020-01-01 01:01:01'),(304,20240829165715,1,'2020-01-01 01:01:01'),(305,20240829165930,1,'2020-01-01 01:01:01'),(306,20240829170023,1,'2020-01-01 01:01:01'),(307,20240829170033,1,'2020-01-01 01:01:01'),(308,20240829170044,1,'2020-01-01 01:01:01'),(309,20240905105135,1,'2020-01-01 01:01:01'),(310,20240905140514,1,'2020-01-01 01:01:01'),(311,20240905200000,1,'2020-01-01 01:01:01'),(312,20240905200001,1,'2020-01-01 01:01:01'); +INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'),(276,20240702123921,1,'2020-01-01 01:01:01'),(277,20240703154849,1,'2020-01-01 01:01:01'),(278,20240707134035,1,'2020-01-01 01:01:01'),(279,20240707134036,1,'2020-01-01 01:01:01'),(280,20240709124958,1,'2020-01-01 01:01:01'),(281,20240709132642,1,'2020-01-01 01:01:01'),(282,20240709183940,1,'2020-01-01 01:01:01'),(283,20240710155623,1,'2020-01-01 01:01:01'),(284,20240723102712,1,'2020-01-01 01:01:01'),(285,20240725152735,1,'2020-01-01 01:01:01'),(286,20240725182118,1,'2020-01-01 01:01:01'),(287,20240726100517,1,'2020-01-01 01:01:01'),(288,20240730171504,1,'2020-01-01 01:01:01'),(289,20240730174056,1,'2020-01-01 01:01:01'),(290,20240730215453,1,'2020-01-01 01:01:01'),(291,20240730374423,1,'2020-01-01 01:01:01'),(292,20240801115359,1,'2020-01-01 01:01:01'),(293,20240802101043,1,'2020-01-01 01:01:01'),(294,20240802113716,1,'2020-01-01 01:01:01'),(295,20240814135330,1,'2020-01-01 01:01:01'),(296,20240815000000,1,'2020-01-01 01:01:01'),(297,20240815000001,1,'2020-01-01 01:01:01'),(298,20240816103247,1,'2020-01-01 01:01:01'),(299,20240820091218,1,'2020-01-01 01:01:01'),(300,20240826111228,1,'2020-01-01 01:01:01'),(301,20240826160025,1,'2020-01-01 01:01:01'),(302,20240829165448,1,'2020-01-01 01:01:01'),(303,20240829165605,1,'2020-01-01 01:01:01'),(304,20240829165715,1,'2020-01-01 01:01:01'),(305,20240829165930,1,'2020-01-01 01:01:01'),(306,20240829170023,1,'2020-01-01 01:01:01'),(307,20240829170033,1,'2020-01-01 01:01:01'),(308,20240829170044,1,'2020-01-01 01:01:01'),(309,20240905105135,1,'2020-01-01 01:01:01'),(310,20240905140514,1,'2020-01-01 01:01:01'),(311,20240905200000,1,'2020-01-01 01:01:01'),(312,20240905200001,1,'2020-01-01 01:01:01'),(313,20240927081858,1,'2020-01-01 01:01:01'),(314,20240930171917,1,'2020-01-01 01:01:01'); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `mobile_device_management_solutions` ( @@ -1459,11 +1459,13 @@ CREATE TABLE `queries` ( `automations_enabled` tinyint unsigned NOT NULL DEFAULT '0', `logging_type` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'snapshot', `discard_data` tinyint(1) NOT NULL DEFAULT '1', + `is_scheduled` tinyint(1) GENERATED ALWAYS AS ((`schedule_interval` > 0)) STORED NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `idx_team_id_name_unq` (`team_id_char`,`name`), UNIQUE KEY `idx_name_team_id_unq` (`name`,`team_id_char`), KEY `author_id` (`author_id`), KEY `idx_team_id_saved_auto_interval` (`team_id`,`saved`,`automations_enabled`,`schedule_interval`), + KEY `idx_queries_schedule_automations` (`is_scheduled`,`automations_enabled`), CONSTRAINT `queries_ibfk_1` FOREIGN KEY (`author_id`) REFERENCES `users` (`id`) ON DELETE SET NULL, CONSTRAINT `queries_ibfk_2` FOREIGN KEY (`team_id`) REFERENCES `teams` (`id`) ON DELETE CASCADE ) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/server/datastore/mysql/software_installers.go b/server/datastore/mysql/software_installers.go index 7d7f0169e3ad..da9f5f7ff395 100644 --- a/server/datastore/mysql/software_installers.go +++ b/server/datastore/mysql/software_installers.go @@ -174,8 +174,9 @@ func (ds *Datastore) getOrGenerateSoftwareInstallerTitleID(ctx context.Context, insertArgs := []any{payload.Title, payload.Source} if payload.BundleIdentifier != "" { - selectStmt = `SELECT id FROM software_titles WHERE bundle_identifier = ?` - selectArgs = []any{payload.BundleIdentifier} + // match by bundle identifier first, or standard matching if we don't have a bundle identifier match + selectStmt = `SELECT id FROM software_titles WHERE bundle_identifier = ? OR (name = ? AND source = ? AND browser = '') ORDER BY bundle_identifier = ? DESC LIMIT 1` + selectArgs = []any{payload.BundleIdentifier, payload.Title, payload.Source, payload.BundleIdentifier} insertStmt = `INSERT INTO software_titles (name, source, bundle_identifier, browser) VALUES (?, ?, ?, '')` insertArgs = append(insertArgs, payload.BundleIdentifier) } diff --git a/server/datastore/mysql/software_installers_test.go b/server/datastore/mysql/software_installers_test.go index 178b85807148..49be5c29c220 100644 --- a/server/datastore/mysql/software_installers_test.go +++ b/server/datastore/mysql/software_installers_test.go @@ -34,6 +34,7 @@ func TestSoftwareInstallers(t *testing.T) { {"HasSelfServiceSoftwareInstallers", testHasSelfServiceSoftwareInstallers}, {"DeleteSoftwareInstallersAssignedToPolicy", testDeleteSoftwareInstallersAssignedToPolicy}, {"GetHostLastInstallData", testGetHostLastInstallData}, + {"GetOrGenerateSoftwareInstallerTitleID", testGetOrGenerateSoftwareInstallerTitleID}, } for _, c := range cases { @@ -1098,3 +1099,86 @@ func testGetHostLastInstallData(t *testing.T, ds *Datastore) { require.NoError(t, err) require.Nil(t, host2LastInstall) } + +func testGetOrGenerateSoftwareInstallerTitleID(t *testing.T, ds *Datastore) { + ctx := context.Background() + + host1 := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now()) + host2 := test.NewHost(t, ds, "host2", "", "host2key", "host2uuid", time.Now()) + + software1 := []fleet.Software{ + {Name: "Existing Title", Version: "0.0.1", Source: "apps", BundleIdentifier: "existing.title"}, + } + software2 := []fleet.Software{ + {Name: "Existing Title", Version: "v0.0.2", Source: "apps", BundleIdentifier: "existing.title"}, + {Name: "Existing Title", Version: "0.0.3", Source: "apps", BundleIdentifier: "existing.title"}, + {Name: "Existing Title Without Bundle", Version: "0.0.3", Source: "apps"}, + } + + _, err := ds.UpdateHostSoftware(ctx, host1.ID, software1) + require.NoError(t, err) + _, err = ds.UpdateHostSoftware(ctx, host2.ID, software2) + require.NoError(t, err) + require.NoError(t, ds.SyncHostsSoftware(ctx, time.Now())) + require.NoError(t, ds.ReconcileSoftwareTitles(ctx)) + require.NoError(t, ds.SyncHostsSoftwareTitles(ctx, time.Now())) + + tests := []struct { + name string + payload *fleet.UploadSoftwareInstallerPayload + }{ + { + name: "title that already exists, no bundle identifier in payload", + payload: &fleet.UploadSoftwareInstallerPayload{ + Title: "Existing Title", + Source: "apps", + }, + }, + { + name: "title that already exists, mismatched bundle identifier in payload", + payload: &fleet.UploadSoftwareInstallerPayload{ + Title: "Existing Title", + Source: "apps", + BundleIdentifier: "com.existing.bundle", + }, + }, + { + name: "title that already exists but doesn't have a bundle identifier", + payload: &fleet.UploadSoftwareInstallerPayload{ + Title: "Existing Title Without Bundle", + Source: "apps", + }, + }, + { + name: "title that already exists, no bundle identifier in DB, bundle identifier in payload", + payload: &fleet.UploadSoftwareInstallerPayload{ + Title: "Existing Title Without Bundle", + Source: "apps", + BundleIdentifier: "com.new.bundleid", + }, + }, + { + name: "title that doesn't exist, no bundle identifier in payload", + payload: &fleet.UploadSoftwareInstallerPayload{ + Title: "New Title", + Source: "some_source", + }, + }, + { + name: "title that doesn't exist, with bundle identifier in payload", + payload: &fleet.UploadSoftwareInstallerPayload{ + Title: "New Title With Bundle", + Source: "some_source", + BundleIdentifier: "com.new.bundle", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + id, err := ds.getOrGenerateSoftwareInstallerTitleID(ctx, tt.payload) + require.NoError(t, err) + require.NotEmpty(t, id) + }) + } +} diff --git a/server/datastore/mysql/targets.go b/server/datastore/mysql/targets.go index 16c185ed382b..cbb244acb774 100644 --- a/server/datastore/mysql/targets.go +++ b/server/datastore/mysql/targets.go @@ -60,7 +60,7 @@ func targetSQLCondAndArgs(targets fleet.HostTargets) (sql string, args []interfa OR ( /* 'All hosts' builtin label was selected. */ - id IN (SELECT DISTINCT host_id FROM label_membership WHERE label_id = 6 AND label_id IN (? /* queryLabelIDs */)) + id IN (SELECT DISTINCT host_id FROM label_membership WHERE label_id = (SELECT id from labels WHERE name = 'All Hosts') AND label_id IN (? /* queryLabelIDs */)) ) OR ( diff --git a/server/datastore/mysql/vpp.go b/server/datastore/mysql/vpp.go index 1127497f888e..cecb68ab3820 100644 --- a/server/datastore/mysql/vpp.go +++ b/server/datastore/mysql/vpp.go @@ -371,17 +371,26 @@ func (ds *Datastore) getOrInsertSoftwareTitleForVPPApp(ctx context.Context, tx s insertArgs := []any{app.Name, source} if app.BundleIdentifier != "" { - // NOTE: The index `idx_sw_titles` doesn't include the bundle - // identifier. It's possible for the select to return nothing - // but for the insert to fail if an app with the same name but - // no bundle identifier exists in the DB. + // match by bundle identifier first, or standard matching if we + // don't have a bundle identifier match switch source { case "ios_apps", "ipados_apps": - selectStmt = `SELECT id FROM software_titles WHERE bundle_identifier = ? AND source = ?` - selectArgs = []any{app.BundleIdentifier, source} + selectStmt = ` + SELECT id + FROM software_titles + WHERE (bundle_identifier = ? AND source = ?) OR (name = ? AND source = ? AND browser = '') + ORDER BY bundle_identifier = ? DESC + LIMIT 1` + selectArgs = []any{app.BundleIdentifier, source, app.Name, source, app.BundleIdentifier} default: - selectStmt = `SELECT id FROM software_titles WHERE bundle_identifier = ? AND source NOT IN ('ios_apps', 'ipados_apps')` - selectArgs = []any{app.BundleIdentifier} + selectStmt = ` + SELECT id + FROM software_titles + WHERE (bundle_identifier = ? OR (name = ? AND browser = '')) + AND source NOT IN ('ios_apps', 'ipados_apps') + ORDER BY bundle_identifier = ? DESC + LIMIT 1` + selectArgs = []any{app.BundleIdentifier, app.Name, app.BundleIdentifier} } insertStmt = `INSERT INTO software_titles (name, source, bundle_identifier, browser) VALUES (?, ?, ?, '')` insertArgs = append(insertArgs, app.BundleIdentifier) diff --git a/server/datastore/mysql/vpp_test.go b/server/datastore/mysql/vpp_test.go index 9a6c6796ceee..78cab8acba22 100644 --- a/server/datastore/mysql/vpp_test.go +++ b/server/datastore/mysql/vpp_test.go @@ -2,6 +2,7 @@ package mysql import ( "context" + "fmt" "testing" "time" @@ -30,6 +31,7 @@ func TestVPP(t *testing.T) { {"GetVPPAppByTeamAndTitleID", testGetVPPAppByTeamAndTitleID}, {"VPPTokensCRUD", testVPPTokensCRUD}, {"VPPTokenAppTeamAssociations", testVPPTokenAppTeamAssociations}, + {"GetOrInsertSoftwareTitleForVPPApp", testGetOrInsertSoftwareTitleForVPPApp}, } for _, c := range cases { @@ -1201,3 +1203,97 @@ func testVPPTokenAppTeamAssociations(t *testing.T, ds *Datastore) { _, err = ds.InsertVPPAppWithTeam(ctx, app1, &team2.ID) assert.Error(t, err) } + +func testGetOrInsertSoftwareTitleForVPPApp(t *testing.T, ds *Datastore) { + ctx := context.Background() + + host1 := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now()) + host2 := test.NewHost(t, ds, "host2", "", "host2key", "host2uuid", time.Now()) + + software1 := []fleet.Software{ + {Name: "Existing Title", Version: "0.0.1", Source: "apps", BundleIdentifier: "existing.title"}, + } + software2 := []fleet.Software{ + {Name: "Existing Title", Version: "v0.0.2", Source: "apps", BundleIdentifier: "existing.title"}, + {Name: "Existing Title", Version: "0.0.3", Source: "apps", BundleIdentifier: "existing.title"}, + {Name: "Existing Title Without Bundle", Version: "0.0.3", Source: "apps"}, + } + + _, err := ds.UpdateHostSoftware(ctx, host1.ID, software1) + require.NoError(t, err) + _, err = ds.UpdateHostSoftware(ctx, host2.ID, software2) + require.NoError(t, err) + require.NoError(t, ds.SyncHostsSoftware(ctx, time.Now())) + require.NoError(t, ds.ReconcileSoftwareTitles(ctx)) + require.NoError(t, ds.SyncHostsSoftwareTitles(ctx, time.Now())) + + tests := []struct { + name string + app *fleet.VPPApp + }{ + { + name: "title that already exists, no bundle identifier in payload", + app: &fleet.VPPApp{ + Name: "Existing Title", + LatestVersion: "0.0.1", + BundleIdentifier: "", + }, + }, + { + name: "title that already exists, bundle identifier in payload", + app: &fleet.VPPApp{ + Name: "Existing Title", + LatestVersion: "0.0.2", + BundleIdentifier: "existing.title", + }, + }, + { + name: "title that already exists but doesn't have a bundle identifier", + app: &fleet.VPPApp{ + Name: "Existing Title Without Bundle", + LatestVersion: "0.0.3", + BundleIdentifier: "", + }, + }, + { + name: "title that already exists, no bundle identifier in DB, bundle identifier in payload", + app: &fleet.VPPApp{ + Name: "Existing Title Without Bundle", + LatestVersion: "0.0.3", + BundleIdentifier: "new.bundle.id", + }, + }, + { + name: "title that doesn't exist, no bundle identifier in payload", + app: &fleet.VPPApp{ + Name: "New Title", + LatestVersion: "0.1.0", + BundleIdentifier: "", + }, + }, + { + name: "title that doesn't exist, with bundle identifier in payload", + app: &fleet.VPPApp{ + Name: "New Title", + LatestVersion: "0.1.0", + BundleIdentifier: "new.title.bundle", + }, + }, + } + + for _, platform := range fleet.VPPAppsPlatforms { + for _, tt := range tests { + t.Run(fmt.Sprintf("%s_%v", tt.name, platform), func(t *testing.T) { + tt.app.Platform = platform + var id uint + err := ds.withTx(ctx, func(tx sqlx.ExtContext) error { + var err error + id, err = ds.getOrInsertSoftwareTitleForVPPApp(ctx, tx, tt.app) + return err + }) + require.NoError(t, err) + require.NotEmpty(t, id) + }) + } + } +} diff --git a/server/fleet/labels.go b/server/fleet/labels.go index 001e50793046..e7fd5d93c5e9 100644 --- a/server/fleet/labels.go +++ b/server/fleet/labels.go @@ -158,6 +158,7 @@ const ( BuiltinLabelMacOS14Plus = "macOS 14+ (Sonoma+)" BuiltinLabelIOS = "iOS" BuiltinLabelIPadOS = "iPadOS" + BuiltinLabelFedoraLinux = "Fedora Linux" ) // ReservedLabelNames returns a map of label name strings @@ -175,5 +176,6 @@ func ReservedLabelNames() map[string]struct{} { BuiltinLabelMacOS14Plus: {}, BuiltinLabelIOS: {}, BuiltinLabelIPadOS: {}, + BuiltinLabelFedoraLinux: {}, } } diff --git a/server/mdm/scep/Makefile b/server/mdm/scep/Makefile index e13e4ea8e404..b035f5896272 100644 --- a/server/mdm/scep/Makefile +++ b/server/mdm/scep/Makefile @@ -20,6 +20,8 @@ SCEPSERVER=\ my: scepclient-$(OSARCH) scepserver-$(OSARCH) +win: scepclient-$(OSARCH).exe scepserver-$(OSARCH).exe + docker: scepclient-linux-amd64 scepserver-linux-amd64 $(SCEPCLIENT): @@ -48,4 +50,4 @@ test: test-race: go test -cover -race ./... -.PHONY: my docker $(SCEPCLIENT) $(SCEPSERVER) release clean test test-race +.PHONY: my mywin docker $(SCEPCLIENT) $(SCEPSERVER) release clean test test-race diff --git a/server/mdm/scep/README.md b/server/mdm/scep/README.md index 49a419c2f2ce..f932f5e25cd7 100644 --- a/server/mdm/scep/README.md +++ b/server/mdm/scep/README.md @@ -1,6 +1,7 @@ # scep > The contents of this directory were copied (in February 2024) from https://github.com/fleetdm/scep (the `remove-path-setting-on-scep-handler` branch) which was forked from https://github.com/micromdm/scep. +> They were updated in September 2024 with changes up to github.com/micromdm/scep@781f8042a79cabcf61a5e6c01affdbadcb785932 [![CI](https://github.com/micromdm/scep/workflows/CI/badge.svg)](https://github.com/micromdm/scep/actions) [![Go Reference](https://pkg.go.dev/badge/github.com/micromdm/scep/v2.svg)](https://pkg.go.dev/github.com/micromdm/scep/v2) @@ -16,7 +17,7 @@ Binary releases are available on the [releases page](https://github.com/micromdm To compile the SCEP client and server you will need [a Go compiler](https://golang.org/dl/) as well as standard tools like git, make, etc. 1. Clone the repository and get into the source directory: `git clone https://github.com/micromdm/scep.git && cd scep` -2. Compile the client and server binaries: `make` +2. Compile the client and server binaries: `make` (for Windows: `make win`) The binaries will be compiled in the current directory and named after the architecture. I.e. `scepclient-linux-amd64` and `scepserver-linux-amd64`. @@ -177,58 +178,6 @@ docker run -it --rm -v /path/to/ca/folder:/depot micromdm/scep:latest ca -init docker run -it --rm -v /path/to/ca/folder:/depot -p 8080:8080 micromdm/scep:latest ``` -## SCEP library - -The core `scep` library can be used for both client and server operations. - -``` -go get github.com/micromdm/scep/scep -``` - -For detailed usage, see the [Go Reference](https://pkg.go.dev/github.com/micromdm/scep/v2/scep). - -Example (server): - -```go -// read a request body containing SCEP message -body, err := ioutil.ReadAll(r.Body) -if err != nil { - // handle err -} - -// parse the SCEP message -msg, err := scep.ParsePKIMessage(body) -if err != nil { - // handle err -} - -// do something with msg -fmt.Println(msg.MessageType) - -// extract encrypted pkiEnvelope -err := msg.DecryptPKIEnvelope(CAcert, CAkey) -if err != nil { - // handle err -} - -// use the CSR from decrypted PKCS request and sign -// MyCSRSigner returns an *x509.Certificate here -crt, err := MyCSRSigner(msg.CSRReqMessage.CSR) -if err != nil { - // handle err -} - -// create a CertRep message from the original -certRep, err := msg.Success(CAcert, CAkey, crt) -if err != nil { - // handle err -} - -// send response back -// w is a http.ResponseWriter -w.Write(certRep.Raw) -``` - ## Server library You can import the scep endpoint into another Go project. For an example take a look at [scepserver.go](cmd/scepserver/scepserver.go). diff --git a/server/mdm/scep/challenge/challenge.go b/server/mdm/scep/challenge/challenge.go index dc26c9bd30cd..e4c3fbeb580e 100644 --- a/server/mdm/scep/challenge/challenge.go +++ b/server/mdm/scep/challenge/challenge.go @@ -2,22 +2,31 @@ package challenge import ( + "context" "crypto/x509" "errors" - "github.com/fleetdm/fleet/v4/server/mdm/scep/scep" scepserver "github.com/fleetdm/fleet/v4/server/mdm/scep/server" + + "github.com/smallstep/scep" ) +// Validator validates challenge passwords. +type Validator interface { + // HasChallenge validates pw as valid. + HasChallenge(pw string) (bool, error) +} + // Store is a dynamic challenge password cache. type Store interface { + // SCEPChallenge generates a new challenge password. SCEPChallenge() (string, error) - HasChallenge(pw string) (bool, error) + Validator } -// Middleware wraps next in a CSRSigner that verifies and invalidates the challenge -func Middleware(store Store, next scepserver.CSRSigner) scepserver.CSRSignerFunc { - return func(m *scep.CSRReqMessage) (*x509.Certificate, error) { +// Middleware wraps next in a CSRSigner that verifies and invalidates the challenge. +func Middleware(store Validator, next scepserver.CSRSignerContext) scepserver.CSRSignerContextFunc { + return func(ctx context.Context, m *scep.CSRReqMessage) (*x509.Certificate, error) { // TODO: compare challenge only for PKCSReq? valid, err := store.HasChallenge(m.ChallengePassword) if err != nil { @@ -26,6 +35,6 @@ func Middleware(store Store, next scepserver.CSRSigner) scepserver.CSRSignerFunc if !valid { return nil, errors.New("invalid challenge") } - return next.SignCSR(m) + return next.SignCSRContext(ctx, m) } } diff --git a/server/mdm/scep/challenge/challenge_bolt_test.go b/server/mdm/scep/challenge/challenge_bolt_test.go index 4f6ae7e3fe4c..5527a582d506 100644 --- a/server/mdm/scep/challenge/challenge_bolt_test.go +++ b/server/mdm/scep/challenge/challenge_bolt_test.go @@ -1,14 +1,15 @@ package challenge import ( + "context" "io/ioutil" "os" "testing" challengestore "github.com/fleetdm/fleet/v4/server/mdm/scep/challenge/bolt" - "github.com/fleetdm/fleet/v4/server/mdm/scep/scep" scepserver "github.com/fleetdm/fleet/v4/server/mdm/scep/server" + "github.com/smallstep/scep" bolt "go.etcd.io/bbolt" ) @@ -69,15 +70,18 @@ func TestDynamicChallenge(t *testing.T) { ChallengePassword: challengePassword, } - _, err = signer.SignCSR(csrReq) + ctx := context.Background() + + _, err = signer.SignCSRContext(ctx, csrReq) if err != nil { t.Error(err) } - _, err = signer.SignCSR(csrReq) + _, err = signer.SignCSRContext(ctx, csrReq) if err == nil { t.Error("challenge should not be valid twice") } + } func openTempBolt(prefix string) (*bolt.DB, error) { diff --git a/server/mdm/scep/cmd/scepclient/csr.go b/server/mdm/scep/cmd/scepclient/csr.go index fde14934d086..0eadcac4e355 100644 --- a/server/mdm/scep/cmd/scepclient/csr.go +++ b/server/mdm/scep/cmd/scepclient/csr.go @@ -18,8 +18,8 @@ const ( ) type csrOptions struct { - cn, org, country, ou, locality, province, challenge string - key *rsa.PrivateKey + cn, org, country, ou, locality, province, dnsName, challenge string + key *rsa.PrivateKey } func loadOrMakeCSR(path string, opts *csrOptions) (*x509.CertificateRequest, error) { @@ -44,6 +44,7 @@ func loadOrMakeCSR(path string, opts *csrOptions) (*x509.CertificateRequest, err CertificateRequest: x509.CertificateRequest{ Subject: subject, SignatureAlgorithm: x509.SHA256WithRSA, + DNSNames: subjOrNil(opts.dnsName), }, } if opts.challenge != "" { diff --git a/server/mdm/scep/cmd/scepclient/scepclient.go b/server/mdm/scep/cmd/scepclient/scepclient.go index 91175ffb816c..39d115f17ff8 100644 --- a/server/mdm/scep/cmd/scepclient/scepclient.go +++ b/server/mdm/scep/cmd/scepclient/scepclient.go @@ -18,10 +18,10 @@ import ( "time" scepclient "github.com/fleetdm/fleet/v4/server/mdm/scep/client" - "github.com/fleetdm/fleet/v4/server/mdm/scep/scep" "github.com/go-kit/log" "github.com/go-kit/log/level" + "github.com/smallstep/scep" ) // version info @@ -50,6 +50,7 @@ type runCfg struct { debug bool logfmt string caCertMsg string + dnsName string } func run(cfg runCfg) error { @@ -88,6 +89,7 @@ func run(cfg runCfg) error { province: cfg.province, challenge: cfg.challenge, key: key, + dnsName: cfg.dnsName, } csr, err := loadOrMakeCSR(cfg.csrPath, opts) @@ -113,15 +115,15 @@ func run(cfg runCfg) error { if err != nil { return err } - var certs []*x509.Certificate + var caCerts []*x509.Certificate { if certNum > 1 { - certs, err = scep.CACerts(resp) + caCerts, err = scep.CACerts(resp) if err != nil { return err } } else { - certs, err = x509.ParseCertificates(resp) + caCerts, err = x509.ParseCertificates(resp) if err != nil { return err } @@ -129,7 +131,7 @@ func run(cfg runCfg) error { } if cfg.debug { - logCerts(level.Debug(logger), certs) + logCerts(level.Debug(logger), caCerts) } var signerCert *x509.Certificate @@ -153,7 +155,7 @@ func run(cfg runCfg) error { tmpl := &scep.PKIMessage{ MessageType: msgType, - Recipients: certs, + Recipients: caCerts, SignerKey: key, SignerCert: signerCert, } @@ -180,7 +182,7 @@ func run(cfg runCfg) error { return errors.Join(err, fmt.Errorf("PKIOperation for %s", msgType)) } - respMsg, err = scep.ParsePKIMessage(respBytes, scep.WithLogger(logger), scep.WithCACerts(msg.Recipients)) + respMsg, err = scep.ParsePKIMessage(respBytes, scep.WithLogger(logger), scep.WithCACerts(caCerts)) if err != nil { return errors.Join(err, fmt.Errorf("parsing pkiMessage response %s", msgType)) } @@ -251,7 +253,7 @@ func validateFingerprint(fingerprint string) (hash []byte, err error) { return } -func validateFlags(keyPath, serverURL string) error { +func validateFlags(keyPath, serverURL, caFingerprint string, useKeyEnciphermentSelector bool) error { if keyPath == "" { return errors.New("must specify private key path") } @@ -262,6 +264,9 @@ func validateFlags(keyPath, serverURL string) error { if err != nil { return fmt.Errorf("invalid server-url flag parameter %s", err) } + if caFingerprint != "" && useKeyEnciphermentSelector { + return errors.New("ca-fingerprint and key-encipherment-selector can't be used at the same time") + } return nil } @@ -280,14 +285,19 @@ func main() { flProvince = flag.String("province", "", "province for certificate") flCountry = flag.String("country", "US", "country code in certificate") flCACertMessage = flag.String("cacert-message", "", "message sent with GetCACert operation") + flDNSName = flag.String("dnsname", "", "DNS name to be included in the certificate (SAN)") // in case of multiple certificate authorities, we need to figure out who the recipient of the encrypted - // data is. - flCAFingerprint = flag.String("ca-fingerprint", "", "SHA-256 digest of CA certificate for NDES server. Note: Changed from MD5.") + // data is. This can be done using either the CA fingerprint, or based on the key usage encoded in the + // certificates returned by the authority. + flCAFingerprint = flag.String("ca-fingerprint", "", + "SHA-256 digest of CA certificate for NDES server. Note: Changed from MD5.") + flKeyEnciphermentSelector = flag.Bool("key-encipherment-selector", false, "Filter CA certificates by key encipherment usage") flDebugLogging = flag.Bool("debug", false, "enable debug logging") flLogJSON = flag.Bool("log-json", false, "use JSON for log output") ) + flag.Parse() // print version information @@ -296,19 +306,22 @@ func main() { os.Exit(0) } - if err := validateFlags(*flPKeyPath, *flServerURL); err != nil { + if err := validateFlags(*flPKeyPath, *flServerURL, *flCAFingerprint, *flKeyEnciphermentSelector); err != nil { fmt.Println(err) os.Exit(1) } caCertsSelector := scep.NopCertsSelector() - if *flCAFingerprint != "" { + switch { + case *flCAFingerprint != "": hash, err := validateFingerprint(*flCAFingerprint) if err != nil { fmt.Printf("invalid fingerprint: %s\n", err) os.Exit(1) } caCertsSelector = scep.FingerprintCertsSelector(fingerprintHashType, hash) + case *flKeyEnciphermentSelector: + caCertsSelector = scep.EnciphermentCertsSelector() } dir := filepath.Dir(*flPKeyPath) @@ -341,6 +354,7 @@ func main() { debug: *flDebugLogging, logfmt: logfmt, caCertMsg: *flCACertMessage, + dnsName: *flDNSName, } if err := run(cfg); err != nil { diff --git a/server/mdm/scep/cmd/scepserver/scepserver.go b/server/mdm/scep/cmd/scepserver/scepserver.go index 8bdb5225ffad..58e624501f07 100644 --- a/server/mdm/scep/cmd/scepserver/scepserver.go +++ b/server/mdm/scep/cmd/scepserver/scepserver.go @@ -31,7 +31,7 @@ var ( ) func main() { - caCMD := flag.NewFlagSet("ca", flag.ExitOnError) + var caCMD = flag.NewFlagSet("ca", flag.ExitOnError) { if len(os.Args) >= 2 { if os.Args[1] == "ca" { @@ -148,9 +148,9 @@ func main() { if *flSignServerAttrs { signerOpts = append(signerOpts, scepdepot.WithSeverAttrs()) } - var signer scepserver.CSRSigner = scepdepot.NewSigner(depot, signerOpts...) + var signer scepserver.CSRSignerContext = scepserver.SignCSRAdapter(scepdepot.NewSigner(depot, signerOpts...)) if *flChallengePassword != "" { - signer = scepserver.ChallengeMiddleware(*flChallengePassword, signer) + signer = scepserver.StaticChallengeMiddleware(*flChallengePassword, signer) } if csrVerifier != nil { signer = csrverifier.Middleware(csrVerifier, signer) diff --git a/server/mdm/scep/cryptoutil/cryptoutil_test.go b/server/mdm/scep/cryptoutil/cryptoutil_test.go index 1adaaf41a431..53a73ee9b36f 100644 --- a/server/mdm/scep/cryptoutil/cryptoutil_test.go +++ b/server/mdm/scep/cryptoutil/cryptoutil_test.go @@ -11,7 +11,7 @@ import ( ) func TestGenerateSubjectKeyID(t *testing.T) { - ecdsaKey, err := ecdsa.GenerateKey(elliptic.P224(), rand.Reader) + ecKey, err := ecdsa.GenerateKey(elliptic.P224(), rand.Reader) if err != nil { t.Fatal(err) } @@ -20,7 +20,7 @@ func TestGenerateSubjectKeyID(t *testing.T) { pub crypto.PublicKey }{ {"RSA", &rsa.PublicKey{N: big.NewInt(123), E: 65537}}, - {"ECDSA", &ecdsa.PublicKey{X: ecdsaKey.X, Y: ecdsaKey.Y, Curve: elliptic.P224()}}, + {"ECDSA", ecKey.Public()}, } { test := test t.Run(test.testName, func(t *testing.T) { diff --git a/server/mdm/scep/csrverifier/csrverifier.go b/server/mdm/scep/csrverifier/csrverifier.go index 8a0dce89ebe3..8ce4eb07f63f 100644 --- a/server/mdm/scep/csrverifier/csrverifier.go +++ b/server/mdm/scep/csrverifier/csrverifier.go @@ -2,11 +2,12 @@ package csrverifier import ( + "context" "crypto/x509" "errors" - "github.com/fleetdm/fleet/v4/server/mdm/scep/scep" scepserver "github.com/fleetdm/fleet/v4/server/mdm/scep/server" + "github.com/smallstep/scep" ) // CSRVerifier verifies the raw decrypted CSR. @@ -15,8 +16,8 @@ type CSRVerifier interface { } // Middleware wraps next in a CSRSigner that runs verifier -func Middleware(verifier CSRVerifier, next scepserver.CSRSigner) scepserver.CSRSignerFunc { - return func(m *scep.CSRReqMessage) (*x509.Certificate, error) { +func Middleware(verifier CSRVerifier, next scepserver.CSRSignerContext) scepserver.CSRSignerContextFunc { + return func(ctx context.Context, m *scep.CSRReqMessage) (*x509.Certificate, error) { ok, err := verifier.Verify(m.RawDecrypted) if err != nil { return nil, err @@ -24,6 +25,6 @@ func Middleware(verifier CSRVerifier, next scepserver.CSRSigner) scepserver.CSRS if !ok { return nil, errors.New("CSR verify failed") } - return next.SignCSR(m) + return next.SignCSRContext(ctx, m) } } diff --git a/server/mdm/scep/depot/bolt/depot.go b/server/mdm/scep/depot/bolt/depot.go index eb85e4ec17a9..4e6b75afe211 100644 --- a/server/mdm/scep/depot/bolt/depot.go +++ b/server/mdm/scep/depot/bolt/depot.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "math/big" + "sync" "github.com/fleetdm/fleet/v4/server/mdm/scep/depot" @@ -18,6 +19,7 @@ import ( // https://github.com/boltdb/bolt type Depot struct { *bolt.DB + serialMu sync.RWMutex } const ( @@ -36,7 +38,7 @@ func NewBoltDepot(db *bolt.DB) (*Depot, error) { if err != nil { return nil, err } - return &Depot{db}, nil + return &Depot{DB: db}, nil } // For some read operations Bolt returns a direct memory reference to @@ -93,26 +95,28 @@ func (db *Depot) Put(cn string, crt *x509.Certificate) error { if crt == nil || crt.Raw == nil { return fmt.Errorf("%q does not specify a valid certificate for storage", cn) } - serial, err := db.Serial() - if err != nil { - return err - } - - err = db.Update(func(tx *bolt.Tx) error { + err := db.Update(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(certBucket)) if bucket == nil { return fmt.Errorf("bucket %q not found!", certBucket) } - name := cn + "." + serial.String() + name := cn + "." + crt.SerialNumber.String() return bucket.Put([]byte(name), crt.Raw) }) + return err +} + +func (db *Depot) Serial() (*big.Int, error) { + db.serialMu.Lock() + defer db.serialMu.Unlock() + s, err := db.readSerial() if err != nil { - return err + return nil, err } - return db.incrementSerial(serial) + return s, db.incrementSerial(s) } -func (db *Depot) Serial() (*big.Int, error) { +func (db *Depot) readSerial() (*big.Int, error) { s := big.NewInt(2) if !db.hasKey([]byte("serial")) { if err := db.writeSerial(s); err != nil { @@ -132,10 +136,7 @@ func (db *Depot) Serial() (*big.Int, error) { s = s.SetBytes(k) return nil }) - if err != nil { - return nil, err - } - return s, nil + return s, err } func (db *Depot) writeSerial(s *big.Int) error { @@ -156,7 +157,7 @@ func (db *Depot) hasKey(name []byte) bool { if bucket == nil { return fmt.Errorf("bucket %q not found!", certBucket) } - k := bucket.Get([]byte("serial")) + k := bucket.Get(name) if k != nil { present = true } @@ -166,15 +167,8 @@ func (db *Depot) hasKey(name []byte) bool { } func (db *Depot) incrementSerial(s *big.Int) error { - serial := s.Add(s, big.NewInt(1)) - err := db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(certBucket)) - if bucket == nil { - return fmt.Errorf("bucket %q not found!", certBucket) - } - return bucket.Put([]byte("serial"), serial.Bytes()) - }) - return err + serial := new(big.Int).Add(s, big.NewInt(1)) + return db.writeSerial(serial) } func (db *Depot) HasCN(cn string, allowTime int, cert *x509.Certificate, revokeOldCertificate bool) (bool, error) { @@ -185,8 +179,7 @@ func (db *Depot) HasCN(cn string, allowTime int, cert *x509.Certificate, revokeO } var hasCN bool err := db.View(func(tx *bolt.Tx) error { - // TODO: "scep_certificates" is internal const in micromdm/scep - curs := tx.Bucket([]byte("scep_certificates")).Cursor() + curs := tx.Bucket([]byte(certBucket)).Cursor() prefix := []byte(cert.Subject.CommonName) for k, v := curs.Seek(prefix); k != nil && bytes.HasPrefix(k, prefix); k, v = curs.Next() { if bytes.Compare(v, cert.Raw) == 0 { diff --git a/server/mdm/scep/depot/bolt/depot_test.go b/server/mdm/scep/depot/bolt/depot_test.go index 837df03310d7..aadd5b81f8d4 100644 --- a/server/mdm/scep/depot/bolt/depot_test.go +++ b/server/mdm/scep/depot/bolt/depot_test.go @@ -96,7 +96,7 @@ func TestDepot_incrementSerial(t *testing.T) { if err := db.incrementSerial(tt.args); (err != nil) != tt.wantErr { t.Errorf("%q. Depot.incrementSerial() error = %v, wantErr %v", tt.name, err, tt.wantErr) } - got, _ := db.Serial() + got, _ := db.readSerial() if !reflect.DeepEqual(got, tt.want) { t.Errorf("%q. Depot.Serial() = %v, want %v", tt.name, got, tt.want) } diff --git a/server/mdm/scep/depot/cacert.go b/server/mdm/scep/depot/cacert.go index 65d44a0c8eb7..7aba250c3115 100644 --- a/server/mdm/scep/depot/cacert.go +++ b/server/mdm/scep/depot/cacert.go @@ -27,7 +27,9 @@ func NewCACert(opts ...CACertOption) *CACert { organization: "scep-ca", organizationalUnit: "SCEP CA", years: 10, - keyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + keyUsage: x509.KeyUsageCertSign | + x509.KeyUsageCRLSign | + x509.KeyUsageDigitalSignature, } for _, opt := range opts { opt(c) diff --git a/server/mdm/scep/depot/file/depot.go b/server/mdm/scep/depot/file/depot.go index 0c5d1e65c561..20b9b0e405a5 100644 --- a/server/mdm/scep/depot/file/depot.go +++ b/server/mdm/scep/depot/file/depot.go @@ -16,6 +16,7 @@ import ( "path/filepath" "strconv" "strings" + "sync" "time" ) @@ -31,7 +32,9 @@ func NewFileDepot(path string) (*fileDepot, error) { } type fileDepot struct { - dirPath string + dirPath string + serialMu sync.Mutex + dbMu sync.Mutex } func (d *fileDepot) CA(pass []byte) ([]*x509.Certificate, *rsa.PrivateKey, error) { @@ -75,10 +78,7 @@ func (d *fileDepot) Put(cn string, crt *x509.Certificate) error { return err } - serial, err := d.Serial() - if err != nil { - return err - } + serial := crt.SerialNumber if crt.Subject.CommonName == "" { // this means our cn was replaced by the certificate Signature @@ -103,14 +103,12 @@ func (d *fileDepot) Put(cn string, crt *x509.Certificate) error { return err } - if err := d.incrementSerial(serial); err != nil { - return err - } - return nil } func (d *fileDepot) Serial() (*big.Int, error) { + d.serialMu.Lock() + defer d.serialMu.Unlock() name := d.path("serial") s := big.NewInt(2) if err := d.check("serial"); err != nil { @@ -136,11 +134,14 @@ func (d *fileDepot) Serial() (*big.Int, error) { if !ok { return nil, errors.New("could not convert " + data + " to serial number") } + if err := d.incrementSerial(serial); err != nil { + return serial, err + } return serial, nil } func makeOpenSSLTime(t time.Time) string { - y := (t.Year() % 100) + y := t.Year() % 100 validDate := fmt.Sprintf("%02d%02d%02d%02d%02d%02dZ", y, t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second()) return validDate } @@ -174,6 +175,7 @@ func makeDn(cert *x509.Certificate) string { // Determine if the cadb already has a valid certificate with the same name func (d *fileDepot) HasCN(_ string, allowTime int, cert *x509.Certificate, revokeOldCertificate bool) (bool, error) { + var addDB bytes.Buffer candidates := make(map[string]string) @@ -254,6 +256,9 @@ func (d *fileDepot) HasCN(_ string, allowTime int, cert *x509.Certificate, revok } func (d *fileDepot) writeDB(cn string, serial *big.Int, filename string, cert *x509.Certificate) error { + d.dbMu.Lock() + defer d.dbMu.Unlock() + var dbEntry bytes.Buffer // Revoke old certificate @@ -363,8 +368,9 @@ func (d *fileDepot) path(name string) string { } const ( - rsaPrivateKeyPEMBlockType = "RSA PRIVATE KEY" - certificatePEMBlockType = "CERTIFICATE" + rsaPrivateKeyPEMBlockType = "RSA PRIVATE KEY" + pkcs8PrivateKeyPEMBlockType = "PRIVATE KEY" + certificatePEMBlockType = "CERTIFICATE" ) // load an encrypted private key from disk @@ -373,15 +379,33 @@ func loadKey(data []byte, password []byte) (*rsa.PrivateKey, error) { if pemBlock == nil { return nil, errors.New("PEM decode failed") } - if pemBlock.Type != rsaPrivateKeyPEMBlockType { + switch pemBlock.Type { + case rsaPrivateKeyPEMBlockType: + if x509.IsEncryptedPEMBlock(pemBlock) { + b, err := x509.DecryptPEMBlock(pemBlock, password) + if err != nil { + return nil, err + } + return x509.ParsePKCS1PrivateKey(b) + } + return x509.ParsePKCS1PrivateKey(pemBlock.Bytes) + case pkcs8PrivateKeyPEMBlockType: + priv, err := x509.ParsePKCS8PrivateKey(pemBlock.Bytes) + if err != nil { + return nil, err + } + switch priv := priv.(type) { + case *rsa.PrivateKey: + return priv, nil + // case *dsa.PublicKey: + // case *ecdsa.PublicKey: + // case ed25519.PublicKey: + default: + panic("unsupported type of public key. SCEP need RSA private key") + } + default: return nil, errors.New("unmatched type or headers") } - - b, err := x509.DecryptPEMBlock(pemBlock, password) - if err != nil { - return nil, err - } - return x509.ParsePKCS1PrivateKey(b) } // load an encrypted private key from disk diff --git a/server/mdm/scep/depot/signer.go b/server/mdm/scep/depot/signer.go index 3f5e3562ddd3..d2f33ae45174 100644 --- a/server/mdm/scep/depot/signer.go +++ b/server/mdm/scep/depot/signer.go @@ -3,21 +3,20 @@ package depot import ( "crypto/rand" "crypto/x509" - "sync" "time" "github.com/fleetdm/fleet/v4/server/mdm/scep/cryptoutil" - "github.com/fleetdm/fleet/v4/server/mdm/scep/scep" + "github.com/smallstep/scep" ) // Signer signs x509 certificates and stores them in a Depot type Signer struct { depot Depot - mu sync.Mutex caPass string allowRenewalDays int validityDays int serverAttrs bool + signatureAlgo x509.SignatureAlgorithm } // Option customizes Signer @@ -29,6 +28,7 @@ func NewSigner(depot Depot, opts ...Option) *Signer { depot: depot, allowRenewalDays: 14, validityDays: 365, + signatureAlgo: 0, } for _, opt := range opts { opt(s) @@ -36,6 +36,15 @@ func NewSigner(depot Depot, opts ...Option) *Signer { return s } +// WithSignatureAlgorithm sets the signature algorithm to be used to sign certificates. +// When set to a non-zero value, this would take preference over the default behaviour of +// matching the signing algorithm from the x509 CSR. +func WithSignatureAlgorithm(a x509.SignatureAlgorithm) Option { + return func(s *Signer) { + s.signatureAlgo = a + } +} + // WithCAPass specifies the password to use with an encrypted CA key func WithCAPass(pass string) Option { return func(s *Signer) { @@ -70,14 +79,16 @@ func (s *Signer) SignCSR(m *scep.CSRReqMessage) (*x509.Certificate, error) { return nil, err } - s.mu.Lock() - defer s.mu.Unlock() - serial, err := s.depot.Serial() if err != nil { return nil, err } + var signatureAlgo x509.SignatureAlgorithm + if s.signatureAlgo != 0 { + signatureAlgo = s.signatureAlgo + } + // create cert template tmpl := &x509.Certificate{ SerialNumber: serial, @@ -89,7 +100,7 @@ func (s *Signer) SignCSR(m *scep.CSRReqMessage) (*x509.Certificate, error) { ExtKeyUsage: []x509.ExtKeyUsage{ x509.ExtKeyUsageClientAuth, }, - SignatureAlgorithm: m.CSR.SignatureAlgorithm, + SignatureAlgorithm: signatureAlgo, DNSNames: m.CSR.DNSNames, EmailAddresses: m.CSR.EmailAddresses, IPAddresses: m.CSR.IPAddresses, diff --git a/server/mdm/scep/scep/certs_selector.go b/server/mdm/scep/scep/certs_selector.go deleted file mode 100644 index 62d726d951b2..000000000000 --- a/server/mdm/scep/scep/certs_selector.go +++ /dev/null @@ -1,57 +0,0 @@ -package scep - -import ( - "bytes" - "crypto" - "crypto/x509" -) - -// A CertsSelector filters certificates. -type CertsSelector interface { - SelectCerts([]*x509.Certificate) []*x509.Certificate -} - -// CertsSelectorFunc is a type of function that filters certificates. -type CertsSelectorFunc func([]*x509.Certificate) []*x509.Certificate - -func (f CertsSelectorFunc) SelectCerts(certs []*x509.Certificate) []*x509.Certificate { - return f(certs) -} - -// NopCertsSelector returns a CertsSelectorFunc that does not do anything. -func NopCertsSelector() CertsSelectorFunc { - return func(certs []*x509.Certificate) []*x509.Certificate { - return certs - } -} - -// A EnciphermentCertsSelector returns a CertsSelectorFunc that selects -// certificates eligible for key encipherment. This certsSelector can be used -// to filter PKCSReq recipients. -func EnciphermentCertsSelector() CertsSelectorFunc { - return func(certs []*x509.Certificate) (selected []*x509.Certificate) { - enciphermentKeyUsages := x509.KeyUsageKeyEncipherment | x509.KeyUsageDataEncipherment - for _, cert := range certs { - if cert.KeyUsage&enciphermentKeyUsages != 0 { - selected = append(selected, cert) - } - } - return selected - } -} - -// FingerprintCertsSelector selects a certificate that matches hash using -// hashType against the digest of the raw certificate DER bytes -func FingerprintCertsSelector(hashType crypto.Hash, hash []byte) CertsSelectorFunc { - return func(certs []*x509.Certificate) (selected []*x509.Certificate) { - for _, cert := range certs { - h := hashType.New() - h.Write(cert.Raw) - if bytes.Compare(hash, h.Sum(nil)) == 0 { - selected = append(selected, cert) - return - } - } - return - } -} diff --git a/server/mdm/scep/scep/certs_selector_test.go b/server/mdm/scep/scep/certs_selector_test.go deleted file mode 100644 index 643249a04030..000000000000 --- a/server/mdm/scep/scep/certs_selector_test.go +++ /dev/null @@ -1,156 +0,0 @@ -package scep - -import ( - "crypto" - _ "crypto/sha256" - "crypto/x509" - "encoding/hex" - "testing" -) - -func TestFingerprintCertsSelector(t *testing.T) { - for _, test := range []struct { - testName string - hashType crypto.Hash - hash string - certRaw []byte - expectedCount int - }{ - { - "null SHA-256 hash", - crypto.SHA256, - "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - nil, - 1, - }, - { - "3 byte SHA-256 hash", - crypto.SHA256, - "039058c6f2c0cb492c533b0a4d14ef77cc0f78abccced5287d84a1a2011cfb81", - []byte{1, 2, 3}, - 1, - }, - { - "mismatched hash", - crypto.SHA256, - "8db07061ebb4cd0b0cd00825b363e5fb7f8131d8ff2c1fd70d03fa4fd6dc3785", - []byte{4, 5, 6}, - 0, - }, - } { - test := test - t.Run(test.testName, func(t *testing.T) { - t.Parallel() - - fakeCerts := []*x509.Certificate{{Raw: test.certRaw}} - - hash, err := hex.DecodeString(test.hash) - if err != nil { - t.Fatal(err) - } - if want, have := test.hashType.Size(), len(hash); want != have { - t.Errorf("invalid input hash length, want: %d have: %d", want, have) - } - - selected := FingerprintCertsSelector(test.hashType, hash).SelectCerts(fakeCerts) - - if want, have := test.expectedCount, len(selected); want != have { - t.Errorf("wrong selected certs count, want: %d have: %d", want, have) - } - }) - } -} - -func TestEnciphermentCertsSelector(t *testing.T) { - for _, test := range []struct { - testName string - certs []*x509.Certificate - expectedSelectedCerts []*x509.Certificate - }{ - { - "empty certificates list", - []*x509.Certificate{}, - []*x509.Certificate{}, - }, - { - "non-empty certificates list", - []*x509.Certificate{ - {KeyUsage: x509.KeyUsageKeyEncipherment}, - {KeyUsage: x509.KeyUsageDataEncipherment}, - {KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDataEncipherment}, - {KeyUsage: x509.KeyUsageDigitalSignature}, - {}, - }, - []*x509.Certificate{ - {KeyUsage: x509.KeyUsageKeyEncipherment}, - {KeyUsage: x509.KeyUsageDataEncipherment}, - {KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDataEncipherment}, - }, - }, - } { - test := test - t.Run(test.testName, func(t *testing.T) { - t.Parallel() - - selected := EnciphermentCertsSelector().SelectCerts(test.certs) - if !certsKeyUsagesEq(selected, test.expectedSelectedCerts) { - t.Fatal("selected and expected certificates did not match") - } - }) - } -} - -func TestNopCertsSelector(t *testing.T) { - for _, test := range []struct { - testName string - certs []*x509.Certificate - expectedSelectedCerts []*x509.Certificate - }{ - { - "empty certificates list", - []*x509.Certificate{}, - []*x509.Certificate{}, - }, - { - "non-empty certificates list", - []*x509.Certificate{ - {KeyUsage: x509.KeyUsageKeyEncipherment}, - {KeyUsage: x509.KeyUsageDataEncipherment}, - {KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDataEncipherment}, - {KeyUsage: x509.KeyUsageDigitalSignature}, - {}, - }, - []*x509.Certificate{ - {KeyUsage: x509.KeyUsageKeyEncipherment}, - {KeyUsage: x509.KeyUsageDataEncipherment}, - {KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDataEncipherment}, - {KeyUsage: x509.KeyUsageDigitalSignature}, - {}, - }, - }, - } { - test := test - t.Run(test.testName, func(t *testing.T) { - t.Parallel() - - selected := NopCertsSelector().SelectCerts(test.certs) - if !certsKeyUsagesEq(selected, test.expectedSelectedCerts) { - t.Fatal("selected and expected certificates did not match") - } - }) - } -} - -// certsKeyUsagesEq returns true if certs in a have the same key usages -// of certs in b and in the same order. -func certsKeyUsagesEq(a []*x509.Certificate, b []*x509.Certificate) bool { - if len(a) != len(b) { - return false - } - for i, cert := range a { - if cert.KeyUsage != b[i].KeyUsage { - return false - } - } - return true -} diff --git a/server/mdm/scep/scep/scep.go b/server/mdm/scep/scep/scep.go deleted file mode 100644 index dc2bb1fcc824..000000000000 --- a/server/mdm/scep/scep/scep.go +++ /dev/null @@ -1,665 +0,0 @@ -// Package scep provides common functionality for encoding and decoding -// Simple Certificate Enrolment Protocol pki messages as defined by -// https://tools.ietf.org/html/draft-gutmann-scep-02 -package scep - -import ( - "bytes" - "crypto" - "crypto/rand" - "crypto/rsa" - "crypto/x509" - "encoding/asn1" - "encoding/base64" - "errors" - "fmt" - - "github.com/fleetdm/fleet/v4/server/mdm/scep/cryptoutil" - "github.com/fleetdm/fleet/v4/server/mdm/scep/cryptoutil/x509util" - - "github.com/go-kit/log" - "github.com/go-kit/log/level" - "github.com/smallstep/pkcs7" -) - -// errors -var ( - errNotImplemented = errors.New("not implemented") - errUnknownMessageType = errors.New("unknown messageType") -) - -// The MessageType attribute specifies the type of operation performed -// by the transaction. This attribute MUST be included in all PKI -// messages. -// -// The following message types are defined: -type MessageType string - -// Undefined message types are treated as an error. -const ( - CertRep MessageType = "3" - RenewalReq = "17" - UpdateReq = "18" - PKCSReq = "19" - CertPoll = "20" - GetCert = "21" - GetCRL = "22" -) - -func (msg MessageType) String() string { - switch msg { - case CertRep: - return "CertRep (3)" - case RenewalReq: - return "RenewalReq (17)" - case UpdateReq: - return "UpdateReq (18)" - case PKCSReq: - return "PKCSReq (19)" - case CertPoll: - return "CertPoll (20) " - case GetCert: - return "GetCert (21)" - case GetCRL: - return "GetCRL (22)" - default: - panic("scep: unknown messageType" + msg) - } -} - -// PKIStatus is a SCEP pkiStatus attribute which holds transaction status information. -// All SCEP responses MUST include a pkiStatus. -// -// The following pkiStatuses are defined: -type PKIStatus string - -// Undefined pkiStatus attributes are treated as an error -const ( - SUCCESS PKIStatus = "0" - FAILURE = "2" - PENDING = "3" -) - -// FailInfo is a SCEP failInfo attribute -// -// The FailInfo attribute MUST contain one of the following failure -// reasons: -type FailInfo string - -const ( - BadAlg FailInfo = "0" - BadMessageCheck = "1" - BadRequest = "2" - BadTime = "3" - BadCertID = "4" -) - -func (info FailInfo) String() string { - switch info { - case BadAlg: - return "badAlg (0)" - case BadMessageCheck: - return "badMessageCheck (1)" - case BadRequest: - return "badRequest (2)" - case BadTime: - return "badTime (3)" - case BadCertID: - return "badCertID (4)" - default: - panic("scep: unknown failInfo type" + info) - } -} - -// SenderNonce is a random 16 byte number. -// A sender must include the senderNonce in each transaction to a recipient. -type SenderNonce []byte - -// The RecipientNonce MUST be copied from the SenderNonce -// and included in the reply. -type RecipientNonce []byte - -// The TransactionID is a text -// string generated by the client when starting a transaction. The -// client MUST generate a unique string as the transaction identifier, -// which MUST be used for all PKI messages exchanged for a given -// enrolment, encoded as a PrintableString. -type TransactionID string - -// SCEP OIDs -var ( - oidSCEPmessageType = asn1.ObjectIdentifier{2, 16, 840, 1, 113733, 1, 9, 2} - oidSCEPpkiStatus = asn1.ObjectIdentifier{2, 16, 840, 1, 113733, 1, 9, 3} - oidSCEPfailInfo = asn1.ObjectIdentifier{2, 16, 840, 1, 113733, 1, 9, 4} - oidSCEPsenderNonce = asn1.ObjectIdentifier{2, 16, 840, 1, 113733, 1, 9, 5} - oidSCEPrecipientNonce = asn1.ObjectIdentifier{2, 16, 840, 1, 113733, 1, 9, 6} - oidSCEPtransactionID = asn1.ObjectIdentifier{2, 16, 840, 1, 113733, 1, 9, 7} -) - -// WithLogger adds option logging to the SCEP operations. -func WithLogger(logger log.Logger) Option { - return func(c *config) { - c.logger = logger - } -} - -// WithCACerts adds option CA certificates to the SCEP operations. -// Note: This changes the verification behavior of PKCS #7 messages. If this -// option is specified, only caCerts will be used as expected signers. -func WithCACerts(caCerts []*x509.Certificate) Option { - return func(c *config) { - c.caCerts = caCerts - } -} - -// WithCertsSelector adds the certificates certsSelector option to the SCEP -// operations. -// This option is effective when used with NewCSRRequest function. In -// this case, only certificates selected with the certsSelector will be used -// as the PKCS #7 message recipients. -func WithCertsSelector(selector CertsSelector) Option { - return func(c *config) { - c.certsSelector = selector - } -} - -// Option specifies custom configuration for SCEP. -type Option func(*config) - -type config struct { - logger log.Logger - caCerts []*x509.Certificate // specified if CA certificates have already been retrieved - certsSelector CertsSelector -} - -// PKIMessage defines the possible SCEP message types -type PKIMessage struct { - TransactionID - MessageType - SenderNonce - *CertRepMessage - *CSRReqMessage - - // DER Encoded PKIMessage - Raw []byte - - // parsed - p7 *pkcs7.PKCS7 - - // decrypted enveloped content - pkiEnvelope []byte - - // Used to encrypt message - Recipients []*x509.Certificate - - // Signer info - SignerKey *rsa.PrivateKey - SignerCert *x509.Certificate - - logger log.Logger -} - -// CertRepMessage is a type of PKIMessage -type CertRepMessage struct { - PKIStatus - RecipientNonce - FailInfo - - Certificate *x509.Certificate - - degenerate []byte -} - -// CSRReqMessage can be of the type PKCSReq/RenewalReq/UpdateReq -// and includes a PKCS#10 CSR request. -// The content of this message is protected -// by the recipient public key(example CA) -type CSRReqMessage struct { - RawDecrypted []byte - - // PKCS#10 Certificate request inside the envelope - CSR *x509.CertificateRequest - - ChallengePassword string -} - -// ParsePKIMessage unmarshals a PKCS#7 signed data into a PKI message struct -func ParsePKIMessage(data []byte, opts ...Option) (*PKIMessage, error) { - conf := &config{logger: log.NewNopLogger()} - for _, opt := range opts { - opt(conf) - } - - // parse PKCS#7 signed data - p7, err := pkcs7.Parse(data) - if err != nil { - return nil, err - } - - if len(conf.caCerts) > 0 { - // According to RFC #2315 Section 9.1, it is valid that the server sends fewer - // certificates than necessary, if it is expected that those verifying the - // signatures have an alternate means of obtaining necessary certificates. - // In SCEP case, an alternate means is to use GetCaCert request. - // Note: The https://github.com/jscep/jscep implementation logs a warning if - // no certificates were found for signers in the PKCS #7 received from the - // server, but the certificates obtained from GetCaCert request are still - // used for decoding the message. - p7.Certificates = conf.caCerts - } - - if err := p7.Verify(); err != nil { - return nil, err - } - - var tID TransactionID - if err := p7.UnmarshalSignedAttribute(oidSCEPtransactionID, &tID); err != nil { - return nil, err - } - - var msgType MessageType - if err := p7.UnmarshalSignedAttribute(oidSCEPmessageType, &msgType); err != nil { - return nil, err - } - - msg := &PKIMessage{ - TransactionID: tID, - MessageType: msgType, - Raw: data, - p7: p7, - logger: conf.logger, - } - - // log relevant key-values when parsing a pkiMessage. - logKeyVals := []interface{}{ - "msg", "parsed scep pkiMessage", - "scep_message_type", msgType, - "transaction_id", tID, - } - level.Debug(msg.logger).Log(logKeyVals...) - - if err := msg.parseMessageType(); err != nil { - return nil, err - } - - return msg, nil -} - -func (msg *PKIMessage) parseMessageType() error { - switch msg.MessageType { - case CertRep: - var status PKIStatus - if err := msg.p7.UnmarshalSignedAttribute(oidSCEPpkiStatus, &status); err != nil { - return err - } - var rn RecipientNonce - if err := msg.p7.UnmarshalSignedAttribute(oidSCEPrecipientNonce, &rn); err != nil { - return err - } - if len(rn) == 0 { - return errors.New("scep pkiMessage must include recipientNonce attribute") - } - cr := &CertRepMessage{ - PKIStatus: status, - RecipientNonce: rn, - } - switch status { - case SUCCESS: - break - case FAILURE: - var fi FailInfo - if err := msg.p7.UnmarshalSignedAttribute(oidSCEPfailInfo, &fi); err != nil { - return err - } - if fi == "" { - return errors.New("scep pkiStatus FAILURE must have a failInfo attribute") - } - cr.FailInfo = fi - case PENDING: - break - default: - return fmt.Errorf("unknown scep pkiStatus %s", status) - } - msg.CertRepMessage = cr - return nil - case PKCSReq, UpdateReq, RenewalReq: - var sn SenderNonce - if err := msg.p7.UnmarshalSignedAttribute(oidSCEPsenderNonce, &sn); err != nil { - return err - } - if len(sn) == 0 { - return errors.New("scep pkiMessage must include senderNonce attribute") - } - msg.SenderNonce = sn - return nil - case GetCRL, GetCert, CertPoll: - return errNotImplemented - default: - return errUnknownMessageType - } -} - -// DecryptPKIEnvelope decrypts the pkcs envelopedData inside the SCEP PKIMessage -func (msg *PKIMessage) DecryptPKIEnvelope(cert *x509.Certificate, key *rsa.PrivateKey) error { - p7, err := pkcs7.Parse(msg.p7.Content) - if err != nil { - return err - } - msg.pkiEnvelope, err = p7.Decrypt(cert, key) - if err != nil { - return err - } - - logKeyVals := []interface{}{ - "msg", "decrypt pkiEnvelope", - } - defer func() { level.Debug(msg.logger).Log(logKeyVals...) }() - - switch msg.MessageType { - case CertRep: - certs, err := CACerts(msg.pkiEnvelope) - if err != nil { - return err - } - msg.CertRepMessage.Certificate = certs[0] - logKeyVals = append(logKeyVals, "ca_certs", len(certs)) - return nil - case PKCSReq, UpdateReq, RenewalReq: - csr, err := x509.ParseCertificateRequest(msg.pkiEnvelope) - if err != nil { - return errors.Join(err, errors.New("parse CSR from pkiEnvelope")) - } - // check for challengePassword - cp, err := x509util.ParseChallengePassword(msg.pkiEnvelope) - if err != nil { - return errors.Join(err, errors.New("scep: parse challenge password in pkiEnvelope")) - } - msg.CSRReqMessage = &CSRReqMessage{ - RawDecrypted: msg.pkiEnvelope, - CSR: csr, - ChallengePassword: cp, - } - logKeyVals = append(logKeyVals, "has_challenge", cp != "") - return nil - case GetCRL, GetCert, CertPoll: - return errNotImplemented - default: - return errUnknownMessageType - } -} - -func (msg *PKIMessage) Fail(crtAuth *x509.Certificate, keyAuth *rsa.PrivateKey, info FailInfo) (*PKIMessage, error) { - config := pkcs7.SignerInfoConfig{ - ExtraSignedAttributes: []pkcs7.Attribute{ - { - Type: oidSCEPtransactionID, - Value: msg.TransactionID, - }, - { - Type: oidSCEPpkiStatus, - Value: FAILURE, - }, - { - Type: oidSCEPfailInfo, - Value: info, - }, - { - Type: oidSCEPmessageType, - Value: CertRep, - }, - { - Type: oidSCEPsenderNonce, - Value: msg.SenderNonce, - }, - { - Type: oidSCEPrecipientNonce, - Value: msg.SenderNonce, - }, - }, - } - - sd, err := pkcs7.NewSignedData(nil) - if err != nil { - return nil, err - } - - // sign the attributes - if err := sd.AddSigner(crtAuth, keyAuth, config); err != nil { - return nil, err - } - - certRepBytes, err := sd.Finish() - if err != nil { - return nil, err - } - - cr := &CertRepMessage{ - PKIStatus: FAILURE, - FailInfo: BadRequest, - RecipientNonce: RecipientNonce(msg.SenderNonce), - } - - // create a CertRep message from the original - crepMsg := &PKIMessage{ - Raw: certRepBytes, - TransactionID: msg.TransactionID, - MessageType: CertRep, - CertRepMessage: cr, - } - - return crepMsg, nil -} - -// Success returns a new PKIMessage with CertRep data using an already-issued certificate -func (msg *PKIMessage) Success(crtAuth *x509.Certificate, keyAuth *rsa.PrivateKey, crt *x509.Certificate) (*PKIMessage, error) { - // check if CSRReqMessage has already been decrypted - if msg.CSRReqMessage.CSR == nil { - if err := msg.DecryptPKIEnvelope(crtAuth, keyAuth); err != nil { - return nil, err - } - } - - // create a degenerate cert structure - deg, err := DegenerateCertificates([]*x509.Certificate{crt}) - if err != nil { - return nil, err - } - - // encrypt degenerate data using the original messages recipients - e7, err := pkcs7.Encrypt(deg, msg.p7.Certificates) - if err != nil { - return nil, err - } - - // PKIMessageAttributes to be signed - config := pkcs7.SignerInfoConfig{ - ExtraSignedAttributes: []pkcs7.Attribute{ - { - Type: oidSCEPtransactionID, - Value: msg.TransactionID, - }, - { - Type: oidSCEPpkiStatus, - Value: SUCCESS, - }, - { - Type: oidSCEPmessageType, - Value: CertRep, - }, - { - Type: oidSCEPsenderNonce, - Value: msg.SenderNonce, - }, - { - Type: oidSCEPrecipientNonce, - Value: msg.SenderNonce, - }, - }, - } - - signedData, err := pkcs7.NewSignedData(e7) - if err != nil { - return nil, err - } - // add the certificate into the signed data type - // this cert must be added before the signedData because the recipient will expect it - // as the first certificate in the array - signedData.AddCertificate(crt) - // sign the attributes - if err := signedData.AddSigner(crtAuth, keyAuth, config); err != nil { - return nil, err - } - - certRepBytes, err := signedData.Finish() - if err != nil { - return nil, err - } - - cr := &CertRepMessage{ - PKIStatus: SUCCESS, - RecipientNonce: RecipientNonce(msg.SenderNonce), - Certificate: crt, - degenerate: deg, - } - - // create a CertRep message from the original - crepMsg := &PKIMessage{ - Raw: certRepBytes, - TransactionID: msg.TransactionID, - MessageType: CertRep, - CertRepMessage: cr, - } - - return crepMsg, nil -} - -// DegenerateCertificates creates degenerate certificates pkcs#7 type -func DegenerateCertificates(certs []*x509.Certificate) ([]byte, error) { - var buf bytes.Buffer - for _, cert := range certs { - buf.Write(cert.Raw) - } - degenerate, err := pkcs7.DegenerateCertificate(buf.Bytes()) - if err != nil { - return nil, err - } - return degenerate, nil -} - -// CACerts extract CA Certificate or chain from pkcs7 degenerate signed data -func CACerts(data []byte) ([]*x509.Certificate, error) { - p7, err := pkcs7.Parse(data) - if err != nil { - return nil, err - } - return p7.Certificates, nil -} - -// NewCSRRequest creates a scep PKI PKCSReq/UpdateReq message -func NewCSRRequest(csr *x509.CertificateRequest, tmpl *PKIMessage, opts ...Option) (*PKIMessage, error) { - conf := &config{logger: log.NewNopLogger(), certsSelector: NopCertsSelector()} - for _, opt := range opts { - opt(conf) - } - - derBytes := csr.Raw - recipients := conf.certsSelector.SelectCerts(tmpl.Recipients) - if len(recipients) < 1 { - if len(tmpl.Recipients) >= 1 { - // our certsSelector eliminated any CA/RA recipients - return nil, errors.New("no selected CA/RA recipients") - } - return nil, errors.New("no CA/RA recipients") - } - e7, err := pkcs7.Encrypt(derBytes, recipients) - if err != nil { - return nil, err - } - - signedData, err := pkcs7.NewSignedData(e7) - if err != nil { - return nil, err - } - - // create transaction ID from public key hash - tID, err := newTransactionID(csr.PublicKey) - if err != nil { - return nil, err - } - - sn, err := newNonce() - if err != nil { - return nil, err - } - - level.Debug(conf.logger).Log( - "msg", "creating SCEP CSR request", - "transaction_id", tID, - "signer_cn", tmpl.SignerCert.Subject.CommonName, - ) - - // PKIMessageAttributes to be signed - config := pkcs7.SignerInfoConfig{ - ExtraSignedAttributes: []pkcs7.Attribute{ - { - Type: oidSCEPtransactionID, - Value: tID, - }, - { - Type: oidSCEPmessageType, - Value: tmpl.MessageType, - }, - { - Type: oidSCEPsenderNonce, - Value: sn, - }, - }, - } - - // sign attributes - if err := signedData.AddSigner(tmpl.SignerCert, tmpl.SignerKey, config); err != nil { - return nil, err - } - - rawPKIMessage, err := signedData.Finish() - if err != nil { - return nil, err - } - - cr := &CSRReqMessage{ - CSR: csr, - } - - newMsg := &PKIMessage{ - Raw: rawPKIMessage, - MessageType: tmpl.MessageType, - TransactionID: tID, - SenderNonce: sn, - CSRReqMessage: cr, - Recipients: recipients, - logger: conf.logger, - } - - return newMsg, nil -} - -func newNonce() (SenderNonce, error) { - size := 16 - b := make([]byte, size) - _, err := rand.Read(b) - if err != nil { - return SenderNonce{}, err - } - return SenderNonce(b), nil -} - -// use public key to create a deterministric transactionID -func newTransactionID(key crypto.PublicKey) (TransactionID, error) { - id, err := cryptoutil.GenerateSubjectKeyID(key) - if err != nil { - return "", err - } - - encHash := base64.StdEncoding.EncodeToString(id) - return TransactionID(encHash), nil -} diff --git a/server/mdm/scep/scep/scep_test.go b/server/mdm/scep/scep/scep_test.go deleted file mode 100644 index 4b330beeebce..000000000000 --- a/server/mdm/scep/scep/scep_test.go +++ /dev/null @@ -1,349 +0,0 @@ -package scep_test - -import ( - "crypto/rand" - "crypto/rsa" - "crypto/x509" - "crypto/x509/pkix" - "encoding/pem" - "errors" - "io/ioutil" - "math/big" - "testing" - "time" - - "github.com/fleetdm/fleet/v4/server/mdm/scep/cryptoutil" - "github.com/fleetdm/fleet/v4/server/mdm/scep/depot" - "github.com/fleetdm/fleet/v4/server/mdm/scep/scep" -) - -func testParsePKIMessage(t *testing.T, data []byte) *scep.PKIMessage { - msg, err := scep.ParsePKIMessage(data) - if err != nil { - t.Fatal(err) - } - validateParsedPKIMessage(t, msg) - return msg -} - -func validateParsedPKIMessage(t *testing.T, msg *scep.PKIMessage) { - if msg.TransactionID == "" { - t.Errorf("expected TransactionID attribute") - } - if msg.MessageType == "" { - t.Errorf("expected MessageType attribute") - } - switch msg.MessageType { - case scep.CertRep: - if len(msg.RecipientNonce) == 0 { - t.Errorf("expected RecipientNonce attribute") - } - case scep.PKCSReq, scep.UpdateReq, scep.RenewalReq: - if len(msg.SenderNonce) == 0 { - t.Errorf("expected SenderNonce attribute") - } - } -} - -// Tests the case when servers reply with PKCS #7 signed-data that contains no -// certificates assuming that the client can request CA certificates using -// GetCaCert request. -func TestParsePKIEnvelopeCert_MissingCertificatesForSigners(t *testing.T) { - certRepMissingCertificates := loadTestFile(t, "testdata/testca2/CertRep_NoCertificatesForSigners.der") - caPEM := loadTestFile(t, "testdata/testca2/ca2.pem") - - // Try to parse the PKIMessage without providing certificates for signers. - _, err := scep.ParsePKIMessage(certRepMissingCertificates) - if err == nil { - t.Fatal("parsed PKIMessage without providing signer certificates") - } - - signerCert := decodePEMCert(t, caPEM) - msg, err := scep.ParsePKIMessage(certRepMissingCertificates, scep.WithCACerts([]*x509.Certificate{signerCert})) - if err != nil { - t.Fatalf("failed to parse PKIMessage: %v", err) - } - validateParsedPKIMessage(t, msg) -} - -func TestDecryptPKIEnvelopeCSR(t *testing.T) { - pkcsReq := loadTestFile(t, "testdata/PKCSReq.der") - msg := testParsePKIMessage(t, pkcsReq) - cacert, cakey := loadCACredentials(t) - err := msg.DecryptPKIEnvelope(cacert, cakey) - if err != nil { - t.Fatal(err) - } - if msg.CSRReqMessage.CSR == nil { - t.Errorf("expected non-nil CSR field") - } -} - -func TestDecryptPKIEnvelopeCert(t *testing.T) { - certRep := loadTestFile(t, "testdata/CertRep.der") - testParsePKIMessage(t, certRep) - // clientcert, clientkey := loadClientCredentials(t) - // err = msg.DecryptPKIEnvelope(clientcert, clientkey) - // if err != nil { - // t.Fatal(err) - // } -} - -func TestSignCSR(t *testing.T) { - pkcsReq := loadTestFile(t, "testdata/PKCSReq.der") - msg := testParsePKIMessage(t, pkcsReq) - cacert, cakey := loadCACredentials(t) - err := msg.DecryptPKIEnvelope(cacert, cakey) - if err != nil { - t.Fatal(err) - } - csr := msg.CSRReqMessage.CSR - id, err := cryptoutil.GenerateSubjectKeyID(csr.PublicKey) - if err != nil { - t.Fatal(err) - } - tmpl := &x509.Certificate{ - SerialNumber: big.NewInt(4), - Subject: csr.Subject, - NotBefore: time.Now().Add(-600).UTC(), - NotAfter: time.Now().AddDate(1, 0, 0).UTC(), - SubjectKeyId: id, - ExtKeyUsage: []x509.ExtKeyUsage{ - x509.ExtKeyUsageAny, - x509.ExtKeyUsageClientAuth, - }, - } - // sign the CSR creating a DER encoded cert - crtBytes, err := x509.CreateCertificate(rand.Reader, tmpl, cacert, csr.PublicKey, cakey) - if err != nil { - t.Fatal(err) - } - crt, err := x509.ParseCertificate(crtBytes) - if err != nil { - t.Fatal(err) - } - certRep, err := msg.Success(cacert, cakey, crt) - if err != nil { - t.Fatal(err) - } - testParsePKIMessage(t, certRep.Raw) -} - -func TestNewCSRRequest(t *testing.T) { - for _, test := range []struct { - testName string - keyUsage x509.KeyUsage - certsSelectorFunc scep.CertsSelectorFunc - shouldCreateCSR bool - }{ - { - "KeyEncipherment not set with NOP certificates selector", - x509.KeyUsageCertSign, - scep.NopCertsSelector(), - true, - }, - { - "KeyEncipherment is set with NOP certificates selector", - x509.KeyUsageCertSign | x509.KeyUsageKeyEncipherment, - scep.NopCertsSelector(), - true, - }, - { - "KeyEncipherment not set with Encipherment certificates selector", - x509.KeyUsageCertSign, - scep.EnciphermentCertsSelector(), - false, - }, - { - "KeyEncipherment is set with Encipherment certificates selector", - x509.KeyUsageCertSign | x509.KeyUsageKeyEncipherment, - scep.EnciphermentCertsSelector(), - true, - }, - } { - test := test - t.Run(test.testName, func(t *testing.T) { - key, err := newRSAKey(2048) - if err != nil { - t.Fatal(err) - } - derBytes, err := newCSR(key, "john.doe@example.com", "US", "com.apple.scep.2379B935-294B-4AF1-A213-9BD44A2C6688") - if err != nil { - t.Fatal(err) - } - csr, err := x509.ParseCertificateRequest(derBytes) - if err != nil { - t.Fatal(err) - } - clientcert, clientkey := loadClientCredentials(t) - cacert, cakey := createCaCertWithKeyUsage(t, test.keyUsage) - tmpl := &scep.PKIMessage{ - MessageType: scep.PKCSReq, - Recipients: []*x509.Certificate{cacert}, - SignerCert: clientcert, - SignerKey: clientkey, - } - - pkcsreq, err := scep.NewCSRRequest(csr, tmpl, scep.WithCertsSelector(test.certsSelectorFunc)) - if test.shouldCreateCSR && err != nil { - t.Fatalf("keyUsage: %d, failed creating a CSR request: %v", test.keyUsage, err) - } - if !test.shouldCreateCSR && err == nil { - t.Fatalf("keyUsage: %d, shouldn't have created a CSR: %v", test.keyUsage, err) - } - if !test.shouldCreateCSR { - return - } - msg := testParsePKIMessage(t, pkcsreq.Raw) - err = msg.DecryptPKIEnvelope(cacert, cakey) - if err != nil { - t.Fatal(err) - } - }) - } -} - -// create a new RSA private key -func newRSAKey(bits int) (*rsa.PrivateKey, error) { - private, err := rsa.GenerateKey(rand.Reader, bits) - if err != nil { - return nil, err - } - return private, nil -} - -// create a CSR using the same parameters as Keychain Access would produce -func newCSR(priv *rsa.PrivateKey, email, country, cname string) ([]byte, error) { - subj := pkix.Name{ - Country: []string{country}, - CommonName: cname, - ExtraNames: []pkix.AttributeTypeAndValue{{ - Type: []int{1, 2, 840, 113549, 1, 9, 1}, - Value: email, - }}, - } - template := &x509.CertificateRequest{ - Subject: subj, - } - return x509.CreateCertificateRequest(rand.Reader, template, priv) -} - -func loadTestFile(t *testing.T, path string) []byte { - data, err := ioutil.ReadFile(path) - if err != nil { - t.Fatal(err) - } - return data -} - -// createCaCertWithKeyUsage generates a CA key and certificate with keyUsage. -func createCaCertWithKeyUsage(t *testing.T, keyUsage x509.KeyUsage) (*x509.Certificate, *rsa.PrivateKey) { - key, err := rsa.GenerateKey(rand.Reader, 2048) - if err != nil { - t.Fatal(err) - } - caCert := depot.NewCACert( - depot.WithCountry("US"), - depot.WithOrganization("MICROMDM"), - depot.WithCommonName("MICROMDM SCEP CA"), - depot.WithKeyUsage(keyUsage), - ) - crtBytes, err := caCert.SelfSign(rand.Reader, &key.PublicKey, key) - if err != nil { - t.Fatal(err) - } - cert, err := x509.ParseCertificate(crtBytes) - if err != nil { - t.Fatal(err) - } - return cert, key -} - -func loadCACredentials(t *testing.T) (*x509.Certificate, *rsa.PrivateKey) { - cert, err := loadCertFromFile("testdata/testca/ca.crt") - if err != nil { - t.Fatal(err) - } - key, err := loadKeyFromFile("testdata/testca/ca.key") - if err != nil { - t.Fatal(err) - } - return cert, key -} - -func loadClientCredentials(t *testing.T) (*x509.Certificate, *rsa.PrivateKey) { - cert, err := loadCertFromFile("testdata/testclient/client.pem") - if err != nil { - t.Fatal(err) - } - key, err := loadKeyFromFile("testdata/testclient/client.key") - if err != nil { - t.Fatal(err) - } - return cert, key -} - -const ( - rsaPrivateKeyPEMBlockType = "RSA PRIVATE KEY" - certificatePEMBlockType = "CERTIFICATE" -) - -func loadCertFromFile(path string) (*x509.Certificate, error) { - data, err := ioutil.ReadFile(path) - if err != nil { - return nil, err - } - - pemBlock, _ := pem.Decode(data) - if pemBlock == nil { - return nil, errors.New("PEM decode failed") - } - if pemBlock.Type != certificatePEMBlockType { - return nil, errors.New("unmatched type or headers") - } - return x509.ParseCertificate(pemBlock.Bytes) -} - -// load an encrypted private key from disk -func loadKeyFromFile(path string) (*rsa.PrivateKey, error) { - data, err := ioutil.ReadFile(path) - if err != nil { - return nil, err - } - - pemBlock, _ := pem.Decode(data) - if pemBlock == nil { - return nil, errors.New("PEM decode failed") - } - if pemBlock.Type != rsaPrivateKeyPEMBlockType { - return nil, errors.New("unmatched type or headers") - } - - // testca key has a password - if len(pemBlock.Headers) > 0 { - password := []byte("") - b, err := x509.DecryptPEMBlock(pemBlock, password) - if err != nil { - return nil, err - } - return x509.ParsePKCS1PrivateKey(b) - } - - return x509.ParsePKCS1PrivateKey(pemBlock.Bytes) -} - -func decodePEMCert(t *testing.T, data []byte) *x509.Certificate { - pemBlock, _ := pem.Decode(data) - if pemBlock == nil { - t.Fatal("PEM decode failed") - } - if pemBlock.Type != certificatePEMBlockType { - t.Fatal("unmatched type or headers") - } - - cert, err := x509.ParseCertificate(pemBlock.Bytes) - if err != nil { - t.Fatal(err) - } - return cert -} diff --git a/server/mdm/scep/scep/testdata/CertRep.der b/server/mdm/scep/scep/testdata/CertRep.der deleted file mode 100755 index 16ebc2bebbbb..000000000000 Binary files a/server/mdm/scep/scep/testdata/CertRep.der and /dev/null differ diff --git a/server/mdm/scep/scep/testdata/testca2/CertRep_NoCertificatesForSigners.der b/server/mdm/scep/scep/testdata/testca2/CertRep_NoCertificatesForSigners.der deleted file mode 100644 index 6ed5f52f5839..000000000000 Binary files a/server/mdm/scep/scep/testdata/testca2/CertRep_NoCertificatesForSigners.der and /dev/null differ diff --git a/server/mdm/scep/scep/testdata/testca2/ca2.pem b/server/mdm/scep/scep/testdata/testca2/ca2.pem deleted file mode 100644 index b45d4981d0ef..000000000000 --- a/server/mdm/scep/scep/testdata/testca2/ca2.pem +++ /dev/null @@ -1,101 +0,0 @@ -Certificate: - Data: - Version: 3 (0x2) - Serial Number: - 47:00:00:00:02:f5:40:0d:75:85:dd:87:88:00:00:00:00:00:02 - Signature Algorithm: sha256WithRSAEncryption - Issuer: DC = org, DC = example, CN = example-CERT-PROV-CA - Validity - Not Before: Oct 30 19:07:21 2020 GMT - Not After : Oct 30 19:07:21 2022 GMT - Subject: C = US, CN = CERT-PROV-CA-MSCEP-RA - Subject Public Key Info: - Public Key Algorithm: rsaEncryption - RSA Public-Key: (2048 bit) - Modulus: - 00:a1:0b:13:03:0a:fc:2f:ed:92:22:3f:a5:01:2d: - f9:46:f5:2c:fb:38:f9:0e:f1:b9:48:65:30:61:95: - 84:15:a2:15:68:16:61:5d:cb:d5:83:d4:69:c7:da: - 4a:79:a3:c4:a2:e7:5b:33:b1:bc:e0:a1:3e:4e:1b: - 96:be:ff:34:a1:a5:da:ca:cd:ee:99:5e:91:e3:dc: - 96:ba:92:0a:77:01:82:2c:cb:c9:b5:30:69:de:39: - d3:59:34:86:35:e4:ce:7f:ca:b5:7c:2a:58:14:21: - 2a:4f:d8:0d:c6:90:17:a3:29:2d:b2:1f:89:e9:53: - 10:5b:e3:36:01:af:6c:01:08:2c:e8:43:cc:89:3d: - 99:13:39:85:76:d8:18:3f:df:db:1a:4d:fd:fa:39: - fa:f6:7c:86:d9:70:1b:0f:3a:e8:6b:fa:3d:e5:e4: - 38:c1:3e:3c:d1:c5:c7:74:ca:77:74:a1:0c:f1:dd: - f4:28:9b:d9:99:7d:1e:e9:36:9f:6a:da:64:6e:90: - 59:58:d6:db:e8:e3:5c:08:41:30:bd:14:35:de:0c: - 4a:9a:9c:1c:1e:ce:86:7d:cc:be:47:37:f6:5c:c4: - 91:86:7e:9a:9f:9f:d0:de:49:e4:bd:71:b0:d1:33: - b1:1f:ca:43:fe:e7:b0:f1:48:cf:40:79:1a:2e:f8: - 30:d5 - Exponent: 65537 (0x10001) - X509v3 extensions: - 1.3.6.1.4.1.311.20.2: - .,.E.n.r.o.l.l.m.e.n.t.A.g.e.n.t.O.f.f.l.i.n.e - X509v3 Extended Key Usage: - 1.3.6.1.4.1.311.20.2.1 - X509v3 Key Usage: critical - Digital Signature - X509v3 Subject Key Identifier: - D2:DB:A3:DF:13:2B:EE:D5:88:A1:3E:8C:28:0E:2A:00:7B:C7:18:19 - X509v3 Authority Key Identifier: - keyid:4B:C5:63:29:BB:CF:68:AF:18:1E:C5:99:E8:DF:55:F7:23:9A:08:EB - - X509v3 CRL Distribution Points: - - Full Name: - URI:ldap:///CN=example-CERT-PROV-CA,CN=cert-prov-ca,CN=CDP,CN=Public%20Key%20Services,CN=Services,CN=Configuration,DC=example,DC=org?certificateRevocationList?base?objectClass=cRLDistributionPoint - - Authority Information Access: - CA Issuers - URI:ldap:///CN=example-CERT-PROV-CA,CN=AIA,CN=Public%20Key%20Services,CN=Services,CN=Configuration,DC=example,DC=org?cACertificate?base?objectClass=certificationAuthority - - Signature Algorithm: sha256WithRSAEncryption - 06:c8:d1:6f:ba:9b:48:84:a3:63:8e:4b:0d:73:85:91:7d:e4: - ce:50:9b:de:09:99:91:a3:1e:e6:ce:6f:ec:bf:2b:ce:bd:8d: - 0a:6c:0e:98:3f:b2:1f:cc:ab:53:62:2a:99:61:2d:76:9d:16: - 5d:27:f4:db:b6:9d:08:91:5b:cb:0c:18:09:b0:ab:38:e8:66: - ad:7b:45:53:81:11:16:aa:b2:5f:f6:ca:58:c0:fc:3c:98:04: - a6:0b:cd:28:28:8f:74:96:c4:57:7b:d1:a6:df:c8:ac:2f:cf: - 79:69:2e:ae:7c:e4:af:ad:ef:74:6f:c9:42:f7:03:3d:fe:48: - 25:05:d5:23:96:4a:4b:ed:a2:15:cf:b6:fe:06:d9:53:72:8e: - d2:14:3f:ab:83:db:22:e1:9b:16:51:f5:b6:ec:05:13:ad:2b: - fb:a4:1c:4c:97:17:29:5e:15:b9:f9:49:fb:33:7c:6d:b5:89: - ad:3d:50:64:9d:38:59:87:4d:9c:4f:39:44:48:34:96:77:f2: - 4b:1c:ad:84:94:e9:b7:9f:f7:1b:35:8a:c7:ab:18:14:59:d1: - f2:14:93:c3:a8:8f:6b:47:53:c9:9f:e2:f5:59:00:34:e6:23: - 2f:ce:5e:84:f9:81:ad:6b:cc:b3:ef:2c:04:5c:de:16:54:ba: - eb:38:f4:3b ------BEGIN CERTIFICATE----- -MIIFVDCCBDygAwIBAgITRwAAAAL1QA11hd2HiAAAAAAAAjANBgkqhkiG9w0BAQsF -ADBNMRMwEQYKCZImiZPyLGQBGRYDb3JnMRcwFQYKCZImiZPyLGQBGRYHZXhhbXBs -ZTEdMBsGA1UEAxMUZXhhbXBsZS1DRVJULVBST1YtQ0EwHhcNMjAxMDMwMTkwNzIx -WhcNMjIxMDMwMTkwNzIxWjAtMQswCQYDVQQGEwJVUzEeMBwGA1UEAxMVQ0VSVC1Q -Uk9WLUNBLU1TQ0VQLVJBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA -oQsTAwr8L+2SIj+lAS35RvUs+zj5DvG5SGUwYZWEFaIVaBZhXcvVg9Rpx9pKeaPE -oudbM7G84KE+ThuWvv80oaXays3umV6R49yWupIKdwGCLMvJtTBp3jnTWTSGNeTO -f8q1fCpYFCEqT9gNxpAXoyktsh+J6VMQW+M2Aa9sAQgs6EPMiT2ZEzmFdtgYP9/b -Gk39+jn69nyG2XAbDzroa/o95eQ4wT480cXHdMp3dKEM8d30KJvZmX0e6Tafatpk -bpBZWNbb6ONcCEEwvRQ13gxKmpwcHs6Gfcy+Rzf2XMSRhn6an5/Q3knkvXGw0TOx -H8pD/uew8UjPQHkaLvgw1QIDAQABo4ICSzCCAkcwOwYJKwYBBAGCNxQCBC4eLABF -AG4AcgBvAGwAbABtAGUAbgB0AEEAZwBlAG4AdABPAGYAZgBsAGkAbgBlMBUGA1Ud -JQQOMAwGCisGAQQBgjcUAgEwDgYDVR0PAQH/BAQDAgeAMB0GA1UdDgQWBBTS26Pf -Eyvu1YihPowoDioAe8cYGTAfBgNVHSMEGDAWgBRLxWMpu89orxgexZno31X3I5oI -6zCB1wYDVR0fBIHPMIHMMIHJoIHGoIHDhoHAbGRhcDovLy9DTj1leGFtcGxlLUNF -UlQtUFJPVi1DQSxDTj1jZXJ0LXByb3YtY2EsQ049Q0RQLENOPVB1YmxpYyUyMEtl -eSUyMFNlcnZpY2VzLENOPVNlcnZpY2VzLENOPUNvbmZpZ3VyYXRpb24sREM9ZXhh -bXBsZSxEQz1vcmc/Y2VydGlmaWNhdGVSZXZvY2F0aW9uTGlzdD9iYXNlP29iamVj -dENsYXNzPWNSTERpc3RyaWJ1dGlvblBvaW50MIHGBggrBgEFBQcBAQSBuTCBtjCB -swYIKwYBBQUHMAKGgaZsZGFwOi8vL0NOPWV4YW1wbGUtQ0VSVC1QUk9WLUNBLENO -PUFJQSxDTj1QdWJsaWMlMjBLZXklMjBTZXJ2aWNlcyxDTj1TZXJ2aWNlcyxDTj1D -b25maWd1cmF0aW9uLERDPWV4YW1wbGUsREM9b3JnP2NBQ2VydGlmaWNhdGU/YmFz -ZT9vYmplY3RDbGFzcz1jZXJ0aWZpY2F0aW9uQXV0aG9yaXR5MA0GCSqGSIb3DQEB -CwUAA4IBAQAGyNFvuptIhKNjjksNc4WRfeTOUJveCZmRox7mzm/svyvOvY0KbA6Y -P7IfzKtTYiqZYS12nRZdJ/Tbtp0IkVvLDBgJsKs46Gate0VTgREWqrJf9spYwPw8 -mASmC80oKI90lsRXe9Gm38isL895aS6ufOSvre90b8lC9wM9/kglBdUjlkpL7aIV -z7b+BtlTco7SFD+rg9si4ZsWUfW27AUTrSv7pBxMlxcpXhW5+Un7M3xttYmtPVBk -nThZh02cTzlESDSWd/JLHK2ElOm3n/cbNYrHqxgUWdHyFJPDqI9rR1PJn+L1WQA0 -5iMvzl6E+YGta8yz7ywEXN4WVLrrOPQ7 ------END CERTIFICATE----- diff --git a/server/mdm/scep/scep/testdata/testclient/client.key b/server/mdm/scep/scep/testdata/testclient/client.key deleted file mode 100644 index be2febb14f83..000000000000 --- a/server/mdm/scep/scep/testdata/testclient/client.key +++ /dev/null @@ -1,19 +0,0 @@ -Bag Attributes - friendlyName: com.apple.security.scep.063D7953-1338-4BF0-8F99-913382996224 - localKeyID: A1 30 90 76 1A 30 F6 66 64 F8 5D 37 43 3D 20 65 E1 11 2B A1 -Key Attributes: ------BEGIN RSA PRIVATE KEY----- -MIICXgIBAAKBgQDT9YGr0H8dpozAEi5l2XkWyKy2JD3yEybI9A1ZDXcK/78UPQ+C -4tBb6BTRJWDWoZFlFcHUGbZWXbySPw6ggBsLl4feF1A+hjtCjlZsRF4mnfctixkr -dP+UGl37UunsW63mn8uM6oM+7elhB2zRscZrZPBDKZx1V+Et+BFrX49xNwIDAQAB -AoGAP9bzDmfG0YxnWjZfqSd+NCGO+3EhAzdHeEEhgA/xKevrhlH5yQc9kGDvXCrw -5tRU8WhDL/nqlEq5UCcT5b2P5zp1L0PY+X6gD5C7KGEIio5SvimAnQMh2HCKftDc -KX9NA9EJLq9BUsqK9HjXdsfIGzyoqhZQpDGTrgyVlfm/zwkCQQDyuKJvxm/6tWry -GBN0eBLyA4F738MFP/3kb87zUsGTD8dh92vwjMhGv1woKp09POs2s2MnADw2wOEa -hh+v6R5zAkEA344KSwaKZZSf7E5qFayg3qG9M55uhbus9CazoAWiceYMR6ImIevA -EtnhwQczIRGI8Bp4jgUtO9gu4IgjCUHNLQJBAIJkS+cuPGP75+sMog70noDi/0GT -0MnWOcfphMzUzWb6mAr6B0Of7cuL668sTXJjcpzdO8vs5WworAU6vnUbEB8CQQCB -+Hy3fcf8otoPcs9uZnzosrPjTNsI2UIGeHG6OUxmV88P3o+47O0wiIgdx2fMc/tf -TKSGPTA9OMSYOc3U1fLJAkEArLyCHxxDzcEuhyHnmoct/dTUg5Q8CYKIQfo5oVKZ -jvtL/r0udFpxLUDxZ7590I32cTSrtfgNBJegHr54YKN9KA== ------END RSA PRIVATE KEY----- diff --git a/server/mdm/scep/scep/testdata/testclient/client.pem b/server/mdm/scep/scep/testdata/testclient/client.pem deleted file mode 100644 index 3de20270b294..000000000000 --- a/server/mdm/scep/scep/testdata/testclient/client.pem +++ /dev/null @@ -1,58 +0,0 @@ -Certificate: - Data: - Version: 3 (0x2) - Serial Number: - 6f:4f:31:6a:b2:da:d4:ce:d0:fc:09:fb:b9:26:90:03:d6:09:a4:8c - Signature Algorithm: sha256WithRSAEncryption - Issuer: C = US, ST = USA, O = etcd-ca, OU = CA, CN = etcd-ca - Validity - Not Before: Feb 16 12:11:45 2021 GMT - Not After : Dec 26 12:11:45 2030 GMT - Subject: C = US, ST = USA, O = etcd-ca, OU = CA, CN = etcd-ca - Subject Public Key Info: - Public Key Algorithm: rsaEncryption - RSA Public-Key: (1024 bit) - Modulus: - 00:d3:f5:81:ab:d0:7f:1d:a6:8c:c0:12:2e:65:d9: - 79:16:c8:ac:b6:24:3d:f2:13:26:c8:f4:0d:59:0d: - 77:0a:ff:bf:14:3d:0f:82:e2:d0:5b:e8:14:d1:25: - 60:d6:a1:91:65:15:c1:d4:19:b6:56:5d:bc:92:3f: - 0e:a0:80:1b:0b:97:87:de:17:50:3e:86:3b:42:8e: - 56:6c:44:5e:26:9d:f7:2d:8b:19:2b:74:ff:94:1a: - 5d:fb:52:e9:ec:5b:ad:e6:9f:cb:8c:ea:83:3e:ed: - e9:61:07:6c:d1:b1:c6:6b:64:f0:43:29:9c:75:57: - e1:2d:f8:11:6b:5f:8f:71:37 - Exponent: 65537 (0x10001) - X509v3 extensions: - X509v3 Subject Key Identifier: - A1:30:90:76:1A:30:F6:66:64:F8:5D:37:43:3D:20:65:E1:11:2B:A1 - X509v3 Authority Key Identifier: - keyid:A1:30:90:76:1A:30:F6:66:64:F8:5D:37:43:3D:20:65:E1:11:2B:A1 - - X509v3 Basic Constraints: critical - CA:TRUE - Signature Algorithm: sha256WithRSAEncryption - 96:f0:7b:28:3b:7a:06:2e:cd:37:23:19:f3:98:0c:a2:d3:16: - 9e:5a:b7:56:ca:9d:9d:ca:a4:59:78:b3:29:b1:3c:18:e8:dc: - 4c:f6:64:62:84:a3:19:ca:ca:b0:34:ed:d2:6f:9b:a6:38:20: - 98:64:db:c5:cb:a4:ce:b2:9c:62:a2:0e:e2:76:cb:f4:a1:c5: - 40:ee:c5:b4:18:9d:9e:5a:bf:bd:72:29:96:f8:82:05:87:d3: - fb:84:12:91:ea:e0:86:02:b1:63:c2:59:6a:10:9a:b7:7d:e2: - be:f3:19:31:31:3e:bb:21:4d:a0:16:f9:c0:94:ba:0f:e6:3d: - 37:26 ------BEGIN CERTIFICATE----- -MIICdDCCAd2gAwIBAgIUb08xarLa1M7Q/An7uSaQA9YJpIwwDQYJKoZIhvcNAQEL -BQAwTDELMAkGA1UEBhMCVVMxDDAKBgNVBAgMA1VTQTEQMA4GA1UECgwHZXRjZC1j -YTELMAkGA1UECwwCQ0ExEDAOBgNVBAMMB2V0Y2QtY2EwHhcNMjEwMjE2MTIxMTQ1 -WhcNMzAxMjI2MTIxMTQ1WjBMMQswCQYDVQQGEwJVUzEMMAoGA1UECAwDVVNBMRAw -DgYDVQQKDAdldGNkLWNhMQswCQYDVQQLDAJDQTEQMA4GA1UEAwwHZXRjZC1jYTCB -nzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEA0/WBq9B/HaaMwBIuZdl5FsistiQ9 -8hMmyPQNWQ13Cv+/FD0PguLQW+gU0SVg1qGRZRXB1Bm2Vl28kj8OoIAbC5eH3hdQ -PoY7Qo5WbEReJp33LYsZK3T/lBpd+1Lp7Fut5p/LjOqDPu3pYQds0bHGa2TwQymc -dVfhLfgRa1+PcTcCAwEAAaNTMFEwHQYDVR0OBBYEFKEwkHYaMPZmZPhdN0M9IGXh -ESuhMB8GA1UdIwQYMBaAFKEwkHYaMPZmZPhdN0M9IGXhESuhMA8GA1UdEwEB/wQF -MAMBAf8wDQYJKoZIhvcNAQELBQADgYEAlvB7KDt6Bi7NNyMZ85gMotMWnlq3Vsqd -ncqkWXizKbE8GOjcTPZkYoSjGcrKsDTt0m+bpjggmGTbxcukzrKcYqIO4nbL9KHF -QO7FtBidnlq/vXIplviCBYfT+4QSkerghgKxY8JZahCat33ivvMZMTE+uyFNoBb5 -wJS6D+Y9NyY= ------END CERTIFICATE----- diff --git a/server/mdm/scep/server/csrsigner.go b/server/mdm/scep/server/csrsigner.go index 7e2fdd88f7c1..bfae3101a665 100644 --- a/server/mdm/scep/server/csrsigner.go +++ b/server/mdm/scep/server/csrsigner.go @@ -1,13 +1,30 @@ package scepserver import ( + "context" "crypto/subtle" "crypto/x509" "errors" - "github.com/fleetdm/fleet/v4/server/mdm/scep/scep" + "github.com/smallstep/scep" ) +// CSRSignerContext is a handler for signing CSRs by a CA/RA. +// +// SignCSRContext should take the CSR in the CSRReqMessage and return a +// Certificate signed by the CA. +type CSRSignerContext interface { + SignCSRContext(context.Context, *scep.CSRReqMessage) (*x509.Certificate, error) +} + +// CSRSignerContextFunc is an adapter for CSR signing by the CA/RA. +type CSRSignerContextFunc func(context.Context, *scep.CSRReqMessage) (*x509.Certificate, error) + +// SignCSR calls f(ctx, m). +func (f CSRSignerContextFunc) SignCSRContext(ctx context.Context, m *scep.CSRReqMessage) (*x509.Certificate, error) { + return f(ctx, m) +} + // CSRSigner is a handler for CSR signing by the CA/RA // // SignCSR should take the CSR in the CSRReqMessage and return a @@ -16,29 +33,36 @@ type CSRSigner interface { SignCSR(*scep.CSRReqMessage) (*x509.Certificate, error) } -// CSRSignerFunc is an adapter for CSR signing by the CA/RA +// CSRSignerFunc is an adapter for CSR signing by the CA/RA. type CSRSignerFunc func(*scep.CSRReqMessage) (*x509.Certificate, error) -// SignCSR calls f(m) +// SignCSR calls f(m). func (f CSRSignerFunc) SignCSR(m *scep.CSRReqMessage) (*x509.Certificate, error) { return f(m) } -// NopCSRSigner does nothing -func NopCSRSigner() CSRSignerFunc { - return func(m *scep.CSRReqMessage) (*x509.Certificate, error) { +// NopCSRSigner does nothing. +func NopCSRSigner() CSRSignerContextFunc { + return func(_ context.Context, _ *scep.CSRReqMessage) (*x509.Certificate, error) { return nil, nil } } -// ChallengeMiddleware wraps next in a CSRSigner that validates the challenge from the CSR -func ChallengeMiddleware(challenge string, next CSRSigner) CSRSignerFunc { +// StaticChallengeMiddleware wraps next and validates the challenge from the CSR. +func StaticChallengeMiddleware(challenge string, next CSRSignerContext) CSRSignerContextFunc { challengeBytes := []byte(challenge) - return func(m *scep.CSRReqMessage) (*x509.Certificate, error) { + return func(ctx context.Context, m *scep.CSRReqMessage) (*x509.Certificate, error) { // TODO: compare challenge only for PKCSReq? if subtle.ConstantTimeCompare(challengeBytes, []byte(m.ChallengePassword)) != 1 { return nil, errors.New("invalid challenge") } + return next.SignCSRContext(ctx, m) + } +} + +// SignCSRAdapter adapts a next (i.e. no context) to a context signer. +func SignCSRAdapter(next CSRSigner) CSRSignerContextFunc { + return func(_ context.Context, m *scep.CSRReqMessage) (*x509.Certificate, error) { return next.SignCSR(m) } } diff --git a/server/mdm/scep/server/csrsigner_test.go b/server/mdm/scep/server/csrsigner_test.go index 54576d1c7b4f..5e24b8c35b68 100644 --- a/server/mdm/scep/server/csrsigner_test.go +++ b/server/mdm/scep/server/csrsigner_test.go @@ -1,25 +1,28 @@ package scepserver import ( + "context" "testing" - "github.com/fleetdm/fleet/v4/server/mdm/scep/scep" + "github.com/smallstep/scep" ) func TestChallengeMiddleware(t *testing.T) { testPW := "RIGHT" - signer := ChallengeMiddleware(testPW, NopCSRSigner()) + signer := StaticChallengeMiddleware(testPW, NopCSRSigner()) csrReq := &scep.CSRReqMessage{ChallengePassword: testPW} - _, err := signer.SignCSR(csrReq) + ctx := context.Background() + + _, err := signer.SignCSRContext(ctx, csrReq) if err != nil { t.Error(err) } csrReq.ChallengePassword = "WRONG" - _, err = signer.SignCSR(csrReq) + _, err = signer.SignCSRContext(ctx, csrReq) if err == nil { t.Error("invalid challenge should generate an error") } diff --git a/server/mdm/scep/server/endpoint.go b/server/mdm/scep/server/endpoint.go index 799d4108652c..e865a3341653 100644 --- a/server/mdm/scep/server/endpoint.go +++ b/server/mdm/scep/server/endpoint.go @@ -185,6 +185,7 @@ func EndpointLoggingMiddleware(logger log.Logger) endpoint.Middleware { logger.Log(append(keyvals, "error", err, "took", time.Since(begin))...) }(time.Now()) return next(ctx, request) + } } } diff --git a/server/mdm/scep/server/service.go b/server/mdm/scep/server/service.go index 85d01910d38e..d387f3f39cce 100644 --- a/server/mdm/scep/server/service.go +++ b/server/mdm/scep/server/service.go @@ -6,9 +6,8 @@ import ( "crypto/x509" "errors" - "github.com/fleetdm/fleet/v4/server/mdm/scep/scep" - - "github.com/go-kit/log" + "github.com/go-kit/kit/log" + "github.com/smallstep/scep" ) // Service is the interface for all supported SCEP server operations. @@ -47,7 +46,7 @@ type service struct { // The (chainable) CSR signing function. Intended to handle all // SCEP request functionality such as CSR & challenge checking, CA // issuance, RA proxying, etc. - signer CSRSigner + signer CSRSignerContext /// info logging is implemented in the service middleware layer. debugLogger log.Logger @@ -83,7 +82,7 @@ func (svc *service) PKIOperation(ctx context.Context, data []byte) ([]byte, erro return nil, err } - crt, err := svc.signer.SignCSR(msg.CSRReqMessage) + crt, err := svc.signer.SignCSRContext(ctx, msg.CSRReqMessage) if err == nil && crt == nil { err = errors.New("no signed certificate") } @@ -122,7 +121,7 @@ func WithAddlCA(ca *x509.Certificate) ServiceOption { } // NewService creates a new scep service -func NewService(crt *x509.Certificate, key *rsa.PrivateKey, signer CSRSigner, opts ...ServiceOption) (Service, error) { +func NewService(crt *x509.Certificate, key *rsa.PrivateKey, signer CSRSignerContext, opts ...ServiceOption) (Service, error) { s := &service{ crt: crt, key: key, diff --git a/server/mdm/scep/server/service_bolt_test.go b/server/mdm/scep/server/service_bolt_test.go index e0c8db1fcdb0..6374497cf61b 100644 --- a/server/mdm/scep/server/service_bolt_test.go +++ b/server/mdm/scep/server/service_bolt_test.go @@ -16,9 +16,9 @@ import ( scepdepot "github.com/fleetdm/fleet/v4/server/mdm/scep/depot" boltdepot "github.com/fleetdm/fleet/v4/server/mdm/scep/depot/bolt" - "github.com/fleetdm/fleet/v4/server/mdm/scep/scep" scepserver "github.com/fleetdm/fleet/v4/server/mdm/scep/server" + "github.com/smallstep/scep" bolt "go.etcd.io/bbolt" ) @@ -45,7 +45,7 @@ func TestCaCert(t *testing.T) { caCert := certs[0] // SCEP service - svc, err := scepserver.NewService(caCert, key, scepdepot.NewSigner(depot)) + svc, err := scepserver.NewService(caCert, key, scepserver.SignCSRAdapter(scepdepot.NewSigner(depot))) if err != nil { t.Fatal(err) } @@ -130,6 +130,12 @@ func TestCaCert(t *testing.T) { t.Error("no established chain between issued cert and CA") } + if csr.SignatureAlgorithm != respCert.SignatureAlgorithm { + t.Fatal(fmt.Errorf("cert signature algo %s different from csr signature algo %s", + csr.SignatureAlgorithm.String(), + respCert.SignatureAlgorithm.String())) + } + // verify unique certificate serials for _, ser := range serCollector { if respCert.SerialNumber.Cmp(ser) == 0 { @@ -138,6 +144,7 @@ func TestCaCert(t *testing.T) { } serCollector = append(serCollector, respCert.SerialNumber) } + } func createDB(mode os.FileMode, options *bolt.Options) *boltdepot.Depot { diff --git a/server/mdm/scep/server/transport_test.go b/server/mdm/scep/server/transport_test.go index 05bdc7f9ee5f..becc5091d070 100644 --- a/server/mdm/scep/server/transport_test.go +++ b/server/mdm/scep/server/transport_test.go @@ -34,7 +34,7 @@ func TestCACaps(t *testing.T) { } func TestEncodePKCSReq_Request(t *testing.T) { - pkcsreq := loadTestFile(t, "../scep/testdata/PKCSReq.der") + pkcsreq := loadTestFile(t, "../testdata/PKCSReq.der") msg := scepserver.SCEPRequest{ Operation: "PKIOperation", Message: pkcsreq, @@ -64,8 +64,10 @@ func TestEncodePKCSReq_Request(t *testing.T) { t.Errorf("expected GET PKIOperation to have a non-empty message field") } } + }) } + } func TestGetCACertMessage(t *testing.T) { @@ -87,7 +89,7 @@ func TestGetCACertMessage(t *testing.T) { func TestPKIOperation(t *testing.T) { server, _, teardown := newServer(t) defer teardown() - pkcsreq := loadTestFile(t, "../scep/testdata/PKCSReq.der") + pkcsreq := loadTestFile(t, "../testdata/PKCSReq.der") body := bytes.NewReader(pkcsreq) url := server.URL + "/scep?operation=PKIOperation" resp, err := http.Post(url, "", body) //nolint:gosec @@ -102,7 +104,7 @@ func TestPKIOperation(t *testing.T) { func TestPKIOperationGET(t *testing.T) { server, _, teardown := newServer(t) defer teardown() - pkcsreq := loadTestFile(t, "../scep/testdata/PKCSReq.der") + pkcsreq := loadTestFile(t, "../testdata/PKCSReq.der") message := base64.StdEncoding.EncodeToString(pkcsreq) req, err := http.NewRequest("GET", server.URL+"/scep", nil) if err != nil { @@ -202,7 +204,7 @@ func newServer(t *testing.T, opts ...scepserver.ServiceOption) (*httptest.Server var err error var depot depot.Depot // cert storage { - depot, err = filedepot.NewFileDepot("../scep/testdata/testca") + depot, err = filedepot.NewFileDepot("../testdata/testca") if err != nil { t.Fatal(err) } @@ -227,8 +229,8 @@ func newServer(t *testing.T, opts ...scepserver.ServiceOption) (*httptest.Server server := httptest.NewServer(r) teardown := func() { server.Close() - os.Remove("../scep/testdata/testca/serial") - os.Remove("../scep/testdata/testca/index.txt") + os.Remove("../testdata/testca/serial") + os.Remove("../testdata/testca/index.txt") } return server, svc, teardown } diff --git a/server/mdm/scep/scep/testdata/PKCSReq.der b/server/mdm/scep/testdata/PKCSReq.der similarity index 100% rename from server/mdm/scep/scep/testdata/PKCSReq.der rename to server/mdm/scep/testdata/PKCSReq.der diff --git a/server/mdm/scep/scep/testdata/testca/ca.crt b/server/mdm/scep/testdata/testca/ca.crt similarity index 100% rename from server/mdm/scep/scep/testdata/testca/ca.crt rename to server/mdm/scep/testdata/testca/ca.crt diff --git a/server/mdm/scep/scep/testdata/testca/ca.crt.info b/server/mdm/scep/testdata/testca/ca.crt.info similarity index 100% rename from server/mdm/scep/scep/testdata/testca/ca.crt.info rename to server/mdm/scep/testdata/testca/ca.crt.info diff --git a/server/mdm/scep/scep/testdata/testca/ca.key b/server/mdm/scep/testdata/testca/ca.key similarity index 100% rename from server/mdm/scep/scep/testdata/testca/ca.key rename to server/mdm/scep/testdata/testca/ca.key diff --git a/server/mdm/scep/scep/testdata/testca/ca.pem b/server/mdm/scep/testdata/testca/ca.pem similarity index 100% rename from server/mdm/scep/scep/testdata/testca/ca.pem rename to server/mdm/scep/testdata/testca/ca.pem diff --git a/server/mdm/scep/scep/testdata/testca/sceptest.mobileconfig b/server/mdm/scep/testdata/testca/sceptest.mobileconfig similarity index 100% rename from server/mdm/scep/scep/testdata/testca/sceptest.mobileconfig rename to server/mdm/scep/testdata/testca/sceptest.mobileconfig diff --git a/server/service/appconfig.go b/server/service/appconfig.go index 17b83dcbbfa1..22998d24d601 100644 --- a/server/service/appconfig.go +++ b/server/service/appconfig.go @@ -545,15 +545,55 @@ func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte, applyOpts fle } } + // Reset teams for ABM tokens that exist in Fleet but aren't present in the config being passed + tokensInCfg := make(map[string]struct{}) + for _, t := range newAppConfig.MDM.AppleBusinessManager.Value { + tokensInCfg[t.OrganizationName] = struct{}{} + } + + toks, err := svc.ds.ListABMTokens(ctx) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "listing ABM tokens") + } + for _, tok := range toks { + if _, ok := tokensInCfg[tok.OrganizationName]; !ok { + tok.MacOSDefaultTeamID = nil + tok.IOSDefaultTeamID = nil + tok.IPadOSDefaultTeamID = nil + if err := svc.ds.SaveABMToken(ctx, tok); err != nil { + return nil, ctxerr.Wrap(ctx, err, "saving ABM token assignments") + } + } + } + if (appConfig.MDM.AppleBusinessManager.Set && appConfig.MDM.AppleBusinessManager.Valid) || appConfig.MDM.DeprecatedAppleBMDefaultTeam != "" { for _, tok := range abmAssignments { - fmt.Println(tok.EncryptedToken) if err := svc.ds.SaveABMToken(ctx, tok); err != nil { return nil, ctxerr.Wrap(ctx, err, "saving ABM token assignments") } } } + // Reset teams for VPP tokens that exist in Fleet but aren't present in the config being passed + clear(tokensInCfg) + + for _, t := range newAppConfig.MDM.VolumePurchasingProgram.Value { + tokensInCfg[t.Location] = struct{}{} + } + + vppToks, err := svc.ds.ListVPPTokens(ctx) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "listing VPP tokens") + } + for _, tok := range vppToks { + if _, ok := tokensInCfg[tok.Location]; !ok { + tok.Teams = nil + if _, err := svc.ds.UpdateVPPTokenTeams(ctx, tok.ID, nil); err != nil { + return nil, ctxerr.Wrap(ctx, err, "saving VPP token teams") + } + } + } + if appConfig.MDM.VolumePurchasingProgram.Set && appConfig.MDM.VolumePurchasingProgram.Valid { for tokenID, tokenTeams := range vppAssignments { if _, err := svc.ds.UpdateVPPTokenTeams(ctx, tokenID, tokenTeams); err != nil { diff --git a/server/service/appconfig_test.go b/server/service/appconfig_test.go index 0fb0d318d26f..2c6e6bc10849 100644 --- a/server/service/appconfig_test.go +++ b/server/service/appconfig_test.go @@ -51,6 +51,18 @@ func TestAppConfigAuth(t *testing.T) { return nil } + ds.SaveABMTokenFunc = func(ctx context.Context, tok *fleet.ABMToken) error { + return nil + } + + ds.ListVPPTokensFunc = func(ctx context.Context) ([]*fleet.VPPTokenDB, error) { + return []*fleet.VPPTokenDB{}, nil + } + + ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { + return []*fleet.ABMToken{}, nil + } + testCases := []struct { name string user *fleet.User @@ -647,6 +659,18 @@ func TestModifyAppConfigSMTPConfigured(t *testing.T) { return nil } + ds.SaveABMTokenFunc = func(ctx context.Context, tok *fleet.ABMToken) error { + return nil + } + + ds.ListVPPTokensFunc = func(ctx context.Context) ([]*fleet.VPPTokenDB, error) { + return []*fleet.VPPTokenDB{}, nil + } + + ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { + return []*fleet.ABMToken{}, nil + } + // Disable SMTP. newAppConfig := fleet.AppConfig{ SMTPSettings: &fleet.SMTPSettings{ @@ -751,6 +775,18 @@ func TestTransparencyURL(t *testing.T) { return nil } + ds.SaveABMTokenFunc = func(ctx context.Context, tok *fleet.ABMToken) error { + return nil + } + + ds.ListVPPTokensFunc = func(ctx context.Context) ([]*fleet.VPPTokenDB, error) { + return []*fleet.VPPTokenDB{}, nil + } + + ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { + return []*fleet.ABMToken{}, nil + } + ac, err := svc.AppConfigObfuscated(ctx) require.NoError(t, err) require.Equal(t, tt.initialURL, ac.FleetDesktop.TransparencyURL) @@ -800,6 +836,18 @@ func TestTransparencyURLDowngradeLicense(t *testing.T) { return nil } + ds.SaveABMTokenFunc = func(ctx context.Context, tok *fleet.ABMToken) error { + return nil + } + + ds.ListVPPTokensFunc = func(ctx context.Context) ([]*fleet.VPPTokenDB, error) { + return []*fleet.VPPTokenDB{}, nil + } + + ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { + return []*fleet.ABMToken{}, nil + } + ac, err := svc.AppConfigObfuscated(ctx) require.NoError(t, err) require.Equal(t, "https://example.com/transparency", ac.FleetDesktop.TransparencyURL) @@ -1090,6 +1138,15 @@ func TestMDMAppleConfig(t *testing.T) { depStorage.StoreAssignerProfileFunc = func(ctx context.Context, name string, profileUUID string) error { return nil } + ds.SaveABMTokenFunc = func(ctx context.Context, tok *fleet.ABMToken) error { + return nil + } + ds.ListVPPTokensFunc = func(ctx context.Context) ([]*fleet.VPPTokenDB, error) { + return []*fleet.VPPTokenDB{}, nil + } + ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { + return []*fleet.ABMToken{{OrganizationName: t.Name()}}, nil + } ac, err := svc.AppConfigObfuscated(ctx) require.NoError(t, err) @@ -1168,6 +1225,15 @@ func TestModifyAppConfigSMTPSSOAgentOptions(t *testing.T) { ) error { return nil } + ds.SaveABMTokenFunc = func(ctx context.Context, tok *fleet.ABMToken) error { + return nil + } + ds.ListVPPTokensFunc = func(ctx context.Context) ([]*fleet.VPPTokenDB, error) { + return []*fleet.VPPTokenDB{}, nil + } + ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { + return []*fleet.ABMToken{}, nil + } // Not sending smtp_settings, sso_settings or agent_settings will do nothing. b := []byte(`{}`) @@ -1297,6 +1363,18 @@ func TestModifyEnableAnalytics(t *testing.T) { return nil } + ds.SaveABMTokenFunc = func(ctx context.Context, tok *fleet.ABMToken) error { + return nil + } + + ds.ListVPPTokensFunc = func(ctx context.Context) ([]*fleet.VPPTokenDB, error) { + return []*fleet.VPPTokenDB{}, nil + } + + ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { + return []*fleet.ABMToken{}, nil + } + ac, err := svc.AppConfigObfuscated(ctx) require.NoError(t, err) require.Equal(t, tt.initialEnabled, ac.ServerSettings.EnableAnalytics) diff --git a/server/service/handler.go b/server/service/handler.go index 7012393952bc..7443436b8e42 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -1096,18 +1096,18 @@ func registerSCEP( mdmStorage fleet.MDMAppleStore, logger kitlog.Logger, ) error { - var signer scepserver.CSRSigner = scep_depot.NewSigner( + var signer scepserver.CSRSignerContext = scepserver.SignCSRAdapter(scep_depot.NewSigner( scepStorage, scep_depot.WithValidityDays(scepConfig.AppleSCEPSignerValidityDays), scep_depot.WithAllowRenewalDays(scepConfig.AppleSCEPSignerAllowRenewalDays), - ) + )) assets, err := mdmStorage.GetAllMDMConfigAssetsByName(context.Background(), []fleet.MDMAssetName{fleet.MDMAssetSCEPChallenge}) if err != nil { return fmt.Errorf("retrieving SCEP challenge: %w", err) } scepChallenge := string(assets[fleet.MDMAssetSCEPChallenge].Value) - signer = scepserver.ChallengeMiddleware(scepChallenge, signer) + signer = scepserver.StaticChallengeMiddleware(scepChallenge, signer) scepService := NewSCEPService( mdmStorage, signer, diff --git a/server/service/installer_test.go b/server/service/installer_test.go deleted file mode 100644 index c66ae79ded2c..000000000000 --- a/server/service/installer_test.go +++ /dev/null @@ -1,179 +0,0 @@ -package service - -import ( - "context" - "io" - "strings" - "testing" - - "github.com/fleetdm/fleet/v4/server/authz" - "github.com/fleetdm/fleet/v4/server/config" - "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" - "github.com/fleetdm/fleet/v4/server/fleet" - "github.com/fleetdm/fleet/v4/server/mock" - "github.com/fleetdm/fleet/v4/server/test" - "github.com/stretchr/testify/require" -) - -func setup(t *testing.T) (context.Context, *mock.Store, *mock.InstallerStore, fleet.Service) { - ds := new(mock.Store) - is := new(mock.InstallerStore) - cfg := config.TestConfig() - cfg.Server.SandboxEnabled = true - svc, ctx := newTestServiceWithConfig(t, ds, cfg, nil, nil, &TestServerOpts{Is: is, FleetConfig: &cfg}) - ctx = test.UserContext(ctx, test.UserAdmin) - ds.VerifyEnrollSecretFunc = func(ctx context.Context, enrollSecret string) (*fleet.EnrollSecret, error) { - return &fleet.EnrollSecret{Secret: "xyz"}, nil - - } - return ctx, ds, is, svc -} - -func TestGetInstaller(t *testing.T) { - t.Run("unauthorized access is not allowed", func(t *testing.T) { - _, _, _, svc := setup(t) - _, _, err := svc.GetInstaller(context.Background(), fleet.Installer{}) - require.Error(t, err) - require.Contains(t, err.Error(), authz.ForbiddenErrorMessage) - }) - - t.Run("errors if store is not configured", func(t *testing.T) { - ctx, ds, _, _ := setup(t) - cfg := config.TestConfig() - cfg.Server.SandboxEnabled = true - svc, _ := newTestServiceWithConfig(t, ds, cfg, nil, nil, &TestServerOpts{Is: nil, FleetConfig: &cfg}) - _, _, err := svc.GetInstaller(ctx, fleet.Installer{}) - require.Error(t, err) - require.ErrorContains(t, err, "installer storage has not been configured") - }) - - t.Run("errors if the provided enroll secret cannot be found", func(t *testing.T) { - ctx, ds, _, svc := setup(t) - ds.VerifyEnrollSecretFunc = func(ctx context.Context, enrollSecret string) (*fleet.EnrollSecret, error) { - return nil, newNotFoundError() - } - _, _, err := svc.GetInstaller(ctx, fleet.Installer{}) - require.Error(t, err) - var nfe *notFoundError - require.ErrorAs(t, err, &nfe) - require.True(t, ds.VerifyEnrollSecretFuncInvoked) - }) - - t.Run("errors if there's a problem verifying the enroll secret", func(t *testing.T) { - ctx, ds, _, svc := setup(t) - ds.VerifyEnrollSecretFunc = func(ctx context.Context, enrollSecret string) (*fleet.EnrollSecret, error) { - return nil, ctxerr.New(ctx, "test error") - } - _, _, err := svc.GetInstaller(ctx, fleet.Installer{}) - require.Error(t, err) - require.ErrorContains(t, err, "test error") - require.True(t, ds.VerifyEnrollSecretFuncInvoked) - }) - - t.Run("errors if there's a problem checking the blob storage", func(t *testing.T) { - ctx, ds, is, svc := setup(t) - is.GetFunc = func(ctx context.Context, installer fleet.Installer) (io.ReadCloser, int64, error) { - return nil, int64(0), ctxerr.New(ctx, "test error") - } - _, _, err := svc.GetInstaller(ctx, fleet.Installer{}) - require.Error(t, err) - require.ErrorContains(t, err, "test error") - require.True(t, ds.VerifyEnrollSecretFuncInvoked) - require.True(t, is.GetFuncInvoked) - }) - - t.Run("returns binary data with the installer", func(t *testing.T) { - ctx, ds, is, svc := setup(t) - is.GetFunc = func(ctx context.Context, installer fleet.Installer) (io.ReadCloser, int64, error) { - str := "test" - length := int64(len(str)) - reader := io.NopCloser(strings.NewReader(str)) - return reader, length, nil - } - blob, length, err := svc.GetInstaller(ctx, fleet.Installer{}) - require.NoError(t, err) - body, err := io.ReadAll(blob) - require.Equal(t, "test", string(body)) - require.EqualValues(t, length, len(body)) - require.NoError(t, err) - require.True(t, ds.VerifyEnrollSecretFuncInvoked) - require.True(t, is.GetFuncInvoked) - }) -} -func TestCheckInstallerExistence(t *testing.T) { - t.Run("unauthorized access is not allowed", func(t *testing.T) { - _, _, _, svc := setup(t) - err := svc.CheckInstallerExistence(context.Background(), fleet.Installer{}) - require.Error(t, err) - require.Contains(t, err.Error(), authz.ForbiddenErrorMessage) - }) - - t.Run("errors if store is not configured", func(t *testing.T) { - ctx, ds, _, _ := setup(t) - cfg := config.TestConfig() - cfg.Server.SandboxEnabled = true - svc, _ := newTestServiceWithConfig(t, ds, cfg, nil, nil, &TestServerOpts{Is: nil, FleetConfig: &cfg}) - err := svc.CheckInstallerExistence(ctx, fleet.Installer{}) - require.Error(t, err) - require.ErrorContains(t, err, "installer storage has not been configured") - }) - - t.Run("errors if the provided enroll secret cannot be found", func(t *testing.T) { - ctx, ds, _, svc := setup(t) - ds.VerifyEnrollSecretFunc = func(ctx context.Context, enrollSecret string) (*fleet.EnrollSecret, error) { - return nil, newNotFoundError() - } - err := svc.CheckInstallerExistence(ctx, fleet.Installer{}) - require.Error(t, err) - var nfe *notFoundError - require.ErrorAs(t, err, &nfe) - require.True(t, ds.VerifyEnrollSecretFuncInvoked) - }) - - t.Run("errors if there's a problem verifying the enroll secret", func(t *testing.T) { - ctx, ds, _, svc := setup(t) - ds.VerifyEnrollSecretFunc = func(ctx context.Context, enrollSecret string) (*fleet.EnrollSecret, error) { - return nil, ctxerr.New(ctx, "test error") - } - err := svc.CheckInstallerExistence(ctx, fleet.Installer{}) - require.Error(t, err) - require.ErrorContains(t, err, "test error") - require.True(t, ds.VerifyEnrollSecretFuncInvoked) - }) - - t.Run("errors if there's a problem checking the blob storage", func(t *testing.T) { - ctx, ds, is, svc := setup(t) - is.ExistsFunc = func(ctx context.Context, installer fleet.Installer) (bool, error) { - return false, ctxerr.New(ctx, "test error") - } - err := svc.CheckInstallerExistence(ctx, fleet.Installer{}) - require.Error(t, err) - require.ErrorContains(t, err, "test error") - require.True(t, ds.VerifyEnrollSecretFuncInvoked) - require.True(t, is.ExistsFuncInvoked) - }) - - t.Run("errors with not found if the installer is not in the storage", func(t *testing.T) { - ctx, ds, is, svc := setup(t) - is.ExistsFunc = func(ctx context.Context, installer fleet.Installer) (bool, error) { - return false, nil - } - err := svc.CheckInstallerExistence(ctx, fleet.Installer{}) - require.Error(t, err) - var nfe *notFoundError - require.ErrorAs(t, err, &nfe) - require.True(t, ds.VerifyEnrollSecretFuncInvoked) - require.True(t, is.ExistsFuncInvoked) - }) - - t.Run("returns no errors if the installer exists", func(t *testing.T) { - ctx, ds, is, svc := setup(t) - is.ExistsFunc = func(ctx context.Context, installer fleet.Installer) (bool, error) { - return true, nil - } - err := svc.CheckInstallerExistence(ctx, fleet.Installer{}) - require.NoError(t, err) - require.True(t, ds.VerifyEnrollSecretFuncInvoked) - require.True(t, is.ExistsFuncInvoked) - }) -} diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index 8b1f84820506..8332e65eaf94 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -8310,24 +8310,6 @@ func (s *integrationTestSuite) TestSSODisabled() { require.Contains(t, string(body), "/login?status=org_disabled") // html contains a script that redirects to this path } -func (s *integrationTestSuite) TestSandboxEndpoints() { - t := s.T() - validEmail := testUsers["user1"].Email - validPwd := testUsers["user1"].PlaintextPassword - hdrs := map[string]string{"Content-Type": "application/x-www-form-urlencoded"} - - // demo login endpoint always fails - formBody := make(url.Values) - formBody.Set("email", validEmail) - formBody.Set("password", validPwd) - res := s.DoRawWithHeaders("POST", "/api/v1/fleet/demologin", []byte(formBody.Encode()), http.StatusInternalServerError, hdrs) - require.NotEqual(t, http.StatusOK, res.StatusCode) - - // installers endpoint is not enabled - url, installersBody := installerPOSTReq(enrollSecret, "pkg", s.token, false) - s.DoRaw("POST", url, installersBody, http.StatusInternalServerError) -} - func (s *integrationTestSuite) TestGetHostBatteries() { t := s.T() diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index cb2a97966846..3d6673afd3b6 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -14366,3 +14366,41 @@ func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsSoftwareInstallers require.NoError(t, err) require.Nil(t, hostVanillaOsquery5Team1LastInstall) } + +func (s *integrationEnterpriseTestSuite) TestSoftwareInstallersWithoutBundleIdentifier() { + t := s.T() + ctx := context.Background() + + // Create a host without a team + host, err := s.ds.NewHost(context.Background(), &fleet.Host{ + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now().Add(-1 * time.Minute), + OsqueryHostID: ptr.String(t.Name()), + NodeKey: ptr.String(t.Name()), + UUID: uuid.New().String(), + Hostname: fmt.Sprintf("%sfoo.local", t.Name()), + Platform: "darwin", + }) + require.NoError(t, err) + + software := []fleet.Software{ + {Name: "DummyApp.app", Version: "0.0.2", Source: "apps"}, + } + // we must ingest the title with an empty bundle identifier for this + // test to be valid + require.Empty(t, software[0].BundleIdentifier) + _, err = s.ds.UpdateHostSoftware(ctx, host.ID, software) + require.NoError(t, err) + require.NoError(t, s.ds.SyncHostsSoftware(ctx, time.Now())) + require.NoError(t, s.ds.ReconcileSoftwareTitles(ctx)) + require.NoError(t, s.ds.SyncHostsSoftwareTitles(ctx, time.Now())) + + payload := &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "install", + Filename: "dummy_installer.pkg", + Version: "0.0.2", + } + s.uploadSoftwareInstaller(payload, http.StatusOK, "") +} diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index ce134a2bd5fe..6eee1327bb2b 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -879,6 +879,26 @@ func (s *integrationMDMTestSuite) TestAppleGetAppleMDM() { require.Equal(t, tm.Name, tok.MacOSTeam.Name) require.Equal(t, tm.Name, tok.IOSTeam.Name) require.Equal(t, tm.Name, tok.IPadOSTeam.Name) + + // Reset the teams via app config + acResp = appConfigResponse{} + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ + "mdm": { + "apple_business_manager": [] + } + }`), http.StatusOK, &acResp) + + tokensResp = listABMTokensResponse{} + s.DoJSON("GET", "/api/latest/fleet/abm_tokens", nil, http.StatusOK, &tokensResp) + tok = s.getABMTokenByName(tmOrgName, tokensResp.Tokens) + require.NotNil(t, tok) + require.False(t, tok.TermsExpired) + require.Equal(t, "abc", tok.AppleID) + require.Equal(t, tmOrgName, tok.OrganizationName) + require.Equal(t, s.server.URL+"/mdm/apple/mdm", tok.MDMServerURL) + require.Equal(t, fleet.TeamNameNoTeam, tok.MacOSTeam.Name) + require.Equal(t, fleet.TeamNameNoTeam, tok.IOSTeam.Name) + require.Equal(t, fleet.TeamNameNoTeam, tok.IPadOSTeam.Name) } func (s *integrationMDMTestSuite) getABMTokenByName(orgName string, tokens []*fleet.ABMToken) *fleet.ABMToken { @@ -10532,6 +10552,25 @@ func (s *integrationMDMTestSuite) TestVPPApps() { var resPatchVPP patchVPPTokensTeamsResponse s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/vpp_tokens/%d/teams", resp.Tokens[0].ID), patchVPPTokensTeamsRequest{TeamIDs: []uint{}}, http.StatusOK, &resPatchVPP) + // Reset the token's teams by omitting the token from app config + acResp := appConfigResponse{} + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ + "mdm": { "volume_purchasing_program": null } + }`), http.StatusOK, &acResp) + + resp = getVPPTokensResponse{} + s.DoJSON("GET", "/api/latest/fleet/vpp_tokens", &getVPPTokensRequest{}, http.StatusOK, &resp) + require.NoError(t, resp.Err) + require.Len(t, resp.Tokens, 1) + require.Equal(t, orgName, resp.Tokens[0].OrgName) + require.Equal(t, location, resp.Tokens[0].Location) + require.Equal(t, expTime, resp.Tokens[0].RenewDate) + require.Empty(t, resp.Tokens[0].Teams) + + // Add the team back + resPatchVPP = patchVPPTokensTeamsResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/vpp_tokens/%d/teams", resp.Tokens[0].ID), patchVPPTokensTeamsRequest{TeamIDs: []uint{}}, http.StatusOK, &resPatchVPP) + // Get list of VPP apps from "Apple" // We're passing team 1 here, but we haven't added any app store apps to that team, so we get // back all available apps in our VPP location. @@ -10801,14 +10840,6 @@ func (s *integrationMDMTestSuite) TestVPPApps() { s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/vpp_tokens/%d/teams", vppRes.Token.ID), patchVPPTokensTeamsRequest{TeamIDs: []uint{team.ID}}, http.StatusOK, &resPatchVPP) - // mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { - // _, err := q.ExecContext(context.Background(), "UPDATE vpp_tokens SET renew_at = ? WHERE organization_name = ?", time.Now().Add(-1*time.Hour), "badtoken") - // return err - // }) - - // r := s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/%d", mdmHost.ID, errTitleID), &installSoftwareRequest{}, http.StatusUnprocessableEntity) - // require.Contains(t, extractServerErrorText(r.Body), "VPP token expired") - // Disable the token s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/vpp_tokens/%d/teams", vppRes.Token.ID), patchVPPTokensTeamsRequest{}, http.StatusOK, &resPatchVPP) diff --git a/server/service/integration_sandbox_test.go b/server/service/integration_sandbox_test.go deleted file mode 100644 index 9016da8880db..000000000000 --- a/server/service/integration_sandbox_test.go +++ /dev/null @@ -1,148 +0,0 @@ -package service - -import ( - "context" - "fmt" - "io" - "net/http" - "net/url" - "os" - "testing" - - "github.com/fleetdm/fleet/v4/server/config" - "github.com/fleetdm/fleet/v4/server/datastore/s3" - "github.com/fleetdm/fleet/v4/server/fleet" - kitlog "github.com/go-kit/log" - "github.com/stretchr/testify/require" - "github.com/stretchr/testify/suite" -) - -const enrollSecret = "xyz/abc$@" - -type integrationSandboxTestSuite struct { - suite.Suite - withServer - installers []fleet.Installer -} - -func (s *integrationSandboxTestSuite) SetupSuite() { - s.withDS.SetupSuite("integrationSandboxTestSuite") - t := s.T() - - // make sure sandbox is enabled - cfg := config.TestConfig() - cfg.Server.SandboxEnabled = true - - is := s3.SetupTestInstallerStore(t, "integration-tests", "") - opts := &TestServerOpts{FleetConfig: &cfg, Is: is} - if os.Getenv("FLEET_INTEGRATION_TESTS_DISABLE_LOG") != "" { - opts.Logger = kitlog.NewNopLogger() - } - users, server := RunServerForTestsWithDS(t, s.ds, opts) - s.server = server - s.users = users - s.token = s.getTestAdminToken() - s.installers = s3.SeedTestInstallerStore(t, is, enrollSecret) - - err := s.ds.ApplyEnrollSecrets(context.TODO(), nil, []*fleet.EnrollSecret{{Secret: enrollSecret}}) - require.NoError(t, err) -} - -func TestIntegrationsSandbox(t *testing.T) { - testingSuite := new(integrationSandboxTestSuite) - testingSuite.s = &testingSuite.Suite - suite.Run(t, testingSuite) -} - -func (s *integrationSandboxTestSuite) TestDemoLogin() { - t := s.T() - - validEmail := testUsers["user1"].Email - validPwd := testUsers["user1"].PlaintextPassword - wrongPwd := "nope" - hdrs := map[string]string{"Content-Type": "application/x-www-form-urlencoded"} - - formBody := make(url.Values) - formBody.Set("email", validEmail) - formBody.Set("password", wrongPwd) - res := s.DoRawWithHeaders("POST", "/api/v1/fleet/demologin", []byte(formBody.Encode()), http.StatusUnauthorized, hdrs) - require.Equal(t, http.StatusUnauthorized, res.StatusCode) - - formBody.Set("email", validEmail) - formBody.Set("password", validPwd) - res = s.DoRawWithHeaders("POST", "/api/v1/fleet/demologin", []byte(formBody.Encode()), http.StatusOK, hdrs) - resBody, err := io.ReadAll(res.Body) - require.NoError(t, err) - require.Equal(t, http.StatusOK, res.StatusCode) - require.Contains(t, string(resBody), `window.location = "/"`) - require.Regexp(t, `window.localStorage.setItem\('FLEET::auth_token', '[^']+'\)`, string(resBody)) -} - -func (s *integrationSandboxTestSuite) TestInstallerGet() { - t := s.T() - - validURL, formBody := installerPOSTReq(enrollSecret, "pkg", s.token, false) - - r := s.DoRaw("POST", validURL, formBody, http.StatusOK) - body, err := io.ReadAll(r.Body) - require.NoError(t, err) - require.Equal(t, "mock", string(body)) - require.Equal(t, "application/octet-stream", r.Header.Get("Content-Type")) - require.Equal(t, "4", r.Header.Get("Content-Length")) - require.Equal(t, `attachment;filename="fleet-osquery.pkg"`, r.Header.Get("Content-Disposition")) - require.Equal(t, `nosniff`, r.Header.Get("X-Content-Type-Options")) - - // unauthorized requests - s.DoRawNoAuth("POST", validURL, nil, http.StatusUnauthorized) - s.token = "invalid" - s.Do("POST", validURL, nil, http.StatusUnauthorized) - s.token = s.cachedAdminToken - - // wrong enroll secret - wrongURL, wrongFormBody := installerPOSTReq("wrong-enroll", "pkg", s.token, false) - s.Do("POST", wrongURL, wrongFormBody, http.StatusNotFound) - - // non-existent package - wrongURL, wrongFormBody = installerPOSTReq(enrollSecret, "exe", s.token, false) - s.Do("POST", wrongURL, wrongFormBody, http.StatusNotFound) -} - -func (s *integrationSandboxTestSuite) TestInstallerHeadCheck() { - validURL := installerURL(enrollSecret, "pkg", false) - s.DoRaw("HEAD", validURL, nil, http.StatusOK) - - // unauthorized requests - s.DoRawNoAuth("HEAD", validURL, nil, http.StatusUnauthorized) - s.token = "invalid" - s.DoRaw("HEAD", validURL, nil, http.StatusUnauthorized) - s.token = s.cachedAdminToken - - // wrong enroll secret - invalidURL := installerURL("wrong-enroll", "pkg", false) - s.DoRaw("HEAD", invalidURL, nil, http.StatusNotFound) - - // non-existent package - invalidURL = installerURL(enrollSecret, "exe", false) - s.DoRaw("HEAD", invalidURL, nil, http.StatusNotFound) -} - -func installerURL(secret, kind string, desktop bool) string { - path := fmt.Sprintf("/api/latest/fleet/download_installer/%s?enroll_secret=%s", kind, secret) - if desktop { - path += "&desktop=1" - } - return path -} - -func installerPOSTReq(secret, kind, token string, desktop bool) (string, []byte) { - path := installerURL(secret, kind, desktop) - d := "0" - if desktop { - d = "1" - } - formBody := make(url.Values) - formBody.Set("token", token) - formBody.Set("enroll_secret", secret) - formBody.Set("desktop", d) - return path, []byte(formBody.Encode()) -} diff --git a/server/service/mail_test.go b/server/service/mail_test.go index be041e7e6ec6..c83168301cac 100644 --- a/server/service/mail_test.go +++ b/server/service/mail_test.go @@ -86,6 +86,18 @@ func TestMailService(t *testing.T) { return invite, nil } + ds.SaveABMTokenFunc = func(ctx context.Context, tok *fleet.ABMToken) error { + return nil + } + + ds.ListVPPTokensFunc = func(ctx context.Context) ([]*fleet.VPPTokenDB, error) { + return []*fleet.VPPTokenDB{}, nil + } + + ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { + return []*fleet.ABMToken{}, nil + } + ctx = test.UserContext(ctx, test.UserAdmin) // (1) Modifying the app config `sender_address` field to trigger a test e-mail send. diff --git a/server/service/mdm_scep.go b/server/service/mdm_scep.go index 2032580686a4..92f9a95ae197 100644 --- a/server/service/mdm_scep.go +++ b/server/service/mdm_scep.go @@ -8,10 +8,10 @@ import ( "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mdm/assets" - "github.com/fleetdm/fleet/v4/server/mdm/scep/scep" scepserver "github.com/fleetdm/fleet/v4/server/mdm/scep/server" "github.com/go-kit/log" + "github.com/smallstep/scep" ) var _ scepserver.Service = (*service)(nil) @@ -20,7 +20,7 @@ type service struct { // The (chainable) CSR signing function. Intended to handle all // SCEP request functionality such as CSR & challenge checking, CA // issuance, RA proxying, etc. - signer scepserver.CSRSigner + signer scepserver.CSRSignerContext /// info logging is implemented in the service middleware layer. debugLogger log.Logger @@ -64,7 +64,7 @@ func (svc *service) PKIOperation(ctx context.Context, data []byte) ([]byte, erro return nil, err } - crt, err := svc.signer.SignCSR(msg.CSRReqMessage) + crt, err := svc.signer.SignCSRContext(ctx, msg.CSRReqMessage) if err == nil && crt == nil { err = errors.New("no signed certificate") } @@ -83,7 +83,7 @@ func (svc *service) GetNextCACert(ctx context.Context) ([]byte, error) { } // NewService creates a new scep service -func NewSCEPService(ds fleet.MDMAssetRetriever, signer scepserver.CSRSigner, logger log.Logger) scepserver.Service { +func NewSCEPService(ds fleet.MDMAssetRetriever, signer scepserver.CSRSignerContext, logger log.Logger) scepserver.Service { return &service{ signer: signer, debugLogger: log.NewNopLogger(), diff --git a/server/service/osquery_test.go b/server/service/osquery_test.go index 0697c7de69ce..ff913ac46f30 100644 --- a/server/service/osquery_test.go +++ b/server/service/osquery_test.go @@ -1062,6 +1062,7 @@ func verifyDiscovery(t *testing.T, queries, discovery map[string]string) { hostDetailQueryPrefix + "orbit_info": {}, hostDetailQueryPrefix + "software_vscode_extensions": {}, hostDetailQueryPrefix + "software_macos_firefox": {}, + hostDetailQueryPrefix + "battery_windows": {}, } for name := range queries { require.NotEmpty(t, discovery[name]) diff --git a/server/service/osquery_utils/queries.go b/server/service/osquery_utils/queries.go index f13454b2d110..5c93b5d2d680 100644 --- a/server/service/osquery_utils/queries.go +++ b/server/service/osquery_utils/queries.go @@ -555,7 +555,7 @@ var extraDetailQueries = map[string]DetailQuery{ DirectIngestFunc: directIngestChromeProfiles, Discovery: discoveryTable("google_chrome_profiles"), }, - "battery": { + "battery_macos": { Query: `SELECT serial_number, cycle_count, health FROM battery;`, Platforms: []string{"darwin"}, DirectIngestFunc: directIngestBattery, @@ -563,6 +563,12 @@ var extraDetailQueries = map[string]DetailQuery{ // osquery table on darwin (https://osquery.io/schema/5.3.0#battery), it is // always present. }, + "battery_windows": { + Query: `SELECT serial_number, cycle_count, designed_capacity, max_capacity FROM battery`, + Platforms: []string{"windows"}, + DirectIngestFunc: directIngestBattery, + Discovery: discoveryTable("battery"), // added to Windows in v5.12.1 (https://github.com/osquery/osquery/releases/tag/5.12.1) + }, "os_windows": { // This query is used to populate the `operating_systems` and `host_operating_system` // tables. Separately, the `hosts` table is populated via the `os_version` and @@ -1297,23 +1303,70 @@ func directIngestChromeProfiles(ctx context.Context, logger log.Logger, host *fl func directIngestBattery(ctx context.Context, logger log.Logger, host *fleet.Host, ds fleet.Datastore, rows []map[string]string) error { mapping := make([]*fleet.HostBattery, 0, len(rows)) for _, row := range rows { - cycleCount, err := strconv.ParseInt(EmptyToZero(row["cycle_count"]), 10, 64) + cycleCount, err := strconv.Atoi(EmptyToZero(row["cycle_count"])) if err != nil { return err } - mapping = append(mapping, &fleet.HostBattery{ - HostID: host.ID, - SerialNumber: row["serial_number"], - CycleCount: int(cycleCount), - // database type is VARCHAR(40) and since there isn't a - // canonical list of strings we can get for health, we - // truncate the value just in case. - Health: fmt.Sprintf("%.40s", row["health"]), - }) + + switch host.Platform { + case "darwin": + mapping = append(mapping, &fleet.HostBattery{ + HostID: host.ID, + SerialNumber: row["serial_number"], + CycleCount: cycleCount, + // database type is VARCHAR(40) and since there isn't a + // canonical list of strings we can get for health, we + // truncate the value just in case. + Health: fmt.Sprintf("%.40s", row["health"]), + }) + case "windows": + health, err := generateWindowsBatteryHealth(row["designed_capacity"], row["max_capacity"]) + if err != nil { + level.Error(logger).Log("op", "directIngestBattery", "hostID", host.ID, "err", err) + } + + mapping = append(mapping, &fleet.HostBattery{ + HostID: host.ID, + SerialNumber: row["serial_number"], + CycleCount: cycleCount, + Health: health, + }) + } } return ds.ReplaceHostBatteries(ctx, host.ID, mapping) } +const ( + batteryStatusUnknown = "Unknown" + batteryStatusDegraded = "Check Battery" + batteryStatusGood = "Good" + batteryDegradedThreshold = 80 +) + +func generateWindowsBatteryHealth(designedCapacity, maxCapacity string) (string, error) { + if designedCapacity == "" || maxCapacity == "" { + return batteryStatusUnknown, fmt.Errorf("missing battery capacity values, designed: %s, max: %s", designedCapacity, maxCapacity) + } + + designed, err := strconv.ParseInt(designedCapacity, 10, 64) + if err != nil { + return batteryStatusUnknown, err + } + + max, err := strconv.ParseInt(maxCapacity, 10, 64) + if err != nil { + return batteryStatusUnknown, err + } + + health := float64(max) / float64(designed) * 100 + + if health < batteryDegradedThreshold { + return batteryStatusDegraded, nil + } + + return batteryStatusGood, nil +} + func directIngestWindowsUpdateHistory( ctx context.Context, logger log.Logger, diff --git a/server/service/osquery_utils/queries_test.go b/server/service/osquery_utils/queries_test.go index 8a2931447083..3593ff20f1a5 100644 --- a/server/service/osquery_utils/queries_test.go +++ b/server/service/osquery_utils/queries_test.go @@ -279,7 +279,8 @@ func TestGetDetailQueries(t *testing.T) { "mdm_windows", "munki_info", "google_chrome_profiles", - "battery", + "battery_macos", + "battery_windows", "os_windows", "os_unix_like", "os_chrome", @@ -296,7 +297,7 @@ func TestGetDetailQueries(t *testing.T) { sortedKeysCompare(t, queriesNoConfig, baseQueries) queriesWithoutWinOSVuln := GetDetailQueries(context.Background(), config.FleetConfig{Vulnerabilities: config.VulnerabilitiesConfig{DisableWinOSVulnerabilities: true}}, nil, nil) - require.Len(t, queriesWithoutWinOSVuln, 25) + require.Len(t, queriesWithoutWinOSVuln, 26) queriesWithUsers := GetDetailQueries(context.Background(), config.FleetConfig{App: config.AppConfig{EnableScheduledQueryStats: true}}, nil, &fleet.Features{EnableHostUsers: true}) qs := append(baseQueries, "users", "users_chrome", "scheduled_query_stats") @@ -984,7 +985,8 @@ func TestDirectIngestBattery(t *testing.T) { } host := fleet.Host{ - ID: 1, + ID: 1, + Platform: "darwin", } err := directIngestBattery(context.Background(), log.NewNopLogger(), &host, ds, []map[string]string{ @@ -994,6 +996,37 @@ func TestDirectIngestBattery(t *testing.T) { require.NoError(t, err) require.True(t, ds.ReplaceHostBatteriesFuncInvoked) + + ds.ReplaceHostBatteriesFunc = func(ctx context.Context, id uint, mappings []*fleet.HostBattery) error { + require.Equal(t, mappings, []*fleet.HostBattery{ + {HostID: uint(2), SerialNumber: "a", CycleCount: 2, Health: batteryStatusGood}, + {HostID: uint(2), SerialNumber: "b", CycleCount: 3, Health: batteryStatusDegraded}, + {HostID: uint(2), SerialNumber: "c", CycleCount: 4, Health: batteryStatusUnknown}, + {HostID: uint(2), SerialNumber: "d", CycleCount: 5, Health: batteryStatusUnknown}, + {HostID: uint(2), SerialNumber: "e", CycleCount: 6, Health: batteryStatusUnknown}, + {HostID: uint(2), SerialNumber: "f", CycleCount: 7, Health: batteryStatusUnknown}, + }) + return nil + } + + // reset the ds flag + ds.ReplaceHostBatteriesFuncInvoked = false + + host = fleet.Host{ + ID: 2, + Platform: "windows", + } + + err = directIngestBattery(context.Background(), log.NewNopLogger(), &host, ds, []map[string]string{ + {"serial_number": "a", "cycle_count": "2", "designed_capacity": "3000", "max_capacity": "2400"}, // max_capacity >= 80% + {"serial_number": "b", "cycle_count": "3", "designed_capacity": "3000", "max_capacity": "2399"}, // max_capacity < 50% + {"serial_number": "c", "cycle_count": "4", "designed_capacity": "3000", "max_capacity": ""}, // missing max_capacity + {"serial_number": "d", "cycle_count": "5", "designed_capacity": "", "max_capacity": ""}, // missing designed_capacity and max_capacity + {"serial_number": "e", "cycle_count": "6", "designed_capacity": "", "max_capacity": "2000"}, // missing designed_capacity + {"serial_number": "f", "cycle_count": "7", "designed_capacity": "foo", "max_capacity": "bar"}, // invalid designed_capacity and max_capacity + }) + require.NoError(t, err) + require.True(t, ds.ReplaceHostBatteriesFuncInvoked) } func TestDirectIngestOSWindows(t *testing.T) { diff --git a/server/service/service_appconfig_test.go b/server/service/service_appconfig_test.go index b6318c584b39..d83479e5f156 100644 --- a/server/service/service_appconfig_test.go +++ b/server/service/service_appconfig_test.go @@ -373,6 +373,18 @@ func TestModifyAppConfigPatches(t *testing.T) { return nil } + ds.SaveABMTokenFunc = func(ctx context.Context, tok *fleet.ABMToken) error { + return nil + } + + ds.ListVPPTokensFunc = func(ctx context.Context) ([]*fleet.VPPTokenDB, error) { + return []*fleet.VPPTokenDB{}, nil + } + + ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { + return []*fleet.ABMToken{}, nil + } + configJSON := []byte(`{"org_info": { "org_name": "Acme", "org_logo_url": "somelogo.jpg" }}`) ctx = test.UserContext(ctx, test.UserAdmin) diff --git a/server/test/new_objects.go b/server/test/new_objects.go index f8a2de578c24..8a285783e5e8 100644 --- a/server/test/new_objects.go +++ b/server/test/new_objects.go @@ -176,6 +176,13 @@ func AddBuiltinLabels(t *testing.T, ds fleet.Datastore) { LabelType: fleet.LabelTypeBuiltIn, LabelMembershipType: fleet.LabelMembershipTypeManual, }, + { + Name: "Fedora Linux", + Platform: "rhel", + Query: "select 1 from os_version where name = 'Fedora Linux';", + LabelType: fleet.LabelTypeBuiltIn, + LabelMembershipType: fleet.LabelMembershipTypeDynamic, + }, } names := fleet.ReservedLabelNames() diff --git a/server/vulnerabilities/io/github.go b/server/vulnerabilities/io/github.go index 8d696bf2240a..005a894281aa 100644 --- a/server/vulnerabilities/io/github.go +++ b/server/vulnerabilities/io/github.go @@ -114,14 +114,17 @@ func (gh GitHubClient) list(ctx context.Context, prefix string, ctor func(fileNa } results := make(map[MetadataFileName]string) - for _, e := range releases[0].Assets { - name := e.GetName() - if strings.HasPrefix(name, prefix) { - metadataFileName, err := ctor(name) - if err != nil { - return nil, err + + if len(releases) > 0 { + for _, e := range releases[0].Assets { + name := e.GetName() + if strings.HasPrefix(name, prefix) { + metadataFileName, err := ctor(name) + if err != nil { + return nil, err + } + results[metadataFileName] = e.GetBrowserDownloadURL() } - results[metadataFileName] = e.GetBrowserDownloadURL() } } return results, nil diff --git a/server/vulnerabilities/msrc/msrc_api.go b/server/vulnerabilities/msrc/msrc_api.go index 1465852b579a..c44f7356d1a5 100644 --- a/server/vulnerabilities/msrc/msrc_api.go +++ b/server/vulnerabilities/msrc/msrc_api.go @@ -23,6 +23,11 @@ type MSRCAPI interface { GetFeed(time.Month, int) (string, error) } +// FeedNotFound is returned when a MSRC feed was not found. +// +// E.g. September 2024 bulleting was released on the 2nd. +var FeedNotFound = errors.New("feed not found") + type MSRCClient struct { client *http.Client workDir string @@ -44,7 +49,7 @@ func feedName(date time.Time) string { } func (msrc MSRCClient) getURL(date time.Time) (*url.URL, error) { - return url.Parse(msrc.baseURL + "/cvrf/v2.0/document/" + feedName(date)) + return url.Parse(msrc.baseURL + "/cvrf/v3.0/document/" + feedName(date)) } // GetFeed downloads the MSRC security feed for 'month' and 'year' into 'workDir', returning the @@ -68,6 +73,9 @@ func (msrc MSRCClient) GetFeed(month time.Month, year int) (string, error) { } if err := download.Download(msrc.client, u, dst); err != nil { + if errors.Is(err, download.NotFound) { + return "", FeedNotFound + } return "", err } diff --git a/server/vulnerabilities/msrc/msrc_api_test.go b/server/vulnerabilities/msrc/msrc_api_test.go index 79ea381eb428..592977315f9c 100644 --- a/server/vulnerabilities/msrc/msrc_api_test.go +++ b/server/vulnerabilities/msrc/msrc_api_test.go @@ -50,7 +50,7 @@ func TestMSRCClient(t *testing.T) { dir := t.TempDir() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/cvrf/v2.0/document/2021-Oct" { + if r.URL.Path == "/cvrf/v3.0/document/2021-Oct" { w.WriteHeader(http.StatusOK) w.Write([]byte("some payload")) //nolint:errcheck } diff --git a/server/vulnerabilities/nvd/cve_test.go b/server/vulnerabilities/nvd/cve_test.go index 691f3e321a7d..f9f8b12562ab 100644 --- a/server/vulnerabilities/nvd/cve_test.go +++ b/server/vulnerabilities/nvd/cve_test.go @@ -343,7 +343,7 @@ func TestTranslateCPEToCVE(t *testing.T) { }, "cpe:2.3:a:python:python:3.9.6:*:*:*:*:windows:*:*": { includedCVEs: []cve{ - {ID: "CVE-2024-4030", resolvedInVersion: "3.12.4"}, + {ID: "CVE-2024-4030", resolvedInVersion: "3.9.20"}, }, continuesToUpdate: true, }, diff --git a/terraform/addons/byo-firehose-logging-destination/target-account/.header.md b/terraform/addons/byo-firehose-logging-destination/target-account/.header.md index 8e754c402a0f..e361e1aa6233 100644 --- a/terraform/addons/byo-firehose-logging-destination/target-account/.header.md +++ b/terraform/addons/byo-firehose-logging-destination/target-account/.header.md @@ -6,4 +6,17 @@ The reason we need a local IAM role in your account is so that we can assume rol The Firehose service is KMS encrypted, so the IAM Role we assume into needs permission to the KMS key that is being used to encrypt the data going into Firehose. Additionally, if the data is being delivered to S3, it will also be encrypted with KMS using the AWS S3 KMS key that is managed by AWS. This is because only customer managed keys can be shared across accounts, and the Firehose delivery stream is actually the one writing to S3. -This code sets up a secure and controlled environment for the Fleet application to perform its necessary actions on the Firehose service within your AWS Account. \ No newline at end of file +This code sets up a secure and controlled environment for the Fleet application to perform its necessary actions on the Firehose service within your AWS Account. + +If you wanted to make changes to the individual files to fit your environment, feel free. However, it's recommended to use a module like the example below for simplicity. + +``` +module "firehose_logging" { + source = "github.com/fleetdm/fleet//terraform/addons/byo-firehose-logging-destination/target-account" + + # Variables + osquery_logging_destination_bucket_name = {your-desired-bucket-prefix} + fleet_iam_role_arn = {supplied by Fleet} + sts_external_id = {if using} +} +``` diff --git a/terraform/addons/byo-firehose-logging-destination/target-account/README.md b/terraform/addons/byo-firehose-logging-destination/target-account/README.md index b4eb9af91bbd..f378e24fafbf 100644 --- a/terraform/addons/byo-firehose-logging-destination/target-account/README.md +++ b/terraform/addons/byo-firehose-logging-destination/target-account/README.md @@ -8,6 +8,18 @@ The Firehose service is KMS encrypted, so the IAM Role we assume into needs perm This code sets up a secure and controlled environment for the Fleet application to perform its necessary actions on the Firehose service within your AWS Account. +If you wanted to make changes to the individual files to fit your environment, feel free. However, it's recommended to use a module like the example below for simplicity. + +``` +module "firehose_logging" { + source = "github.com/fleetdm/fleet//terraform/addons/byo-firehose-logging-destination/target-account" + + # Variables + osquery_logging_destination_bucket_name = {your-desired-bucket-prefix} + fleet_iam_role_arn = {supplied by Fleet} + sts_external_id = {if using} +} +``` ## Requirements | Name | Version | diff --git a/terraform/addons/logging-alb/main.tf b/terraform/addons/logging-alb/main.tf index 632d04fb6e60..58437a12fbf2 100644 --- a/terraform/addons/logging-alb/main.tf +++ b/terraform/addons/logging-alb/main.tf @@ -250,4 +250,6 @@ resource "aws_athena_workgroup" "logs" { } } } + + force_destroy = true } diff --git a/tools/seed_data/queries/seed_queries.go b/tools/seed_data/queries/seed_queries.go new file mode 100644 index 000000000000..857fe4b2b4f7 --- /dev/null +++ b/tools/seed_data/queries/seed_queries.go @@ -0,0 +1,74 @@ +package main + +import ( + "database/sql" + "fmt" + "log" + "strings" + + _ "github.com/go-sql-driver/mysql" +) + +const ( + batchSize = 1000 + totalRecords = 1000000 +) + +func main() { + // MySQL connection details from your Docker Compose file + user := "fleet" + password := "insecure" + host := "localhost" // Assuming you are running this script on the same host as Docker + port := "3306" + database := "fleet" + + // Construct the MySQL DSN (Data Source Name) + dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local", user, password, host, port, database) + + // Open MySQL connection + db, err := sql.Open("mysql", dsn) + if err != nil { + log.Fatal(err) + } + defer db.Close() + + // Disable foreign key checks to improve performance + _, err = db.Exec("SET FOREIGN_KEY_CHECKS=0") + if err != nil { + log.Fatal(err) + } + + // Prepare the insert statement + stmtPrefix := "INSERT INTO `queries` (`saved`, `name`, `description`, `query`, `author_id`, `observer_can_run`, `team_id`, `team_id_char`, `platform`, `min_osquery_version`, `schedule_interval`, `automations_enabled`, `logging_type`, `discard_data`) VALUES " + stmtSuffix := ";" + + // Insert records in batches + for batch := 0; batch < totalRecords/batchSize; batch++ { + var valueStrings []string + var valueArgs []interface{} + + // Generate batch of 1000 records + for i := 0; i < batchSize; i++ { + queryID := batch*batchSize + i + 1 + valueStrings = append(valueStrings, "(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)") + valueArgs = append(valueArgs, 0, fmt.Sprintf("query_%d", queryID), "", "SELECT * FROM processes;", 1, 0, nil, "", "", "", 0, 0, "snapshot", 0) + } + + // Construct and execute the batch insert + stmt := stmtPrefix + strings.Join(valueStrings, ",") + stmtSuffix + _, err := db.Exec(stmt, valueArgs...) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Inserted batch %d/%d\n", batch+1, totalRecords/batchSize) + } + + // Re-enable foreign key checks + _, err = db.Exec("SET FOREIGN_KEY_CHECKS=1") + if err != nil { + log.Fatal(err) + } + + fmt.Println("Finished inserting 1 million records.") +} diff --git a/tools/seed_data/README.md b/tools/seed_data/vulnerabilities/README.md similarity index 100% rename from tools/seed_data/README.md rename to tools/seed_data/vulnerabilities/README.md diff --git a/tools/seed_data/seed_vuln_data.go b/tools/seed_data/vulnerabilities/seed_vuln_data.go similarity index 100% rename from tools/seed_data/seed_vuln_data.go rename to tools/seed_data/vulnerabilities/seed_vuln_data.go diff --git a/tools/seed_data/software-macos.csv b/tools/seed_data/vulnerabilities/software-macos.csv similarity index 100% rename from tools/seed_data/software-macos.csv rename to tools/seed_data/vulnerabilities/software-macos.csv diff --git a/tools/seed_data/software-ubuntu.csv b/tools/seed_data/vulnerabilities/software-ubuntu.csv similarity index 100% rename from tools/seed_data/software-ubuntu.csv rename to tools/seed_data/vulnerabilities/software-ubuntu.csv diff --git a/tools/seed_data/software-win.csv b/tools/seed_data/vulnerabilities/software-win.csv similarity index 100% rename from tools/seed_data/software-win.csv rename to tools/seed_data/vulnerabilities/software-win.csv diff --git a/website/api/controllers/webhooks/receive-from-github.js b/website/api/controllers/webhooks/receive-from-github.js index 4dcbad029b2c..3a9088fc6521 100644 --- a/website/api/controllers/webhooks/receive-from-github.js +++ b/website/api/controllers/webhooks/receive-from-github.js @@ -91,6 +91,7 @@ module.exports = { 'rebeccaui', 'allenhouchins', 'harrisonravazzolo', + 'KendraAtFleet', ]; let GREEN_LABEL_COLOR = 'C2E0C6';// « Used in multiple places below. (FUTURE: Use the "+" prefix for this instead of color. 2022-05-05) diff --git a/website/assets/images/articles/automatic-software-install-add-software.png b/website/assets/images/articles/automatic-software-install-add-software.png new file mode 100644 index 000000000000..4fdd54fe6488 Binary files /dev/null and b/website/assets/images/articles/automatic-software-install-add-software.png differ diff --git a/website/assets/images/articles/automatic-software-install-install-software.png b/website/assets/images/articles/automatic-software-install-install-software.png new file mode 100644 index 000000000000..5e0aaef0b19e Binary files /dev/null and b/website/assets/images/articles/automatic-software-install-install-software.png differ diff --git a/website/assets/images/articles/automatic-software-install-policies-manage.png b/website/assets/images/articles/automatic-software-install-policies-manage.png new file mode 100644 index 000000000000..862c98eb151b Binary files /dev/null and b/website/assets/images/articles/automatic-software-install-policies-manage.png differ diff --git a/website/assets/images/articles/automatic-software-install-top-image.png b/website/assets/images/articles/automatic-software-install-top-image.png new file mode 100644 index 000000000000..ed188acd17ee Binary files /dev/null and b/website/assets/images/articles/automatic-software-install-top-image.png differ diff --git a/website/assets/images/articles/automatic-software-install-workflow.png b/website/assets/images/articles/automatic-software-install-workflow.png new file mode 100644 index 000000000000..10dd582e13d9 Binary files /dev/null and b/website/assets/images/articles/automatic-software-install-workflow.png differ diff --git a/website/assets/images/articles/fleet-now-supports-ios-and-ipados-software-deployment-and-automated-patch-management-1-2000x1000@2x.png b/website/assets/images/articles/fleet-now-supports-ios-and-ipados-software-deployment-and-automated-patch-management-1-2000x1000@2x.png new file mode 100644 index 000000000000..afda36ee57e2 Binary files /dev/null and b/website/assets/images/articles/fleet-now-supports-ios-and-ipados-software-deployment-and-automated-patch-management-1-2000x1000@2x.png differ diff --git a/website/assets/images/articles/fleet-now-supports-ios-and-ipados-software-deployment-and-automated-patch-management-2-2000x1000@2x.png b/website/assets/images/articles/fleet-now-supports-ios-and-ipados-software-deployment-and-automated-patch-management-2-2000x1000@2x.png new file mode 100644 index 000000000000..9ea35ee6843c Binary files /dev/null and b/website/assets/images/articles/fleet-now-supports-ios-and-ipados-software-deployment-and-automated-patch-management-2-2000x1000@2x.png differ diff --git a/website/assets/images/articles/fleet-supports-macos-15-sequoia-ios-18-and-ipados-18-1600x900@2x.jpg b/website/assets/images/articles/fleet-supports-macos-15-sequoia-ios-18-and-ipados-18-1600x900@2x.jpg new file mode 100644 index 000000000000..d66f91aaf53a Binary files /dev/null and b/website/assets/images/articles/fleet-supports-macos-15-sequoia-ios-18-and-ipados-18-1600x900@2x.jpg differ diff --git a/website/assets/images/articles/sysadmin-diaries-gitops-a-strategic-advantage-1-312x207@2x.jpg b/website/assets/images/articles/sysadmin-diaries-gitops-a-strategic-advantage-1-312x207@2x.jpg new file mode 100644 index 000000000000..02a8d7ad0118 Binary files /dev/null and b/website/assets/images/articles/sysadmin-diaries-gitops-a-strategic-advantage-1-312x207@2x.jpg differ diff --git a/website/assets/images/articles/sysadmin-diaries-gitops-a-strategic-advantage-2-1999x680@2x.png b/website/assets/images/articles/sysadmin-diaries-gitops-a-strategic-advantage-2-1999x680@2x.png new file mode 100644 index 000000000000..3ecc4e437f12 Binary files /dev/null and b/website/assets/images/articles/sysadmin-diaries-gitops-a-strategic-advantage-2-1999x680@2x.png differ diff --git a/website/assets/images/articles/sysadmin-diaries-gitops-a-strategic-advantage-3-566x415@2x.png b/website/assets/images/articles/sysadmin-diaries-gitops-a-strategic-advantage-3-566x415@2x.png new file mode 100644 index 000000000000..232d33010323 Binary files /dev/null and b/website/assets/images/articles/sysadmin-diaries-gitops-a-strategic-advantage-3-566x415@2x.png differ diff --git a/website/config/routes.js b/website/config/routes.js index 28fdbbf106ed..92618498739c 100644 --- a/website/config/routes.js +++ b/website/config/routes.js @@ -353,6 +353,11 @@ module.exports.routes = { 'GET /device-management/fleet-user-stories-wayfair': '/success-stories/fleet-user-stories-wayfair', 'GET /handbook/security': '/handbook/digital-experience/security', 'GET /handbook/security/security-policies':'/handbook/digital-experience/security-policies#information-security-policy-and-acceptable-use-policy',// « reasoning: https://github.com/fleetdm/fleet/pull/9624 + 'GET /handbook/business-operations/security-policies':'/handbook/digital-experience/security-policies', + 'GET /handbook/business-operations/application-security': '/handbook/digital-experience/application-security', + 'GET /handbook/business-operations/security-audits': '/handbook/digital-experience/security-audits', + 'GET /handbook/business-operations/security': '/handbook/digital-experience/security', + 'GET /handbook/business-operations/vendor-questionnaires': '/handbook/digital-experience/vendor-questionnaires', 'GET /handbook/handbook': '/handbook/company/handbook', 'GET /handbook/company/development-groups': '/handbook/company/product-groups', 'GET /docs/using-fleet/mdm-macos-settings': '/docs/using-fleet/mdm-custom-macos-settings', @@ -482,6 +487,7 @@ module.exports.routes = { 'GET /docs/using-fleet/standard-query-library': (req,res)=> { return res.redirect(301, '/guides/standard-query-library');}, 'GET /docs/using-fleet/mdm-commands': (req,res)=> { return res.redirect(301, '/guides/mdm-commands');}, 'GET /docs/using-fleet/log-destinations': (req,res)=> { return res.redirect(301, '/guides/log-destinations');}, + 'GET /guides/how-to-uninstall-osquery': (req,res)=> { return res.redirect(301, '/guides/how-to-uninstall-fleetd');}, // ╔╦╗╦╔═╗╔═╗ ╦═╗╔═╗╔╦╗╦╦═╗╔═╗╔═╗╔╦╗╔═╗ ┬ ╔╦╗╔═╗╦ ╦╔╗╔╦ ╔═╗╔═╗╔╦╗╔═╗ // ║║║║╚═╗║ ╠╦╝║╣ ║║║╠╦╝║╣ ║ ║ ╚═╗ ┌┼─ ║║║ ║║║║║║║║ ║ ║╠═╣ ║║╚═╗ @@ -565,10 +571,14 @@ module.exports.routes = { 'GET /learn-more-about/apple-business-manager-teams-api': 'https://github.com/fleetdm/fleet/blob/main/docs/Contributing/API-for-contributors.md#update-abm-tokens-teams', 'GET /learn-more-about/apple-business-manager-gitops': '/docs/using-fleet/gitops#apple-business-manager', 'GET /learn-more-about/s3-bootstrap-package': '/docs/configuration/fleet-server-configuration#s-3-software-installers-bucket', + 'GET /learn-more-about/available-os-update-versions': '/guides/enforce-os-updates#available-macos-ios-and-ipados-versions', + 'GET /learn-more-about/policy-automation-install-software': '/guides/automatic-software-install-in-fleet', 'GET /learn-more-about/exe-install-scripts': '/guides/exe-install-scripts', 'GET /learn-more-about/install-scripts': '/guides/deploy-software-packages#install-script', 'GET /learn-more-about/uninstall-scripts': '/guides/deploy-software-packages#uninstall-script', - 'GET /learn-more-about/read-package-version': '/guides/deploy-software-packages##add-a-software-package-to-a-team', + 'GET /learn-more-about/read-package-version': '/guides/deploy-software-packages#add-a-software-package-to-a-team', + 'GET /learn-more-about/fleetctl': '/guides/fleetctl', + // Sitemap // =============================================================================================================