diff --git a/.github/workflows/dogfood-gitops.yml b/.github/workflows/dogfood-gitops.yml new file mode 100644 index 0000000000..14c23204cc --- /dev/null +++ b/.github/workflows/dogfood-gitops.yml @@ -0,0 +1,61 @@ +name: 'Apply latest configuration to dogfood with gitops' + +on: + push: + branches: + - main + paths: + - 'it-and-security/**' + - '.github/workflows/dogfood-gitops.yml' + workflow_dispatch: # allows manual triggering + +defaults: + run: + shell: bash + +# Limit permissions of GITHUB_TOKEN. +permissions: + contents: read + +jobs: + fleet-gitops: + timeout-minutes: 10 + runs-on: ubuntu-latest + steps: + - name: Checkout our repository + uses: actions/checkout@v4 + + - name: Checkout GitOps repository + uses: actions/checkout@v4 + with: + repository: fleetdm/fleet-gitops + ref: main + path: fleet-gitops + + - name: Apply env vars to profiles + env: + MANAGED_CHROME_ENROLLMENT_TOKEN: ${{ secrets.CLOUD_MANAGEMENT_ENROLLMENT_TOKEN }} + run: | + envsubst < ./it-and-security/lib/configuration-profiles/macos-chrome-enrollment.mobileconfig > ./it-and-security/lib/configuration-profiles/macos-chrome-enrollment.confidential.mobileconfig + mv ./it-and-security/lib/configuration-profiles/macos-chrome-enrollment.confidential.mobileconfig ./it-and-security/lib/configuration-profiles/macos-chrome-enrollment.mobileconfig + + - name: Apply latest configuration to Fleet + uses: ./fleet-gitops/.github/gitops-action + with: + working-directory: ${{ github.workspace }}/fleet-gitops + env: + FLEET_GITOPS_DIR: ${{ github.workspace }}/it-and-security + FLEET_URL: https://dogfood.fleetdm.com + FLEET_API_TOKEN: ${{ secrets.DOGFOOD_API_TOKEN }} + DOGFOOD_APPLE_BM_DEFAULT_TEAM: Workstations + DOGFOOD_MACOS_MIGRATION_WEBHOOK_URL: ${{ secrets.DOGFOOD_MACOS_MIGRATION_WEBHOOK_URL }} + DOGFOOD_GLOBAL_ENROLL_SECRET: ${{ secrets.DOGFOOD_GLOBAL_ENROLL_SECRET }} + DOGFOOD_SSO_ISSUER_URI: ${{ secrets.DOGFOOD_SSO_ISSUER_URI }} + DOGFOOD_SSO_METADATA: ${{ secrets.DOGFOOD_SSO_METADATA }} + DOGFOOD_FAILING_POLICIES_WEBHOOK_URL: ${{ secrets.DOGFOOD_FAILING_POLICIES_WEBHOOK_URL }} + DOGFOOD_VULNERABILITIES_WEBHOOK_URL: ${{ secrets.DOGFOOD_VULNERABILITIES_WEBHOOK_URL }} + DOGFOOD_WORKSTATIONS_ENROLL_SECRET: ${{ secrets.DOGFOOD_WORKSTATIONS_ENROLL_SECRET }} + DOGFOOD_WORKSTATIONS_CANARY_ENROLL_SECRET: ${{ secrets.DOGFOOD_WORKSTATIONS_CANARY_ENROLL_SECRET }} + DOGFOOD_SERVERS_ENROLL_SECRET: ${{ secrets.DOGFOOD_SERVERS_ENROLL_SECRET }} + DOGFOOD_SERVERS_CANARY_ENROLL_SECRET: ${{ secrets.DOGFOOD_SERVERS_CANARY_ENROLL_SECRET }} + DOGFOOD_EXPLORE_DATA_ENROLL_SECRET: ${{ secrets.DOGFOOD_EXPLORE_DATA_ENROLL_SECRET }} diff --git a/.github/workflows/example-workflow.yaml b/.github/workflows/example-workflow.yaml deleted file mode 100644 index 5a19e87b9f..0000000000 --- a/.github/workflows/example-workflow.yaml +++ /dev/null @@ -1,44 +0,0 @@ -# This workflow applies the latest configuration profiles (macOS settings) and macOS updates minimum version and deadline to the provided team. -name: Apply latest configuration profiles (example) -on: - push: - branches: - - main - paths: - - "path/to/**.mobileconfig" - workflow_dispatch: # Manual - -# This allows a subsequently queued workflow run to interrupt previous runs -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id}} - cancel-in-progress: true - -defaults: - run: - # fail-fast using bash -eo pipefail. See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#exit-codes-and-error-action-preference - shell: bash - -permissions: - contents: read - -env: - FLEET_API_TOKEN: ${{ secrets.DOGFOOD_API_TOKEN }} - FLEET_URL: ${{ secrets.DOGFOOD_URL }} - TOKEN_USED_BY_PROFILE: ${{ secrets.TOKEN_USED_BY_PROFILE }} - -jobs: - apply-profiles: - timeout-minutes: 5 - runs-on: ubuntu-latest - steps: - - name: Apply configuration profiles and updates - uses: fleetdm/fleet-mdm-gitops@15072f2739ef92c6357414ddd86e89b6bf302a2b - with: - FLEET_API_TOKEN: $FLEET_API_TOKEN - FLEET_URL: $FLEET_URL - FLEET_TEAM_NAME: šŸ’»šŸ£ Workstations (canary) - MDM_CONFIG_REPO: fleetdm/fleet - MDM_CONFIG_DIRECTORY: mdm_profiles - MAC_OS_MIN_VERSION: 13.3.2 - MAC_OS_VERSION_DEADLINE: 2023-06-15 - MAC_OS_ENABLE_DISK_ENCRYPTION: true diff --git a/.github/workflows/fleetctl-workstations-canary.yml b/.github/workflows/fleetctl-workstations-canary.yml deleted file mode 100644 index 26c3919766..0000000000 --- a/.github/workflows/fleetctl-workstations-canary.yml +++ /dev/null @@ -1,49 +0,0 @@ -# This workflow applies the latest configuration profiles (macOS settings) and macOS updates minimum version and deadline to the workstations (canary) team. -# It uses a fleet instance also built and executed from source. -# -# It runs automatically when a file is changed in /mdm_profiles. -name: Apply latest configuration profiles and macOS updates (Canary) - -on: - push: - branches: - - main - paths: - - "mdm_profiles/**.mobileconfig" - - ".github/workflows/fleetctl-workstations-canary.yml" - workflow_dispatch: # Manual - -# This allows a subsequently queued workflow run to interrupt previous runs -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id}} - cancel-in-progress: true - -defaults: - run: - # fail-fast using bash -eo pipefail. See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#exit-codes-and-error-action-preference - shell: bash - -permissions: - contents: read - -env: - DOGFOOD_API_TOKEN: ${{ secrets.DOGFOOD_API_TOKEN }} - DOGFOOD_URL: ${{ secrets.DOGFOOD_URL }} - CLOUD_MANAGEMENT_ENROLLMENT_TOKEN: ${{ secrets.CLOUD_MANAGEMENT_ENROLLMENT_TOKEN }} - -jobs: - apply-profiles: - timeout-minutes: 5 - runs-on: ubuntu-latest - steps: - - name: Apply configuration profiles and updates - uses: fleetdm/fleet-mdm-gitops@15072f2739ef92c6357414ddd86e89b6bf302a2b # v1.1.0 - with: - FLEET_API_TOKEN: $DOGFOOD_API_TOKEN - FLEET_URL: $DOGFOOD_URL - FLEET_TEAM_NAME: šŸ’»šŸ£ Workstations (canary) - MDM_CONFIG_REPO: fleetdm/fleet - MDM_CONFIG_DIRECTORY: mdm_profiles - MAC_OS_MIN_VERSION: "14.2" - MAC_OS_VERSION_DEADLINE: 2023-12-15 - MAC_OS_ENABLE_DISK_ENCRYPTION: true diff --git a/.github/workflows/fleetctl-workstations.yml b/.github/workflows/fleetctl-workstations.yml deleted file mode 100644 index fb45ddac96..0000000000 --- a/.github/workflows/fleetctl-workstations.yml +++ /dev/null @@ -1,49 +0,0 @@ -# This workflow applies the latest configuration profiles (macOS settings) and macOS updates minimum version and deadline to the workstations team. -# It uses a Fleet instance also built and executed from source. -# -# It runs when the GitHub action is triggered manually -name: Apply latest configuration profiles and macOS updates - -on: - push: - branches: - - main - paths: - - "mdm_profiles/**.mobileconfig" - - ".github/workflows/fleetctl-workstations.yml" - workflow_dispatch: # Manual - -# This allows a subsequently queued workflow run to interrupt previous runs -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id}} - cancel-in-progress: true - -defaults: - run: - # fail-fast using bash -eo pipefail. See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#exit-codes-and-error-action-preference - shell: bash - -permissions: - contents: read - -env: - DOGFOOD_API_TOKEN: ${{ secrets.DOGFOOD_API_TOKEN }} - DOGFOOD_URL: ${{ secrets.DOGFOOD_URL }} - CLOUD_MANAGEMENT_ENROLLMENT_TOKEN: ${{ secrets.CLOUD_MANAGEMENT_ENROLLMENT_TOKEN }} - -jobs: - apply-profiles: - timeout-minutes: 5 - runs-on: ubuntu-latest - steps: - - name: Apply configuration profiles and updates - uses: fleetdm/fleet-mdm-gitops@15072f2739ef92c6357414ddd86e89b6bf302a2b # v1.1.0 - with: - FLEET_API_TOKEN: $DOGFOOD_API_TOKEN - FLEET_URL: $DOGFOOD_URL - FLEET_TEAM_NAME: šŸ’» Workstations - MDM_CONFIG_REPO: fleetdm/fleet - MDM_CONFIG_DIRECTORY: mdm_profiles - MAC_OS_MIN_VERSION: "14.2" - MAC_OS_VERSION_DEADLINE: 2023-12-19 - MAC_OS_ENABLE_DISK_ENCRYPTION: true diff --git a/.github/workflows/test-puppet.yml b/.github/workflows/test-puppet.yml index 0b8f3c2382..82531fc272 100644 --- a/.github/workflows/test-puppet.yml +++ b/.github/workflows/test-puppet.yml @@ -7,7 +7,7 @@ on: - patch-* pull_request: paths: - - 'ee/tools/puppet/fleetdm/*.*' + - 'ee/tools/puppet/fleetdm/**' - '.github/workflows/test-puppet.yml' workflow_dispatch: # Manual diff --git a/.github/workflows/tfsec.yml b/.github/workflows/tfsec.yml deleted file mode 100644 index 37f535258b..0000000000 --- a/.github/workflows/tfsec.yml +++ /dev/null @@ -1,48 +0,0 @@ -name: tfsec - -on: - push: - branches: - - main - paths: - - '**.tf' - pull_request: - paths: - - '**.tf' - workflow_dispatch: # Manual dispatch - -# This allows a subsequently queued workflow run to interrupt previous runs -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id}} - cancel-in-progress: true - -defaults: - run: - # fail-fast using bash -eo pipefail. See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#exit-codes-and-error-action-preference - shell: bash - -permissions: - contents: read - -jobs: - tfsec: - permissions: - contents: read # for actions/checkout to fetch code - security-events: write # for github/codeql-action/upload-sarif to upload SARIF results - name: tfsec sarif report - runs-on: ubuntu-latest - - steps: - - name: Clone repo - uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b - - - name: tfsec - uses: tfsec/tfsec-sarif-action@21ded20e8ca120cd9d3d6ab04ef746477542a608 - with: - sarif_file: tfsec.sarif - - - name: Upload SARIF file - uses: github/codeql-action/upload-sarif@f6e388ebf0efc915c6c5b165b019ee61a6746a38 - with: - # Path to SARIF file relative to the root of the repository - sarif_file: tfsec.sarif diff --git a/.github/workflows/trivy-scan.yml b/.github/workflows/trivy-scan.yml new file mode 100644 index 0000000000..0d362fe9b4 --- /dev/null +++ b/.github/workflows/trivy-scan.yml @@ -0,0 +1,53 @@ +name: Trivy vulnerability scan +on: + push: + branches: + - main + paths: + - "**.tf" + pull_request: + paths: + - "**.tf" + workflow_dispatch: + schedule: + - cron: "0 4 * * *" # Nightly 4AM UTC + +# This allows a subsequently queued workflow run to interrupt previous runs +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id}} + cancel-in-progress: true + +defaults: + run: + # fail-fast using bash -eo pipefail. See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#exit-codes-and-error-action-preference + shell: bash + +permissions: + contents: read + +jobs: + trivy: + permissions: + contents: read # for actions/checkout to fetch code + security-events: write # for github/codeql-action/upload-sarif to upload SARIF results + name: Trivy sarif report + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0 + + - name: Run Trivy vulnerability scanner in repo mode + uses: aquasecurity/trivy-action@062f2592684a31eb3aa050cc61e7ca1451cecd3d # 0.18.0 + with: + scan-type: "fs" + ignore-unfixed: false + format: "sarif" + output: "trivy-results.sarif" + severity: "CRITICAL,HIGH,MEDIUM,LOW" + trivyignores: ".trivyignore" + + - name: Upload Trivy scan results to GitHub Security tab + uses: github/codeql-action/upload-sarif@8a470fddafa5cbb6266ee11b37ef4d8aae19c571 # v3.24.6 + with: + sarif_file: "trivy-results.sarif" diff --git a/.github/workflows/trivy_scan.yml b/.github/workflows/trivy_scan.yml deleted file mode 100644 index cc31bb39cc..0000000000 --- a/.github/workflows/trivy_scan.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Trivy vulnerability scan -on: - workflow_dispatch: - schedule: - - cron: '0 4 * * *' # Nightly 4AM UTC -jobs: - build: - name: Trivy - runs-on: ubuntu-20.04 - steps: - - name: Checkout code - uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0 - - - name: Run Trivy vulnerability scanner in repo mode - uses: aquasecurity/trivy-action@8bd2f9fbda2109502356ff8a6a89da55b1ead252 # master - with: - scan-type: 'fs' - ignore-unfixed: true - format: 'sarif' - output: 'trivy-results.sarif' - severity: 'CRITICAL' - skip-dirs: 'website/,tools/,infrastructure/,test/,orbit/pkg/insecure/' - trivyignores: '.trivyignore' - security-checks: 'vuln' - - - name: Upload Trivy scan results to GitHub Security tab - uses: github/codeql-action/upload-sarif@f6e388ebf0efc915c6c5b165b019ee61a6746a38 # v2.2.5 - with: - sarif_file: 'trivy-results.sarif' diff --git a/CHANGELOG.md b/CHANGELOG.md index 79c5d92192..1ba584f34c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,19 @@ +## Fleet 4.46.1 (Feb 27, 2024) + +### Bug fixes + +* Fixed a bug in running queries via API. + - Query campaign not clearing from Redis after timeout +* Added logging when a Redis connection is blocked for a long time waiting for live query results. +* Added support for the `redis.conn_wait_timeout` configuration setting for Redis standalone (it was previously only supported on Redis cluster). +* Added Redis cleanup of inactive queries in a cron job, so temporary Redis failures to stop a live query doesn't leave such queries around for a long time. +* Fixed orphaned live queries in Redis when client terminates connection + - `POST /api/latest/fleet/queries/{id}/run` + - `GET /api/latest/fleet/queries/run` + - `POST /api/latest/fleet/hosts/identifier/{identifier}/query` + - `POST /api/latest/fleet/hosts/{id}/query` +* Added --server_frequent_cleanups_enabled (FLEET_SERVER_FREQUENT_CLEANUPS_ENABLED) flag to enable cron job to clean up stale data running every 15 minutes. Currently disabled by default. + ## Fleet 4.46.0 (Feb 26, 2024) ### Changes diff --git a/CODEOWNERS b/CODEOWNERS index 52f1680b77..eba3b36a49 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -84,15 +84,16 @@ go.mod @fleetdm/go # # (see website/config/custom.js for DRIs of other paths not listed here) ############################################################################################## -/handbook/company @mikermcneil -/handbook/README.md @mikermcneil -/handbook/business-operations @sampfluger88 -/handbook/digital-experience @sampfluger88 -/handbook/customer-success @sampfluger88 -/handbook/demand @sampfluger88 -/handbook/engineering @sampfluger88 -/handbook/sales @sampfluger88 -/handbook/product-design @sampfluger88 +/handbook/company @mikermcneil +/handbook/README.md @mikermcneil +/handbook/business-operations @sampfluger88 +/handbook/digital-experience @sampfluger88 +/handbook/customer-success @sampfluger88 +/handbook/demand @sampfluger88 +/handbook/engineering @sampfluger88 @lukeheath +/handbook/sales @sampfluger88 +/handbook/product-design @sampfluger88 +/handbook/company/product-groups @sampfluger88 @lukeheath ############################################################################################## # 🦿 GitHub issue templates diff --git a/articles/the-device-security-tightrope-balancing-cost-and-protection-in-K-12-schools.md b/articles/the-device-security-tightrope-balancing-cost-and-protection-in-K-12-schools.md new file mode 100644 index 0000000000..72d6535287 --- /dev/null +++ b/articles/the-device-security-tightrope-balancing-cost-and-protection-in-K-12-schools.md @@ -0,0 +1,81 @@ +# The device security tightrope: balancing cost and protection in K-12 schools + +![The device security tightrope: balancing cost and protection in K-12 schools](../website/assets/images/articles/the-device-security-tightrope-balancing-cost-and-protection-in-K-12-schools-1600x900@2x.png) + +In today's digital classrooms, the diversity of devices and operating systems—from Macs and Windows to Linux and Chromebooks—presents unique challenges for K-12 schools. As technology becomes increasingly integral to education, the complexity of managing cybersecurity risks grows. The task is daunting: ensuring robust protection against cyber threats while grappling with limited budgets and IT resources. In August 2023, the [U.S. Department of Education highlighted these challenges](https://www.ed.gov/news/press-releases/department-of-education-announces-k-12-cybersecurity-resilience-efforts), emphasizing the need for schools to balance cost with effective cybersecurity measures. This blog post delves into the evolving landscape of digital education, exploring strategies for K-12 schools to navigate the precarious balance between securing their digital infrastructure and managing costs efficiently. Drawing on recent governmental efforts and expert insights, we'll outline actionable steps to lead to a more secure and enriching learning environment for all. + + +## The growing cybersecurity challenge in education + +In August 2023 U.S. Department of Education posted a press release, ā€œ[Key K-12 Cybersecurity Resilience Efforts](https://www.ed.gov/news/press-releases/department-of-education-announces-k-12-cybersecurity-resilience-efforts),ā€ discussing the growing security challenges for educational institutions. U.S. Secretary of Education Miguel Cardona is quoted as saying, + +
Let’s face it: in today’s digital age, our students and their teachers will increasingly use technology in the classroom. Schools have access to more devices and connectivity than ever before, and this technology in education has incredible potential to help students better connect with their learning and achieve, and teachers better engage with their students. But to make the most of these benefits, we must effectively manage the risks. Just as we expect everyone in a school system to plan and prepare for physical risks, we must now also ensure everyone helps plan and prepare for digital risks in our schools and classrooms. The Department of Education has listened to the field about the importance of K-12 cybersecurity, and today we are coming together to recognize this and indicate our next steps. +
+ + +## National attention on cybersecurity in schools + +Even the White House is showing concerns. In August 2023, the White House sponsored The White House’s Back to School Safely: Cybersecurity Summit for K-12 Schools. They discussed the challenges educators, students, and families face in this increasing number of devices schools have to support, as well as the publication by the Department's Office of Educational Technology and CISA titled "K-12 Digital Infrastructure Brief: Defensible and Resilient." Key considerations include: + + + + + +* **Enhancing continuous risk management:** Addressing the ever-evolving threat landscape by proactively managing cybersecurity risks. +* **Utilizing analogies for understanding:** Leveraging lessons from physical world scenarios to comprehensively grasp and tackle cybersecurity challenges. +* **Prioritizing and implementing mitigation strategies:** Identifying the most critical risks and applying effective mitigations such as multi-factor authentication, robust password policies, phishing prevention, and regular software updates. +* **Building resilience for cyber incidents:** Developing and practicing cyber incident response plans to minimize the impact of potential cybersecurity breaches. +* **Vendor engagement for enhanced security:** Encouraging vendors to invest in secure design principles, obtain cyber risk assurance certifications, and establish security vulnerability disclosure practices. + + +## The overlooked factor: balancing cost and security + +Juan Hernandez's article in [The Prey Project in August 2023](https://preyproject.com/blog/device-security-policies-it-schools) discusses why device management is essential for K-12 schools. Hernandez’s article doesn’t address one of the most significant factors, cost. Today K-12 schools struggle with tight budgets, lack of technical resources, and overworked IT and educator staff require school districts to walk a tightrope between cost and risk. Below are a few considerations designed to help schools with this balancing act. + + +## Strategies for cost-effective cybersecurity in schools + + + +* **Embrace open-source:** Consider open-source solutions for their affordability, agility, and strong community support. +* **Cost-effectiveness:** Explore solutions that replace redundant, unused tools and avoid vendor lock-in. +* **Converge IT solutions:** Integrate tools and processes into IT operations for a holistic approach. +* **Consolidate and automate tasks:** Integrate and automate IT operations to reduce redundancy and free up staff for strategic initiatives. +* **Prioritize updates:** Act fast on critical patches to minimize vulnerabilities. Be intentional about what you patch. Focus on known exploits and vulnerabilities that exist in your environment first. +* **Implement endpoint protection:** Defend against cyber threats like ransomware to safeguard sensitive data. +* **User education:** Empower users with cybersecurity awareness training and offer transparency about data collection to build trust. + + +## Additional considerations for a comprehensive approach to cybersecurity + + + +* **Compliance:** Ensure adherence to data privacy regulations like COPPA, FERPA, and GDPR. +* **Continuous adaptation:** Regularly evaluate and adapt your security posture to stay ahead of evolving threats. + + +## Creating a secure and affordable digital learning environment + +In navigating the complex landscape of device security in K-12 schools, it's clear that a balanced approach is not just beneficial; it's necessary. Schools can protect their digital environments by integrating cost-effective strategies with robust cybersecurity measures without straining their budgets. As we've explored, this involves embracing open-source solutions, prioritizing critical updates, and fostering a culture of cybersecurity awareness among all users. + +We understand that every school's situation is unique, with its specific challenges and resources. Therefore, we encourage educational institutions to consider and adapt the strategies discussed here to fit their unique circumstances. Implementing these practices may require time and effort, but the payoff in enhanced security and peace of mind is invaluable. + + +## Let's start a conversation + +How does your school balance the demands of cybersecurity with budget limitations? Have you found innovative solutions or faced particular challenges in this journey? Share your stories, questions, and insights with us. Your experiences enrich our collective understanding and contribute to building a resilient, informed, and supportive community around the critical issue of K-12 cybersecurity. + +Join the dialogue in our community [Fleet Slack channels](https://fleetdm.com/support) to exchange ideas, ask questions, and connect with peers navigating similar challenges. Your input is vital in shaping effective and sustainable cybersecurity practices in education. + +Together, we can create safer digital spaces for our students and educators, ensuring that technology remains a powerful tool for learning and growth, not a vulnerability. + + + + + + + + + + + diff --git a/changes/10488-remote-wipe b/changes/10488-remote-wipe new file mode 100644 index 0000000000..e180b89c90 --- /dev/null +++ b/changes/10488-remote-wipe @@ -0,0 +1 @@ +* Added the `POST /api/v1/fleet/hosts/:id/wipe` Fleet Premium API endpoint to support remote wiping a host. diff --git a/changes/15461-host-dep-assign-profile-responses b/changes/15461-host-dep-assign-profile-responses new file mode 100644 index 0000000000..4bb2178425 --- /dev/null +++ b/changes/15461-host-dep-assign-profile-responses @@ -0,0 +1 @@ +- Added functionality to surface MDM devices where DEP assignment failed. \ No newline at end of file diff --git a/changes/16242-policy-data-for-hosts b/changes/16242-policy-data-for-hosts new file mode 100644 index 0000000000..d5a5eeae85 --- /dev/null +++ b/changes/16242-policy-data-for-hosts @@ -0,0 +1 @@ +GET /hosts API endpoint can now populate policies with populate_policies=true query parameter. diff --git a/changes/16331-address-redis-issues-live-query b/changes/16331-address-redis-issues-live-query deleted file mode 100644 index d5796f8967..0000000000 --- a/changes/16331-address-redis-issues-live-query +++ /dev/null @@ -1,3 +0,0 @@ -* Added logging when a Redis connection is blocked for a long time waiting for live query results. -* Added support for the `redis.conn_wait_timeout` configuration setting for Redis standalone (it was previously only supported on Redis cluster). -* Added Redis cleanup of inactive queries in a cron job, so temporary Redis failures to stop a live query doesn't leave such queries around for a long time. diff --git a/changes/16341-fix-device-user-spacing b/changes/16341-fix-device-user-spacing new file mode 100644 index 0000000000..b4ea7a2c3e --- /dev/null +++ b/changes/16341-fix-device-user-spacing @@ -0,0 +1 @@ +- Updates to spacing on device user page \ No newline at end of file diff --git a/changes/16350-no-team-live-queries b/changes/16350-no-team-live-queries new file mode 100644 index 0000000000..8444e48294 --- /dev/null +++ b/changes/16350-no-team-live-queries @@ -0,0 +1 @@ +* Add a "No team" team option when running live queries from the UI diff --git a/changes/16350-target-no-team-for-live-query b/changes/16350-target-no-team-for-live-query new file mode 100644 index 0000000000..7095c3cfe0 --- /dev/null +++ b/changes/16350-target-no-team-for-live-query @@ -0,0 +1,2 @@ +- API endpoint GET fleet/targets/count can target 'No team' with team_id=0 +- API endpoint POST fleet/queries/run (for async live queries) can target 'No team' with team_id=0 diff --git a/changes/16593-disk-encryption-verifying b/changes/16593-disk-encryption-verifying new file mode 100644 index 0000000000..44e531026d --- /dev/null +++ b/changes/16593-disk-encryption-verifying @@ -0,0 +1 @@ +* Display disk encryption status in macOS as "verifying" while Fleet verifies if the escrowed key can be decrypted. diff --git a/changes/16608-search-target-icon b/changes/16608-search-target-icon new file mode 100644 index 0000000000..50ca4e4179 --- /dev/null +++ b/changes/16608-search-target-icon @@ -0,0 +1 @@ +- Fix position of live query/poilcy host search icon diff --git a/changes/16663-pencil-icon-alignment b/changes/16663-pencil-icon-alignment new file mode 100644 index 0000000000..4bf6aa5d6e --- /dev/null +++ b/changes/16663-pencil-icon-alignment @@ -0,0 +1,2 @@ +- Fix a bug where the pencil icons next to the edit query name and description fields were + inconsistently spaced. diff --git a/changes/16858-vuln-processing-optimization b/changes/16858-vuln-processing-optimization new file mode 100644 index 0000000000..52f1a99710 --- /dev/null +++ b/changes/16858-vuln-processing-optimization @@ -0,0 +1 @@ +Reduced vulnerability processing time by grouping vulnerability dictionary by vendor. diff --git a/changes/16859-vuln-unicode-err b/changes/16859-vuln-unicode-err new file mode 100644 index 0000000000..b974f7cb1f --- /dev/null +++ b/changes/16859-vuln-unicode-err @@ -0,0 +1 @@ +- fixed error during vulnerability processing on non-ascii software names \ No newline at end of file diff --git a/changes/16950-transfer-selected-all-hosts b/changes/16950-transfer-selected-all-hosts new file mode 100644 index 0000000000..d4b619600d --- /dev/null +++ b/changes/16950-transfer-selected-all-hosts @@ -0,0 +1 @@ +- Bug fix: Correctly transfer hosts on multiple pages between teams diff --git a/changes/17091-team-host-status-webhook b/changes/17091-team-host-status-webhook new file mode 100644 index 0000000000..220a13d54f --- /dev/null +++ b/changes/17091-team-host-status-webhook @@ -0,0 +1 @@ +- Implement the UI for the team-specific host status webhook feature. diff --git a/changes/17094-per-team-host-status-webhook b/changes/17094-per-team-host-status-webhook new file mode 100644 index 0000000000..56eaf2dd73 --- /dev/null +++ b/changes/17094-per-team-host-status-webhook @@ -0,0 +1 @@ +Enabling setting host status webhook at the team level via REST API and fleetctl apply/gitops. diff --git a/changes/17197-cleanup-campaign-when-ctx-canceled b/changes/17197-cleanup-campaign-when-ctx-canceled deleted file mode 100644 index 7ff75f5515..0000000000 --- a/changes/17197-cleanup-campaign-when-ctx-canceled +++ /dev/null @@ -1 +0,0 @@ -* Fix orphaned live queries in Redis when client terminates connection (`POST /api/latest/fleet/queries/{id}/run`, `GET /api/latest/fleet/queries/run`, `POST /api/latest/fleet/hosts/identifier/{identifier}/query` and `POST /api/latest/fleet/hosts/{id}/query`). diff --git a/changes/17232-cert-renewal-auth b/changes/17232-cert-renewal-auth new file mode 100644 index 0000000000..9cc7f9b2cd --- /dev/null +++ b/changes/17232-cert-renewal-auth @@ -0,0 +1 @@ +* Prevent running cleanup tasks and re-enqueuing commands for hosts on SCEP renewals. diff --git a/changes/fix-16335-copy b/changes/fix-16335-copy new file mode 100644 index 0000000000..69e4d38ce7 --- /dev/null +++ b/changes/fix-16335-copy @@ -0,0 +1 @@ +- Updates copy in `fleetctl` error when attempting to upload malformed profiles. \ No newline at end of file diff --git a/changes/issue-10489-ui-for-wiping-host b/changes/issue-10489-ui-for-wiping-host new file mode 100644 index 0000000000..066cc8f06e --- /dev/null +++ b/changes/issue-10489-ui-for-wiping-host @@ -0,0 +1 @@ +- add UI for wiping a host with fleet mdm. diff --git a/changes/issue-10494-add-wipe-cli b/changes/issue-10494-add-wipe-cli new file mode 100644 index 0000000000..5184225b79 --- /dev/null +++ b/changes/issue-10494-add-wipe-cli @@ -0,0 +1 @@ +- add wipe command to fleetctl diff --git a/changes/issue-16837-rollup-of-mdm-solutions b/changes/issue-16837-rollup-of-mdm-solutions new file mode 100644 index 0000000000..594a4571df --- /dev/null +++ b/changes/issue-16837-rollup-of-mdm-solutions @@ -0,0 +1 @@ +- rollup the mdm solutions by name on the dashboard mdm card diff --git a/changes/jve-16335 b/changes/jve-16335 new file mode 100644 index 0000000000..692f761ccf --- /dev/null +++ b/changes/jve-16335 @@ -0,0 +1 @@ +- Enables usage of `` nodes in Windows MDM profiles. \ No newline at end of file diff --git a/charts/fleet/Chart.yaml b/charts/fleet/Chart.yaml index 973a096049..9935473716 100644 --- a/charts/fleet/Chart.yaml +++ b/charts/fleet/Chart.yaml @@ -8,7 +8,7 @@ version: v6.0.2 home: https://github.com/fleetdm/fleet sources: - https://github.com/fleetdm/fleet.git -appVersion: v4.45.0 +appVersion: v4.46.1 dependencies: - name: mysql condition: mysql.enabled diff --git a/charts/fleet/values.yaml b/charts/fleet/values.yaml index d4e7f8ec24..8a8d55b5b9 100644 --- a/charts/fleet/values.yaml +++ b/charts/fleet/values.yaml @@ -2,7 +2,7 @@ # All settings related to how Fleet is deployed in Kubernetes hostName: fleet.localhost replicas: 3 # The number of Fleet instances to deploy -imageTag: v4.45.0 # Version of Fleet to deploy +imageTag: v4.46.1 # Version of Fleet to deploy podAnnotations: {} # Additional annotations to add to the Fleet pod serviceAccountAnnotations: {} # Additional annotations to add to the Fleet service account resources: diff --git a/cmd/fleet/cron.go b/cmd/fleet/cron.go index 2400a29598..fad23d1d15 100644 --- a/cmd/fleet/cron.go +++ b/cmd/fleet/cron.go @@ -643,6 +643,9 @@ func newWorkerIntegrationsSchedule( } return nil }), + schedule.WithJob("dep_cooldowns", func(ctx context.Context) error { + return worker.ProcessDEPCooldowns(ctx, ds, logger) + }), ) return s, nil @@ -705,7 +708,6 @@ func newCleanupsAndAggregationSchedule( ctx context.Context, instanceID string, ds fleet.Datastore, - lq fleet.LiveQueryStore, logger kitlog.Logger, enrollHostLimiter fleet.EnrollHostLimiter, config *config.FleetConfig, @@ -721,6 +723,13 @@ func newCleanupsAndAggregationSchedule( schedule.WithAltLockID("leader"), schedule.WithLogger(kitlog.With(logger, "cron", name)), // Run cleanup jobs first. + schedule.WithJob( + "distributed_query_campaigns", + func(ctx context.Context) error { + _, err := ds.CleanupDistributedQueryCampaigns(ctx, time.Now().UTC()) + return err + }, + ), schedule.WithJob( "incoming_hosts", func(ctx context.Context) error { @@ -846,16 +855,16 @@ func newFrequentCleanupsSchedule( s := schedule.New( ctx, name, instanceID, defaultInterval, ds, ds, // Using leader for the lock to be backwards compatilibity with old deployments. - schedule.WithAltLockID("leader"), + schedule.WithAltLockID("leader_frequent_cleanups"), schedule.WithLogger(kitlog.With(logger, "cron", name)), // Run cleanup jobs first. schedule.WithJob( - "distributed_query_campaigns", + "redis_live_queries", func(ctx context.Context) error { - _, err := ds.CleanupDistributedQueryCampaigns(ctx, time.Now().UTC()) - if err != nil { - return err - } + // It's necessary to avoid lingering live queries in case of: + // - (Unknown) bug in the implementation, or, + // - Redis is so overloaded already that the lq.StopQuery in svc.CompleteCampaign fails to execute, or, + // - MySQL is so overloaded that ds.SaveDistributedQueryCampaign in svc.CompleteCampaign fails to execute. names, err := lq.LoadActiveQueryNames() if err != nil { return err @@ -865,10 +874,8 @@ func newFrequentCleanupsSchedule( if err != nil { return err } - if err := lq.CleanupInactiveQueries(ctx, completed); err != nil { - return err - } - return nil + err = lq.CleanupInactiveQueries(ctx, completed) + return err }, ), ) diff --git a/cmd/fleet/serve.go b/cmd/fleet/serve.go index 01ef84b29f..729cc31801 100644 --- a/cmd/fleet/serve.go +++ b/cmd/fleet/serve.go @@ -680,10 +680,14 @@ the way that the Fleet server works. } }() - if err := cronSchedules.StartCronSchedule(func() (fleet.CronSchedule, error) { - return newFrequentCleanupsSchedule(ctx, instanceID, ds, liveQueryStore, logger) - }); err != nil { - initFatal(err, "failed to register frequent_cleanups schedule") + if config.Server.FrequentCleanupsEnabled { + if err := cronSchedules.StartCronSchedule( + func() (fleet.CronSchedule, error) { + return newFrequentCleanupsSchedule(ctx, instanceID, ds, liveQueryStore, logger) + }, + ); err != nil { + initFatal(err, "failed to register frequent_cleanups schedule") + } } if err := cronSchedules.StartCronSchedule( @@ -693,7 +697,7 @@ the way that the Fleet server works. commander = apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService) } return newCleanupsAndAggregationSchedule( - ctx, instanceID, ds, liveQueryStore, logger, redisWrapperDS, &config, commander, + ctx, instanceID, ds, logger, redisWrapperDS, &config, commander, ) }, ); err != nil { diff --git a/cmd/fleet/serve_test.go b/cmd/fleet/serve_test.go index 7dd690966b..0da6a384ed 100644 --- a/cmd/fleet/serve_test.go +++ b/cmd/fleet/serve_test.go @@ -255,7 +255,7 @@ func TestAutomationsSchedule(t *testing.T) { calledOnce := make(chan struct{}) calledTwice := make(chan struct{}) - ds.TotalAndUnseenHostsSinceFunc = func(ctx context.Context, daysCount int) (int, int, error) { + ds.TotalAndUnseenHostsSinceFunc = func(ctx context.Context, teamID *uint, daysCount int) (int, []uint, error) { defer func() { select { case <-calledOnce: @@ -268,7 +268,7 @@ func TestAutomationsSchedule(t *testing.T) { close(calledOnce) } }() - return 10, 6, nil + return 10, []uint{1, 2, 3, 4, 5, 6}, nil } ctx, cancelFunc := context.WithCancel(context.Background()) diff --git a/cmd/fleetctl/apply_test.go b/cmd/fleetctl/apply_test.go index 3de289565a..b2584ae524 100644 --- a/cmd/fleetctl/apply_test.go +++ b/cmd/fleetctl/apply_test.go @@ -385,6 +385,60 @@ spec: assert.Equal(t, newMDMSettings, teamsByName["team1"].Config.MDM) // enroll secret not cleared since not provided assert.Equal(t, []*fleet.EnrollSecret{{Secret: "BBB"}}, enrolledSecretsCalled[uint(42)]) + + // Apply team host_status_webhook + filename = writeTmpYml( + t, ` +apiVersion: v1 +kind: team +spec: + team: + name: team1 + webhook_settings: + host_status_webhook: + days_count: 14 + destination_url: https://example.com + enable_host_status_webhook: true + host_percentage: 25 +`, + ) + + require.Equal(t, "[+] applied 1 teams\n", runAppForTest(t, []string{"apply", "-f", filename})) + // Ensure the webhook settings are applied + assert.Equal( + t, fleet.HostStatusWebhookSettings{ + DaysCount: 14, + DestinationURL: "https://example.com", + Enable: true, + HostPercentage: 25, + }, teamsByName["team1"].Config.WebhookSettings.HostStatusWebhook, + ) + assert.Equal(t, fleet.FailingPoliciesWebhookSettings{}, teamsByName["team1"].Config.WebhookSettings.FailingPoliciesWebhook) + // enroll secret not cleared since not provided + assert.Equal(t, []*fleet.EnrollSecret{{Secret: "BBB"}}, enrolledSecretsCalled[uint(42)]) + + // Apply empty webhook settings + filename = writeTmpYml( + t, ` +apiVersion: v1 +kind: team +spec: + team: + name: team1 + webhook_settings: +`, + ) + + require.Equal(t, "[+] applied 1 teams\n", runAppForTest(t, []string{"apply", "-f", filename})) + // Ensure the webhook settings have not changed + assert.Equal( + t, fleet.HostStatusWebhookSettings{ + DaysCount: 14, + DestinationURL: "https://example.com", + Enable: true, + HostPercentage: 25, + }, teamsByName["team1"].Config.WebhookSettings.HostStatusWebhook, + ) } func writeTmpYml(t *testing.T, contents string) string { diff --git a/cmd/fleetctl/get_test.go b/cmd/fleetctl/get_test.go index f1808cbdf9..8bf99d7742 100644 --- a/cmd/fleetctl/get_test.go +++ b/cmd/fleetctl/get_test.go @@ -360,7 +360,7 @@ func TestGetHosts(t *testing.T) { }, nil } - ds.GetHostLockWipeStatusFunc = func(ctx context.Context, hostID uint, fleetPlatform string) (*fleet.HostLockWipeStatus, error) { + ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) { return &fleet.HostLockWipeStatus{}, nil } diff --git a/cmd/fleetctl/gitops_test.go b/cmd/fleetctl/gitops_test.go index 6c2f033cf4..4f77c2b112 100644 --- a/cmd/fleetctl/gitops_test.go +++ b/cmd/fleetctl/gitops_test.go @@ -514,4 +514,39 @@ func TestFullTeamGitOps(t *testing.T) { assert.Len(t, appliedScripts, 1) assert.Len(t, appliedMacProfiles, 1) assert.Len(t, appliedWinProfiles, 1) + assert.True(t, savedTeam.Config.WebhookSettings.HostStatusWebhook.Enable) + assert.Equal(t, "https://example.com/host_status_webhook", savedTeam.Config.WebhookSettings.HostStatusWebhook.DestinationURL) + + // Now clear the settings + tmpFile, err := os.CreateTemp(t.TempDir(), "*.yml") + require.NoError(t, err) + secret := "TestSecret" + t.Setenv("TEST_SECRET", secret) + + _, err = tmpFile.WriteString( + ` +controls: +queries: +policies: +agent_options: +name: ${TEST_TEAM_NAME} +team_settings: + secrets: [{"secret":"${TEST_SECRET}"}] +`, + ) + require.NoError(t, err) + + // Dry run + savedTeam = nil + _ = runAppForTest(t, []string{"gitops", "-f", tmpFile.Name(), "--dry-run"}) + assert.Nil(t, savedTeam) + + // Real run + _ = runAppForTest(t, []string{"gitops", "-f", tmpFile.Name()}) + require.NotNil(t, savedTeam) + assert.Equal(t, teamName, savedTeam.Name) + require.Len(t, enrolledSecrets, 1) + assert.Equal(t, secret, enrolledSecrets[0].Secret) + assert.False(t, savedTeam.Config.WebhookSettings.HostStatusWebhook.Enable) + assert.Equal(t, "", savedTeam.Config.WebhookSettings.HostStatusWebhook.DestinationURL) } diff --git a/cmd/fleetctl/mdm.go b/cmd/fleetctl/mdm.go index e230c91434..ce5841dab6 100644 --- a/cmd/fleetctl/mdm.go +++ b/cmd/fleetctl/mdm.go @@ -27,6 +27,7 @@ func mdmCommand() *cli.Command { mdmRunCommand(), mdmLockCommand(), mdmUnlockCommand(), + mdmWipeCommand(), }, } } @@ -179,38 +180,11 @@ func mdmLockCommand() *cli.Command { Action: func(c *cli.Context) error { hostIdent := c.String("host") - if len(hostIdent) == 0 { - return errors.New("No host targeted. Please provide --host.") - } - - client, err := clientFromCLI(c) + client, host, err := hostMdmActionSetup(c, hostIdent, "lock") if err != nil { - return fmt.Errorf("create client: %w", err) - } - - host, err := client.HostByIdentifier(hostIdent) - if err != nil { - var nfe service.NotFoundErr - if errors.As(err, &nfe) { - return errors.New("The host doesn't exist. Please provide a valid host identifier.") - } - - var sce kithttp.StatusCoder - if errors.As(err, &sce) { - if sce.StatusCode() == http.StatusForbidden { - return errors.New("Permission denied. You don't have permission to lock this host.") - } - } return err } - if host.Platform == "windows" || host.Platform == "darwin" { - if host.MDM.EnrollmentStatus == nil || !strings.HasPrefix(*host.MDM.EnrollmentStatus, "On") || - host.MDM.Name != fleet.WellKnownMDMFleet { - return errors.New(`Can't lock the host because it doesn't have MDM turned on.`) - } - } - if err := client.MDMLockHost(host.ID); err != nil { return fmt.Errorf("Failed to lock host: %w", err) } @@ -245,38 +219,11 @@ func mdmUnlockCommand() *cli.Command { Action: func(c *cli.Context) error { hostIdent := c.String("host") - if len(hostIdent) == 0 { - return errors.New("No host targeted. Please provide --host.") - } - - client, err := clientFromCLI(c) + client, host, err := hostMdmActionSetup(c, hostIdent, "unlock") if err != nil { - return fmt.Errorf("create client: %w", err) - } - - host, err := client.HostByIdentifier(hostIdent) - if err != nil { - var nfe service.NotFoundErr - if errors.As(err, &nfe) { - return errors.New("The host doesn't exist. Please provide a valid host identifier.") - } - - var sce kithttp.StatusCoder - if errors.As(err, &sce) { - if sce.StatusCode() == http.StatusForbidden { - return errors.New("Permission denied. You don't have permission to unlock this host.") - } - } return err } - if host.Platform == "windows" || host.Platform == "darwin" { - if host.MDM.EnrollmentStatus == nil || !strings.HasPrefix(*host.MDM.EnrollmentStatus, "On") || - host.MDM.Name != fleet.WellKnownMDMFleet { - return errors.New(`Can't unlock the host because it doesn't have MDM turned on.`) - } - } - pin, err := client.MDMUnlockHost(host.ID) if err != nil { return fmt.Errorf("Failed to unlock host: %w", err) @@ -306,3 +253,88 @@ fleetctl get host %s }, } } + +// create a mdm command to wipe the device +func mdmWipeCommand() *cli.Command { + return &cli.Command{ + Name: "wipe", + Usage: "Wipe a host to erase all content on a workstation.", + Flags: []cli.Flag{contextFlag(), debugFlag(), &cli.StringFlag{ + Name: "host", + Usage: "The host, specified by identifier, that you want to wipe.", + Required: true, + }}, + Action: func(c *cli.Context) error { + hostIdent := c.String("host") + + client, host, err := hostMdmActionSetup(c, hostIdent, "wipe") + if err != nil { + return err + } + + config, err := client.GetAppConfig() + if err != nil { + return err + } + + // linux hosts need scripts to be enabled in the org settings to wipe. + if host.Platform == "linux" && config.ServerSettings.ScriptsDisabled { + return errors.New("Can't wipe host because running scripts is disabled in organization settings.") + } + + if err := client.MDMWipeHost(host.ID); err != nil { + return fmt.Errorf("Failed to wipe host: %w", err) + } + + fmt.Fprintf(c.App.Writer, ` +The host will wipe when it comes online. + +Copy and run this command to see results: + +fleetctl get host %s`, hostIdent) + + return nil + }, + } +} + +// Does some common setup for the host mdm actions such as validating the host, +// creating the client, getting the desired host, checking permissions, and +// ensuring MDM is turned on for the host. +func hostMdmActionSetup(c *cli.Context, hostIdent string, actionType string) (client *service.Client, host *service.HostDetailResponse, err error) { + if len(hostIdent) == 0 { + return nil, nil, errors.New("No host targeted. Please provide --host.") + } + + client, err = clientFromCLI(c) + if err != nil { + return nil, nil, fmt.Errorf("create client: %w", err) + } + + host, err = client.HostByIdentifier(hostIdent) + if err != nil { + var nfe service.NotFoundErr + if errors.As(err, &nfe) { + fmt.Println(hostIdent) + return nil, nil, errors.New("The host doesn't exist. Please provide a valid host identifier.") + } + + var sce kithttp.StatusCoder + if errors.As(err, &sce) { + if sce.StatusCode() == http.StatusForbidden { + return nil, nil, fmt.Errorf("Permission denied. You don't have permission to %s this host.", actionType) + } + } + return nil, nil, err + } + + // check mdm is on for the host + if host.Platform == "windows" || host.Platform == "darwin" { + if host.MDM.EnrollmentStatus == nil || !strings.HasPrefix(*host.MDM.EnrollmentStatus, "On") || + host.MDM.Name != fleet.WellKnownMDMFleet { + return nil, nil, fmt.Errorf("Can't %s the host because it doesn't have MDM turned on.", actionType) + } + } + + return client, host, nil +} diff --git a/cmd/fleetctl/mdm_test.go b/cmd/fleetctl/mdm_test.go index 797e05cc11..d275e15aab 100644 --- a/cmd/fleetctl/mdm_test.go +++ b/cmd/fleetctl/mdm_test.go @@ -196,7 +196,7 @@ func TestMDMRunCommand(t *testing.T) { ds.GetHostMDMMacOSSetupFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDMMacOSSetup, error) { return nil, nil } - ds.GetHostLockWipeStatusFunc = func(ctx context.Context, hostID uint, fleetPlatform string) (*fleet.HostLockWipeStatus, error) { + ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) { return &fleet.HostLockWipeStatus{}, nil } ds.ListHostsLiteByUUIDsFunc = func(ctx context.Context, filter fleet.TeamFilter, uuids []string) ([]*fleet.Host, error) { @@ -370,6 +370,21 @@ func TestMDMLockCommand(t *testing.T) { MDMInfo: &fleet.HostMDM{Enrolled: true, Name: fleet.WellKnownMDMFleet}, MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("On (manual)")}, } + winEnrolledWP := &fleet.Host{ + ID: 12, + UUID: "win-enrolled-wp", + Platform: "windows", + MDMInfo: &fleet.HostMDM{Enrolled: true, Name: fleet.WellKnownMDMFleet}, + MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("On (manual)")}, + } + macEnrolledWP := &fleet.Host{ + ID: 13, + UUID: "mac-enrolled-wp", + Platform: "darwin", + MDMInfo: &fleet.HostMDM{Enrolled: true, Name: fleet.WellKnownMDMFleet}, + MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("On (manual)")}, + } + hostByUUID := make(map[string]*fleet.Host) hostsByID := make(map[uint]*fleet.Host) for _, h := range []*fleet.Host{ @@ -384,6 +399,8 @@ func TestMDMLockCommand(t *testing.T) { macEnrolledUP, winEnrolledLP, macEnrolledLP, + winEnrolledWP, + macEnrolledWP, } { hostByUUID[h.UUID] = h hostsByID[h.ID] = h @@ -393,58 +410,28 @@ func TestMDMLockCommand(t *testing.T) { winEnrolledUP.ID: winEnrolledUP, macEnrolledUP.ID: macEnrolledUP, } + lockPending := map[uint]*fleet.Host{ winEnrolledLP.ID: winEnrolledLP, macEnrolledLP.ID: macEnrolledLP, } - enqueuer := new(mock.MDMAppleStore) - license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)} + wipePending := map[uint]*fleet.Host{ + winEnrolledWP.ID: winEnrolledWP, + macEnrolledWP.ID: macEnrolledWP, + } - _, ds := runServerWithMockedDS(t, &service.TestServerOpts{ - MDMStorage: enqueuer, - MDMPusher: mockPusher{}, - License: license, - NoCacheDatastore: true, - }) + ds := setupTestServer(t) + setupDSMocks(ds, hostByUUID, hostsByID) + + // custom ds mocks for these tests + ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) { + fleetPlatform := host.FleetPlatform() - // Mock datastore funcs - ds.HostByIdentifierFunc = func(ctx context.Context, identifier string) (*fleet.Host, error) { - h, ok := hostByUUID[identifier] - if !ok { - return nil, ¬FoundError{} - } - return h, nil - } - ds.LoadHostSoftwareFunc = func(ctx context.Context, host *fleet.Host, includeCVEScores bool) error { - return nil - } - ds.ListPacksForHostFunc = func(ctx context.Context, hid uint) (packs []*fleet.Pack, err error) { - return nil, nil - } - ds.ListHostBatteriesFunc = func(ctx context.Context, id uint) ([]*fleet.HostBattery, error) { - return nil, nil - } - ds.ListPoliciesForHostFunc = func(ctx context.Context, host *fleet.Host) ([]*fleet.HostPolicy, error) { - return nil, nil - } - ds.ListLabelsForHostFunc = func(ctx context.Context, hid uint) ([]*fleet.Label, error) { - return nil, nil - } - ds.GetHostMDMAppleProfilesFunc = func(ctx context.Context, hostUUID string) ([]fleet.HostMDMAppleProfile, error) { - return nil, nil - } - ds.GetHostMDMWindowsProfilesFunc = func(ctx context.Context, hostUUID string) ([]fleet.HostMDMWindowsProfile, error) { - return nil, nil - } - ds.GetHostMDMMacOSSetupFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDMMacOSSetup, error) { - return nil, nil - } - ds.GetHostLockWipeStatusFunc = func(ctx context.Context, hostID uint, fleetPlatform string) (*fleet.HostLockWipeStatus, error) { var status fleet.HostLockWipeStatus status.HostFleetPlatform = fleetPlatform - if _, ok := unlockPending[hostID]; ok { + if _, ok := unlockPending[host.ID]; ok { if fleetPlatform == "darwin" { status.UnlockPIN = "1234" status.UnlockRequestedAt = time.Now() @@ -454,7 +441,7 @@ func TestMDMLockCommand(t *testing.T) { status.UnlockScript = &fleet.HostScriptResult{} } - if _, ok := lockPending[hostID]; ok { + if _, ok := lockPending[host.ID]; ok { if fleetPlatform == "darwin" { status.LockMDMCommand = &fleet.MDMCommand{} return &status, nil @@ -463,38 +450,24 @@ func TestMDMLockCommand(t *testing.T) { status.LockScript = &fleet.HostScriptResult{} } + if _, ok := wipePending[host.ID]; ok { + if fleetPlatform == "linux" { + status.WipeScript = &fleet.HostScriptResult{ExitCode: nil} + return &status, nil + } + + status.WipeMDMCommand = &fleet.MDMCommand{} + status.WipeMDMCommandResult = nil + return &status, nil + } + return &status, nil } - ds.LockHostViaScriptFunc = func(ctx context.Context, request *fleet.HostScriptRequestPayload) error { - return nil - } - ds.HostLiteFunc = func(ctx context.Context, hostID uint) (*fleet.Host, error) { - h, ok := hostsByID[hostID] - if !ok { - return nil, ¬FoundError{} - } - - return h, nil - } - ds.GetMDMWindowsBitLockerStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostMDMDiskEncryption, error) { - return nil, nil - } - ds.GetHostMDMFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDM, error) { - h, ok := hostsByID[hostID] - if !ok { - return nil, ¬FoundError{} - } - - return h.MDMInfo, nil - } - ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error { + ds.LockHostViaScriptFunc = func(ctx context.Context, request *fleet.HostScriptRequestPayload, platform string) error { return nil } - appCfgAllMDM := &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: true, WindowsEnabledAndConfigured: true}} - appCfgWinMDM := &fleet.AppConfig{MDM: fleet.MDM{WindowsEnabledAndConfigured: true}} - appCfgMacMDM := &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: true}} - appCfgNoMDM := &fleet.AppConfig{MDM: fleet.MDM{}} + appCfgAllMDM, appCfgWinMDM, appCfgMacMDM, appCfgNoMDM := setupAppConigs() successfulOutput := func(ident string) string { return fmt.Sprintf(` @@ -535,25 +508,11 @@ fleetctl mdm unlock --host=%s {appCfgAllMDM, "valid macos but pending unlock", []string{"--host", macEnrolledUP.UUID}, "Host has pending unlock request."}, {appCfgAllMDM, "valid windows but pending lock", []string{"--host", winEnrolledLP.UUID}, "Host has pending lock request."}, {appCfgAllMDM, "valid macos but pending lock", []string{"--host", macEnrolledLP.UUID}, "Host has pending lock request."}, - // TODO: add test for wipe once implemented + {appCfgAllMDM, "valid windows but pending wipe", []string{"--host", winEnrolledWP.UUID}, "Host has pending wipe request."}, + {appCfgAllMDM, "valid macos but pending wipe", []string{"--host", macEnrolledWP.UUID}, "Host has pending wipe request."}, } - for _, c := range cases { - ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { - return c.appCfg, nil - } - enqueuer.EnqueueDeviceLockCommandFunc = func(ctx context.Context, host *fleet.Host, cmd *mdm.Command, pin string) error { - return nil - } - buf, err := runAppNoChecks(append([]string{"mdm", "lock"}, c.flags...)) - if c.wantErr != "" { - require.Error(t, err, c.desc) - require.ErrorContains(t, err, c.wantErr, c.desc) - } else { - require.NoError(t, err, c.desc) - require.Equal(t, buf.String(), successfulOutput(c.flags[1]), c.desc) - } - } + runTestCases(t, ds, "lock", successfulOutput, cases) } func TestMDMUnlockCommand(t *testing.T) { @@ -614,7 +573,6 @@ func TestMDMUnlockCommand(t *testing.T) { MDMInfo: &fleet.HostMDM{Enrolled: true, Name: fleet.WellKnownMDMFleet}, MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("On (manual)")}, } - winEnrolledLP := &fleet.Host{ ID: 10, UUID: "win-enrolled-lp", @@ -629,6 +587,20 @@ func TestMDMUnlockCommand(t *testing.T) { MDMInfo: &fleet.HostMDM{Enrolled: true, Name: fleet.WellKnownMDMFleet}, MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("On (manual)")}, } + winEnrolledWP := &fleet.Host{ + ID: 12, + UUID: "win-enrolled-wp", + Platform: "windows", + MDMInfo: &fleet.HostMDM{Enrolled: true, Name: fleet.WellKnownMDMFleet}, + MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("On (manual)")}, + } + macEnrolledWP := &fleet.Host{ + ID: 13, + UUID: "mac-enrolled-wp", + Platform: "darwin", + MDMInfo: &fleet.HostMDM{Enrolled: true, Name: fleet.WellKnownMDMFleet}, + MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("On (manual)")}, + } hostByUUID := make(map[string]*fleet.Host) hostsByID := make(map[uint]*fleet.Host) @@ -644,6 +616,8 @@ func TestMDMUnlockCommand(t *testing.T) { macEnrolledUP, winEnrolledLP, macEnrolledLP, + winEnrolledWP, + macEnrolledWP, } { hostByUUID[h.UUID] = h hostsByID[h.ID] = h @@ -664,56 +638,21 @@ func TestMDMUnlockCommand(t *testing.T) { macEnrolledLP.ID: macEnrolledLP, } - enqueuer := new(mock.MDMAppleStore) - license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)} - - enqueuer.EnqueueDeviceLockCommandFunc = func(ctx context.Context, host *fleet.Host, cmd *mdm.Command, pin string) error { - return nil + wipePending := map[uint]*fleet.Host{ + winEnrolledWP.ID: winEnrolledWP, + macEnrolledWP.ID: macEnrolledWP, } - _, ds := runServerWithMockedDS(t, &service.TestServerOpts{ - MDMStorage: enqueuer, - MDMPusher: mockPusher{}, - License: license, - NoCacheDatastore: true, - }) + ds := setupTestServer(t) + setupDSMocks(ds, hostByUUID, hostsByID) + + // custom mocks for these test + ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) { + fleetPlatform := host.FleetPlatform() - // Mock datastore funcs - ds.HostByIdentifierFunc = func(ctx context.Context, identifier string) (*fleet.Host, error) { - h, ok := hostByUUID[identifier] - if !ok { - return nil, ¬FoundError{} - } - return h, nil - } - ds.LoadHostSoftwareFunc = func(ctx context.Context, host *fleet.Host, includeCVEScores bool) error { - return nil - } - ds.ListPacksForHostFunc = func(ctx context.Context, hid uint) (packs []*fleet.Pack, err error) { - return nil, nil - } - ds.ListHostBatteriesFunc = func(ctx context.Context, id uint) ([]*fleet.HostBattery, error) { - return nil, nil - } - ds.ListPoliciesForHostFunc = func(ctx context.Context, host *fleet.Host) ([]*fleet.HostPolicy, error) { - return nil, nil - } - ds.ListLabelsForHostFunc = func(ctx context.Context, hid uint) ([]*fleet.Label, error) { - return nil, nil - } - ds.GetHostMDMAppleProfilesFunc = func(ctx context.Context, hostUUID string) ([]fleet.HostMDMAppleProfile, error) { - return nil, nil - } - ds.GetHostMDMWindowsProfilesFunc = func(ctx context.Context, hostUUID string) ([]fleet.HostMDMWindowsProfile, error) { - return nil, nil - } - ds.GetHostMDMMacOSSetupFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDMMacOSSetup, error) { - return nil, nil - } - ds.GetHostLockWipeStatusFunc = func(ctx context.Context, hostID uint, fleetPlatform string) (*fleet.HostLockWipeStatus, error) { var status fleet.HostLockWipeStatus status.HostFleetPlatform = fleetPlatform - if _, ok := locked[hostID]; ok { + if _, ok := locked[host.ID]; ok { if fleetPlatform == "darwin" { status.LockMDMCommand = &fleet.MDMCommand{} status.LockMDMCommandResult = &fleet.MDMCommandResult{Status: fleet.MDMAppleStatusAcknowledged} @@ -723,7 +662,7 @@ func TestMDMUnlockCommand(t *testing.T) { status.LockScript = &fleet.HostScriptResult{ExitCode: ptr.Int64(0)} } - if _, ok := unlockPending[hostID]; ok { + if _, ok := unlockPending[host.ID]; ok { if fleetPlatform == "darwin" { status.UnlockPIN = "1234" status.UnlockRequestedAt = time.Now() @@ -733,7 +672,7 @@ func TestMDMUnlockCommand(t *testing.T) { status.UnlockScript = &fleet.HostScriptResult{} } - if _, ok := lockPending[hostID]; ok { + if _, ok := lockPending[host.ID]; ok { if fleetPlatform == "darwin" { status.LockMDMCommand = &fleet.MDMCommand{} return &status, nil @@ -742,41 +681,27 @@ func TestMDMUnlockCommand(t *testing.T) { status.LockScript = &fleet.HostScriptResult{} } + if _, ok := wipePending[host.ID]; ok { + if fleetPlatform == "linux" { + status.WipeScript = &fleet.HostScriptResult{ExitCode: nil} + return &status, nil + } + + status.WipeMDMCommand = &fleet.MDMCommand{} + status.WipeMDMCommandResult = nil + return &status, nil + } + return &status, nil } - ds.UnlockHostViaScriptFunc = func(ctx context.Context, request *fleet.HostScriptRequestPayload) error { + ds.UnlockHostViaScriptFunc = func(ctx context.Context, request *fleet.HostScriptRequestPayload, platform string) error { return nil } - ds.UnlockHostManuallyFunc = func(ctx context.Context, hostID uint, ts time.Time) error { - return nil - } - ds.HostLiteFunc = func(ctx context.Context, hostID uint) (*fleet.Host, error) { - h, ok := hostsByID[hostID] - if !ok { - return nil, ¬FoundError{} - } - - return h, nil - } - ds.GetMDMWindowsBitLockerStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostMDMDiskEncryption, error) { - return nil, nil - } - ds.GetHostMDMFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDM, error) { - h, ok := hostsByID[hostID] - if !ok { - return nil, ¬FoundError{} - } - - return h.MDMInfo, nil - } - ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error { + ds.UnlockHostManuallyFunc = func(ctx context.Context, hostID uint, platform string, ts time.Time) error { return nil } - appCfgAllMDM := &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: true, WindowsEnabledAndConfigured: true}} - appCfgWinMDM := &fleet.AppConfig{MDM: fleet.MDM{WindowsEnabledAndConfigured: true}} - appCfgMacMDM := &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: true}} - appCfgNoMDM := &fleet.AppConfig{MDM: fleet.MDM{}} + appCfgAllMDM, appCfgWinMDM, appCfgMacMDM, appCfgNoMDM := setupAppConigs() successfulOutput := func(ident string) string { h := hostByUUID[ident] @@ -801,7 +726,7 @@ fleetctl get host %s }{ {appCfgAllMDM, "no flags", nil, `Required flag "host" not set`}, {appCfgAllMDM, "host flag empty", []string{"--host", ""}, `No host targeted. Please provide --host.`}, - {appCfgAllMDM, "lock non-existent host", []string{"--host", "notfound"}, `The host doesn't exist. Please provide a valid host identifier.`}, + {appCfgAllMDM, "unlock non-existent host", []string{"--host", "notfound"}, `The host doesn't exist. Please provide a valid host identifier.`}, {appCfgMacMDM, "valid windows but only macos mdm", []string{"--host", winEnrolled.UUID}, `Windows MDM isn't turned on.`}, {appCfgAllMDM, "valid windows", []string{"--host", winEnrolled.UUID}, ""}, {appCfgAllMDM, "valid macos", []string{"--host", macEnrolled.UUID}, ""}, @@ -817,22 +742,310 @@ fleetctl get host %s {appCfgAllMDM, "valid macos but pending unlock", []string{"--host", macEnrolledUP.UUID}, ""}, {appCfgAllMDM, "valid windows but pending lock", []string{"--host", winEnrolledLP.UUID}, "Host has pending lock request."}, {appCfgAllMDM, "valid macos but pending lock", []string{"--host", macEnrolledLP.UUID}, "Host has pending lock request."}, - // TODO: add test for wipe once implemented + {appCfgAllMDM, "valid windows but pending wipe", []string{"--host", winEnrolledWP.UUID}, "Host has pending wipe request."}, + {appCfgAllMDM, "valid macos but pending wipe", []string{"--host", macEnrolledWP.UUID}, "Host has pending wipe request."}, } - for _, c := range cases { - ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { - return c.appCfg, nil - } - buf, err := runAppNoChecks(append([]string{"mdm", "unlock"}, c.flags...)) - if c.wantErr != "" { - require.Error(t, err, c.desc) - require.ErrorContains(t, err, c.wantErr, c.desc) - } else { - require.NoError(t, err, c.desc) - require.Contains(t, buf.String(), successfulOutput(c.flags[1]), c.desc) - } + runTestCases(t, ds, "unlock", successfulOutput, cases) +} + +func TestMDMWipeCommand(t *testing.T) { + macEnrolled := &fleet.Host{ + ID: 1, + UUID: "mac-enrolled", + Platform: "darwin", + MDMInfo: &fleet.HostMDM{Enrolled: true, Name: fleet.WellKnownMDMFleet}, + MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("On (manual)")}, } + winEnrolled := &fleet.Host{ + ID: 2, + UUID: "win-enrolled", + Platform: "windows", + MDMInfo: &fleet.HostMDM{Enrolled: true, Name: fleet.WellKnownMDMFleet}, + MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("On (manual)")}, + } + linuxEnrolled := &fleet.Host{ + ID: 3, + UUID: "linux-enrolled", + Platform: "linux", + } + winNotEnrolled := &fleet.Host{ + ID: 4, + UUID: "win-not-enrolled", + Platform: "windows", + } + macNotEnrolled := &fleet.Host{ + ID: 5, + UUID: "mac-not-enrolled", + Platform: "darwin", + } + macPending := &fleet.Host{ + ID: 6, + UUID: "mac-pending", + Platform: "darwin", + MDMInfo: &fleet.HostMDM{Enrolled: false, Name: fleet.WellKnownMDMFleet}, + MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("Pending")}, + } + winPending := &fleet.Host{ + ID: 7, + UUID: "win-pending", + Platform: "windows", + MDMInfo: &fleet.HostMDM{Enrolled: false, Name: fleet.WellKnownMDMFleet}, + MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("Pending")}, + } + winEnrolledUP := &fleet.Host{ + ID: 8, + UUID: "win-enrolled-up", + Platform: "windows", + MDMInfo: &fleet.HostMDM{Enrolled: true, Name: fleet.WellKnownMDMFleet}, + MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("On (manual)")}, + } + macEnrolledUP := &fleet.Host{ + ID: 9, + UUID: "mac-enrolled-up", + Platform: "darwin", + MDMInfo: &fleet.HostMDM{Enrolled: true, Name: fleet.WellKnownMDMFleet}, + MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("On (manual)")}, + } + winEnrolledLP := &fleet.Host{ + ID: 10, + UUID: "win-enrolled-lp", + Platform: "windows", + MDMInfo: &fleet.HostMDM{Enrolled: true, Name: fleet.WellKnownMDMFleet}, + MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("On (manual)")}, + } + macEnrolledLP := &fleet.Host{ + ID: 11, + UUID: "mac-enrolled-lp", + Platform: "darwin", + MDMInfo: &fleet.HostMDM{Enrolled: true, Name: fleet.WellKnownMDMFleet}, + MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("On (manual)")}, + } + winEnrolledWP := &fleet.Host{ + ID: 12, + UUID: "win-enrolled-wp", + Platform: "windows", + MDMInfo: &fleet.HostMDM{Enrolled: true, Name: fleet.WellKnownMDMFleet}, + MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("On (manual)")}, + } + macEnrolledWP := &fleet.Host{ + ID: 13, + UUID: "mac-enrolled-wp", + Platform: "darwin", + MDMInfo: &fleet.HostMDM{Enrolled: true, Name: fleet.WellKnownMDMFleet}, + MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("On (manual)")}, + } + winEnrolledWiped := &fleet.Host{ + ID: 14, + UUID: "win-enrolled-wiped", + Platform: "windows", + MDMInfo: &fleet.HostMDM{Enrolled: true, Name: fleet.WellKnownMDMFleet}, + MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("On (manual)")}, + } + macEnrolledWiped := &fleet.Host{ + ID: 15, + UUID: "mac-enrolled-wiped", + Platform: "darwin", + MDMInfo: &fleet.HostMDM{Enrolled: true, Name: fleet.WellKnownMDMFleet}, + MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("On (manual)")}, + } + winEnrolledLocked := &fleet.Host{ + ID: 16, + UUID: "win-enrolled-locked", + Platform: "windows", + MDMInfo: &fleet.HostMDM{Enrolled: true, Name: fleet.WellKnownMDMFleet}, + MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("On (manual")}, + } + macEnrolledLocked := &fleet.Host{ + ID: 17, + UUID: "mac-enrolled-locked", + Platform: "darwin", + MDMInfo: &fleet.HostMDM{Enrolled: true, Name: fleet.WellKnownMDMFleet}, + MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("On (manual")}, + } + + hostByUUID := make(map[string]*fleet.Host) + hostsByID := make(map[uint]*fleet.Host) + for _, h := range []*fleet.Host{ + winEnrolled, + macEnrolled, + linuxEnrolled, + macNotEnrolled, + winNotEnrolled, + macPending, + winPending, + winEnrolledUP, + macEnrolledUP, + winEnrolledLP, + macEnrolledLP, + winEnrolledWP, + macEnrolledWP, + winEnrolledWiped, + macEnrolledWiped, + winEnrolledLocked, + macEnrolledLocked, + } { + hostByUUID[h.UUID] = h + hostsByID[h.ID] = h + } + + locked := map[uint]*fleet.Host{ + winEnrolledLocked.ID: winEnrolledLocked, + macEnrolledLocked.ID: macEnrolledLocked, + } + + unlockPending := map[uint]*fleet.Host{ + winEnrolledUP.ID: winEnrolledUP, + macEnrolledUP.ID: macEnrolledUP, + } + + lockPending := map[uint]*fleet.Host{ + winEnrolledLP.ID: winEnrolledLP, + macEnrolledLP.ID: macEnrolledLP, + } + + wipePending := map[uint]*fleet.Host{ + winEnrolledWP.ID: winEnrolledWP, + macEnrolledWP.ID: macEnrolledWP, + } + + wiped := map[uint]*fleet.Host{ + winEnrolledWiped.ID: winEnrolledWiped, + macEnrolledWiped.ID: macEnrolledWiped, + } + + ds := setupTestServer(t) + setupDSMocks(ds, hostByUUID, hostsByID) + + // TODO: custom ds mocks for these tests + ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) { + fleetPlatform := host.FleetPlatform() + + var status fleet.HostLockWipeStatus + status.HostFleetPlatform = fleetPlatform + if _, ok := locked[host.ID]; ok { + if fleetPlatform == "darwin" { + status.LockMDMCommand = &fleet.MDMCommand{} + status.LockMDMCommandResult = &fleet.MDMCommandResult{Status: fleet.MDMAppleStatusAcknowledged} + return &status, nil + } + + status.LockScript = &fleet.HostScriptResult{ExitCode: ptr.Int64(0)} + } + + if _, ok := unlockPending[host.ID]; ok { + if fleetPlatform == "darwin" { + status.UnlockPIN = "1234" + status.UnlockRequestedAt = time.Now() + return &status, nil + } + + status.UnlockScript = &fleet.HostScriptResult{} + } + + if _, ok := lockPending[host.ID]; ok { + if fleetPlatform == "darwin" { + status.LockMDMCommand = &fleet.MDMCommand{} + return &status, nil + } + + status.LockScript = &fleet.HostScriptResult{} + } + + if _, ok := wipePending[host.ID]; ok { + if fleetPlatform == "linux" { + status.WipeScript = &fleet.HostScriptResult{ExitCode: nil} + return &status, nil + } + + status.WipeMDMCommand = &fleet.MDMCommand{} + status.WipeMDMCommandResult = nil + return &status, nil + } + + if _, ok := wiped[host.ID]; ok { + if fleetPlatform == "linux" { + status.WipeScript = &fleet.HostScriptResult{ExitCode: ptr.Int64(0)} + } + + if fleetPlatform == "darwin" { + status.WipeMDMCommand = &fleet.MDMCommand{} + status.WipeMDMCommandResult = &fleet.MDMCommandResult{ + Status: fleet.MDMAppleStatusAcknowledged, + } + } + + if fleetPlatform == "windows" { + status.WipeMDMCommand = &fleet.MDMCommand{} + status.WipeMDMCommandResult = &fleet.MDMCommandResult{ + Status: "200", + } + } + + return &status, nil + } + + return &status, nil + } + ds.UnlockHostViaScriptFunc = func(ctx context.Context, request *fleet.HostScriptRequestPayload, hostFleetPlatform string) error { + return nil + } + ds.UnlockHostManuallyFunc = func(ctx context.Context, hostID uint, hostFleetPlatform string, ts time.Time) error { + return nil + } + ds.WipeHostViaWindowsMDMFunc = func(ctx context.Context, host *fleet.Host, cmd *fleet.MDMWindowsCommand) error { + return nil + } + + ds.WipeHostViaScriptFunc = func(ctx context.Context, request *fleet.HostScriptRequestPayload, hostFleetPlatform string) error { + return nil + } + + appCfgAllMDM, appCfgWinMDM, appCfgMacMDM, appCfgNoMDM := setupAppConigs() + appCfgScriptsDisabled := &fleet.AppConfig{ServerSettings: fleet.ServerSettings{ScriptsDisabled: true}} + + cases := []struct { + appCfg *fleet.AppConfig + desc string + flags []string + wantErr string + }{ + {appCfgAllMDM, "no flags", nil, `Required flag "host" not set`}, + {appCfgAllMDM, "host flag empty", []string{"--host", ""}, `No host targeted. Please provide --host.`}, + {appCfgAllMDM, "wipe non-existent host", []string{"--host", "notfound"}, `The host doesn't exist. Please provide a valid host identifier.`}, + {appCfgMacMDM, "valid windows but only macos mdm", []string{"--host", winEnrolled.UUID}, `Windows MDM isn't turned on.`}, + {appCfgAllMDM, "valid windows", []string{"--host", winEnrolled.UUID}, ""}, + {appCfgAllMDM, "valid macos", []string{"--host", macEnrolled.UUID}, ""}, + {appCfgNoMDM, "valid linux", []string{"--host", linuxEnrolled.UUID}, ""}, + {appCfgNoMDM, "valid windows but no mdm", []string{"--host", winEnrolled.UUID}, `Windows MDM isn't turned on.`}, + {appCfgMacMDM, "valid macos but not enrolled", []string{"--host", macNotEnrolled.UUID}, `Can't wipe the host because it doesn't have MDM turned on.`}, + {appCfgWinMDM, "valid windows but not enrolled", []string{"--host", winNotEnrolled.UUID}, `Can't wipe the host because it doesn't have MDM turned on.`}, + {appCfgWinMDM, "valid windows but pending mdm enroll", []string{"--host", winPending.UUID}, `Can't wipe the host because it doesn't have MDM turned on.`}, + {appCfgMacMDM, "valid macos but pending mdm enroll", []string{"--host", macPending.UUID}, `Can't wipe the host because it doesn't have MDM turned on.`}, + {appCfgAllMDM, "valid windows but pending unlock", []string{"--host", winEnrolledUP.UUID}, "Host has pending unlock request."}, + {appCfgAllMDM, "valid macos but pending unlock", []string{"--host", macEnrolledUP.UUID}, "Host has pending unlock request."}, + {appCfgAllMDM, "valid windows but pending lock", []string{"--host", winEnrolledLP.UUID}, "Host has pending lock request."}, + {appCfgAllMDM, "valid macos but pending lock", []string{"--host", macEnrolledLP.UUID}, "Host has pending lock request."}, + {appCfgAllMDM, "valid windows but pending wipe", []string{"--host", winEnrolledWP.UUID}, "Host has pending wipe request."}, + {appCfgAllMDM, "valid macos but pending wipe", []string{"--host", macEnrolledWP.UUID}, "Host has pending wipe request."}, + {appCfgAllMDM, "valid windows but host wiped", []string{"--host", winEnrolledWiped.UUID}, "Host is already wiped."}, + {appCfgAllMDM, "valid macos but host wiped", []string{"--host", macEnrolledWiped.UUID}, "Host is already wiped."}, + {appCfgAllMDM, "valid windows but host is locked", []string{"--host", winEnrolledLocked.UUID}, "Host cannot be wiped until it is unlocked."}, + {appCfgAllMDM, "valid macos but host is locked", []string{"--host", macEnrolledLocked.UUID}, "Host cannot be wiped until it is unlocked."}, + {appCfgAllMDM, "valid macos but host is locked", []string{"--host", macEnrolledLocked.UUID}, "Host cannot be wiped until it is unlocked."}, + {appCfgScriptsDisabled, "valid linux but script are disabled", []string{"--host", linuxEnrolled.UUID}, "Can't wipe host because running scripts is disabled in organization settings."}, + } + + successfulOutput := func(ident string) string { + return fmt.Sprintf(` +The host will wipe when it comes online. + +Copy and run this command to see results: + +fleetctl get host %s`, ident) + } + + runTestCases(t, ds, "wipe", successfulOutput, cases) } func writeTmpAppleMDMCmd(t *testing.T, commandName string) string { @@ -882,3 +1095,116 @@ func writeTmpMobileconfig(t *testing.T, name string) string { require.NoError(t, err) return tmpFile.Name() } + +// sets up the test server with the mock datastore and returns the mock datastore +func setupTestServer(t *testing.T) *mock.Store { + enqueuer := new(mock.MDMAppleStore) + license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)} + + enqueuer.EnqueueDeviceLockCommandFunc = func(ctx context.Context, host *fleet.Host, cmd *mdm.Command, pin string) error { + return nil + } + + enqueuer.EnqueueDeviceWipeCommandFunc = func(ctx context.Context, host *fleet.Host, cmd *mdm.Command) error { + return nil + } + + _, ds := runServerWithMockedDS(t, &service.TestServerOpts{ + MDMStorage: enqueuer, + MDMPusher: mockPusher{}, + License: license, + NoCacheDatastore: true, + }) + + return ds +} + +// sets up common data store mocks that are needed for the tests. +func setupDSMocks(ds *mock.Store, hostByUUID map[string]*fleet.Host, hostsByID map[uint]*fleet.Host) { + ds.HostByIdentifierFunc = func(ctx context.Context, identifier string) (*fleet.Host, error) { + h, ok := hostByUUID[identifier] + if !ok { + return nil, ¬FoundError{} + } + return h, nil + } + ds.LoadHostSoftwareFunc = func(ctx context.Context, host *fleet.Host, includeCVEScores bool) error { + return nil + } + ds.ListPacksForHostFunc = func(ctx context.Context, hid uint) (packs []*fleet.Pack, err error) { + return nil, nil + } + ds.ListHostBatteriesFunc = func(ctx context.Context, id uint) ([]*fleet.HostBattery, error) { + return nil, nil + } + ds.ListPoliciesForHostFunc = func(ctx context.Context, host *fleet.Host) ([]*fleet.HostPolicy, error) { + return nil, nil + } + ds.ListLabelsForHostFunc = func(ctx context.Context, hid uint) ([]*fleet.Label, error) { + return nil, nil + } + ds.GetHostMDMAppleProfilesFunc = func(ctx context.Context, hostUUID string) ([]fleet.HostMDMAppleProfile, error) { + return nil, nil + } + ds.GetHostMDMWindowsProfilesFunc = func(ctx context.Context, hostUUID string) ([]fleet.HostMDMWindowsProfile, error) { + return nil, nil + } + ds.GetHostMDMMacOSSetupFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDMMacOSSetup, error) { + return nil, nil + } + ds.HostLiteFunc = func(ctx context.Context, hostID uint) (*fleet.Host, error) { + h, ok := hostsByID[hostID] + if !ok { + return nil, ¬FoundError{} + } + + return h, nil + } + ds.GetMDMWindowsBitLockerStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostMDMDiskEncryption, error) { + return nil, nil + } + ds.GetHostMDMFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDM, error) { + h, ok := hostsByID[hostID] + if !ok { + return nil, ¬FoundError{} + } + + return h.MDMInfo, nil + } + ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error { + return nil + } +} + +// sets up the various app configs for the tests. These app configs reflect the various +// states of the MDM configuration. +func setupAppConigs() (*fleet.AppConfig, *fleet.AppConfig, *fleet.AppConfig, *fleet.AppConfig) { + appCfgAllMDM := &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: true, WindowsEnabledAndConfigured: true}} + appCfgWinMDM := &fleet.AppConfig{MDM: fleet.MDM{WindowsEnabledAndConfigured: true}} + appCfgMacMDM := &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: true}} + appCfgNoMDM := &fleet.AppConfig{MDM: fleet.MDM{}} + + return appCfgAllMDM, appCfgWinMDM, appCfgMacMDM, appCfgNoMDM +} + +func runTestCases(t *testing.T, ds *mock.Store, actionType string, successfulOutput func(ident string) string, cases []struct { + appCfg *fleet.AppConfig + desc string + flags []string + wantErr string +}, +) { + for _, c := range cases { + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return c.appCfg, nil + } + buf, err := runAppNoChecks(append([]string{"mdm", actionType}, c.flags...)) + if c.wantErr != "" { + require.Error(t, err, c.desc) + require.ErrorContains(t, err, c.wantErr, c.desc) + } else { + require.NoError(t, err, c.desc) + require.Contains(t, buf.String(), successfulOutput(c.flags[1]), c.desc) + } + } +} diff --git a/cmd/fleetctl/scripts_test.go b/cmd/fleetctl/scripts_test.go index c6a9f9a7c0..61e8eda1d1 100644 --- a/cmd/fleetctl/scripts_test.go +++ b/cmd/fleetctl/scripts_test.go @@ -240,7 +240,7 @@ Fleet records the last 10,000 characters to prevent downtime. } return &fleet.HostScriptResult{}, nil } - ds.GetHostLockWipeStatusFunc = func(ctx context.Context, hostID uint, fleetPlatform string) (*fleet.HostLockWipeStatus, error) { + ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) { return &fleet.HostLockWipeStatus{}, nil } ds.NewHostScriptExecutionRequestFunc = func(ctx context.Context, req *fleet.HostScriptRequestPayload) (*fleet.HostScriptResult, error) { diff --git a/cmd/fleetctl/testdata/expectedGetTeamsJson.json b/cmd/fleetctl/testdata/expectedGetTeamsJson.json index 1ef05fc823..3fabfffecb 100644 --- a/cmd/fleetctl/testdata/expectedGetTeamsJson.json +++ b/cmd/fleetctl/testdata/expectedGetTeamsJson.json @@ -12,6 +12,12 @@ "host_expiry_window": 0 }, "webhook_settings": { + "host_status_webhook": { + "enable_host_status_webhook": false, + "destination_url": "", + "host_percentage": 0, + "days_count": 0 + }, "failing_policies_webhook": { "enable_failing_policies_webhook": false, "destination_url": "", @@ -81,6 +87,12 @@ "host_expiry_window": 15 }, "webhook_settings": { + "host_status_webhook": { + "enable_host_status_webhook": false, + "destination_url": "", + "host_percentage": 0, + "days_count": 0 + }, "failing_policies_webhook": { "enable_failing_policies_webhook": false, "destination_url": "", diff --git a/cmd/fleetctl/testdata/expectedGetTeamsYaml.yml b/cmd/fleetctl/testdata/expectedGetTeamsYaml.yml index c81c5ebfa3..1249b3e5fd 100644 --- a/cmd/fleetctl/testdata/expectedGetTeamsYaml.yml +++ b/cmd/fleetctl/testdata/expectedGetTeamsYaml.yml @@ -26,6 +26,8 @@ spec: enable_end_user_authentication: false macos_setup_assistant: scripts: null + webhook_settings: + host_status_webhook: null name: team1 --- apiVersion: v1 @@ -64,4 +66,6 @@ spec: enable_end_user_authentication: false macos_setup_assistant: scripts: null + webhook_settings: + host_status_webhook: null name: team2 diff --git a/cmd/fleetctl/testdata/expectedHostDetailResponseJson.json b/cmd/fleetctl/testdata/expectedHostDetailResponseJson.json index 7772e27166..8c6e8dc3a7 100644 --- a/cmd/fleetctl/testdata/expectedHostDetailResponseJson.json +++ b/cmd/fleetctl/testdata/expectedHostDetailResponseJson.json @@ -1,100 +1,101 @@ { - "kind": "host", - "apiVersion": "v1", - "spec": { - "created_at": "0001-01-01T00:00:00Z", - "updated_at": "0001-01-01T00:00:00Z", - "id": 0, - "detail_updated_at": "0001-01-01T00:00:00Z", - "label_updated_at": "0001-01-01T00:00:00Z", - "policy_updated_at": "0001-01-01T00:00:00Z", - "last_enrolled_at": "0001-01-01T00:00:00Z", - "last_restarted_at": "0001-01-01T00:00:00Z", - "seen_time": "0001-01-01T00:00:00Z", - "software_updated_at": "0001-01-01T00:00:00Z", - "refetch_requested": false, - "refetch_critical_queries_until": null, - "hostname": "test_host", - "uuid": "", - "platform": "", - "osquery_version": "", - "os_version": "", - "build": "", - "platform_like": "", - "code_name": "", - "uptime": 0, - "memory": 0, - "cpu_type": "", - "cpu_subtype": "", - "cpu_brand": "", - "cpu_physical_cores": 0, - "cpu_logical_cores": 0, - "hardware_vendor": "", - "hardware_model": "", - "hardware_version": "", - "hardware_serial": "", - "computer_name": "test_host", - "public_ip": "", - "primary_ip": "", - "primary_mac": "", - "distributed_interval": 0, - "config_tls_refresh": 0, - "logger_tls_period": 0, - "mdm": { - "device_status": "unlocked", - "encryption_key_available": false, - "enrollment_status": null, - "name": "", - "pending_action": "", - "server_url": null - }, - "team_id": null, - "pack_stats": null, - "team_name": null, - "gigs_disk_space_available": 0, - "percent_disk_space_available": 0, - "gigs_total_disk_space": 0, - "issues": { - "total_issues_count": 0, - "failing_policies_count": 0 - }, - "labels": [], - "packs": [], - "policies": [ - { - "id": 1, - "query": "select 1 from osquery_info where start_time > 1;", - "name": "query1", - "platform": "", - "description": "Some description", - "author_email": "alice@example.com", - "author_id": 1, - "author_name": "Alice", - "response": "passes", - "resolution": "Some resolution", - "team_id": 1, - "updated_at": "0001-01-01T00:00:00Z", - "created_at": "0001-01-01T00:00:00Z", - "critical": false - }, - { - "id": 2, - "query": "select 1 from osquery_info where start_time > 1;", - "name": "query2", - "platform": "", - "description": "", - "author_email": "alice@example.com", - "author_id": 1, - "author_name": "Alice", - "response": "fails", - "team_id": null, - "updated_at": "0001-01-01T00:00:00Z", - "created_at": "0001-01-01T00:00:00Z", - "critical": false - } - ], - "status": "offline", - "display_text": "test_host", - "display_name": "test_host" - } + "kind": "host", + "apiVersion": "v1", + "spec": { + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z", + "id": 0, + "detail_updated_at": "0001-01-01T00:00:00Z", + "label_updated_at": "0001-01-01T00:00:00Z", + "policy_updated_at": "0001-01-01T00:00:00Z", + "last_enrolled_at": "0001-01-01T00:00:00Z", + "last_restarted_at": "0001-01-01T00:00:00Z", + "seen_time": "0001-01-01T00:00:00Z", + "software_updated_at": "0001-01-01T00:00:00Z", + "refetch_requested": false, + "refetch_critical_queries_until": null, + "hostname": "test_host", + "uuid": "", + "platform": "", + "osquery_version": "", + "os_version": "", + "build": "", + "platform_like": "", + "code_name": "", + "uptime": 0, + "memory": 0, + "cpu_type": "", + "cpu_subtype": "", + "cpu_brand": "", + "cpu_physical_cores": 0, + "cpu_logical_cores": 0, + "hardware_vendor": "", + "hardware_model": "", + "hardware_version": "", + "hardware_serial": "", + "computer_name": "test_host", + "public_ip": "", + "primary_ip": "", + "primary_mac": "", + "distributed_interval": 0, + "config_tls_refresh": 0, + "logger_tls_period": 0, + "mdm": { + "dep_profile_error": false, + "device_status": "unlocked", + "encryption_key_available": false, + "enrollment_status": null, + "name": "", + "pending_action": "", + "server_url": null + }, + "team_id": null, + "pack_stats": null, + "team_name": null, + "gigs_disk_space_available": 0, + "percent_disk_space_available": 0, + "gigs_total_disk_space": 0, + "issues": { + "total_issues_count": 0, + "failing_policies_count": 0 + }, + "labels": [], + "packs": [], + "policies": [ + { + "id": 1, + "query": "select 1 from osquery_info where start_time > 1;", + "name": "query1", + "platform": "", + "description": "Some description", + "author_email": "alice@example.com", + "author_id": 1, + "author_name": "Alice", + "response": "passes", + "resolution": "Some resolution", + "team_id": 1, + "updated_at": "0001-01-01T00:00:00Z", + "created_at": "0001-01-01T00:00:00Z", + "critical": false + }, + { + "id": 2, + "query": "select 1 from osquery_info where start_time > 1;", + "name": "query2", + "platform": "", + "description": "", + "author_email": "alice@example.com", + "author_id": 1, + "author_name": "Alice", + "response": "fails", + "team_id": null, + "updated_at": "0001-01-01T00:00:00Z", + "created_at": "0001-01-01T00:00:00Z", + "critical": false + } + ], + "status": "offline", + "display_text": "test_host", + "display_name": "test_host" + } } diff --git a/cmd/fleetctl/testdata/expectedHostDetailResponseYaml.yml b/cmd/fleetctl/testdata/expectedHostDetailResponseYaml.yml index dfb7b26323..fc4431c9e1 100644 --- a/cmd/fleetctl/testdata/expectedHostDetailResponseYaml.yml +++ b/cmd/fleetctl/testdata/expectedHostDetailResponseYaml.yml @@ -32,6 +32,7 @@ spec: last_restarted_at: "0001-01-01T00:00:00Z" logger_tls_period: 0 mdm: + dep_profile_error: false device_status: unlocked encryption_key_available: false enrollment_status: null diff --git a/cmd/fleetctl/testdata/expectedListHostsJson.json b/cmd/fleetctl/testdata/expectedListHostsJson.json index a97e058b3e..2d80597d4b 100644 --- a/cmd/fleetctl/testdata/expectedListHostsJson.json +++ b/cmd/fleetctl/testdata/expectedListHostsJson.json @@ -42,6 +42,7 @@ "config_tls_refresh": 0, "logger_tls_period": 0, "mdm": { + "dep_profile_error": false, "encryption_key_available": false, "enrollment_status": null, "name": "", @@ -113,6 +114,7 @@ "config_tls_refresh": 0, "logger_tls_period": 0, "mdm": { + "dep_profile_error": false, "encryption_key_available": false, "enrollment_status": null, "name": "", diff --git a/cmd/fleetctl/testdata/expectedListHostsMDM.json b/cmd/fleetctl/testdata/expectedListHostsMDM.json index 1c83f5ff27..fcd180320c 100644 --- a/cmd/fleetctl/testdata/expectedListHostsMDM.json +++ b/cmd/fleetctl/testdata/expectedListHostsMDM.json @@ -1,136 +1,138 @@ [ - { - "kind": "host", - "apiVersion": "v1", - "spec": { - "created_at": "0001-01-01T00:00:00Z", - "updated_at": "0001-01-01T00:00:00Z", - "id": 0, - "detail_updated_at": "0001-01-01T00:00:00Z", - "label_updated_at": "0001-01-01T00:00:00Z", - "last_enrolled_at": "0001-01-01T00:00:00Z", - "last_restarted_at": "0001-01-01T00:00:00Z", - "seen_time": "0001-01-01T00:00:00Z", - "software_updated_at": "0001-01-01T00:00:00Z", - "refetch_requested": false, - "refetch_critical_queries_until": null, - "hostname": "test_host", - "display_name": "test_host", - "uuid": "", - "platform": "", - "osquery_version": "", - "os_version": "", - "build": "", - "platform_like": "", - "policy_updated_at": "0001-01-01T00:00:00Z", - "code_name": "", - "uptime": 0, - "memory": 0, - "cpu_type": "", - "cpu_subtype": "", - "cpu_brand": "", - "cpu_physical_cores": 0, - "cpu_logical_cores": 0, - "hardware_vendor": "", - "hardware_model": "", - "hardware_version": "", - "hardware_serial": "", - "computer_name": "test_host", - "public_ip": "", - "primary_ip": "", - "primary_mac": "", - "distributed_interval": 0, - "config_tls_refresh": 0, - "logger_tls_period": 0, - "mdm": { - "encryption_key_available": false, - "enrollment_status": null, - "name": "", - "server_url": null - }, - "team_id": null, - "pack_stats": null, - "team_name": null, - "additional": { - "query1": [ - { - "col1": "val", - "col2": 42 - } - ] - }, - "gigs_disk_space_available": 0, - "percent_disk_space_available": 0, - "gigs_total_disk_space": 0, - "issues": { - "total_issues_count": 0, - "failing_policies_count": 0 - }, - "status": "offline", - "display_text": "test_host" - } - }, - { - "kind": "host", - "apiVersion": "v1", - "spec": { - "created_at": "0001-01-01T00:00:00Z", - "updated_at": "0001-01-01T00:00:00Z", - "id": 0, - "detail_updated_at": "0001-01-01T00:00:00Z", - "label_updated_at": "0001-01-01T00:00:00Z", - "last_enrolled_at": "0001-01-01T00:00:00Z", - "last_restarted_at": "0001-01-01T00:00:00Z", - "seen_time": "0001-01-01T00:00:00Z", - "software_updated_at": "0001-01-01T00:00:00Z", - "refetch_requested": false, - "refetch_critical_queries_until": null, - "hostname": "test_host2", - "uuid": "", - "platform": "", - "osquery_version": "", - "os_version": "", - "build": "", - "platform_like": "", - "policy_updated_at": "0001-01-01T00:00:00Z", - "code_name": "", - "uptime": 0, - "memory": 0, - "cpu_type": "", - "cpu_subtype": "", - "cpu_brand": "", - "cpu_physical_cores": 0, - "cpu_logical_cores": 0, - "hardware_vendor": "", - "hardware_model": "", - "hardware_version": "", - "hardware_serial": "", - "computer_name": "test_host2", - "display_name": "test_host2", - "public_ip": "", - "primary_ip": "", - "primary_mac": "", - "distributed_interval": 0, - "config_tls_refresh": 0, - "logger_tls_period": 0, - "mdm": { - "encryption_key_available": false, - "enrollment_status": null, - "name": "", - "server_url": null - }, - "team_id": null, - "pack_stats": null, - "team_name": null, - "gigs_disk_space_available": 0, - "percent_disk_space_available": 0, - "gigs_total_disk_space": 0, - "issues": { - "total_issues_count": 0, - "failing_policies_count": 0 - }, - "status": "offline", - "display_text": "test_host2" - } - } + { + "kind": "host", + "apiVersion": "v1", + "spec": { + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z", + "id": 0, + "detail_updated_at": "0001-01-01T00:00:00Z", + "label_updated_at": "0001-01-01T00:00:00Z", + "last_enrolled_at": "0001-01-01T00:00:00Z", + "last_restarted_at": "0001-01-01T00:00:00Z", + "seen_time": "0001-01-01T00:00:00Z", + "software_updated_at": "0001-01-01T00:00:00Z", + "refetch_requested": false, + "refetch_critical_queries_until": null, + "hostname": "test_host", + "display_name": "test_host", + "uuid": "", + "platform": "", + "osquery_version": "", + "os_version": "", + "build": "", + "platform_like": "", + "policy_updated_at": "0001-01-01T00:00:00Z", + "code_name": "", + "uptime": 0, + "memory": 0, + "cpu_type": "", + "cpu_subtype": "", + "cpu_brand": "", + "cpu_physical_cores": 0, + "cpu_logical_cores": 0, + "hardware_vendor": "", + "hardware_model": "", + "hardware_version": "", + "hardware_serial": "", + "computer_name": "test_host", + "public_ip": "", + "primary_ip": "", + "primary_mac": "", + "distributed_interval": 0, + "config_tls_refresh": 0, + "logger_tls_period": 0, + "mdm": { + "dep_profile_error": false, + "encryption_key_available": false, + "enrollment_status": null, + "name": "", + "server_url": null + }, + "team_id": null, + "pack_stats": null, + "team_name": null, + "additional": { + "query1": [ + { + "col1": "val", + "col2": 42 + } + ] + }, + "gigs_disk_space_available": 0, + "percent_disk_space_available": 0, + "gigs_total_disk_space": 0, + "issues": { + "total_issues_count": 0, + "failing_policies_count": 0 + }, + "status": "offline", + "display_text": "test_host" + } + }, + { + "kind": "host", + "apiVersion": "v1", + "spec": { + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z", + "id": 0, + "detail_updated_at": "0001-01-01T00:00:00Z", + "label_updated_at": "0001-01-01T00:00:00Z", + "last_enrolled_at": "0001-01-01T00:00:00Z", + "last_restarted_at": "0001-01-01T00:00:00Z", + "seen_time": "0001-01-01T00:00:00Z", + "software_updated_at": "0001-01-01T00:00:00Z", + "refetch_requested": false, + "refetch_critical_queries_until": null, + "hostname": "test_host2", + "uuid": "", + "platform": "", + "osquery_version": "", + "os_version": "", + "build": "", + "platform_like": "", + "policy_updated_at": "0001-01-01T00:00:00Z", + "code_name": "", + "uptime": 0, + "memory": 0, + "cpu_type": "", + "cpu_subtype": "", + "cpu_brand": "", + "cpu_physical_cores": 0, + "cpu_logical_cores": 0, + "hardware_vendor": "", + "hardware_model": "", + "hardware_version": "", + "hardware_serial": "", + "computer_name": "test_host2", + "display_name": "test_host2", + "public_ip": "", + "primary_ip": "", + "primary_mac": "", + "distributed_interval": 0, + "config_tls_refresh": 0, + "logger_tls_period": 0, + "mdm": { + "dep_profile_error": false, + "encryption_key_available": false, + "enrollment_status": null, + "name": "", + "server_url": null + }, + "team_id": null, + "pack_stats": null, + "team_name": null, + "gigs_disk_space_available": 0, + "percent_disk_space_available": 0, + "gigs_total_disk_space": 0, + "issues": { + "total_issues_count": 0, + "failing_policies_count": 0 + }, + "status": "offline", + "display_text": "test_host2" + } + } ] diff --git a/cmd/fleetctl/testdata/expectedListHostsYaml.yml b/cmd/fleetctl/testdata/expectedListHostsYaml.yml index 249b4b884e..ac733c2b83 100644 --- a/cmd/fleetctl/testdata/expectedListHostsYaml.yml +++ b/cmd/fleetctl/testdata/expectedListHostsYaml.yml @@ -36,6 +36,7 @@ spec: last_restarted_at: "0001-01-01T00:00:00Z" logger_tls_period: 0 mdm: + dep_profile_error: false encryption_key_available: false enrollment_status: null name: "" @@ -93,6 +94,7 @@ spec: last_restarted_at: "0001-01-01T00:00:00Z" logger_tls_period: 0 mdm: + dep_profile_error: false encryption_key_available: false enrollment_status: null server_url: null diff --git a/cmd/fleetctl/testdata/gitops/team_config_no_paths.yml b/cmd/fleetctl/testdata/gitops/team_config_no_paths.yml index 6a4fbdf2de..3295e75bbb 100644 --- a/cmd/fleetctl/testdata/gitops/team_config_no_paths.yml +++ b/cmd/fleetctl/testdata/gitops/team_config_no_paths.yml @@ -4,10 +4,11 @@ team_settings: - secret: "SampleSecret123" - secret: "ABC" webhook_settings: - failing_policies_webhook: - enable_failing_policies_webhook: true - destination_url: https://example.tines.com/webhook - policy_ids: [1, 2, 3, 4, 5, 6 ,7, 8, 9] + host_status_webhook: + days_count: 14 + destination_url: https://example.com/host_status_webhook + enable_host_status_webhook: true + host_percentage: 25 features: enable_host_users: true enable_software_inventory: true diff --git a/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml b/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml index 2eefecaf5c..7315325b48 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml @@ -26,6 +26,8 @@ spec: deadline_days: null grace_period_days: null scripts: null + webhook_settings: + host_status_webhook: null name: tm1 --- apiVersion: v1 @@ -54,4 +56,6 @@ spec: deadline_days: null grace_period_days: null scripts: null + webhook_settings: + host_status_webhook: null name: tm2 diff --git a/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml b/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml index 50069b989d..1cce56630c 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml @@ -26,6 +26,8 @@ spec: deadline_days: null grace_period_days: null scripts: null + webhook_settings: + host_status_webhook: null name: tm1 --- apiVersion: v1 @@ -54,4 +56,6 @@ spec: deadline_days: null grace_period_days: null scripts: null + webhook_settings: + host_status_webhook: null name: tm2 diff --git a/cmd/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml b/cmd/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml index fdb20cddfe..c6e8b1653b 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml @@ -26,5 +26,7 @@ spec: windows_settings: custom_settings: null scripts: null + webhook_settings: + host_status_webhook: null name: tm1 diff --git a/cmd/osquery-perf/agent.go b/cmd/osquery-perf/agent.go index ac0a58ca96..d143a37c71 100644 --- a/cmd/osquery-perf/agent.go +++ b/cmd/osquery-perf/agent.go @@ -14,7 +14,6 @@ import ( "io" "log" "math/rand" - "net" "net/http" _ "net/http/pprof" "os" @@ -377,7 +376,13 @@ type agent struct { scheduledQueriesMu sync.Mutex // protects the below members scheduledQueries []string scheduledQueryData []scheduledQuery - bufferedResults []resultLog + // bufferedResults contains result logs that are buffered when + // /api/v1/osquery/log requests to the Fleet server fail. + // + // NOTE: We use a map instead of a slice to prevent the data structure to + // increase indefinitely (we sacrifice accuracy of logs but that's + // a-ok for osquery-perf and load testing). + bufferedResults map[resultLog]int } type entityCount struct { @@ -462,6 +467,7 @@ func newAgent( disableScriptExec: disableScriptExec, disableFleetDesktop: disableFleetDesktop, loggerTLSMaxLines: loggerTLSMaxLines, + bufferedResults: make(map[resultLog]int), } } @@ -562,7 +568,7 @@ func (a *agent) runLoop(i int, onlyAlreadyEnrolled bool) { var results []resultLog now := time.Now().Unix() a.scheduledQueriesMu.Lock() - prevCount := len(a.bufferedResults) + prevCount := a.countBuffered() for i, query := range a.scheduledQueryData { if query.nextRun == 0 || now >= int64(query.nextRun) { results = append(results, resultLog{ @@ -573,25 +579,56 @@ func (a *agent) runLoop(i int, onlyAlreadyEnrolled bool) { a.scheduledQueryData[i].nextRun = float64(now + int64(query.ScheduleInterval)) } } - a.bufferedResults = append(a.bufferedResults, results...) - if len(a.bufferedResults) > 1_000_000 { // osquery buffered_log_max is 1M - extra := len(a.bufferedResults) - 1_000_000 - a.bufferedResults = a.bufferedResults[extra:] + if prevCount+len(results) < 1_000_000 { // osquery buffered_log_max is 1M + a.addToBuffer(results) } a.sendLogsBatch() - newBufferedCount := len(a.bufferedResults) - prevCount + newBufferedCount := a.countBuffered() - prevCount a.stats.UpdateBufferedLogs(newBufferedCount) a.scheduledQueriesMu.Unlock() } } +func (a *agent) countBuffered() int { + var total int + for _, count := range a.bufferedResults { + total += count + } + return total +} + +func (a *agent) addToBuffer(results []resultLog) { + for _, result := range results { + a.bufferedResults[result] += 1 + } +} + +// getBatch returns a random set of logs from the buffered logs. +// NOTE: We sacrifice some accuracy in the name of CPU and memory efficiency. +func (a *agent) getBatch(batchSize int) []resultLog { + results := make([]resultLog, 0, batchSize) + for result, count := range a.bufferedResults { + left := batchSize - len(results) + if left <= 0 { + return results + } + if count > left { + count = left + } + for i := 0; i < count; i++ { + results = append(results, result) + } + } + return results +} + type resultLog struct { packName string queryName string numRows int } -func (r resultLog) emit() json.RawMessage { +func (r resultLog) emit() []byte { return scheduledQueryResults(r.packName, r.queryName, r.numRows) } @@ -602,18 +639,29 @@ func (a *agent) sendLogsBatch() { } batchSize := a.loggerTLSMaxLines - if len(a.bufferedResults) < batchSize { - batchSize = len(a.bufferedResults) + if count := a.countBuffered(); count < batchSize { + batchSize = count } - batch := a.bufferedResults[:batchSize] - batchLogs := make([]json.RawMessage, 0, len(batch)) - for _, result := range batch { - batchLogs = append(batchLogs, result.emit()) - } - if err := a.submitLogs(batchLogs); err != nil { + batch := a.getBatch(batchSize) + if err := a.submitLogs(batch); err != nil { return } - a.bufferedResults = a.bufferedResults[batchSize:] + a.removeBuffered(batchSize) +} + +// removeBuffered removes a random set of logs from the buffered logs. +// NOTE: We sacrifice some accuracy in the name of CPU and memory efficiency. +func (a *agent) removeBuffered(batchSize int) { + for b := batchSize; b > 0; { + for result, count := range a.bufferedResults { + if count > b { + a.bufferedResults[result] -= b + return + } + delete(a.bufferedResults, result) + b -= count + } + } } func (a *agent) runOrbitLoop() { @@ -833,17 +881,18 @@ func (a *agent) execScripts(execIDs []string, orbitClient *service.OrbitClient) } } -func (a *agent) waitingDo(request *http.Request) *http.Response { - response, err := http.DefaultClient.Do(request) +func (a *agent) waitingDo(fn func() *http.Request) *http.Response { + response, err := http.DefaultClient.Do(fn()) for err != nil || response.StatusCode != http.StatusOK { if err != nil { log.Printf("failed to run request: %s", err) } else { // res.StatusCode() != http.StatusOK + response.Body.Close() log.Printf("request failed: %d", response.StatusCode) } a.stats.IncrementErrors(1) <-time.Tick(time.Duration(rand.Intn(120)+1) * time.Second) - response, err = http.DefaultClient.Do(request) + response, err = http.DefaultClient.Do(fn()) } return response } @@ -863,13 +912,14 @@ func (a *agent) orbitEnroll() error { return err } - request, err := http.NewRequest("POST", a.serverAddress+"/api/fleet/orbit/enroll", bytes.NewReader(jsonBytes)) - if err != nil { - return err - } - request.Header.Add("Content-type", "application/json") - - response := a.waitingDo(request) + response := a.waitingDo(func() *http.Request { + request, err := http.NewRequest("POST", a.serverAddress+"/api/fleet/orbit/enroll", bytes.NewReader(jsonBytes)) + if err != nil { + panic(err) + } + request.Header.Add("Content-type", "application/json") + return request + }) defer response.Body.Close() var parsedResp service.EnrollOrbitResponse @@ -900,13 +950,14 @@ func (a *agent) enroll(i int, onlyAlreadyEnrolled bool) error { return err } - request, err := http.NewRequest("POST", a.serverAddress+"/api/osquery/enroll", &body) - if err != nil { - return err - } - request.Header.Add("Content-type", "application/json") - - response := a.waitingDo(request) + response := a.waitingDo(func() *http.Request { + request, err := http.NewRequest("POST", a.serverAddress+"/api/osquery/enroll", &body) + if err != nil { + panic(err) + } + request.Header.Add("Content-type", "application/json") + return request + }) defer response.Body.Close() if response.StatusCode != http.StatusOK { @@ -955,6 +1006,7 @@ func (a *agent) config() error { } `json:"packs"` }{} if err := json.NewDecoder(response.Body).Decode(&parsedResp); err != nil { + a.stats.IncrementConfigErrors() return fmt.Errorf("json parse at config: %w", err) } @@ -1135,6 +1187,7 @@ func (a *agent) DistributedRead() (*distributedReadResponse, error) { var parsedResp distributedReadResponse if err := json.NewDecoder(response.Body).Decode(&parsedResp); err != nil { + a.stats.IncrementDistributedReadErrors() log.Printf("json parse: %s", err) return nil, err } @@ -1589,8 +1642,8 @@ func (a *agent) DistributedWrite(queries map[string]string) error { return nil } -func scheduledQueryResults(packName, queryName string, numResults int) json.RawMessage { - return json.RawMessage(`{ +func scheduledQueryResults(packName, queryName string, numResults int) []byte { + return []byte(`{ "snapshot": [` + rows(numResults) + ` ], "action": "snapshot", @@ -1608,33 +1661,37 @@ func scheduledQueryResults(packName, queryName string, numResults int) json.RawM }`) } -func (a *agent) submitLogs(results []json.RawMessage) error { - // Connection check to prevent unnecessary JSON marshaling when the server is down. - conn, err := net.Dial("tcp", strings.TrimPrefix(a.serverAddress, "https://")) +func (a *agent) connCheck() error { + request, err := http.NewRequest("GET", a.serverAddress+"/version", nil) + if err != nil { + panic(err) + } + response, err := http.DefaultClient.Do(request) if err != nil { return err } - conn.Close() + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return errors.New(http.StatusText(response.StatusCode)) + } + return nil +} - jsonResults, err := json.Marshal(results) - if err != nil { - panic(err) - } - type submitLogsRequest struct { - NodeKey string `json:"node_key"` - LogType string `json:"log_type"` - Data json.RawMessage `json:"data"` - } - slr := submitLogsRequest{ - NodeKey: a.nodeKey, - LogType: "result", - Data: jsonResults, - } - body, err := json.Marshal(slr) - if err != nil { - panic(err) +func (a *agent) submitLogs(results []resultLog) error { + // Connection check to prevent unnecessary JSON marshaling when the server is down. + if err := a.connCheck(); err != nil { + return fmt.Errorf("/version check failed: %w", err) } + var resultLogs []byte + for i, result := range results { + if i > 0 { + resultLogs = append(resultLogs, ',') + } + resultLogs = append(resultLogs, result.emit()...) + } + + body := []byte(`{"node_key": "` + a.nodeKey + `", "log_type": "result", "data": [` + string(resultLogs) + `]}`) request, err := http.NewRequest("POST", a.serverAddress+"/api/osquery/log", bytes.NewReader(body)) if err != nil { return err diff --git a/docs/Contributing/Building-Fleet.md b/docs/Contributing/Building-Fleet.md index d9f3e57d6f..dd2365ad05 100644 --- a/docs/Contributing/Building-Fleet.md +++ b/docs/Contributing/Building-Fleet.md @@ -87,6 +87,16 @@ To do this, run the following from the root of the repository: make deps ``` +Note: If you are using python >= `3.12`, you may have to install `distutils` using pip. + +```sh +pip install setuptools +``` +or +```sh +pip3 install setuptools +``` + When pulling changes, it may be necessary to re-run `make deps` if a new Go or JavaScript dependency was added. ### Generating the packaged JavaScript diff --git a/docs/REST API/rest-api.md b/docs/REST API/rest-api.md index 6b14a2f022..301ed00cb7 100644 --- a/docs/REST API/rest-api.md +++ b/docs/REST API/rest-api.md @@ -2155,9 +2155,9 @@ Returns the count of all hosts organized by status. `online_count` includes all | Name | Type | In | Description | | --------------- | ------- | ---- | ------------------------------------------------------------------------------- | -| team_id | integer | query | The ID of the team whose host counts should be included. Defaults to all teams. | +| team_id | integer | query | _Available in Fleet Premium_. The ID of the team whose host counts should be included. Defaults to all teams. | | platform | string | query | Platform to filter by when counting. Defaults to all platforms. | -| low_disk_space | integer | query | _Available in Fleet Premium_ Returns the count of hosts with less GB of disk space available than this value. Must be a number between 1-100. | +| low_disk_space | integer | query | _Available in Fleet Premium_. Returns the count of hosts with less GB of disk space available than this value. Must be a number between 1-100. | #### Example @@ -2994,7 +2994,7 @@ _Available in Fleet Premium_ | Name | Type | In | Description | | ------- | ------- | ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | team_id | integer | body | **Required**. The ID of the team you'd like to transfer the host(s) to. | -| filters | object | body | **Required** Contains any of the following three properties: `query` for search query keywords. Searchable fields include `hostname`, `hardware_serial`, `uuid`, and `ipv4`. `status` to indicate the status of the hosts to return. Can either be `new`, `online`, `offline`, `mia` or `missing`. `label_id` to indicate the selected label. `label_id` and `status` cannot be used at the same time. | +| filters | object | body | **Required** Contains any of the following four properties: `query` for search query keywords. Searchable fields include `hostname`, `hardware_serial`, `uuid`, and `ipv4`. `status` to indicate the status of the hosts to return. Can either be `new`, `online`, `offline`, `mia` or `missing`. `label_id` to indicate the selected label. `team_id` to indicate the selected team. Note: `label_id` and `status` cannot be used at the same time. | #### Example @@ -3006,7 +3006,8 @@ _Available in Fleet Premium_ { "team_id": 1, "filters": { - "status": "online" + "status": "online", + "team_id": 2, } } ``` @@ -3464,11 +3465,10 @@ Retrieves the aggregated host OS versions information. | Name | Type | In | Description | | --- | --- | --- | --- | -| team_id | integer | query | _Available in Fleet Premium_ Filters the hosts to only include hosts in the specified team. If not provided, all hosts are included. | +| team_id | integer | query | _Available in Fleet Premium_. Filters to only include OS versions for hosts on the specified team. If not provided, OS versions for all hosts are included. | | platform | string | query | Filters the hosts to the specified platform | | os_name | string | query | The name of the operating system to filter hosts by. `os_version` must also be specified with `os_name` | | os_version | string | query | The version of the operating system to filter hosts by. `os_name` must also be specified with `os_version` | -| team_id | integer | query | _Available in Fleet Premium_. Filters to only include OS versions for the specified team. | | page | integer | query | Page number of the results to fetch. | | per_page | integer | query | Results per page. | | order_key | string | query | What to order results by. Allowed fields are: `hosts_count`. Default is `hosts_count` (descending). | @@ -6268,7 +6268,7 @@ Returns a list of global queries or team queries. | --------------- | ------- | ----- | ----------------------------------------------------------------------------------------------------------------------------- | | order_key | string | query | What to order results by. Can be any column in the queries table. | | order_direction | string | query | **Requires `order_key`**. The direction of the order given the order key. Options include `asc` and `desc`. Default is `asc`. | -| team_id | integer | query | The ID of the parent team for the queries to be listed. When omitted, returns global queries. | +| team_id | integer | query | _Available in Fleet Premium_. The ID of the parent team for the queries to be listed. When omitted, returns global queries. | | query | string | query | Search query keywords. Searchable fields include `name`. | @@ -6589,7 +6589,7 @@ Creates a global query or team query. | query | string | body | **Required**. The query in SQL syntax. | | description | string | body | The query's description. | | observer_can_run | bool | body | Whether or not users with the `observer` role can run the query. In Fleet 4.0.0, 3 user roles were introduced (`admin`, `maintainer`, and `observer`). This field is only relevant for the `observer` role. The `observer_plus` role can run any query and is not limited by this flag (`observer_plus` role was added in Fleet 4.30.0). | -| team_id | integer | body | The parent team to which the new query should be added. If omitted, the query will be global. | +| team_id | integer | body | _Available in Fleet Premium_. The parent team to which the new query should be added. If omitted, the query will be global. | | interval | integer | body | The amount of time, in seconds, the query waits before running. Can be set to `0` to never run. Default: 0. | | platform | string | body | The OS platforms where this query will run (other platforms ignored). Comma-separated string. If omitted, runs on all compatible platforms. | | min_osquery_version | string | body | The minimum required osqueryd version installed on a host. If omitted, all osqueryd versions are acceptable. | @@ -6732,7 +6732,7 @@ Deletes the query specified by name. | Name | Type | In | Description | | ---- | ---------- | ---- | ------------------------------------ | | name | string | path | **Required.** The name of the query. | -| team_id | integer | body | The ID of the parent team of the query to be deleted. If omitted, Fleet will search among queries in the global context. | +| team_id | integer | body | _Available in Fleet Premium_. The ID of the parent team of the query to be deleted. If omitted, Fleet will search among queries in the global context. | #### Example @@ -7456,7 +7456,7 @@ Uploads a script, making it available to run on hosts assigned to the specified | Name | Type | In | Description | | ---- | ------- | ---- | -------------------------------------------- | | script | file | form | **Required**. The file containing the script. | -| team_id | integer | form | The team ID. If specified, the script will only be available to hosts assigned to this team. If not specified, the script will only be available to hosts on **no team**. | +| team_id | integer | form | _Available in Fleet Premium_. The team ID. If specified, the script will only be available to hosts assigned to this team. If not specified, the script will only be available to hosts on **no team**. | #### Example @@ -8405,7 +8405,7 @@ _Available in Fleet Premium_ | id | integer | path | **Required.** The desired team's ID. | | name | string | body | The team's name. | | host_ids | list | body | A list of hosts that belong to the team. | -| user_ids | list | body | A list of users that are members of the team. | +| user_ids | list | body | A list of users on the team. | | webhook_settings | object | body | Webhook settings contains for the team. | |   failing_policies_webhook | object | body | Failing policies webhook settings. | |     enable_failing_policies_webhook | boolean | body | Whether or not the failing policies webhook is enabled. | diff --git a/docs/Using Fleet/Audit-logs.md b/docs/Using Fleet/Audit-logs.md index a5e0b9ff2f..2367b31d9f 100644 --- a/docs/Using Fleet/Audit-logs.md +++ b/docs/Using Fleet/Audit-logs.md @@ -1032,6 +1032,23 @@ This activity contains the following fields: } ``` +## wiped_host + +Generated when a user sends a request to wipe a host. + +This activity contains the following fields: +- "host_id": ID of the host. +- "host_display_name": Display name of the host. + +#### Example + +```json +{ + "host_id": 1, + "host_display_name": "Anna's MacBook Pro" +} +``` + diff --git a/docs/Using Fleet/MDM-custom-OS-settings.md b/docs/Using Fleet/MDM-custom-OS-settings.md index c46fa4505f..5d2b740b01 100644 --- a/docs/Using Fleet/MDM-custom-OS-settings.md +++ b/docs/Using Fleet/MDM-custom-OS-settings.md @@ -32,7 +32,7 @@ In the top box, with "Verified," "Verifying," "Pending," and "Failed" statuses, * Pending: hosts that will receive MDM commands to install configuration profiles when the hosts come online. -* Failed: hosts that failed to install configuration profiles. +* Failed: hosts that failed to install configuration profiles. For Windows profiles, the status codes are documented in Microsoft's documentation [here](https://learn.microsoft.com/en-us/windows/client-management/oma-dm-protocol-support#syncml-response-status-codes). In the list of hosts, click on an individual host and click the **OS settings** item to see the status for a specific setting. diff --git a/docs/Using Fleet/MDM-macOS-setup-experience.md b/docs/Using Fleet/MDM-macOS-setup-experience.md index 6ea511b251..8ed4d055fb 100644 --- a/docs/Using Fleet/MDM-macOS-setup-experience.md +++ b/docs/Using Fleet/MDM-macOS-setup-experience.md @@ -273,7 +273,7 @@ To customize the macOS Setup Assistant, we will do the following steps: ### Step 1: create an automatic enrollment profile -1. Download Fleet's example automatic enrollment profile by navigating to the example [here on GitHub](https://github.com/fleetdm/fleet/blob/main/mdm_profiles/automatic_enrollment.json) and clicking the download icon. +1. Download Fleet's example automatic enrollment profile by navigating to the example [here on GitHub](https://github.com/fleetdm/fleet/blob/main/it-and-security/lib/automatic-enrollment.dep.json) and clicking the download icon. 2. Open the automatic enrollment profile and replace the `profile_name` key with your organization's name. diff --git a/docs/Using Fleet/MDM-macOS-setup.md b/docs/Using Fleet/MDM-macOS-setup.md index dd2e31700d..976eda7c57 100644 --- a/docs/Using Fleet/MDM-macOS-setup.md +++ b/docs/Using Fleet/MDM-macOS-setup.md @@ -232,7 +232,7 @@ Set Fleet to be the MDM for all future Macs purchased via Apple or an authorized All hosts that automatically enroll will be assigned to the default team. If no default team is set, then the host will be placed in "No team". -> A host can be transferred to a new (not default) team before it enrolls. Learn how [here](./Teams.md#transfer-hosts-to-a-team). +> 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**. Use either of the following methods to change the default team: diff --git a/docs/Using Fleet/manage-access.md b/docs/Using Fleet/manage-access.md index 83cf0abd68..9637990cc0 100644 --- a/docs/Using Fleet/manage-access.md +++ b/docs/Using Fleet/manage-access.md @@ -60,7 +60,7 @@ GitOps is an API-only and write-only role that can be used on CI/CD pipelines. | Create, edit, and delete policies for all hosts assigned to team\* | | | āœ… | āœ… | āœ… | | Manage [policy automations](https://fleetdm.com/docs/using-fleet/automations#policy-automations) | | | | āœ… | āœ… | | Create, edit, view, and delete users | | | | āœ… | | -| Add and remove team members\* | | | | āœ… | āœ… | +| Add and remove team users\* | | | | āœ… | āœ… | | Create, edit, and delete teams\* | | | | āœ… | āœ… | | Create, edit, and delete [enroll secrets](https://fleetdm.com/docs/deploying/faq#when-do-i-need-to-deploy-a-new-enroll-secret-to-my-hosts) | | | āœ… | āœ… | āœ… | | Create, edit, and delete [enroll secrets for teams](https://fleetdm.com/docs/using-fleet/rest-api#get-enroll-secrets-for-a-team)\* | | | āœ… | āœ… | | @@ -98,7 +98,7 @@ GitOps is an API-only and write-only role that can be used on CI/CD pipelines. \** Applies only to [Fleet REST API](https://fleetdm.com/docs/using-fleet/rest-api) -## Team member permissions +## Team user permissions `Applies only to Fleet Premium` @@ -111,9 +111,9 @@ Users with global access have access to all [hosts](https://fleetdm.com/docs/using-fleet/rest-api#hosts), [software](https://fleetdm.com/docs/using-fleet/rest-api#software), [queries](https://fleetdm.com/docs/using-fleet/rest-api#queries), and [policies](https://fleetdm.com/docs/using-fleet/rest-api#policies). Check out [the user permissions table](#user-permissions) above for global user permissions. -Users can be a member of multiple teams in Fleet. +Users can be assigned to multiple teams in Fleet. -Users that are members of multiple teams can be assigned different roles for each team. For example, a user can be given access to the "Workstations" team and assigned the "Observer" role. This same user can be given access to the "Servers" team and assigned the "Maintainer" role. +Users with access to multiple teams can be assigned different roles for each team. For example, a user can be given access to the "Workstations" team and assigned the "Observer" role. This same user can be given access to the "Servers" team and assigned the "Maintainer" role. | **Action** | Team observer | Team observer+ | Team maintainer | Team admin | Team GitOps | | -------------------------------------------------------------------------------------------------------------------------------- | ------------- | -------------- | --------------- | ---------- | ----------- | @@ -137,7 +137,7 @@ Users that are members of multiple teams can be assigned different roles for eac | Filter hosts using policies | āœ… | āœ… | āœ… | āœ… | | | Create, edit, and delete team policies | | | āœ… | āœ… | āœ… | | Manage [policy automations](https://fleetdm.com/docs/using-fleet/automations#policy-automations) | | | | āœ… | āœ… | -| Add and remove team members | | | | āœ… | āœ… | +| Add and remove team users | | | | āœ… | āœ… | | Edit team name | | | | āœ… | āœ… | | Create, edit, and delete [team enroll secrets](https://fleetdm.com/docs/using-fleet/rest-api#get-enroll-secrets-for-a-team) | | | āœ… | āœ… | | | Read organization settings\* | āœ… | āœ… | āœ… | āœ… | | diff --git a/docs/Using Fleet/segment-hosts.md b/docs/Using Fleet/segment-hosts.md index 303614d790..25ec8a05ec 100644 --- a/docs/Using Fleet/segment-hosts.md +++ b/docs/Using Fleet/segment-hosts.md @@ -6,57 +6,33 @@ ā„¹ļø In Fleet 4.0, Teams were introduced. ``` -- [View teams](#view-teams) -- [Create a team](#create-a-team) -- [Automatically adding hosts to a team](#automatically-adding-hosts-to-a-team) +- [Overview](#overview) +- [Best practice](#best-practice) - [Transfer hosts to a team](#transfer-hosts-to-a-team) -- [Add users to a team](#add-users-to-a-team) -- [Remove a member from a team](#remove-a-member-from-a-team) -- [Remove a team](#remove-a-team) + +## Overview In Fleet, you can group hosts together in a team. -With hosts segmented into exclusive teams, you can apply specific queries, policies, and agent options to each team. +Then, you can give users access to only some teams. -For example, you might create a team for each type of system in your organization. You can name the teams `Workstations`, `Workstations - sandbox`, `Servers`, and `Servers - sandbox`. +This means you manage permissions so that some users can only run queries and manage hosts on the teams these users have access to. -> A popular pattern is to end a team’s name with ā€œ- sandboxā€, then you can use this to test new queries and configuration with staging hosts or volunteers acting as canaries. +You can manage teams in the Fleet UI by selecting **Settings** > **Teams** in the top navigation. From there, you can add or remove teams, manage user access to teams, transfer hosts, or modify team settings. -Then you can: +## Best practice -- Enroll hosts to one team using team specific enroll secrets +The best practice is to create these teams: `Workstations`, `Workstations (canary)`, `Servers`, and `Servers (canary)`. -- Apply unique agent options to each team -- Schedule queries that target one or more teams -- Run live queries against one or more teams - -- Grant users access to one or more - -## View teams - -To view teams: - -In the top navigation select "Settings" and then "Teams." - -## Create a team - -To create a team: - -1. In the top navigation select "Settings" and then, in the sub-navigation, select "Teams." - -2. To the left of the search box, select "Create team." - -3. Enter your new team's name and select "Save." - -## Automatically adding hosts to a team +## Adding hosts to a team Hosts can only belong to one team in Fleet. -You can add hosts to a new team in Fleet by either enrolling the host with a team's enroll secret or by [transferring the host via the Fleet UI](#transfer-hosts-to-a-team) after the host has been enrolled to Fleet. +You can add hosts to a new team in Fleet by either enrolling the host with a team's enroll secret or by transferring the host via the Fleet UI after the host has been enrolled to Fleet. -To automatically add hosts to a team in Fleet, check out the ["Adding hosts" documentation](https://fleetdm.com/docs/using-fleet/adding-hosts#automatically-adding-hosts-to-a-team). +To automatically add hosts to a team in Fleet, check out the [**Adding hosts** documentation](https://fleetdm.com/docs/using-fleet/adding-hosts#automatically-adding-hosts-to-a-team). > If a host was previously enrolled using a global enroll secret, changing the host's osquery enroll > secret will not cause the host to be transferred to the desired team. You must delete the @@ -64,71 +40,8 @@ To automatically add hosts to a team in Fleet, check out the ["Adding hosts" doc > using the new team enroll secret. Alternatively, you can transfer the host via the Fleet UI, the > fleetctl CLI using `fleetctl hosts transfer`, or the [transfer host API endpoint](https://fleetdm.com/docs/using-fleet/rest-api#transfer-hosts-to-a-team). -## Transfer hosts to a team -To transfer a host to a team: - -1. In the top navigation, select "Hosts." - -2. Using the checkboxes in the Hosts table, select the hosts you'd like to transfer. - -3. In the Hosts table header select "Transfer to team." - -4. Choose the team you'd like to transfer the hosts to and confirm the action. - -## Add users to a team - -Global users cannot be added to a team. - -To add users to a team: - -1. In the top navigation, select "Settings" and then, in the sub-navigation, select "Teams." - -2. Find your team and select it. - -3. To the left of the search box, select "Add member." - -4. Select one or more users by searching for their full name and confirm the action. - -Users will be given the [Observer role](https://fleetdm.com/docs/using-fleet/permissions#team-member-permissions) when added to the team. The [Edit a member's role](#edit-a-members-role) provides instructions on changing the permission level of users on a team. - -## Edit a member's role - -To edit a member's role: - -1. In the top navigation, select "Settings" and then, in the sub-navigation, select "Teams." - -2. Find your team and select it. - -3. In the Members table, select the "Actions" button for the user you'd like to edit and then select "Edit." - -4. In the Teams section of the form, to the right of the team you'd like to change the users role on, select "Observer" (this may also say "Maintainer") and then select the new role. - -5. Confirm the action. - -## Remove a member from a team - -To remove a member from a team: - -1. In the top navigation, select "Settings" and then, in the sub-navigation, select "Teams." - -2. Find your team and select it. - -3. In the Members table, select the "Actions" button for the user you'd like to edit and then select "Remove." - -4. Confirm the action. - -## Delete a team - -To delete a team: - -1. In the top navigation, select "Settings" and then, in the sub-navigation, select "Teams." - -2. Find your team and select it. - -3. On the right side, select "Delete team" and confirm the action. - diff --git a/ee/fleetd-chrome/package-lock.json b/ee/fleetd-chrome/package-lock.json index dfdbb4e327..f10cd0199c 100644 --- a/ee/fleetd-chrome/package-lock.json +++ b/ee/fleetd-chrome/package-lock.json @@ -4292,9 +4292,9 @@ } }, "node_modules/ip": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz", - "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.1.tgz", + "integrity": "sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ==", "dev": true }, "node_modules/is-arguments": { diff --git a/ee/server/service/embedded_scripts/linux_wipe.sh b/ee/server/service/embedded_scripts/linux_wipe.sh new file mode 100644 index 0000000000..69a78b1235 --- /dev/null +++ b/ee/server/service/embedded_scripts/linux_wipe.sh @@ -0,0 +1,46 @@ +#!/bin/sh + +# Function to log out all users and lock their passwords except root +logout_users() { + for user in $(who | awk '{print $1}' | sort | uniq) + do + if [ "$user" != "root" ]; then + echo "Logging out $user" + pkill -KILL -u "$user" + passwd -l "$user" + fi + done +} + +# Function to wipe non-essential data +wipe_non_essential_data() { + # Define non-essential paths + non_essential_paths="/home/* /tmp /var/tmp /var/log /home/*/.cache /var/cache /home/*/.local/share/Trash" + + for path in $non_essential_paths + do + if [ -e "$path" ]; then + echo "Wiping $path" + rm -rf "$path" + fi + done +} + +# Function to wipe system files - Warning: This will render the system inoperable +wipe_system_files() { + # Define essential system paths + essential_system_paths="/bin /sbin /usr /lib" + + for path in $essential_system_paths + do + echo "Wiping $path" + rm -rf "$path" + done +} + +# Start the wiping process +logout_users +wipe_non_essential_data +wipe_system_files + +echo "Wiping process completed." diff --git a/ee/server/service/hosts.go b/ee/server/service/hosts.go index 57b5549ff7..f6f9168516 100644 --- a/ee/server/service/hosts.go +++ b/ee/server/service/hosts.go @@ -3,6 +3,7 @@ package service import ( "context" _ "embed" + "errors" "fmt" "net/http" "time" @@ -55,21 +56,22 @@ func (svc *Service) LockHost(ctx context.Context, hostID uint) error { return err } - // TODO(mna): error messages are subtly different in the figma for CLI and - // UI, they should be the same as they come from the same place (the API). - // I used the CLI messages for the implementation. - // locking validations are based on the platform of the host switch host.FleetPlatform() { case "darwin": if err := svc.VerifyMDMAppleConfigured(ctx); err != nil { - err := fleet.NewInvalidArgumentError("host_id", fleet.AppleMDMNotConfiguredMessage).WithStatus(http.StatusBadRequest) + if errors.Is(err, fleet.ErrMDMNotConfigured) { + err = fleet.NewInvalidArgumentError("host_id", fleet.AppleMDMNotConfiguredMessage).WithStatus(http.StatusBadRequest) + } return ctxerr.Wrap(ctx, err, "check macOS MDM enabled") } // on macOS, the lock command requires the host to be MDM-enrolled in Fleet hostMDM, err := svc.ds.GetHostMDM(ctx, host.ID) if err != nil { + if fleet.IsNotFound(err) { + return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Can't lock the host because it doesn't have MDM turned on.")) + } return ctxerr.Wrap(ctx, err, "get host MDM information") } if !hostMDM.IsFleetEnrolled() { @@ -79,7 +81,9 @@ func (svc *Service) LockHost(ctx context.Context, hostID uint) error { case "windows", "linux": if host.FleetPlatform() == "windows" { if err := svc.VerifyMDMWindowsConfigured(ctx); err != nil { - err := fleet.NewInvalidArgumentError("host_id", fleet.WindowsMDMNotConfiguredMessage).WithStatus(http.StatusBadRequest) + if errors.Is(err, fleet.ErrMDMNotConfigured) { + err = fleet.NewInvalidArgumentError("host_id", fleet.WindowsMDMNotConfiguredMessage).WithStatus(http.StatusBadRequest) + } return ctxerr.Wrap(ctx, err, "check windows MDM enabled") } } @@ -94,13 +98,12 @@ func (svc *Service) LockHost(ctx context.Context, hostID uint) error { } default: - // TODO(mna): should we allow/treat ChromeOS as Linux for this purpose? return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", fmt.Sprintf("Unsupported host platform: %s", host.Platform))) } // if there's a lock, unlock or wipe action pending, do not accept the lock // request. - lockWipe, err := svc.ds.GetHostLockWipeStatus(ctx, host.ID, host.FleetPlatform()) + lockWipe, err := svc.ds.GetHostLockWipeStatus(ctx, host) if err != nil { return ctxerr.Wrap(ctx, err, "get host lock/wipe status") } @@ -111,6 +114,8 @@ func (svc *Service) LockHost(ctx context.Context, hostID uint) error { return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Host has pending unlock request. Host cannot be locked again until unlock is complete.")) case lockWipe.IsPendingWipe(): return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Host has pending wipe request. Cannot process lock requests once host is wiped.")) + case lockWipe.IsWiped(): + return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Host is wiped. Cannot process lock requests once host is wiped.")) case lockWipe.IsLocked(): return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Host is already locked.").WithStatus(http.StatusConflict)) } @@ -148,7 +153,9 @@ func (svc *Service) UnlockHost(ctx context.Context, hostID uint) (string, error) // be enabled if host.FleetPlatform() == "windows" { if err := svc.VerifyMDMWindowsConfigured(ctx); err != nil { - err := fleet.NewInvalidArgumentError("host_id", fleet.WindowsMDMNotConfiguredMessage).WithStatus(http.StatusBadRequest) + if errors.Is(err, fleet.ErrMDMNotConfigured) { + err = fleet.NewInvalidArgumentError("host_id", fleet.WindowsMDMNotConfiguredMessage).WithStatus(http.StatusBadRequest) + } return "", ctxerr.Wrap(ctx, err, "check windows MDM enabled") } } @@ -161,11 +168,10 @@ func (svc *Service) UnlockHost(ctx context.Context, hostID uint) (string, error) } default: - // TODO(mna): should we allow/treat ChromeOS as Linux for this purpose? return "", ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", fmt.Sprintf("Unsupported host platform: %s", host.Platform))) } - lockWipe, err := svc.ds.GetHostLockWipeStatus(ctx, host.ID, host.FleetPlatform()) + lockWipe, err := svc.ds.GetHostLockWipeStatus(ctx, host) if err != nil { return "", ctxerr.Wrap(ctx, err, "get host lock/wipe status") } @@ -182,6 +188,8 @@ func (svc *Service) UnlockHost(ctx context.Context, hostID uint) (string, error) return "", ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Host has pending unlock request. The host will unlock when it comes online.")) case lockWipe.IsPendingWipe(): return "", ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Host has pending wipe request. Cannot process unlock requests once host is wiped.")) + case lockWipe.IsWiped(): + return "", ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Host is wiped. Cannot process unlock requests once host is wiped.")) case lockWipe.IsUnlocked(): return "", ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Host is already unlocked.").WithStatus(http.StatusConflict)) } @@ -190,6 +198,97 @@ func (svc *Service) UnlockHost(ctx context.Context, hostID uint) (string, error) return svc.enqueueUnlockHostRequest(ctx, host, lockWipe) } +func (svc *Service) WipeHost(ctx context.Context, hostID uint) error { + // First ensure the user has access to list hosts, then check the specific + // host once team_id is loaded. + if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil { + return err + } + host, err := svc.ds.HostLite(ctx, hostID) + if err != nil { + return ctxerr.Wrap(ctx, err, "get host lite") + } + + // Authorize again with team loaded now that we have the host's team_id. + // Authorize as "execute mdm_command", which is the correct access + // requirement and is what happens for macOS platforms. + if err := svc.authz.Authorize(ctx, fleet.MDMCommandAuthz{TeamID: host.TeamID}, fleet.ActionWrite); err != nil { + return err + } + + // wipe validations are based on the platform of the host, Windows and macOS + // require MDM to be enabled and the host to be MDM-enrolled in Fleet. Linux + // uses scripts, not MDM. + var requireMDM bool + switch host.FleetPlatform() { + case "darwin": + if err := svc.VerifyMDMAppleConfigured(ctx); err != nil { + if errors.Is(err, fleet.ErrMDMNotConfigured) { + err = fleet.NewInvalidArgumentError("host_id", fleet.AppleMDMNotConfiguredMessage).WithStatus(http.StatusBadRequest) + } + return ctxerr.Wrap(ctx, err, "check macOS MDM enabled") + } + requireMDM = true + + case "windows": + if err := svc.VerifyMDMWindowsConfigured(ctx); err != nil { + if errors.Is(err, fleet.ErrMDMNotConfigured) { + err = fleet.NewInvalidArgumentError("host_id", fleet.WindowsMDMNotConfiguredMessage).WithStatus(http.StatusBadRequest) + } + return ctxerr.Wrap(ctx, err, "check windows MDM enabled") + } + requireMDM = true + + case "linux": + // on linux, a script is used to wipe the host so scripts must be enabled + appCfg, err := svc.ds.AppConfig(ctx) + if err != nil { + return ctxerr.Wrap(ctx, err, "get app config") + } + if appCfg.ServerSettings.ScriptsDisabled { + return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Can't wipe host because running scripts is disabled in organization settings.")) + } + + default: + return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", fmt.Sprintf("Unsupported host platform: %s", host.Platform))) + } + + if requireMDM { + // the wipe command requires the host to be MDM-enrolled in Fleet + hostMDM, err := svc.ds.GetHostMDM(ctx, host.ID) + if err != nil { + if fleet.IsNotFound(err) { + return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Can't wipe the host because it doesn't have MDM turned on.")) + } + return ctxerr.Wrap(ctx, err, "get host MDM information") + } + if !hostMDM.IsFleetEnrolled() { + return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Can't wipe the host because it doesn't have MDM turned on.")) + } + } + + // validations based on host's actions status (pending lock, unlock, wipe) + lockWipe, err := svc.ds.GetHostLockWipeStatus(ctx, host) + if err != nil { + return ctxerr.Wrap(ctx, err, "get host lock/wipe status") + } + switch { + case lockWipe.IsPendingLock(): + return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Host has pending lock request. Host cannot be wiped until lock is complete.")) + case lockWipe.IsPendingUnlock(): + return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Host has pending unlock request. Host cannot be wiped until unlock is complete.")) + case lockWipe.IsPendingWipe(): + return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Host has pending wipe request. The host will be wiped when it comes online.")) + case lockWipe.IsLocked(): + return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Host is locked. Host cannot be wiped until it is unlocked.")) + case lockWipe.IsWiped(): + return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Host is already wiped.").WithStatus(http.StatusConflict)) + } + + // all good, go ahead with queuing the wipe request. + return svc.enqueueWipeHostRequest(ctx, host, lockWipe) +} + func (svc *Service) enqueueLockHostRequest(ctx context.Context, host *fleet.Host, lockStatus *fleet.HostLockWipeStatus) error { vc, ok := viewer.FromContext(ctx) if !ok { @@ -232,7 +331,7 @@ func (svc *Service) enqueueLockHostRequest(ctx context.Context, host *fleet.Host ScriptContents: string(script), UserID: &vc.User.ID, SyncRequest: false, - }); err != nil { + }, host.FleetPlatform()); err != nil { return err } @@ -260,7 +359,7 @@ func (svc *Service) enqueueUnlockHostRequest(ctx context.Context, host *fleet.Ho if lockStatus.HostFleetPlatform == "darwin" { // record the unlock request if it was not already recorded if lockStatus.UnlockRequestedAt.IsZero() { - if err := svc.ds.UnlockHostManually(ctx, host.ID, time.Now().UTC()); err != nil { + if err := svc.ds.UnlockHostManually(ctx, host.ID, host.FleetPlatform(), time.Now().UTC()); err != nil { return "", err } } @@ -281,7 +380,7 @@ func (svc *Service) enqueueUnlockHostRequest(ctx context.Context, host *fleet.Ho ScriptContents: string(script), UserID: &vc.User.ID, SyncRequest: false, - }); err != nil { + }, host.FleetPlatform()); err != nil { return "", err } } @@ -301,6 +400,59 @@ func (svc *Service) enqueueUnlockHostRequest(ctx context.Context, host *fleet.Ho return unlockPIN, nil } +func (svc *Service) enqueueWipeHostRequest(ctx context.Context, host *fleet.Host, wipeStatus *fleet.HostLockWipeStatus) error { + vc, ok := viewer.FromContext(ctx) + if !ok { + return fleet.ErrNoContext + } + + switch wipeStatus.HostFleetPlatform { + case "darwin": + wipeCommandUUID := uuid.NewString() + if err := svc.mdmAppleCommander.EraseDevice(ctx, host, wipeCommandUUID); err != nil { + return ctxerr.Wrap(ctx, err, "enqueuing wipe request for darwin") + } + + case "windows": + wipeCmdUUID := uuid.NewString() + wipeCmd := &fleet.MDMWindowsCommand{ + CommandUUID: wipeCmdUUID, + RawCommand: []byte(fmt.Sprintf(windowsWipeCommand, wipeCmdUUID)), + TargetLocURI: "./Device/Vendor/MSFT/RemoteWipe/doWipeProtected", + } + if err := svc.ds.WipeHostViaWindowsMDM(ctx, host, wipeCmd); err != nil { + return ctxerr.Wrap(ctx, err, "enqueuing wipe request for windows") + } + + case "linux": + // TODO(mna): svc.RunHostScript should be refactored so that we can reuse the + // part starting with the validation of the script (just in case), the checks + // that we don't enqueue over the limit, etc. for any other important + // validation we may add over there and that we bypass here by enqueueing the + // script directly in the datastore layer. + if err := svc.ds.WipeHostViaScript(ctx, &fleet.HostScriptRequestPayload{ + HostID: host.ID, + ScriptContents: string(linuxWipeScript), + UserID: &vc.User.ID, + SyncRequest: false, + }, host.FleetPlatform()); err != nil { + return err + } + } + + if err := svc.ds.NewActivity( + ctx, + vc.User, + fleet.ActivityTypeWipedHost{ + HostID: host.ID, + HostDisplayName: host.DisplayName(), + }, + ); err != nil { + return ctxerr.Wrap(ctx, err, "create activity for wipe host request") + } + return nil +} + // TODO(mna): ideally we'd embed the scripts from the scripts/mdm/windows/.. // and scripts/mdm/linux/.. directories where they currently exist, but this is // not possible (not a Go package) and I don't know if those script locations @@ -316,4 +468,21 @@ var ( linuxLockScript []byte //go:embed embedded_scripts/linux_unlock.sh linuxUnlockScript []byte + //go:embed embedded_scripts/linux_wipe.sh + linuxWipeScript []byte + + windowsWipeCommand = ` + + %s + + + ./Device/Vendor/MSFT/RemoteWipe/doWipeProtected + + + chr + text/plain + + + + ` ) diff --git a/ee/server/service/mdm.go b/ee/server/service/mdm.go index 0c28d4a2a2..dd54773140 100644 --- a/ee/server/service/mdm.go +++ b/ee/server/service/mdm.go @@ -139,14 +139,7 @@ func (svc *Service) MDMAppleEraseDevice(ctx context.Context, hostID uint) error return err } - // TODO: save the pin (first return value) in the database - // TODO(mna): same here for when we implement the Wipe story, assuming this - // implementation (which is for the deprecated /mdm/hosts/:id/wipe endpoint) - // should work as the new endpoint, then this should call - // svc.enqueueWipeHostRequest so that it behaves like the new endpoint. And - // yes, we do need to save the generated PIN so the EraseDevice method - // signature must change to return it. - err = svc.mdmAppleCommander.EraseDevice(ctx, []string{host.UUID}, uuid.New().String()) + err = svc.mdmAppleCommander.EraseDevice(ctx, host, uuid.New().String()) if err != nil { return err } @@ -258,7 +251,7 @@ func (svc *Service) updateAppConfigMDMAppleSetup(ctx context.Context, payload fl } func (svc *Service) updateMacOSSetupEnableEndUserAuth(ctx context.Context, enable bool, teamID *uint, teamName *string) error { - if err := worker.QueueMacosSetupAssistantJob(ctx, svc.ds, svc.logger, worker.MacosSetupAssistantUpdateProfile, teamID); err != nil { + if _, err := worker.QueueMacosSetupAssistantJob(ctx, svc.ds, svc.logger, worker.MacosSetupAssistantUpdateProfile, teamID); err != nil { return ctxerr.Wrap(ctx, err, "queue macos setup assistant update profile job") } @@ -574,7 +567,7 @@ func (svc *Service) SetOrUpdateMDMAppleSetupAssistant(ctx context.Context, asst // if the name is the same and the content did not change, uploaded at will stay the same if prevAsst == nil || newAsst.Name != prevAsst.Name || newAsst.UploadedAt.After(prevAsst.UploadedAt) { - if err := worker.QueueMacosSetupAssistantJob( + if _, err := worker.QueueMacosSetupAssistantJob( ctx, svc.ds, svc.logger, @@ -618,7 +611,7 @@ func (svc *Service) DeleteMDMAppleSetupAssistant(ctx context.Context, teamID *ui } if prevAsst != nil { - if err := worker.QueueMacosSetupAssistantJob( + if _, err := worker.QueueMacosSetupAssistantJob( ctx, svc.ds, svc.logger, @@ -802,7 +795,7 @@ func (svc *Service) mdmSSOHandleCallbackAuth(ctx context.Context, auth fleet.Aut } func (svc *Service) mdmAppleSyncDEPProfiles(ctx context.Context) error { - if err := worker.QueueMacosSetupAssistantJob(ctx, svc.ds, svc.logger, worker.MacosSetupAssistantUpdateAllProfiles, nil); err != nil { + if _, err := worker.QueueMacosSetupAssistantJob(ctx, svc.ds, svc.logger, worker.MacosSetupAssistantUpdateAllProfiles, nil); err != nil { return ctxerr.Wrap(ctx, err, "queue macos setup assistant update all profiles job") } return nil diff --git a/ee/server/service/teams.go b/ee/server/service/teams.go index ffb5a908eb..a615012568 100644 --- a/ee/server/service/teams.go +++ b/ee/server/service/teams.go @@ -519,6 +519,9 @@ func (svc *Service) DeleteTeam(ctx context.Context, teamID uint) error { mdmHostSerials := make([]string, 0, len(hosts)) for _, host := range hosts { hostIDs = append(hostIDs, host.ID) + // FIXME: These checks don't work here because host.MDMInfo is not being populated by + // ds.ListHosts call (it populates host.MDM instead). This may be happening in other + // places too. if host.MDMInfo.IsPendingDEPFleetEnrollment() || host.MDMInfo.IsDEPFleetEnrolled() { mdmHostSerials = append(mdmHostSerials, host.HardwareSerial) } @@ -538,7 +541,7 @@ func (svc *Service) DeleteTeam(ctx context.Context, teamID uint) error { } if len(mdmHostSerials) > 0 { - if err := worker.QueueMacosSetupAssistantJob( + if _, err := worker.QueueMacosSetupAssistantJob( ctx, svc.ds, svc.logger, @@ -866,24 +869,33 @@ func (svc *Service) createTeamFromSpec( } } + invalid := &fleet.InvalidArgumentError{} if enableDiskEncryption && !appCfg.MDM.AtLeastOnePlatformEnabledAndConfigured() { - return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("mdm", - `Couldn't edit enable_disk_encryption. Neither macOS MDM nor Windows is turned on. Visit https://fleetdm.com/docs/using-fleet to learn how to turn on MDM.`)) + invalid.Append( + "mdm", + `Couldn't edit enable_disk_encryption. Neither macOS MDM nor Windows is turned on. Visit https://fleetdm.com/docs/using-fleet to learn how to turn on MDM.`, + ) } var hostExpirySettings fleet.HostExpirySettings if spec.HostExpirySettings != nil { if spec.HostExpirySettings.HostExpiryEnabled && spec.HostExpirySettings.HostExpiryWindow <= 0 { - return nil, ctxerr.Wrap( - ctx, fleet.NewInvalidArgumentError( - "host_expiry_settings.host_expiry_window", - `When enabling host expiry, host expiry window must be a positive number.`, - ), + invalid.Append( + "host_expiry_settings.host_expiry_window", "When enabling host expiry, host expiry window must be a positive number.", ) } hostExpirySettings = *spec.HostExpirySettings } + hostStatusWebhook := fleet.HostStatusWebhookSettings{} + if spec.WebhookSettings.HostStatusWebhook != nil { + fleet.ValidateEnabledHostStatusIntegrations(*spec.WebhookSettings.HostStatusWebhook, invalid) + hostStatusWebhook = *spec.WebhookSettings.HostStatusWebhook + } + if invalid.HasErrors() { + return nil, ctxerr.Wrap(ctx, invalid) + } + if dryRun { return &fleet.Team{Name: spec.Name}, nil } @@ -901,6 +913,9 @@ func (svc *Service) createTeamFromSpec( MacOSSetup: macOSSetup, }, HostExpirySettings: hostExpirySettings, + WebhookSettings: fleet.TeamWebhookSettings{ + HostStatusWebhook: hostStatusWebhook, + }, }, Secrets: secrets, }) @@ -1038,18 +1053,25 @@ func (svc *Service) editTeamFromSpec( } // if host_expiry_settings are not provided, do not change them + invalid := &fleet.InvalidArgumentError{} if spec.HostExpirySettings != nil { if spec.HostExpirySettings.HostExpiryEnabled && spec.HostExpirySettings.HostExpiryWindow <= 0 { - return ctxerr.Wrap( - ctx, fleet.NewInvalidArgumentError( - "host_expiry_settings.host_expiry_window", - `When enabling host expiry, host expiry window must be a positive number.`, - ), + invalid.Append( + "host_expiry_settings.host_expiry_window", "When enabling host expiry, host expiry window must be a positive number.", ) } team.Config.HostExpirySettings = *spec.HostExpirySettings } + // If host status webhook is not provided, do not change it + if spec.WebhookSettings.HostStatusWebhook != nil { + fleet.ValidateEnabledHostStatusIntegrations(*spec.WebhookSettings.HostStatusWebhook, invalid) + team.Config.WebhookSettings.HostStatusWebhook = *spec.WebhookSettings.HostStatusWebhook + } + if invalid.HasErrors() { + return ctxerr.Wrap(ctx, invalid) + } + if dryRun { return nil } diff --git a/ee/tools/puppet/fleetdm/CHANGELOG.md b/ee/tools/puppet/fleetdm/CHANGELOG.md index 699e1dbb88..dc5002a04b 100644 --- a/ee/tools/puppet/fleetdm/CHANGELOG.md +++ b/ee/tools/puppet/fleetdm/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to this project will be documented in this file. +## Release 0.2.4 + +**Bug Fixes** + +- If a profile preassignment fails during the run, the profile matcher won't update the profiles in the Fleet server. +- Improved error handling for different API calls during profile preassignment to avoid crashing the Puppet run if some of them fail. + ## Release 0.0.0-beta.1 **Features** diff --git a/ee/tools/puppet/fleetdm/CONTRIBUTING.md b/ee/tools/puppet/fleetdm/CONTRIBUTING.md index eb04ddcb42..ff6ad57382 100644 --- a/ee/tools/puppet/fleetdm/CONTRIBUTING.md +++ b/ee/tools/puppet/fleetdm/CONTRIBUTING.md @@ -72,9 +72,21 @@ The only thing left to do is to add the `fleetdm/fleetdm` module to your server. ## Releasing 1. Bump the module version in the [metadata.json](https://github.com/fleetdm/fleet/blob/main/ee/tools/puppet/fleetdm/metadata.json) file. -2. From the `ee/tools/puppet/fleetdm` directory, run `pdk build`. This will generate a `tar.gz` file in the `ee/tools/puppet/fleetdm/pkg/` directory. -3. Login into the [Puppet Forge](https://forge.puppet.com/), credentials are in 1Password. -4. Visit the [upload page](https://forge.puppet.com/upload) and upload the `tar.gz` file you generated. +2. Ensure all new changes are documented in `./CHANGELOG.md` +3. From the `ee/tools/puppet/fleetdm` directory, run `pdk build`. This will generate a `tar.gz` file in the `ee/tools/puppet/fleetdm/pkg/` directory. +4. Perform a last sanity check running `pdk validate` +5. Test the build by extracting the file you just created and executing a Puppet run: +``` +# extract the build +rm -rf /tmp/puppet-module/fleetdm +mkdir -p /tmp/puppet-module/fleetdm +tar -xzf pkg/fleetdm-fleetdm-0.2.4.tar.gz -C /tmp/puppet-module/fleetdm --strip-components=1 + +# run Puppet +puppet apply --debug --test --modulepath="/tmp/puppet-module" --reports=fleetdm --hiera_config hiera.yaml examples/multiple-teams.pp +``` +6. Login into the [Puppet Forge](https://forge.puppet.com/), credentials are in 1Password. +7. Visit the [upload page](https://forge.puppet.com/upload) and upload the `tar.gz` file you generated. ## Development cheatsheet diff --git a/ee/tools/puppet/fleetdm/lib/puppet/reports/fleetdm.rb b/ee/tools/puppet/fleetdm/lib/puppet/reports/fleetdm.rb index 5bc0b58d31..20c556be57 100644 --- a/ee/tools/puppet/fleetdm/lib/puppet/reports/fleetdm.rb +++ b/ee/tools/puppet/fleetdm/lib/puppet/reports/fleetdm.rb @@ -8,15 +8,21 @@ Puppet::Reports.register_report(:fleetdm) do def process return if noop - client = Puppet::Util::FleetClient.instance + node_name = Puppet[:node_name_value] + if resource_statuses.any? { |r, _| r.downcase.include?('error pre-setting fleetdm::profile') } + Puppet.err("Some resources failed to be assigned, not matching profiles for #{node_name}") + return + end + + client = Puppet::Util::FleetClient.instance run_identifier = "#{catalog_uuid}-#{node_name}" response = client.match_profiles(run_identifier, environment) - if response['error'].empty? Puppet.info("Successfully matched #{node_name} with a team containing configuration profiles") - else - Puppet.err("Error matching node #{node_name} with a team containing configuration profiles: #{response['error']}") + return end + + Puppet.err("Error matching node #{node_name} with a team containing configuration profiles: #{response['error']}") end end diff --git a/ee/tools/puppet/fleetdm/manifests/profile.pp b/ee/tools/puppet/fleetdm/manifests/profile.pp index 5cd7265fca..4c695b98af 100644 --- a/ee/tools/puppet/fleetdm/manifests/profile.pp +++ b/ee/tools/puppet/fleetdm/manifests/profile.pp @@ -47,13 +47,13 @@ define fleetdm::profile ( $changed = $response['resource_changed'] if $err != '' { - notify { "error pre-setting profile ${name} as ${ensure}: ${err}": + notify { "error pre-setting fleetdm::profile ${name} as ${ensure}: ${err}": loglevel => 'err', } } elsif $changed { # NOTE: sending a notification also marks the # 'fleetdm::profile' as changed in the reports. - notify { "successfully pre-set profile ${name} as ${ensure}": } + notify { "successfully pre-set fleetdm::profile ${name} as ${ensure}": } } } } diff --git a/ee/tools/puppet/fleetdm/metadata.json b/ee/tools/puppet/fleetdm/metadata.json index 861b374129..360cb413ea 100644 --- a/ee/tools/puppet/fleetdm/metadata.json +++ b/ee/tools/puppet/fleetdm/metadata.json @@ -1,6 +1,6 @@ { "name": "fleetdm-fleetdm", - "version": "0.2.3", + "version": "0.2.4", "author": "Fleet Device Management Inc", "summary": "MDM management and profile assignment using FleetDM", "license": "proprietary", diff --git a/ee/tools/puppet/fleetdm/spec/unit/reports/fleetdm_spec.rb b/ee/tools/puppet/fleetdm/spec/unit/reports/fleetdm_spec.rb new file mode 100644 index 0000000000..66da374827 --- /dev/null +++ b/ee/tools/puppet/fleetdm/spec/unit/reports/fleetdm_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'puppet/reports' +require_relative '../../../lib/puppet/reports/fleetdm.rb' + +describe 'Puppet::Reports::Fleetdm' do + let(:fleet_client_mock) { instance_double('Puppet::Util::FleetClient') } + let(:catalog_uuid) { '827a74c8-cf98-44da-9ff7-18c5e4bee41e' } + let(:node_name) { Puppet[:node_name_value] } + let(:report) do + report = Puppet::Transaction::Report.new('apply') + report.extend(Puppet::Reports.report(:fleetdm)) + report + end + + before(:each) do + Puppet[:reports] = 'fleetdm' + Puppet::Util::Log.level = :warning + Puppet::Util::Log.newdestination(:console) + + fleet_client_class = class_spy('Puppet::Util::FleetClient') + stub_const('Puppet::Util::FleetClient', fleet_client_class) + allow(fleet_client_class).to receive(:instance) { fleet_client_mock } + allow(SecureRandom).to receive(:uuid).and_return(catalog_uuid) + end + + it 'does not process in noop mode' do + allow(report).to receive(:noop).and_return(true) + expect(fleet_client_mock).not_to receive(:match_profiles) + report.process + end + + it 'logs an error if resources failed to be assigned' do + allow(report).to receive(:resource_statuses).and_return({ 'error pre-setting fleetdm::profile com.apple.SoftwareUpdate as present: forbidden : base forbidden' => 'anything' }) + expect(Puppet).to receive(:err).with(%r{Some resources failed to be assigned}) + expect(fleet_client_mock).not_to receive(:match_profiles) + report.process + end + + it 'successfully matches profiles when there are no errors' do + allow(report).to receive(:noop).and_return(false) + allow(report).to receive(:resource_statuses).and_return({}) + allow(fleet_client_mock).to receive(:match_profiles).and_return({ 'error' => '' }) + allow(report).to receive(:catalog_uuid).and_return(catalog_uuid) + + expect(fleet_client_mock).to receive(:match_profiles).with("#{catalog_uuid}-#{node_name}", anything) + expect(Puppet).to receive(:info).with("Successfully matched #{node_name} with a team containing configuration profiles") + + report.process + end + + it 'logs an error when matching profiles fails' do + allow(report).to receive(:noop).and_return(false) + allow(report).to receive(:resource_statuses).and_return({}) + allow(fleet_client_mock).to receive(:match_profiles).and_return({ 'error' => 'Some error' }) + allow(report).to receive(:catalog_uuid).and_return(catalog_uuid) + + expect(fleet_client_mock).to receive(:match_profiles).with("#{catalog_uuid}-#{node_name}", anything) + expect(Puppet).to receive(:err).with("Error matching node #{node_name} with a team containing configuration profiles: Some error") + + report.process + end +end diff --git a/frontend/components/Card/Card.tsx b/frontend/components/Card/Card.tsx index 36576967e6..08d3c777d7 100644 --- a/frontend/components/Card/Card.tsx +++ b/frontend/components/Card/Card.tsx @@ -15,6 +15,8 @@ interface ICardProps { /** The color of the card. Defaults to `white` */ color?: CardColor; className?: string; + /** Increases to 40px padding. Defaults to `false` */ + largePadding?: boolean; } /** @@ -27,12 +29,16 @@ const Card = ({ includeShadow = false, color = "white", className, + largePadding = false, }: ICardProps) => { const classNames = classnames( baseClass, `${baseClass}__${color}`, `${baseClass}__radius-${borderRadiusSize}`, - { [`${baseClass}__shadow`]: includeShadow }, + { + [`${baseClass}__shadow`]: includeShadow, + [`${baseClass}__large-padding`]: largePadding, + }, className ); diff --git a/frontend/components/Card/_styles.scss b/frontend/components/Card/_styles.scss index ca53c95810..4fec9ffc64 100644 --- a/frontend/components/Card/_styles.scss +++ b/frontend/components/Card/_styles.scss @@ -22,6 +22,11 @@ box-shadow: $box-shadow; } + // 40px padding + &__large-padding { + padding: $pad-xxlarge; + } + // color styles &__white { background-color: $core-white; diff --git a/frontend/components/DataSet/_styles.scss b/frontend/components/DataSet/_styles.scss index 809e7d98c7..bac96ebc16 100644 --- a/frontend/components/DataSet/_styles.scss +++ b/frontend/components/DataSet/_styles.scss @@ -1,7 +1,18 @@ .data-set { font-size: $x-small; + min-width: max-content; dt { font-weight: $bold; + display: flex; + gap: $pad-xsmall; // For deprecated sandbox icons + } + + dd { + display: flex; + gap: $pad-small; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } } diff --git a/frontend/components/LiveQuery/SelectTargets.tsx b/frontend/components/LiveQuery/SelectTargets.tsx index 96fce7a5d7..1c6b074df9 100644 --- a/frontend/components/LiveQuery/SelectTargets.tsx +++ b/frontend/components/LiveQuery/SelectTargets.tsx @@ -120,7 +120,6 @@ const TargetPillSelector = ({ > {displayText()} - {/* {entity.count} */} ); }; @@ -395,8 +394,9 @@ const SelectTargets = ({ return ( <> - {total.toLocaleString()} host{total > 1 ? `s` : ``}{" "} - targeted  ({onlinePercentage()} + {total.toLocaleString()} host + {total > 1 || total === 0 ? `s` : ``} targeted  ( + {onlinePercentage()} %  diff --git a/frontend/components/LiveQuery/TargetsInput/TargetsInput.tsx b/frontend/components/LiveQuery/TargetsInput/TargetsInput.tsx index 80f7b89b38..c56bdd1e33 100644 --- a/frontend/components/LiveQuery/TargetsInput/TargetsInput.tsx +++ b/frontend/components/LiveQuery/TargetsInput/TargetsInput.tsx @@ -7,7 +7,7 @@ import { HOSTS_SEARCH_BOX_PLACEHOLDER } from "utilities/constants"; import DataError from "components/DataError"; // @ts-ignore -import Input from "components/forms/fields/InputFieldWithIcon"; +import InputFieldWithIcon from "components/forms/fields/InputFieldWithIcon/InputFieldWithIcon"; import TableContainer from "components/TableContainer"; import { generateTableHeaders } from "./TargetsInputHostsTableConfig"; @@ -47,7 +47,7 @@ const TargetsInput = ({ return (
- void; + onClickRow?: (value: any) => void; onResultsCountChange?: (value: number) => void; renderFooter?: () => JSX.Element | null; renderPagination?: () => JSX.Element | null; @@ -96,6 +97,7 @@ const DataTable = ({ searchQueryColumn, selectedDropdownFilter, onSelectSingleRow, + onClickRow, onResultsCountChange, renderFooter, renderPagination, @@ -313,7 +315,7 @@ const DataTable = ({ toggleAllPagesSelected(false); }, [toggleAllPagesSelected, toggleAllRowsSelected]); - const onSingleRowClick = useCallback( + const onSelectRowClick = useCallback( (row) => { if (disableMultiRowSelect) { row.toggleRowSelected(); @@ -524,9 +526,10 @@ const DataTable = ({ {...row.getRowProps({ // @ts-ignore // TS complains about prop not existing onClick: () => { - onSingleRowClick && + (onSelectRowClick && disableMultiRowSelect && - onSingleRowClick(row); + onSelectRowClick(row)) || + (onClickRow && onClickRow(row)); }, })} > diff --git a/frontend/components/TableContainer/DataTable/DefaultColumnFilter/_styles.scss b/frontend/components/TableContainer/DataTable/DefaultColumnFilter/_styles.scss index 8e5dc98895..da8bd59501 100644 --- a/frontend/components/TableContainer/DataTable/DefaultColumnFilter/_styles.scss +++ b/frontend/components/TableContainer/DataTable/DefaultColumnFilter/_styles.scss @@ -1,4 +1,8 @@ .filter-cell { + .input-icon-field__input-wrapper { + margin-top: $pad-xsmall; + } + input { height: 40px; width: 100%; @@ -8,7 +12,6 @@ border-radius: 4px; padding: 4px; padding-left: 32px; - margin-top: $pad-xsmall; } .search-field__input-wrapper { @@ -18,9 +21,6 @@ } .icon { - left: 10px; - top: 17px; - path { fill: $ui-fleet-black-33; // Override input icon color } diff --git a/frontend/components/TableContainer/DataTable/HostMdmStatusCell/HostMdmStatusCell.tsx b/frontend/components/TableContainer/DataTable/HostMdmStatusCell/HostMdmStatusCell.tsx new file mode 100644 index 0000000000..36917063a2 --- /dev/null +++ b/frontend/components/TableContainer/DataTable/HostMdmStatusCell/HostMdmStatusCell.tsx @@ -0,0 +1,59 @@ +import React from "react"; +import ReactTooltip from "react-tooltip"; +import { COLORS } from "styles/var/colors"; +import { DEFAULT_EMPTY_CELL_VALUE } from "utilities/constants"; +import Icon from "components/Icon"; +import NotSupported from "components/NotSupported"; +import { IHost } from "interfaces/host"; + +const baseClass = "host-mdm-status-cell"; + +const HostMdmStatusCell = ({ + row: { + original: { id, mdm, platform }, + }, + cell: { value }, +}: { + row: { original: IHost }; + cell: { value: string }; +}): JSX.Element => { + if (platform === "chrome") { + return NotSupported; + } + + if (!value) { + return {DEFAULT_EMPTY_CELL_VALUE}; + } + + return ( + + {value} + {mdm?.dep_profile_error && ( + <> + + + + + + Fleet hit Apple’s API rate limit when preparing the macOS Setup + Assistant for this host. Fleet will try again every hour. + + + + )} + + ); +}; + +export default HostMdmStatusCell; diff --git a/frontend/components/TableContainer/DataTable/HostMdmStatusCell/_styles.scss b/frontend/components/TableContainer/DataTable/HostMdmStatusCell/_styles.scss new file mode 100644 index 0000000000..fdb976ae47 --- /dev/null +++ b/frontend/components/TableContainer/DataTable/HostMdmStatusCell/_styles.scss @@ -0,0 +1,10 @@ +.host-mdm-status-cell { + display: inline-flex; + flex-direction: row; + text-wrap: nowrap; + gap: $pad-small; + + .tooltip__tooltip-text { + text-wrap: wrap; + } +} diff --git a/frontend/components/TableContainer/DataTable/HostMdmStatusCell/index.ts b/frontend/components/TableContainer/DataTable/HostMdmStatusCell/index.ts new file mode 100644 index 0000000000..c5943e3fa4 --- /dev/null +++ b/frontend/components/TableContainer/DataTable/HostMdmStatusCell/index.ts @@ -0,0 +1 @@ +export { default } from "./HostMdmStatusCell"; diff --git a/frontend/components/TableContainer/DataTable/InternalLinkCell/InternalLinkCell.tsx b/frontend/components/TableContainer/DataTable/InternalLinkCell/InternalLinkCell.tsx new file mode 100644 index 0000000000..f4c3601742 --- /dev/null +++ b/frontend/components/TableContainer/DataTable/InternalLinkCell/InternalLinkCell.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import classnames from "classnames"; +import { noop } from "lodash"; + +import Icon from "components/Icon"; + +const baseClass = "internal-link-cell"; + +interface IInternalLinkCellProps { + value: string; + onClick?: () => void; + className?: string; +} + +/** This cell is used when you want a clickable cell value that does not link + * to an url. This can be used when you'd like to trigger an action when the + * cell is clicked such as opening a modal. + * + * TODO: can we find a way to combine this with LinkCell. Would we want to do that? + * Also we can improve naming of this component. + */ +const InternalLinkCell = ({ + value, + onClick = noop, + className, +}: IInternalLinkCellProps) => { + const classNames = classnames(baseClass, className); + + return ( +
+ {/* The content div is to ensure that the clickable area is contained to + the text and icon. This is to prevent the entire cell from being + clickable. TODO: Figure out if this is product wants to hand this. + */} +
+ {value} + +
+
+ ); +}; + +export default InternalLinkCell; diff --git a/frontend/components/TableContainer/DataTable/InternalLinkCell/_styles.scss b/frontend/components/TableContainer/DataTable/InternalLinkCell/_styles.scss new file mode 100644 index 0000000000..c9dc9c6d90 --- /dev/null +++ b/frontend/components/TableContainer/DataTable/InternalLinkCell/_styles.scss @@ -0,0 +1,14 @@ +.internal-link-cell { + + &__content { + font: $x-small; + color: $core-vibrant-blue; + font-weight: $bold; + display: inline-flex; + + &:hover { + cursor: pointer; + text-decoration: underline; + } + } +} diff --git a/frontend/components/TableContainer/DataTable/InternalLinkCell/index.ts b/frontend/components/TableContainer/DataTable/InternalLinkCell/index.ts new file mode 100644 index 0000000000..66785a5a20 --- /dev/null +++ b/frontend/components/TableContainer/DataTable/InternalLinkCell/index.ts @@ -0,0 +1 @@ +export { default } from "./InternalLinkCell"; diff --git a/frontend/components/TableContainer/TableContainer.tsx b/frontend/components/TableContainer/TableContainer.tsx index f96f15cd29..8a99bb46de 100644 --- a/frontend/components/TableContainer/TableContainer.tsx +++ b/frontend/components/TableContainer/TableContainer.tsx @@ -1,6 +1,6 @@ import React, { useState, useCallback, useRef, useEffect } from "react"; import classnames from "classnames"; -import { Row } from "react-table"; +import { Row, UseExpandedRowProps } from "react-table"; import ReactTooltip from "react-tooltip"; import useDeepEffect from "hooks/useDeepEffect"; @@ -34,7 +34,7 @@ interface IRowProps extends Row { }; } -interface ITableContainerProps { +interface ITableContainerProps { columnConfigs: any; // TODO: Figure out type data: any; // TODO: Figure out type isLoading: boolean; @@ -87,7 +87,13 @@ interface ITableContainerProps { customControl?: () => JSX.Element; stackControls?: boolean; onSelectSingleRow?: (value: Row | IRowProps) => void; - /** Use for clientside filtering: Use key global for filtering on any column, or use column id as key */ + /** This is called when you click on a row. This was added as `onSelectSingleRow` + * only work if `disableMultiRowSelect` is also set to `true`. TODO: figure out + * if we want to keep this + */ + onClickRow?: (row: T) => void; + /** Use for clientside filtering: Use key global for filtering on any column, or use column id as + * key */ filters?: Record; renderCount?: () => JSX.Element | null; renderFooter?: () => JSX.Element | null; @@ -101,7 +107,7 @@ const baseClass = "table-container"; const DEFAULT_PAGE_SIZE = 20; const DEFAULT_PAGE_INDEX = 0; -const TableContainer = ({ +const TableContainer = ({ columnConfigs, data, filters, @@ -144,12 +150,13 @@ const TableContainer = ({ customControl, stackControls, onSelectSingleRow, + onClickRow, renderCount, renderFooter, setExportRows, resetPageIndex, disableTableHeader, -}: ITableContainerProps): JSX.Element => { +}: ITableContainerProps) => { const [searchQuery, setSearchQuery] = useState(defaultSearchQuery); const [sortHeader, setSortHeader] = useState(defaultSortHeader || ""); const [sortDirection, setSortDirection] = useState( @@ -437,6 +444,7 @@ const TableContainer = ({ primarySelectAction={primarySelectAction} secondarySelectActions={secondarySelectActions} onSelectSingleRow={onSelectSingleRow} + onClickRow={onClickRow} onResultsCountChange={onResultsCountChange} isClientSidePagination={isClientSidePagination} onClientSidePaginationChange={onClientSidePaginationChange} diff --git a/frontend/components/forms/fields/AutoSizeInputField/AutoSizeInputField.stories.tsx b/frontend/components/forms/fields/AutoSizeInputField/AutoSizeInputField.stories.tsx index 71f696e5f0..cea0e18abf 100644 --- a/frontend/components/forms/fields/AutoSizeInputField/AutoSizeInputField.stories.tsx +++ b/frontend/components/forms/fields/AutoSizeInputField/AutoSizeInputField.stories.tsx @@ -11,7 +11,7 @@ interface IAutoSizeInputFieldProps { placeholder: string; value: string; inputClassName?: string; - maxLength: string; + maxLength: number; hasError?: boolean; isDisabled?: boolean; isFocused?: boolean; @@ -36,7 +36,7 @@ export default { placeholder: "Type here...", type: "", value: "", - maxLength: "250", + maxLength: 250, onFocus: noop, onChange: noop, onKeyPress: noop, diff --git a/frontend/components/forms/fields/AutoSizeInputField/AutoSizeInputField.tsx b/frontend/components/forms/fields/AutoSizeInputField/AutoSizeInputField.tsx index 484e02bac4..30c97c68d5 100644 --- a/frontend/components/forms/fields/AutoSizeInputField/AutoSizeInputField.tsx +++ b/frontend/components/forms/fields/AutoSizeInputField/AutoSizeInputField.tsx @@ -12,12 +12,12 @@ interface IAutoSizeInputFieldProps { placeholder: string; value: string; inputClassName?: string; - maxLength: string; + maxLength: number; hasError?: boolean; isDisabled?: boolean; isFocused?: boolean; - onFocus: () => void; - onBlur: () => void; + onFocus?: () => void; + onBlur?: () => void; onChange: (newSelectedValue: string) => void; onKeyPress: (event: KeyboardEvent) => void; } @@ -33,8 +33,8 @@ const AutoSizeInputField = ({ hasError, isDisabled, isFocused, - onFocus, - onBlur, + onFocus = () => null, + onBlur = () => null, onChange, onKeyPress, }: IAutoSizeInputFieldProps): JSX.Element => { @@ -44,6 +44,7 @@ const AutoSizeInputField = ({ [`${baseClass}--disabled`]: isDisabled, [`${baseClass}--error`]: hasError, [`${baseClass}__textarea`]: true, + "no-value": !inputValue, }); const inputElement = useRef(null); @@ -87,11 +88,10 @@ const AutoSizeInputField = ({ onChange={onInputChange} placeholder={placeholder} value={inputValue} - maxLength={parseInt(maxLength, 10)} + maxLength={maxLength} className={inputClasses} cols={value ? value.length : placeholder.length - 2} rows={1} - tabIndex={0} onFocus={onInputFocus} onBlur={onInputBlur} onKeyPress={onInputKeyPress} diff --git a/frontend/components/forms/fields/AutoSizeInputField/_styles.scss b/frontend/components/forms/fields/AutoSizeInputField/_styles.scss index b12b9ffe16..f3a89f82da 100644 --- a/frontend/components/forms/fields/AutoSizeInputField/_styles.scss +++ b/frontend/components/forms/fields/AutoSizeInputField/_styles.scss @@ -3,7 +3,7 @@ color: $core-fleet-black; &::placeholder { - color: $ui-fleet-black-50; + @include placeholder; } &:focus { @@ -20,7 +20,9 @@ &::after, input, textarea { + white-space: pre-wrap; width: auto; + max-width: 100%; grid-area: 1 / 2; resize: none; background: none; diff --git a/frontend/components/forms/fields/InputFieldWithIcon/InputFieldWithIcon.jsx b/frontend/components/forms/fields/InputFieldWithIcon/InputFieldWithIcon.jsx index 446ede2123..0b68a19764 100644 --- a/frontend/components/forms/fields/InputFieldWithIcon/InputFieldWithIcon.jsx +++ b/frontend/components/forms/fields/InputFieldWithIcon/InputFieldWithIcon.jsx @@ -114,25 +114,27 @@ class InputFieldWithIcon extends InputField { return (
{this.props.label && this.renderHeading()} - { - this.input = r; - }} - tabIndex={tabIndex} - type={type} - value={value} - disabled={disabled} - {...inputOptions} - data-1p-ignore={ignore1Password} - /> - {iconSvg && } - {iconName && } +
+ { + this.input = r; + }} + tabIndex={tabIndex} + type={type} + value={value} + disabled={disabled} + {...inputOptions} + data-1p-ignore={ignore1Password} + /> + {iconSvg && } + {iconName && } +
{renderHelpText()}
); diff --git a/frontend/components/forms/fields/InputFieldWithIcon/_styles.scss b/frontend/components/forms/fields/InputFieldWithIcon/_styles.scss index fed3ee5e7a..13ff7bbb2a 100644 --- a/frontend/components/forms/fields/InputFieldWithIcon/_styles.scss +++ b/frontend/components/forms/fields/InputFieldWithIcon/_styles.scss @@ -18,6 +18,14 @@ } } + // Relative input wrapper with absolute icon corrects icon alignment on all browsers + &__input-wrapper { + position: relative; + height: 40px; + display: flex; + align-items: center; + } + // Refactor to include svg icons &--icon-start { margin-top: 0; @@ -25,10 +33,11 @@ .input-icon-field__icon { position: absolute; left: 12px; - top: 13px; + top: 0; + height: 40px; width: 16px; - font-size: $x-small; - color: $core-fleet-blue; + flex-wrap: wrap; + align-content: center; z-index: 1; } } @@ -51,20 +60,6 @@ color: $core-fleet-blue; } - &:hover, - &:focus { - border: 1px solid $core-vibrant-blue; - - // Icon color matches border color on focus and on hover - + .input-icon-field__icon { - svg { - path { - fill: $core-vibrant-blue; - } - } - } - } - &:focus { outline: none; } @@ -82,6 +77,33 @@ } } + &__input-wrapper:hover { + .input-icon-field__input { + border: 1px solid $core-vibrant-blue; + } + + // Icon color matches border color on focus and on hover + .input-icon-field__icon { + svg { + path { + fill: $core-vibrant-blue; + } + } + } + } + + .input-icon-field__input:focus { + border: 1px solid $core-vibrant-blue; + + // Icon color matches border color on focus and on hover + + .input-icon-field__icon { + svg { + path { + fill: $core-vibrant-blue; + } + } + } + } &__label { display: block; font-size: $x-small; diff --git a/frontend/context/app.tsx b/frontend/context/app.tsx index 5784534c4e..4dc0d2137f 100644 --- a/frontend/context/app.tsx +++ b/frontend/context/app.tsx @@ -106,7 +106,8 @@ type InitialStateType = { isSandboxMode?: boolean; isFreeTier?: boolean; isPremiumTier?: boolean; - isMdmEnabledAndConfigured?: boolean; + isMacMdmEnabledAndConfigured?: boolean; + isWindowsMdmEnabledAndConfigured?: boolean; isGlobalAdmin?: boolean; isGlobalMaintainer?: boolean; isGlobalObserver?: boolean; @@ -156,7 +157,8 @@ export const initialState = { isSandboxMode: false, isFreeTier: undefined, isPremiumTier: undefined, - isMdmEnabledAndConfigured: undefined, + isMacMdmEnabledAndConfigured: undefined, + isWindowsMdmEnabledAndConfigured: undefined, isGlobalAdmin: undefined, isGlobalMaintainer: undefined, isGlobalObserver: undefined, @@ -212,7 +214,12 @@ const setPermissions = ( isSandboxMode: permissions.isSandboxMode(config), isFreeTier: permissions.isFreeTier(config), isPremiumTier: permissions.isPremiumTier(config), - isMdmEnabledAndConfigured: permissions.isMdmEnabledAndConfigured(config), + isMacMdmEnabledAndConfigured: permissions.isMacMdmEnabledAndConfigured( + config + ), + isWindowsMdmEnabledAndConfigured: permissions.isWindowsMdmEnabledAndConfigured( + config + ), isGlobalAdmin: permissions.isGlobalAdmin(user), isGlobalMaintainer: permissions.isGlobalMaintainer(user), isGlobalObserver: permissions.isGlobalObserver(user), @@ -365,7 +372,8 @@ const AppProvider = ({ children }: Props): JSX.Element => { isSandboxMode: state.isSandboxMode, isFreeTier: state.isFreeTier, isPremiumTier: state.isPremiumTier, - isMdmEnabledAndConfigured: state.isMdmEnabledAndConfigured, + isMacMdmEnabledAndConfigured: state.isMacMdmEnabledAndConfigured, + isWindowsMdmEnabledAndConfigured: state.isWindowsMdmEnabledAndConfigured, isGlobalAdmin: state.isGlobalAdmin, isGlobalMaintainer: state.isGlobalMaintainer, isGlobalObserver: state.isGlobalObserver, diff --git a/frontend/interfaces/activity.ts b/frontend/interfaces/activity.ts index ed047aa777..143efeaf0a 100644 --- a/frontend/interfaces/activity.ts +++ b/frontend/interfaces/activity.ts @@ -66,7 +66,15 @@ export enum ActivityType { EditedWindowsUpdates = "edited_windows_updates", LockedHost = "locked_host", UnlockedHost = "unlocked_host", + WipedHost = "wiped_host", } + +// This is a subset of ActivityType that are shown only for the host past activities +export type IHostPastActivityType = + | ActivityType.RanScript + | ActivityType.LockedHost + | ActivityType.UnlockedHost; + export interface IActivity { created_at: string; id: number; @@ -77,6 +85,11 @@ export interface IActivity { type: ActivityType; details?: IActivityDetails; } + +export type IPastActivity = Omit & { + type: IHostPastActivityType; +}; + export interface IActivityDetails { pack_id?: number; pack_name?: string; diff --git a/frontend/interfaces/host.ts b/frontend/interfaces/host.ts index 8df2467664..4465dd3ad1 100644 --- a/frontend/interfaces/host.ts +++ b/frontend/interfaces/host.ts @@ -157,12 +157,13 @@ interface IMdmMacOsSetup { bootstrap_package_name: string; } -export type HostMdmDeviceStatus = "unlocked" | "locked"; -export type HostMdmPendingAction = "unlock" | "lock" | ""; +export type HostMdmDeviceStatus = "unlocked" | "locked" | "wiped"; +export type HostMdmPendingAction = "unlock" | "lock" | "wipe" | ""; export interface IHostMdmData { encryption_key_available: boolean; enrollment_status: MdmEnrollmentStatus | null; + dep_profile_error?: boolean; name?: string; id?: number; server_url: string | null; diff --git a/frontend/interfaces/team.ts b/frontend/interfaces/team.ts index cda3a18d46..435075902a 100644 --- a/frontend/interfaces/team.ts +++ b/frontend/interfaces/team.ts @@ -74,7 +74,7 @@ export interface ITeam extends ITeamSummary { */ export type ITeamWebhookSettings = Pick< IWebhookSettings, - "vulnerabilities_webhook" | "failing_policies_webhook" + "vulnerabilities_webhook" | "failing_policies_webhook" | "host_status_webhook" >; /** diff --git a/frontend/pages/DashboardPage/DashboardPage.tsx b/frontend/pages/DashboardPage/DashboardPage.tsx index 8aa61bedbc..6053fcc0b4 100644 --- a/frontend/pages/DashboardPage/DashboardPage.tsx +++ b/frontend/pages/DashboardPage/DashboardPage.tsx @@ -1,4 +1,4 @@ -import React, { useContext, useState, useEffect } from "react"; +import React, { useContext, useState, useEffect, useRef } from "react"; import { InjectedRouter } from "react-router"; import { useQuery } from "react-query"; @@ -61,6 +61,7 @@ import Mdm from "./cards/MDM"; import Munki from "./cards/Munki"; import OperatingSystems from "./cards/OperatingSystems"; import AddHostsModal from "../../components/AddHostsModal"; +import MdmSolutionModal from "./components/MdmSolutionModal"; const baseClass = "dashboard-page"; @@ -132,11 +133,14 @@ const DashboardPage = ({ router, location }: IDashboardProps): JSX.Element => { const [showMdmCard, setShowMdmCard] = useState(true); const [showSoftwareCard, setShowSoftwareCard] = useState(false); const [showAddHostsModal, setShowAddHostsModal] = useState(false); + const [showMdmSolutionModal, setShowMdmSolutionModal] = useState(false); const [showOperatingSystemsUI, setShowOperatingSystemsUI] = useState(false); const [showHostsUI, setShowHostsUI] = useState(false); // Hides UI on first load only const [mdmStatusData, setMdmStatusData] = useState([]); const [mdmSolutions, setMdmSolutions] = useState([]); + const selectedMdmSolution = useRef(null); + const [munkiIssuesData, setMunkiIssuesData] = useState< IMunkiIssuesAggregate[] >([]); @@ -614,6 +618,10 @@ const DashboardPage = ({ router, location }: IDashboardProps): JSX.Element => { mdmSolutions={mdmSolutions} selectedPlatformLabelId={selectedPlatformLabelId} selectedTeamId={currentTeamId} + onClickMdmSolution={(mdmSolution) => { + selectedMdmSolution.current = mdmSolution.name; + setShowMdmSolutionModal(true); + }} /> ), })} @@ -713,6 +721,28 @@ const DashboardPage = ({ router, location }: IDashboardProps): JSX.Element => { ); }; + const renderMdmSolutionModal = () => { + if (!mdmSolutions) { + return null; + } + + const selectedMdmSolutions = mdmSolutions?.filter( + (solution) => solution.name === selectedMdmSolution.current + ); + + return ( + { + setShowMdmSolutionModal(false); + selectedMdmSolution.current = null; + }} + /> + ); + }; + const renderDashboardHeader = () => { if (isPremiumTier) { if (userTeams) { @@ -785,6 +815,7 @@ const DashboardPage = ({ router, location }: IDashboardProps): JSX.Element => {
{renderCards()} {showAddHostsModal && renderAddHostsModal()} + {showMdmSolutionModal && renderMdmSolutionModal()}
); diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tests.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tests.tsx index a18dd1aae7..e8bff33d98 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tests.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tests.tsx @@ -1165,4 +1165,17 @@ describe("Activity Feed", () => { screen.getByText("deleted multiple queries", { exact: false }) ).toBeInTheDocument(); }); + // test for wipe activity + it("renders a 'wiped_host' type activity for a team", () => { + const activity = createMockActivity({ + type: ActivityType.WipedHost, + details: { + host_display_name: "Foo Host", + }, + }); + render(); + + expect(screen.getByText("wiped", { exact: false })).toBeInTheDocument(); + expect(screen.getByText("Foo Host", { exact: false })).toBeInTheDocument(); + }); }); diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx index dadda97c84..f576ad0532 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx @@ -755,6 +755,14 @@ const TAGGED_TEMPLATES = { ); }, + wipedHost: (activity: IActivity) => { + return ( + <> + {" "} + wiped {activity.details?.host_display_name}. + + ); + }, }; const getDetail = ( @@ -907,6 +915,9 @@ const getDetail = ( case ActivityType.UnlockedHost: { return TAGGED_TEMPLATES.unlockedHost(activity); } + case ActivityType.WipedHost: { + return TAGGED_TEMPLATES.wipedHost(activity); + } default: { return TAGGED_TEMPLATES.defaultActivityTemplate(activity); } diff --git a/frontend/pages/DashboardPage/cards/MDM/MDM.tests.tsx b/frontend/pages/DashboardPage/cards/MDM/MDM.tests.tsx index 81fd8c14b0..ad49d09198 100644 --- a/frontend/pages/DashboardPage/cards/MDM/MDM.tests.tsx +++ b/frontend/pages/DashboardPage/cards/MDM/MDM.tests.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { noop } from "lodash"; import { render, screen } from "@testing-library/react"; import { renderWithSetup } from "test/test-utils"; @@ -7,25 +8,35 @@ import { createMockMdmSolution } from "__mocks__/mdmMock"; import MDM from "./MDM"; describe("MDM Card", () => { - it("render the correct number of MDM solutions", () => { - render( + it("rolls up the data by mdm solution name and render the correct number of MDM solutions", () => { + const { debug } = render( ); - expect(screen.getAllByText("MDM Solution").length).toBe(2); + expect(screen.getAllByText("MDM Solution").length).toBe(1); + expect(screen.getAllByText("Test Solution").length).toBe(1); + expect(screen.getAllByText("Test Solution 2").length).toBe(1); + expect(screen.getAllByText("Unknown").length).toBe(1); }); it("render the correct number of Enrollment status", async () => { const { user } = renderWithSetup( void; } const DEFAULT_SORT_DIRECTION = "desc"; @@ -59,6 +65,28 @@ const EmptyMdmSolutions = (): JSX.Element => ( /> ); +type IMdmSolutionMap = Record; + +const reduceSolutionsToObj = (mdmSolutions: IMdmSolution[]) => { + return mdmSolutions.reduce((acc, nextSolution) => { + // The solution name can be null so we add an Unknown key to the + // accumulator in this case. + if (nextSolution.name === null) { + if (acc.Unknown) { + acc.Unknown.hosts_count += nextSolution.hosts_count; + } else { + acc.Unknown = Object.assign({ ...nextSolution }); + } + } else if (acc[nextSolution.name]) { + acc[nextSolution.name].hosts_count += nextSolution.hosts_count; + } else { + acc[nextSolution.name] = Object.assign({ ...nextSolution }); + } + + return acc; + }, {}); +}; + const Mdm = ({ isFetching, error, @@ -66,6 +94,7 @@ const Mdm = ({ mdmSolutions, selectedPlatformLabelId, selectedTeamId, + onClickMdmSolution, }: IMdmCardProps): JSX.Element => { const [navTabIndex, setNavTabIndex] = useState(0); @@ -73,18 +102,23 @@ const Mdm = ({ setNavTabIndex(index); }; + const rolledupMdmSolutionsData = useMemo(() => { + if (!mdmSolutions) { + return []; + } + + return Object.values(reduceSolutionsToObj(mdmSolutions)); + }, [mdmSolutions]); + const solutionsTableHeaders = useMemo( - () => generateSolutionsTableHeaders(selectedTeamId), - [selectedTeamId] + () => generateSolutionsTableHeaders(), + [] ); const statusTableHeaders = useMemo( () => generateStatusTableHeaders(selectedTeamId), [selectedTeamId] ); - const solutionsDataSet = generateSolutionsDataSet( - mdmSolutions, - selectedPlatformLabelId - ); + const solutionsDataSet = generateSolutionsDataSet(rolledupMdmSolutionsData); const statusDataSet = generateStatusDataSet( mdmStatusData, selectedPlatformLabelId @@ -93,6 +127,10 @@ const Mdm = ({ // Renders opaque information as host information is loading const opacity = isFetching ? { opacity: 0 } : { opacity: 1 }; + const handleSolutionRowClick = (row: IRowProps) => { + onClickMdmSolution(row.original); + }; + return (
{isFetching && ( @@ -111,7 +149,8 @@ const Mdm = ({ {error ? ( ) : ( - + className={`${baseClass}__mdm-solutions-table`} columnConfigs={solutionsTableHeaders} data={solutionsDataSet} isLoading={isFetching} @@ -121,9 +160,9 @@ const Mdm = ({ emptyComponent={EmptyMdmSolutions} showMarkAllPages={false} isAllPagesSelected={false} - isClientSidePagination disableCount - pageSize={PAGE_SIZE} + disablePagination + onClickRow={handleSolutionRowClick} /> )} @@ -132,6 +171,7 @@ const Mdm = ({ ) : ( JSX.Element) | string; @@ -46,79 +38,36 @@ interface IDataColumn { disableSortBy?: boolean; } -export const generateSolutionsTableHeaders = ( - teamId?: number -): IDataColumn[] => [ +export const generateSolutionsTableHeaders = (): IDataColumn[] => [ { title: "Name", Header: "Name", disableSortBy: true, - accessor: "name", + accessor: "displayName", Cell: (cellProps: ICellProps) => ( - + ), }, - { - title: "Server URL", - Header: "Server URL", - disableSortBy: true, - accessor: "server_url", - Cell: (cellProps: ICellProps) => , - }, { title: "Hosts", - Header: (cellProps: IHeaderProps) => ( - - ), + Header: "Hosts", + disableSortBy: true, accessor: "hosts_count", Cell: (cellProps: ICellProps) => , }, - { - title: "", - Header: "", - disableSortBy: true, - disableGlobalFilter: true, - accessor: "linkToFilteredHosts", - Cell: (cellProps: IStringCellProps) => { - return ( - - ); - }, - disableHidden: true, - }, ]; -const enhanceSolutionsData = ( - solutions: IMdmSolution[], - selectedPlatformLabelId?: number -): IMdmSolution[] => { - return Object.values(solutions).map((solution) => { - return { - id: solution.id, - name: solution.name || "Unknown", - server_url: solution.server_url, - hosts_count: solution.hosts_count, - selectedPlatformLabelId, - }; - }); -}; - export const generateSolutionsDataSet = ( - solutions: IMdmSolution[] | null, - selectedPlatformLabelId?: number + solutions: IMdmSolution[] | null ): IMdmSolution[] => { if (!solutions) { return []; } - return [...enhanceSolutionsData(solutions, selectedPlatformLabelId)]; + + return solutions.map((solution) => { + return { + ...solution, + displayName: solution.name ?? "Unknown", + }; + }); }; diff --git a/frontend/pages/DashboardPage/cards/MDM/_styles.scss b/frontend/pages/DashboardPage/cards/MDM/_styles.scss index cd27f12b6f..e4f3526fe3 100644 --- a/frontend/pages/DashboardPage/cards/MDM/_styles.scss +++ b/frontend/pages/DashboardPage/cards/MDM/_styles.scss @@ -7,47 +7,39 @@ display: none; } - .data-table-block { - .data-table__table { - table-layout: fixed; + &__mdm-status-table { + .data-table-block { + .data-table__table { + thead { + .status__header { + width: 30%; + } - thead { - .name__header, - .status__header { - width: 30%; - } - .server_url__header { - width: 30%; - } - .hosts_count__header, - .hosts__header { - border-right: 0; - padding-right: 0; - width: 60px; - } - .linkToFilteredHosts__header { - width: 140px; - } - } + .hosts__header { + border-right: 0; + padding-right: 0; + width: 60px; + } - tbody { - .mdm-solution-link { - opacity: 0; - transition: 250ms; - text-overflow: none; - } - tr:hover { - .mdm-solution-link { - opacity: 1; + .linkToFilteredHosts__header { + width: 140px; } } } } - } - .count-loading { - color: $ui-fleet-black-50; - } - .count-error { - color: $ui-error; + + tbody { + .mdm-solution-link { + opacity: 0; + transition: 250ms; + text-overflow: none; + } + tr:hover { + .mdm-solution-link { + opacity: 1; + } + } + } + } } diff --git a/frontend/pages/DashboardPage/components/MdmSolutionModal/MdmSolutionModal.tsx b/frontend/pages/DashboardPage/components/MdmSolutionModal/MdmSolutionModal.tsx new file mode 100644 index 0000000000..e8063c86d1 --- /dev/null +++ b/frontend/pages/DashboardPage/components/MdmSolutionModal/MdmSolutionModal.tsx @@ -0,0 +1,75 @@ +import React, { useMemo } from "react"; + +import { IMdmSolution } from "interfaces/mdm"; + +import Modal from "components/Modal"; +import TableContainer from "components/TableContainer"; +import Button from "components/buttons/Button"; + +import { + generateSolutionsDataSet, + generateSolutionsTableHeaders, +} from "./MdmSolutionModalTableConfig"; + +const baseClass = "mdm-solution-modal"; + +const SOLUTIONS_DEFAULT_SORT_HEADER = "hosts_count"; +const DEFAULT_SORT_DIRECTION = "desc"; + +interface IMdmSolutionModalProps { + mdmSolutions: IMdmSolution[]; + selectedPlatformLabelId?: number; + selectedTeamId?: number; + onCancel: () => void; +} + +const MdmSolutionsModal = ({ + mdmSolutions, + selectedPlatformLabelId, + selectedTeamId, + onCancel, +}: IMdmSolutionModalProps) => { + const solutionsTableHeaders = useMemo( + () => generateSolutionsTableHeaders(selectedTeamId), + [selectedTeamId] + ); + const solutionsDataSet = generateSolutionsDataSet( + mdmSolutions, + selectedPlatformLabelId + ); + + return ( + + <> +
+ null} // if this modal is shown, this table should never be empty + columnConfigs={solutionsTableHeaders} + data={solutionsDataSet} + defaultSortHeader={SOLUTIONS_DEFAULT_SORT_HEADER} + defaultSortDirection={DEFAULT_SORT_DIRECTION} + resultsTitle="MDM" + showMarkAllPages={false} + isAllPagesSelected={false} + disableCount + disablePagination + disableTableHeader + /> +
+
+ +
+ +
+ ); +}; + +export default MdmSolutionsModal; diff --git a/frontend/pages/DashboardPage/components/MdmSolutionModal/MdmSolutionModalTableConfig.tsx b/frontend/pages/DashboardPage/components/MdmSolutionModal/MdmSolutionModalTableConfig.tsx new file mode 100644 index 0000000000..085df2eec0 --- /dev/null +++ b/frontend/pages/DashboardPage/components/MdmSolutionModal/MdmSolutionModalTableConfig.tsx @@ -0,0 +1,103 @@ +import React from "react"; + +import { IMdmSolution } from "interfaces/mdm"; + +import TextCell from "components/TableContainer/DataTable/TextCell"; +import ViewAllHostsLink from "components/ViewAllHostsLink"; +import TooltipWrapper from "components/TooltipWrapper"; +import HeaderCell from "components/TableContainer/DataTable/HeaderCell"; + +// NOTE: cellProps come from react-table +// more info here https://react-table.tanstack.com/docs/api/useTable#cell-properties + +interface IMDMSolutionWithPlatformId extends IMdmSolution { + selectedPlatformLabelId?: number; +} + +interface ICellProps { + cell: { + value: string; + }; + row: { + original: IMDMSolutionWithPlatformId; + }; +} + +interface IHeaderProps { + column: { + title: string; + isSortedDesc: boolean; + }; +} + +interface IDataColumn { + title: string; + Header: ((props: IHeaderProps) => JSX.Element) | string; + accessor: string; + Cell: (props: ICellProps) => JSX.Element; + disableGlobalFilter?: boolean; + disableHidden?: boolean; + disableSortBy?: boolean; +} + +export const generateSolutionsTableHeaders = ( + teamId?: number +): IDataColumn[] => [ + { + title: "Server URL", + Header: (): JSX.Element => { + const titleWithToolTip = ( + + The MDM server URL is used to connect hosts with the MDM service. + For cross-platform MDM solutions, each operating system has a + different URL. + + } + className="server-url-header" + > + Status + + ); + return ; + }, + disableSortBy: true, + accessor: "server_url", + Cell: (cellProps: ICellProps) => , + }, + { + title: "Hosts", + Header: "Hosts", + disableSortBy: true, + accessor: "hosts_count", + Cell: (cellProps: ICellProps) => ( +
+ + +
+ ), + }, +]; + +export const generateSolutionsDataSet = ( + solutions: IMdmSolution[] | null, + selectedPlatformLabelId?: number +): IMdmSolution[] => { + if (!solutions) { + return []; + } + + return solutions.map((solution) => { + return { + ...solution, + selectedPlatformLabelId, + }; + }); +}; diff --git a/frontend/pages/DashboardPage/components/MdmSolutionModal/_styles.scss b/frontend/pages/DashboardPage/components/MdmSolutionModal/_styles.scss new file mode 100644 index 0000000000..edce7e88f5 --- /dev/null +++ b/frontend/pages/DashboardPage/components/MdmSolutionModal/_styles.scss @@ -0,0 +1,7 @@ +.mdm-solution-modal { + .host-count-cell { + display: flex; + justify-content: space-between; + align-items: center; + } +} diff --git a/frontend/pages/DashboardPage/components/MdmSolutionModal/index.ts b/frontend/pages/DashboardPage/components/MdmSolutionModal/index.ts new file mode 100644 index 0000000000..92175e4ae7 --- /dev/null +++ b/frontend/pages/DashboardPage/components/MdmSolutionModal/index.ts @@ -0,0 +1 @@ +export { default } from "./MdmSolutionModal"; diff --git a/frontend/pages/admin/IntegrationsPage/IntegrationPage.tests.tsx b/frontend/pages/admin/IntegrationsPage/IntegrationPage.tests.tsx index 94a09ee034..a567506653 100644 --- a/frontend/pages/admin/IntegrationsPage/IntegrationPage.tests.tsx +++ b/frontend/pages/admin/IntegrationsPage/IntegrationPage.tests.tsx @@ -22,7 +22,7 @@ describe("Integrations Page", () => { const render = createCustomRenderer({ withBackendMock: true, context: { - app: { isMdmEnabledAndConfigured: true }, + app: { isMacMdmEnabledAndConfigured: true }, }, }); diff --git a/frontend/pages/admin/OrgSettingsPage/_styles.scss b/frontend/pages/admin/OrgSettingsPage/_styles.scss index 55b5e283d8..cfbfb51a9e 100644 --- a/frontend/pages/admin/OrgSettingsPage/_styles.scss +++ b/frontend/pages/admin/OrgSettingsPage/_styles.scss @@ -71,6 +71,10 @@ } } } + .button--inverse { + // compensate for button's padding + margin-left: -$pad-small; + } } &__section-description { @@ -94,14 +98,6 @@ margin-bottom: $pad-medium; } - &__host-status-webhook-preview-modal { - .flex-end { - margin-top: 20px; - display: flex; - justify-content: flex-end; - } - } - &__disabled-usage-statistics-checkbox { @include disabled; } diff --git a/frontend/pages/admin/OrgSettingsPage/cards/HostStatusWebhook/HostStatusWebhook.tsx b/frontend/pages/admin/OrgSettingsPage/cards/HostStatusWebhook/HostStatusWebhook.tsx index 28219e8773..650a5b9721 100644 --- a/frontend/pages/admin/OrgSettingsPage/cards/HostStatusWebhook/HostStatusWebhook.tsx +++ b/frontend/pages/admin/OrgSettingsPage/cards/HostStatusWebhook/HostStatusWebhook.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect } from "react"; -import { syntaxHighlight } from "utilities/helpers"; + +import HostStatusWebhookPreviewModal from "pages/admin/components/HostStatusWebhookPreviewModal"; import Button from "components/buttons/Button"; import Checkbox from "components/forms/fields/Checkbox"; @@ -10,14 +11,12 @@ import InputField from "components/forms/fields/InputField"; import validUrl from "components/forms/validators/valid_url"; import SectionHeader from "components/SectionHeader"; -import Modal from "components/Modal"; import { IAppConfigFormProps, IFormField, IAppConfigFormErrors, percentageOfHosts, numberOfDays, - hostStatusPreview, } from "../constants"; const baseClass = "app-config-form"; @@ -107,38 +106,6 @@ const HostStatusWebhook = ({ handleSubmit(formDataToSubmit); }; - const renderHostStatusWebhookPreviewModal = () => { - if (!showHostStatusWebhookPreviewModal) { - return null; - } - - return ( - - <> -

- An example request sent to your configured Destination URL. -

-
-
-          
-
- -
- -
- ); - }; - return (
@@ -192,6 +159,7 @@ const HostStatusWebhook = ({ name="hostStatusWebhookHostPercentage" value={hostStatusWebhookHostPercentage} parseTarget + searchable={false} onBlur={validateForm} tooltip={

@@ -210,6 +178,7 @@ const HostStatusWebhook = ({ name="hostStatusWebhookDaysCount" value={hostStatusWebhookDaysCount} parseTarget + searchable={false} onBlur={validateForm} tooltip={

@@ -234,8 +203,11 @@ const HostStatusWebhook = ({

- {showHostStatusWebhookPreviewModal && - renderHostStatusWebhookPreviewModal()} + {showHostStatusWebhookPreviewModal && ( + + )}
); }; diff --git a/frontend/pages/admin/OrgSettingsPage/cards/WebAddress/WebAddress.tsx b/frontend/pages/admin/OrgSettingsPage/cards/WebAddress/WebAddress.tsx index 9b2bd342c9..eca866581e 100644 --- a/frontend/pages/admin/OrgSettingsPage/cards/WebAddress/WebAddress.tsx +++ b/frontend/pages/admin/OrgSettingsPage/cards/WebAddress/WebAddress.tsx @@ -65,7 +65,11 @@ const WebAddress = ({
+ Include base path only (eg. no /latest) + + } onChange={handleInputChange} name="serverURL" value={serverURL} diff --git a/frontend/pages/admin/OrgSettingsPage/cards/constants.ts b/frontend/pages/admin/OrgSettingsPage/cards/constants.ts index f92f33403a..b66a634fa0 100644 --- a/frontend/pages/admin/OrgSettingsPage/cards/constants.ts +++ b/frontend/pages/admin/OrgSettingsPage/cards/constants.ts @@ -63,20 +63,9 @@ export const numberOfDays = [ { label: "14 days", value: 14 }, ]; -export const hostStatusPreview = { - text: - "More than X% of your hosts have not checked into Fleet for more than Y days. You’ve been sent this message because the Host status webhook is enabled in your Fleet instance.", - data: { - unseen_hosts: 1, - total_hosts: 2, - days_unseen: 3, - }, -}; - export default { authMethodOptions, authTypeOptions, percentageOfHosts, numberOfDays, - hostStatusPreview, }; diff --git a/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/TeamSettings/TeamSettings.tsx b/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/TeamSettings/TeamSettings.tsx index d8f32ebb3e..5f46376aa5 100644 --- a/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/TeamSettings/TeamSettings.tsx +++ b/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/TeamSettings/TeamSettings.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useContext, useEffect, useState } from "react"; +import React, { useCallback, useContext, useState } from "react"; import { useQuery } from "react-query"; @@ -6,6 +6,8 @@ import { NotificationContext } from "context/notification"; import useTeamIdParam from "hooks/useTeamIdParam"; +import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants"; + import { IApiError } from "interfaces/errors"; import { IConfig } from "interfaces/config"; import { ITeamConfig } from "interfaces/team"; @@ -14,32 +16,97 @@ import { ITeamSubnavProps } from "interfaces/team_subnav"; import configAPI from "services/entities/config"; import teamsAPI, { ILoadTeamResponse } from "services/entities/teams"; +import HostStatusWebhookPreviewModal from "pages/admin/components/HostStatusWebhookPreviewModal"; +import { + numberOfDays, + percentageOfHosts, +} from "pages/admin/OrgSettingsPage/cards/constants"; + +import validURL from "components/forms/validators/valid_url"; + import Button from "components/buttons/Button"; import DataError from "components/DataError"; // @ts-ignore import InputField from "components/forms/fields/InputField"; import Spinner from "components/Spinner"; import SectionHeader from "components/SectionHeader"; +// @ts-ignore +import Dropdown from "components/forms/fields/Dropdown"; +import Checkbox from "components/forms/fields/Checkbox"; import TeamHostExpiryToggle from "./components/TeamHostExpiryToggle"; const baseClass = "team-settings"; +type ITeamSettingsFormData = { + teamHostExpiryEnabled: boolean; + teamHostExpiryWindow: number | string; + teamHostStatusWebhookEnabled: boolean; + teamHostStatusWebhookDestinationUrl: string; + teamHostStatusWebhookHostPercentage: number; + teamHostStatusWebhookWindow: number; +}; + +type FormNames = keyof ITeamSettingsFormData; + const HOST_EXPIRY_ERROR_TEXT = "Host expiry window must be a positive number."; +const validateTeamSettingsFormData = ( + // will never be called if global setting is not loaded, default to satisfy typechecking + curGlobalHostExpiryEnabled = false, + curFormData: ITeamSettingsFormData +) => { + const errors: Record = {}; + + // validate host expiry fields + const numHostExpiryWindow = Number(curFormData.teamHostExpiryWindow); + if ( + // with no global setting, team window can't be empty if enabled + (!curGlobalHostExpiryEnabled && + curFormData.teamHostExpiryEnabled && + !numHostExpiryWindow) || + // if nonempty, must be a positive number + isNaN(numHostExpiryWindow) || + // if overriding a global setting, can be empty to disable local setting + numHostExpiryWindow < 0 + ) { + errors.host_expiry_window = HOST_EXPIRY_ERROR_TEXT; + } + + // validate host webhook fields + if (curFormData.teamHostStatusWebhookEnabled) { + if (!validURL({ url: curFormData.teamHostStatusWebhookDestinationUrl })) { + const errorPrefix = curFormData.teamHostStatusWebhookDestinationUrl + ? `${curFormData.teamHostStatusWebhookDestinationUrl} is not` + : "Please enter"; + errors.host_status_webhook_destination_url = `${errorPrefix} a valid webhook destination URL`; + } + } + + return errors; +}; + const TeamSettings = ({ location, router }: ITeamSubnavProps) => { - const [ - formDataTeamHostExpiryEnabled, - setFormDataTeamHostExpiryEnabled, - ] = useState(false); // default false until API response - const [ - formDataTeamHostExpiryWindow, - setFormDataTeamHostExpiryWindow, - ] = useState(""); + const [formData, setFormData] = useState({ + teamHostExpiryEnabled: false, + teamHostExpiryWindow: "" as number | string, + teamHostStatusWebhookEnabled: false, + teamHostStatusWebhookDestinationUrl: "", + teamHostStatusWebhookHostPercentage: 1, + teamHostStatusWebhookWindow: 1, + }); const [updatingTeamSettings, setUpdatingTeamSettings] = useState(false); const [formErrors, setFormErrors] = useState>( {} ); + const [ + showHostStatusWebhookPreviewModal, + setShowHostStatusWebhookPreviewModal, + ] = useState(false); + + const toggleHostStatusWebhookPreviewModal = () => { + setShowHostStatusWebhookPreviewModal(!showHostStatusWebhookPreviewModal); + }; const { renderFlash } = useContext(NotificationContext); @@ -80,63 +147,60 @@ const TeamSettings = ({ location, router }: ITeamSubnavProps) => { ["teamConfig", teamIdForApi], () => teamsAPI.load(teamIdForApi), { + ...DEFAULT_USE_QUERY_OPTIONS, enabled: isRouteOk && !!teamIdForApi, select: (data) => data.team, onSuccess: (teamConfig) => { - // default this setting to current team setting - // can be updated by user actions - setFormDataTeamHostExpiryEnabled( - teamConfig?.host_expiry_settings?.host_expiry_enabled ?? false - ); - setFormDataTeamHostExpiryWindow( - teamConfig.host_expiry_settings?.host_expiry_window ?? "" - ); + setFormData({ + // host expiry settings + teamHostExpiryEnabled: + teamConfig?.host_expiry_settings?.host_expiry_enabled ?? false, + teamHostExpiryWindow: + teamConfig?.host_expiry_settings?.host_expiry_window ?? "", + // host status webhook settings + teamHostStatusWebhookEnabled: + teamConfig?.webhook_settings?.host_status_webhook + ?.enable_host_status_webhook ?? false, + teamHostStatusWebhookDestinationUrl: + teamConfig?.webhook_settings?.host_status_webhook + ?.destination_url ?? "", + teamHostStatusWebhookHostPercentage: + teamConfig?.webhook_settings?.host_status_webhook + ?.host_percentage ?? 1, + teamHostStatusWebhookWindow: + teamConfig?.webhook_settings?.host_status_webhook?.days_count ?? 1, + }); }, - refetchOnWindowFocus: false, } ); - const validate = useCallback(() => { - const errors: Record = {}; - const numHostExpiryWindow = Number(formDataTeamHostExpiryWindow); - if ( - // with no global setting, team window can't be empty if enabled - (!globalHostExpiryEnabled && - formDataTeamHostExpiryEnabled && - !numHostExpiryWindow) || - // if nonempty, must be a positive number - isNaN(numHostExpiryWindow) || - // if overriding a global setting, can be empty to disable local setting - numHostExpiryWindow < 0 - ) { - errors.host_expiry_window = HOST_EXPIRY_ERROR_TEXT; - } + const onInputChange = useCallback( + (newVal: { name: FormNames; value: string | number | boolean }) => { + const { name, value } = newVal; + const newFormData = { ...formData, [name]: value }; + setFormData(newFormData); + setFormErrors( + validateTeamSettingsFormData(globalHostExpiryEnabled, newFormData) + ); + }, + [formData, globalHostExpiryEnabled] + ); - setFormErrors(errors); - }, [ - formDataTeamHostExpiryEnabled, - formDataTeamHostExpiryWindow, - globalHostExpiryEnabled, - ]); - - useEffect(() => { - validate(); - }, [formDataTeamHostExpiryEnabled, formDataTeamHostExpiryWindow, validate]); - - const updateTeamHostExpiry = useCallback( + const updateTeamSettings = useCallback( (evt: React.MouseEvent) => { evt.preventDefault(); + setUpdatingTeamSettings(true); - const castedHostExpiryWindow = Number(formDataTeamHostExpiryWindow); + const castedHostExpiryWindow = Number(formData.teamHostExpiryWindow); let enableHostExpiry; if (globalHostExpiryEnabled) { if (!castedHostExpiryWindow) { enableHostExpiry = false; } else { - enableHostExpiry = formDataTeamHostExpiryEnabled; + enableHostExpiry = formData.teamHostExpiryEnabled; } } else { - enableHostExpiry = formDataTeamHostExpiryEnabled; + enableHostExpiry = formData.teamHostExpiryEnabled; } teamsAPI .update( @@ -145,6 +209,15 @@ const TeamSettings = ({ location, router }: ITeamSubnavProps) => { host_expiry_enabled: enableHostExpiry, host_expiry_window: castedHostExpiryWindow, }, + webhook_settings: { + host_status_webhook: { + enable_host_status_webhook: + formData.teamHostStatusWebhookEnabled, + destination_url: formData.teamHostStatusWebhookDestinationUrl, + host_percentage: formData.teamHostStatusWebhookHostPercentage, + days_count: formData.teamHostStatusWebhookWindow, + }, + }, }, teamIdForApi ) @@ -163,8 +236,7 @@ const TeamSettings = ({ location, router }: ITeamSubnavProps) => { }); }, [ - formDataTeamHostExpiryEnabled, - formDataTeamHostExpiryWindow, + formData, globalHostExpiryEnabled, refetchTeamConfig, renderFlash, @@ -180,24 +252,103 @@ const TeamSettings = ({ location, router }: ITeamSubnavProps) => { return ; } return ( - + + + + Enable host status webhook + + + {formData.teamHostStatusWebhookEnabled && ( + <> + + Provide a URL to deliver
+ the webhook request to. +

+ } + /> + + Select the minimum percentage of hosts that +
+ must fail to check into Fleet in order to trigger +
+ the webhook request. +

+ } + /> + + Select the minimum number of days that the +
+ configured Percentage of hosts must fail to +
+ check into Fleet in order to trigger the +
+ webhook request. +

+ } + /> + + )} + {globalHostExpiryEnabled !== undefined && ( + onInputChange({ name: "teamHostExpiryEnabled", value: isEnabled }) + } /> )} - {formDataTeamHostExpiryEnabled && ( + {formData.teamHostExpiryEnabled && ( )} @@ -216,8 +367,13 @@ const TeamSettings = ({ location, router }: ITeamSubnavProps) => { return (
- {renderForm()} + {showHostStatusWebhookPreviewModal && ( + + )}
); }; diff --git a/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/TeamSettings/_styles.scss b/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/TeamSettings/_styles.scss index 88ea21c181..d70e2ec67a 100644 --- a/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/TeamSettings/_styles.scss +++ b/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/TeamSettings/_styles.scss @@ -1,3 +1,13 @@ .team-settings { max-width: $settings-form-max-width; + .section-header { + margin-bottom: 0; + &:not(:first-child) { + margin-top: $pad-medium; + } + } + .button--inverse { + // compensate for button's padding + margin: -$pad-small; + } } diff --git a/frontend/pages/admin/components/HostStatusWebhookPreviewModal/HostStatusWebhookPreviewModal.stories.tsx b/frontend/pages/admin/components/HostStatusWebhookPreviewModal/HostStatusWebhookPreviewModal.stories.tsx new file mode 100644 index 0000000000..c035bef47c --- /dev/null +++ b/frontend/pages/admin/components/HostStatusWebhookPreviewModal/HostStatusWebhookPreviewModal.stories.tsx @@ -0,0 +1,15 @@ +import { Meta, StoryObj } from "@storybook/react"; + +import HostStatusWebhookPreviewModal from "./HostStatusWebhookPreviewModal"; + +const meta: Meta = { + title: "Components/HostStatusWebhookPreviewModal", + component: HostStatusWebhookPreviewModal, + args: { isTeamScope: false }, +}; + +export default meta; + +type Story = StoryObj; + +export const Basic: Story = {}; diff --git a/frontend/pages/admin/components/HostStatusWebhookPreviewModal/HostStatusWebhookPreviewModal.tsx b/frontend/pages/admin/components/HostStatusWebhookPreviewModal/HostStatusWebhookPreviewModal.tsx new file mode 100644 index 0000000000..ed444cf69e --- /dev/null +++ b/frontend/pages/admin/components/HostStatusWebhookPreviewModal/HostStatusWebhookPreviewModal.tsx @@ -0,0 +1,65 @@ +import React from "react"; + +import { syntaxHighlight } from "utilities/helpers"; + +import Button from "components/buttons/Button"; +import Modal from "components/Modal"; + +const baseClass = "host-status-webhook-preview-modal"; + +const getHostStatusPreview = (teamScope?: boolean) => { + const data = { + unseen_hosts: 1, + total_hosts: 2, + days_unseen: 3, + team_id: 123, + } as Record; + + if (!teamScope) { + delete data.team_id; + } + + return { + text: + "More than X% of your hosts have not checked into Fleet for more than Y days. You’ve been sent this message because the Host status webhook is enabled in your Fleet instance.", + data, + }; +}; + +interface IHostStatusWebhookPreviewModal { + isTeamScope?: boolean; + toggleModal: () => void; +} + +const HostStatusWebhookPreviewModal = ({ + isTeamScope = false, + toggleModal, +}: IHostStatusWebhookPreviewModal) => { + return ( + + <> +

+ An example request sent to your configured Destination URL. +

+
+
+        
+
+ +
+ +
+ ); +}; + +export default HostStatusWebhookPreviewModal; diff --git a/frontend/pages/admin/components/HostStatusWebhookPreviewModal/_styles.scss b/frontend/pages/admin/components/HostStatusWebhookPreviewModal/_styles.scss new file mode 100644 index 0000000000..be055768d0 --- /dev/null +++ b/frontend/pages/admin/components/HostStatusWebhookPreviewModal/_styles.scss @@ -0,0 +1,7 @@ +.host-status-webhook-preview-modal { + .flex-end { + margin-top: 20px; + display: flex; + justify-content: flex-end; + } +} diff --git a/frontend/pages/admin/components/HostStatusWebhookPreviewModal/index.ts b/frontend/pages/admin/components/HostStatusWebhookPreviewModal/index.ts new file mode 100644 index 0000000000..5c8bb32c27 --- /dev/null +++ b/frontend/pages/admin/components/HostStatusWebhookPreviewModal/index.ts @@ -0,0 +1 @@ +export { default } from "./HostStatusWebhookPreviewModal"; diff --git a/frontend/pages/hosts/ManageHostsPage/HostTableConfig.tsx b/frontend/pages/hosts/ManageHostsPage/HostTableConfig.tsx index 63bec0daf9..f3f0ff9b7f 100644 --- a/frontend/pages/hosts/ManageHostsPage/HostTableConfig.tsx +++ b/frontend/pages/hosts/ManageHostsPage/HostTableConfig.tsx @@ -9,6 +9,7 @@ import { IDeviceUser, IHost } from "interfaces/host"; import Checkbox from "components/forms/fields/Checkbox"; import DiskSpaceGraph from "components/DiskSpaceGraph"; import HeaderCell from "components/TableContainer/DataTable/HeaderCell/HeaderCell"; +import HostMdmStatusCell from "components/TableContainer/DataTable/HostMdmStatusCell/HostMdmStatusCell"; import IssueCell from "components/TableContainer/DataTable/IssueCell/IssueCell"; import LinkCell from "components/TableContainer/DataTable/LinkCell/LinkCell"; import StatusIndicator from "components/StatusIndicator"; @@ -402,15 +403,7 @@ const allHostTableHeaders: IDataColumn[] = [ disableSortBy: true, accessor: "mdm.enrollment_status", id: "mdm_enrollment_status", - Cell: (cellProps: ICellProps) => { - if (cellProps.row.original.platform === "chrome") { - return NotSupported; - } - if (cellProps.cell.value) { - return ; - } - return {DEFAULT_EMPTY_CELL_VALUE}; - }, + Cell: HostMdmStatusCell, }, { title: "MDM server URL", diff --git a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx index 7f0b756ca8..fdb117e5a3 100644 --- a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx +++ b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx @@ -1034,6 +1034,8 @@ const ManageHostsPage = ({ setIsUpdatingHosts(true); const teamId = typeof transferTeam.id === "number" ? transferTeam.id : null; + const currentTeam = teamIdForApi; + let action = hostsAPI.transferToTeam(teamId, selectedHostIds); if (isAllMatchingHostsSelected) { @@ -1044,6 +1046,7 @@ const ManageHostsPage = ({ query: searchQuery, status, labelId, + currentTeam, }); } diff --git a/frontend/pages/hosts/details/DeviceUserPage/_styles.scss b/frontend/pages/hosts/details/DeviceUserPage/_styles.scss index 16883beaa1..ffd6d65f3d 100644 --- a/frontend/pages/hosts/details/DeviceUserPage/_styles.scss +++ b/frontend/pages/hosts/details/DeviceUserPage/_styles.scss @@ -37,30 +37,6 @@ display: flex; flex-direction: row; } - - .info { - &__header { - display: block; - color: $core-fleet-black; - font-weight: $bold; - font-size: $x-small; - text-align: left; - } - - &__data { - display: block; - color: $core-fleet-black; - font-weight: normal; - font-size: $x-small; - text-align: left; - margin-top: 10px; - } - } - } - - &__action-button-container { - display: flex; - align-items: center; } &__device_mapping { @@ -87,11 +63,7 @@ } } - .section--policies - .data-table-block - .data-table__table - thead - .response__header { + .policies-card .data-table-block .data-table__table thead .response__header { width: $col-md; } } diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/HostActionsDropdown.tests.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/HostActionsDropdown.tests.tsx index 2094050495..57426f660c 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/HostActionsDropdown.tests.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/HostActionsDropdown.tests.tsx @@ -94,7 +94,7 @@ describe("Host Actions Dropdown", () => { const render = createCustomRenderer({ context: { app: { - isMdmEnabledAndConfigured: true, + isMacMdmEnabledAndConfigured: true, isGlobalAdmin: true, currentUser: createMockUser(), }, @@ -122,7 +122,7 @@ describe("Host Actions Dropdown", () => { const render = createCustomRenderer({ context: { app: { - isMdmEnabledAndConfigured: true, + isMacMdmEnabledAndConfigured: true, isGlobalMaintainer: true, currentUser: createMockUser(), }, @@ -150,7 +150,7 @@ describe("Host Actions Dropdown", () => { const render = createCustomRenderer({ context: { app: { - isMdmEnabledAndConfigured: true, + isMacMdmEnabledAndConfigured: true, currentUser: createMockUser({ teams: [createMockTeam({ id: 1, role: "admin" })], }), @@ -179,7 +179,7 @@ describe("Host Actions Dropdown", () => { const render = createCustomRenderer({ context: { app: { - isMdmEnabledAndConfigured: true, + isMacMdmEnabledAndConfigured: true, currentUser: createMockUser({ teams: [createMockTeam({ id: 1, role: "maintainer" })], }), @@ -208,7 +208,7 @@ describe("Host Actions Dropdown", () => { const render = createCustomRenderer({ context: { app: { - isMdmEnabledAndConfigured: true, + isMacMdmEnabledAndConfigured: true, currentUser: createMockUser(), }, }, @@ -235,7 +235,7 @@ describe("Host Actions Dropdown", () => { const render = createCustomRenderer({ context: { app: { - isMdmEnabledAndConfigured: true, + isMacMdmEnabledAndConfigured: true, isGlobalAdmin: true, currentUser: createMockUser(), }, @@ -267,7 +267,7 @@ describe("Host Actions Dropdown", () => { const render = createCustomRenderer({ context: { app: { - isMdmEnabledAndConfigured: true, + isMacMdmEnabledAndConfigured: true, isGlobalAdmin: true, currentUser: createMockUser(), }, @@ -402,7 +402,7 @@ describe("Host Actions Dropdown", () => { context: { app: { isPremiumTier: true, - isMdmEnabledAndConfigured: true, + isMacMdmEnabledAndConfigured: true, isGlobalAdmin: true, currentUser: createMockUser(), }, @@ -431,7 +431,7 @@ describe("Host Actions Dropdown", () => { context: { app: { isPremiumTier: true, - isMdmEnabledAndConfigured: true, + isMacMdmEnabledAndConfigured: true, isGlobalAdmin: true, currentUser: createMockUser(), }, @@ -460,7 +460,7 @@ describe("Host Actions Dropdown", () => { context: { app: { isPremiumTier: true, - isMdmEnabledAndConfigured: true, + isMacMdmEnabledAndConfigured: true, isGlobalAdmin: true, currentUser: createMockUser(), }, @@ -491,7 +491,7 @@ describe("Host Actions Dropdown", () => { context: { app: { isPremiumTier: true, - isMdmEnabledAndConfigured: true, + isMacMdmEnabledAndConfigured: true, isGlobalAdmin: true, currentUser: createMockUser(), }, @@ -520,7 +520,7 @@ describe("Host Actions Dropdown", () => { context: { app: { isPremiumTier: true, - isMdmEnabledAndConfigured: true, + isMacMdmEnabledAndConfigured: true, isGlobalAdmin: true, currentUser: createMockUser(), }, @@ -549,7 +549,7 @@ describe("Host Actions Dropdown", () => { context: { app: { isPremiumTier: true, - isMdmEnabledAndConfigured: true, + isMacMdmEnabledAndConfigured: true, isGlobalAdmin: true, currentUser: createMockUser(), }, @@ -578,7 +578,7 @@ describe("Host Actions Dropdown", () => { context: { app: { isPremiumTier: true, - isMdmEnabledAndConfigured: true, + isMacMdmEnabledAndConfigured: true, isGlobalAdmin: true, currentUser: createMockUser(), }, @@ -601,5 +601,126 @@ describe("Host Actions Dropdown", () => { expect(screen.queryByText("Unlock")).not.toBeInTheDocument(); }); + + it("does not renders when a mac host but does not have Fleet mac mdm enabled and configured", async () => { + const render = createCustomRenderer({ + context: { + app: { + isPremiumTier: true, + isMacMdmEnabledAndConfigured: false, + isWindowsMdmEnabledAndConfigured: true, + isGlobalAdmin: true, + currentUser: createMockUser(), + }, + }, + }); + + const { user } = render( + + ); + + await user.click(screen.getByText("Actions")); + + expect(screen.queryByText("Unlock")).not.toBeInTheDocument(); + }); + }); + + describe("Wipe action", () => { + it("renders only when the host is unlocked", async () => { + const render = createCustomRenderer({ + context: { + app: { + isPremiumTier: true, + isMacMdmEnabledAndConfigured: true, + isGlobalAdmin: true, + currentUser: createMockUser(), + }, + }, + }); + + const { user } = render( + + ); + + await user.click(screen.getByText("Actions")); + + expect(screen.getByText("Wipe")).toBeInTheDocument(); + }); + + it("does not renders when a windows host but does not have Fleet windows mdm enabled and configured", async () => { + const render = createCustomRenderer({ + context: { + app: { + isPremiumTier: true, + isMacMdmEnabledAndConfigured: true, + isWindowsMdmEnabledAndConfigured: false, + isGlobalAdmin: true, + currentUser: createMockUser(), + }, + }, + }); + + const { user } = render( + + ); + + await user.click(screen.getByText("Actions")); + + expect(screen.queryByText("Wipe")).not.toBeInTheDocument(); + }); + + it("does not renders when a mac host but does not have Fleet mac mdm enabled and configured", async () => { + const render = createCustomRenderer({ + context: { + app: { + isPremiumTier: true, + isMacMdmEnabledAndConfigured: false, + isWindowsMdmEnabledAndConfigured: true, + isGlobalAdmin: true, + currentUser: createMockUser(), + }, + }, + }); + + const { user } = render( + + ); + + await user.click(screen.getByText("Actions")); + + expect(screen.queryByText("Wipe")).not.toBeInTheDocument(); + }); }); }); diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/HostActionsDropdown.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/HostActionsDropdown.tsx index 378b72b9bc..988986012b 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/HostActionsDropdown.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/HostActionsDropdown.tsx @@ -38,7 +38,8 @@ const HostActionsDropdown = ({ isPremiumTier = false, isGlobalAdmin = false, isGlobalMaintainer = false, - isMdmEnabledAndConfigured = false, + isMacMdmEnabledAndConfigured = false, + isWindowsMdmEnabledAndConfigured = false, isSandboxMode = false, currentUser, } = useContext(AppContext); @@ -67,7 +68,8 @@ const HostActionsDropdown = ({ hostMdmEnrollmentStatus ?? "" ), isFleetMdm: mdmName === "Fleet", - isMdmEnabledAndConfigured, + isMacMdmEnabledAndConfigured, + isWindowsMdmEnabledAndConfigured, doesStoreEncryptionKey: doesStoreEncryptionKey ?? false, isSandboxMode, hostMdmDeviceStatus, diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/helpers.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/helpers.tsx index 7cee2c5e96..5310ea7ba9 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/helpers.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/helpers.tsx @@ -44,11 +44,11 @@ const DEFAULT_OPTIONS = [ value: "lock", disabled: false, }, - // { - // label: "Wipe", - // value: "wipe", - // disabled: false, - // }, + { + label: "Wipe", + value: "wipe", + disabled: false, + }, { label: "Unlock", value: "unlock", @@ -74,7 +74,8 @@ interface IHostActionConfigOptions { isHostOnline: boolean; isEnrolledInMdm: boolean; isFleetMdm: boolean; - isMdmEnabledAndConfigured: boolean; + isMacMdmEnabledAndConfigured: boolean; + isWindowsMdmEnabledAndConfigured: boolean; doesStoreEncryptionKey: boolean; isSandboxMode: boolean; hostMdmDeviceStatus: HostMdmDeviceStatusUIState; @@ -93,11 +94,11 @@ const canEditMdm = (config: IHostActionConfigOptions) => { isTeamMaintainer, isEnrolledInMdm, isFleetMdm, - isMdmEnabledAndConfigured, + isMacMdmEnabledAndConfigured, } = config; return ( config.hostPlatform === "darwin" && - isMdmEnabledAndConfigured && + isMacMdmEnabledAndConfigured && isEnrolledInMdm && isFleetMdm && (isGlobalAdmin || isGlobalMaintainer || isTeamAdmin || isTeamMaintainer) @@ -107,7 +108,7 @@ const canEditMdm = (config: IHostActionConfigOptions) => { const canLockHost = ({ isPremiumTier, hostPlatform, - isMdmEnabledAndConfigured, + isMacMdmEnabledAndConfigured, isEnrolledInMdm, isFleetMdm, isGlobalAdmin, @@ -120,7 +121,7 @@ const canLockHost = ({ const canLockDarwin = hostPlatform === "darwin" && isFleetMdm && - isMdmEnabledAndConfigured && + isMacMdmEnabledAndConfigured && isEnrolledInMdm; return ( @@ -143,23 +144,23 @@ const canWipeHost = ({ isTeamObserver, isFleetMdm, isEnrolledInMdm, - isMdmEnabledAndConfigured, + isMacMdmEnabledAndConfigured, + isWindowsMdmEnabledAndConfigured, hostPlatform, + hostMdmDeviceStatus, }: IHostActionConfigOptions) => { - // TODO: remove when we work on wipe issue. - return false; + const hostMdmEnabled = + (hostPlatform === "darwin" && isMacMdmEnabledAndConfigured) || + (hostPlatform === "windows" && isWindowsMdmEnabledAndConfigured); // macOS and Windows hosts have the same conditions and can be wiped if they // are enrolled in MDM and the MDM is enabled. - const canWipeMacOrWindows = - (hostPlatform === "darwin" || hostPlatform === "windows") && - isFleetMdm && - isMdmEnabledAndConfigured && - isEnrolledInMdm; + const canWipeMacOrWindows = hostMdmEnabled && isFleetMdm && isEnrolledInMdm; return ( isPremiumTier && - (hostPlatform === "linux" || canWipeMacOrWindows) && + hostMdmDeviceStatus === "unlocked" && + (isLinuxLike(hostPlatform) || canWipeMacOrWindows) && (isGlobalAdmin || isGlobalMaintainer || isGlobalObserver || @@ -177,14 +178,14 @@ const canUnlock = ({ isTeamMaintainer, isFleetMdm, isEnrolledInMdm, - isMdmEnabledAndConfigured, + isMacMdmEnabledAndConfigured, hostPlatform, hostMdmDeviceStatus, }: IHostActionConfigOptions) => { - const canLockDarwin = + const canUnlockDarwin = hostPlatform === "darwin" && isFleetMdm && - isMdmEnabledAndConfigured && + isMacMdmEnabledAndConfigured && isEnrolledInMdm; // "unlocking" for a macOS host means that somebody saw the unlock pin, but @@ -198,7 +199,7 @@ const canUnlock = ({ isPremiumTier && isValidState && (isGlobalAdmin || isGlobalMaintainer || isTeamAdmin || isTeamMaintainer) && - (canLockDarwin || hostPlatform === "windows" || isLinuxLike(hostPlatform)) + (canUnlockDarwin || hostPlatform === "windows" || isLinuxLike(hostPlatform)) ); }; @@ -265,9 +266,9 @@ const filterOutOptions = ( options = options.filter((option) => option.value !== "lock"); } - // if (!canWipeHost(config)) { - // options = options.filter((option) => option.value !== "wipe"); - // } + if (!canWipeHost(config)) { + options = options.filter((option) => option.value !== "wipe"); + } if (!canUnlock(config)) { options = options.filter((option) => option.value !== "unlock"); @@ -292,7 +293,12 @@ const setOptionsAsDisabled = ( }; let optionsToDisable: IDropdownOption[] = []; - if (!isHostOnline) { + if ( + !isHostOnline || + isDeviceStatusUpdating(hostMdmDeviceStatus) || + hostMdmDeviceStatus === "locked" || + hostMdmDeviceStatus === "wiped" + ) { optionsToDisable = optionsToDisable.concat( options.filter( (option) => option.value === "query" || option.value === "mdmOff" @@ -304,16 +310,6 @@ const setOptionsAsDisabled = ( options.filter((option) => option.value === "transfer") ); } - if ( - isDeviceStatusUpdating(hostMdmDeviceStatus) || - hostMdmDeviceStatus === "locked" - ) { - optionsToDisable = optionsToDisable.concat( - options.filter( - (option) => option.value === "query" || option.value === "mdmOff" - ) - ); - } disableOptions(optionsToDisable); return options; diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx index 2da5e28db2..99faef93dd 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx @@ -14,6 +14,7 @@ import { NotificationContext } from "context/notification"; import activitiesAPI, { IActivitiesResponse, + IPastActivitiesResponse, IUpcomingActivitiesResponse, } from "services/entities/activities"; import hostAPI from "services/entities/hosts"; @@ -90,6 +91,7 @@ import { HostMdmDeviceStatusUIState, getHostDeviceStatusUIState, } from "../helpers"; +import WipeModal from "./modals/WipeModal"; const baseClass = "host-details"; @@ -164,6 +166,7 @@ const HostDetailsPage = ({ ); const [showLockHostModal, setShowLockHostModal] = useState(false); const [showUnlockHostModal, setShowUnlockHostModal] = useState(false); + const [showWipeModal, setShowWipeModal] = useState(false); const [scriptDetailsId, setScriptDetailsId] = useState(""); const [selectedPolicy, setSelectedPolicy] = useState( null @@ -366,9 +369,9 @@ const HostDetailsPage = ({ isError: pastActivitiesIsError, refetch: refetchPastActivities, } = useQuery< - IActivitiesResponse, + IPastActivitiesResponse, Error, - IActivitiesResponse, + IPastActivitiesResponse, Array<{ scope: string; pageIndex: number; @@ -644,6 +647,9 @@ const HostDetailsPage = ({ case "unlock": setShowUnlockHostModal(true); break; + case "wipe": + setShowWipeModal(true); + break; default: // do nothing } }; @@ -976,6 +982,14 @@ const HostDetailsPage = ({ onClose={() => setShowUnlockHostModal(false)} /> )} + {showWipeModal && ( + setHostMdmDeviceState("wiping")} + onClose={() => setShowWipeModal(false)} + /> + )} ); diff --git a/frontend/pages/hosts/details/HostDetailsPage/_styles.scss b/frontend/pages/hosts/details/HostDetailsPage/_styles.scss index 04fede9abc..308cf8b230 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/_styles.scss +++ b/frontend/pages/hosts/details/HostDetailsPage/_styles.scss @@ -9,11 +9,12 @@ // grid layout styles for the host details page &__details-panel { display: grid; - gap: $pad-medium + gap: $pad-medium; } @media screen and (min-width: $break-md) { - &__details-panel { + &__details-panel.react-tabs__tab-panel--selected { + // Must be selected to show grid grid-template-columns: 1fr 1fr; grid-template-areas: "about about" @@ -22,7 +23,7 @@ "users users"; } - .about { + .about-card { grid-area: about; } @@ -30,7 +31,7 @@ grid-area: activity; } - .agent-options { + .agent-options-card { grid-area: agent-options; } @@ -38,13 +39,13 @@ grid-area: labels; } - .section--users { + .users-card { grid-area: users; } } - .about, - .osquery { + .about-card, + .agent-options-card { .info { &__item { &--about { @@ -66,6 +67,11 @@ } } } + + .munki-issues-card { + margin-top: $pad-medium; + } + .button img { transform: scale(0.5); } @@ -91,6 +97,4 @@ width: auto; } } - - } diff --git a/frontend/pages/hosts/details/HostDetailsPage/modals/WipeModal/WipeModal.tsx b/frontend/pages/hosts/details/HostDetailsPage/modals/WipeModal/WipeModal.tsx new file mode 100644 index 0000000000..7df40b2adb --- /dev/null +++ b/frontend/pages/hosts/details/HostDetailsPage/modals/WipeModal/WipeModal.tsx @@ -0,0 +1,77 @@ +import React, { useContext } from "react"; + +import hostAPI from "services/entities/hosts"; +import { getErrorReason } from "interfaces/errors"; + +import Modal from "components/Modal"; +import Button from "components/buttons/Button"; +import Checkbox from "components/forms/fields/Checkbox"; +import { NotificationContext } from "context/notification"; + +const baseClass = "wipe-modal"; + +interface IWipeModalProps { + id: number; + hostName: string; + onSuccess: () => void; + onClose: () => void; +} + +const WipeModal = ({ id, hostName, onSuccess, onClose }: IWipeModalProps) => { + const { renderFlash } = useContext(NotificationContext); + const [lockChecked, setLockChecked] = React.useState(false); + const [isWiping, setIsWiping] = React.useState(false); + + const onWipe = async () => { + setIsWiping(true); + try { + await hostAPI.wipeHost(id); + onSuccess(); + renderFlash("success", "Success! Host is wiping."); + } catch (e) { + renderFlash("error", getErrorReason(e)); + } + onClose(); + setIsWiping(false); + }; + + return ( + + <> +
+

All content will be erased on this host.

+
+ + Please check to confirm: + + setLockChecked(value)} + > + I wish to wipe {hostName} + +
+
+ +
+ + +
+ +
+ ); +}; + +export default WipeModal; diff --git a/frontend/pages/hosts/details/HostDetailsPage/modals/WipeModal/_styles.scss b/frontend/pages/hosts/details/HostDetailsPage/modals/WipeModal/_styles.scss new file mode 100644 index 0000000000..e5616edd18 --- /dev/null +++ b/frontend/pages/hosts/details/HostDetailsPage/modals/WipeModal/_styles.scss @@ -0,0 +1,14 @@ +.wipe-modal { + p { + margin: 0; + } + + &__modal-content { + display: grid; + gap: $pad-large; + } + + &__wipe-checkbox { + margin-top: $pad-small; + } +} diff --git a/frontend/pages/hosts/details/HostDetailsPage/modals/WipeModal/index.ts b/frontend/pages/hosts/details/HostDetailsPage/modals/WipeModal/index.ts new file mode 100644 index 0000000000..299b889006 --- /dev/null +++ b/frontend/pages/hosts/details/HostDetailsPage/modals/WipeModal/index.ts @@ -0,0 +1 @@ +export { default } from "./WipeModal"; diff --git a/frontend/pages/hosts/details/_styles.scss b/frontend/pages/hosts/details/_styles.scss index be6ee4c520..49c80a82b3 100644 --- a/frontend/pages/hosts/details/_styles.scss +++ b/frontend/pages/hosts/details/_styles.scss @@ -1,5 +1,9 @@ .host-details, .device-user { + .react-tabs__tab-panel--selected { + margin-top: $pad-medium; + } + display: flex; flex-direction: column; gap: $pad-medium; @@ -10,64 +14,11 @@ display: flex; flex-direction: column; } - .section { + .card { &__header { font-size: $medium; font-weight: $bold; - margin: 0 0 $pad-large 0; - } - - .info-flex { - display: flex; - flex-wrap: wrap; - - .info-flex__item--title { - margin-bottom: 2.5rem; - } - - &__item { - font-size: $x-small; - display: flex; - flex-direction: column; - white-space: nowrap; - - &--title { - margin-right: $pad-xxlarge; - - .info-flex__data { - display: flex; - gap: $pad-xsmall; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - - img { - width: 16px; - height: 16px; - vertical-align: sub; - } - - .icon { - width: 16px; - height: 16px; - align-self: center; - } - - &__text { - padding-left: $pad-xsmall; - } - } - } - } - - &__header { - color: $core-fleet-black; - font-weight: $bold; - } - - &__no-team { - color: $ui-fleet-black-50; - } + margin: 0 0 $pad-medium 0; } .info-grid { @@ -91,21 +42,8 @@ display: flex; flex-direction: column; white-space: nowrap; - - &--title { - margin-right: $pad-xxlarge; - - .info__data { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - } - } - &__header { - color: $core-fleet-black; - font-weight: $bold; } + &__data { .device-mapping { &__source { @@ -187,10 +125,6 @@ .focus-visible { background-color: $ui-vibrant-blue-10; } - - .section { - margin-top: $pad-medium; - } } .col-50 { @@ -260,6 +194,6 @@ // we dont need the margin on the host details page as we are not using grid css // for the spacing. -.host-details__tabs-wrapper .section { +.host-details__tabs-wrapper .card { margin-top: 0; } diff --git a/frontend/pages/hosts/details/cards/About/About.tsx b/frontend/pages/hosts/details/cards/About/About.tsx index 93448cfcce..361f8b2064 100644 --- a/frontend/pages/hosts/details/cards/About/About.tsx +++ b/frontend/pages/hosts/details/cards/About/About.tsx @@ -4,6 +4,7 @@ import ReactTooltip from "react-tooltip"; import { HumanTimeDiffWithFleetLaunchCutoff } from "components/HumanTimeDiffWithDateTip"; import TooltipWrapper from "components/TooltipWrapper"; import CustomLink from "components/CustomLink"; +import Card from "components/Card"; import { IHostMdmData, @@ -16,6 +17,7 @@ import { MDM_STATUS_TOOLTIP, } from "utilities/constants"; import { COLORS } from "styles/var/colors"; +import DataSet from "components/DataSet"; const getDeviceUserTipContent = (deviceMapping: IDeviceUser[]) => { if (deviceMapping.length === 0) { @@ -39,6 +41,8 @@ interface IAboutProps { mdm?: IHostMdmData; } +const baseClass = "about-card"; + const About = ({ aboutData, deviceMapping, @@ -83,18 +87,9 @@ const About = ({ const renderSerialAndIPs = () => { return ( <> -
- Serial number - {aboutData.hardware_serial} -
-
- Private IP address - {aboutData.primary_ip} -
-
- Public IP address - {renderPublicIp()} -
+ + + ); }; @@ -102,12 +97,10 @@ const About = ({ const renderMunkiData = () => { return munki ? ( <> -
- Munki version - - {munki.version || DEFAULT_EMPTY_CELL_VALUE} - -
+ ) : null; }; @@ -118,22 +111,20 @@ const About = ({ } return ( <> -
- MDM status - + {mdm.enrollment_status} - -
-
- MDM server URL - - {mdm.server_url || DEFAULT_EMPTY_CELL_VALUE} - -
+ } + /> + ); }; @@ -159,12 +150,11 @@ const About = ({ ); } } - return ( -
- Used by - - {newDeviceMapping.length > 1 ? ( + 1 ? ( @@ -175,9 +165,9 @@ const About = ({ ) : ( displayPrimaryUser - )} - -
+ ) + } + /> ); }; @@ -191,12 +181,7 @@ const About = ({ const location = [geolocation?.city_name, geolocation?.country_iso] .filter(Boolean) .join(", "); - return ( -
- Location - {location} -
- ); + return ; }; const renderBattery = () => { @@ -207,39 +192,39 @@ const About = ({ return null; } return ( -
- Battery condition - - {aboutData.batteries?.[0]?.health} - -
+ ); }; return ( -
-

About

+ +

About

-
- Added to Fleet - + - -
-
- Last restarted - + } + /> + - -
-
- Hardware model - {aboutData.hardware_model} -
+ } + /> + {renderSerialAndIPs()} {renderMunkiData()} {renderMdmData()} @@ -247,7 +232,7 @@ const About = ({ {renderGeolocation()} {renderBattery()}
-
+ ); }; diff --git a/frontend/pages/hosts/details/cards/About/_styles.scss b/frontend/pages/hosts/details/cards/About/_styles.scss new file mode 100644 index 0000000000..4cfb5ee69d --- /dev/null +++ b/frontend/pages/hosts/details/cards/About/_styles.scss @@ -0,0 +1,5 @@ +.about-card { + .text-muted { + color: $ui-fleet-black-50; + } +} diff --git a/frontend/pages/hosts/details/cards/Activity/Activity.tsx b/frontend/pages/hosts/details/cards/Activity/Activity.tsx index 2421182e22..50123ecd1f 100644 --- a/frontend/pages/hosts/details/cards/Activity/Activity.tsx +++ b/frontend/pages/hosts/details/cards/Activity/Activity.tsx @@ -2,7 +2,10 @@ import React from "react"; import { Tab, TabList, TabPanel, Tabs } from "react-tabs"; import { IActivityDetails } from "interfaces/activity"; -import { IActivitiesResponse } from "services/entities/activities"; +import { + IPastActivitiesResponse, + IUpcomingActivitiesResponse, +} from "services/entities/activities"; import Card from "components/Card"; import TabsWrapper from "components/TabsWrapper"; @@ -45,7 +48,7 @@ const UpcomingTooltip = () => { interface IActivityProps { activeTab: "past" | "upcoming"; - activities?: IActivitiesResponse; + activities?: IPastActivitiesResponse | IUpcomingActivitiesResponse; isLoading?: boolean; isError?: boolean; upcomingCount: number; @@ -68,7 +71,12 @@ const Activity = ({ }: IActivityProps) => { // TODO: add count to upcoming activities tab when available via API return ( - + {isLoading && (
@@ -93,7 +101,7 @@ const Activity = ({ + | React.FC +> = { + [ActivityType.RanScript]: RanScriptActivityItem, + [ActivityType.LockedHost]: LockedHostActivityItem, + [ActivityType.UnlockedHost]: UnlockedHostActivityItem, +}; diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityItems/LockedHostActivityItem/LockedHostActivityItem.tsx b/frontend/pages/hosts/details/cards/Activity/ActivityItems/LockedHostActivityItem/LockedHostActivityItem.tsx new file mode 100644 index 0000000000..09bc6a7c89 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/ActivityItems/LockedHostActivityItem/LockedHostActivityItem.tsx @@ -0,0 +1,18 @@ +import React from "react"; + +import { IHostActivityItemComponentProps } from "../../ActivityConfig"; +import HostActivityItem from "../../HostActivityItem"; + +const baseClass = "locked-host-activity-item"; + +const LockedHostActivityItem = ({ + activity, +}: IHostActivityItemComponentProps) => { + return ( + + {activity.actor_full_name} locked this host. + + ); +}; + +export default LockedHostActivityItem; diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityItems/LockedHostActivityItem/index.ts b/frontend/pages/hosts/details/cards/Activity/ActivityItems/LockedHostActivityItem/index.ts new file mode 100644 index 0000000000..ad51e3fabb --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/ActivityItems/LockedHostActivityItem/index.ts @@ -0,0 +1 @@ +export { default } from "./LockedHostActivityItem"; diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityItems/RanScriptActivityItem/RanScriptActivityItem.tsx b/frontend/pages/hosts/details/cards/Activity/ActivityItems/RanScriptActivityItem/RanScriptActivityItem.tsx new file mode 100644 index 0000000000..94b1a8dc7c --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/ActivityItems/RanScriptActivityItem/RanScriptActivityItem.tsx @@ -0,0 +1,28 @@ +import React from "react"; + +import { formatScriptNameForActivityItem } from "utilities/helpers"; + +import HostActivityItem from "../../HostActivityItem"; +import { IHostActivityItemComponentPropsWithShowDetails } from "../../ActivityConfig"; +import ShowDetailsButton from "../../ShowDetailsButton"; + +const baseClass = "ran-script-activity-item"; + +const RanScriptActivityItem = ({ + activity, + onShowDetails, +}: IHostActivityItemComponentPropsWithShowDetails) => { + return ( + + {activity.actor_full_name} + <> + {" "} + ran {formatScriptNameForActivityItem(activity.details?.script_name)} on + this host.{" "} + + + + ); +}; + +export default RanScriptActivityItem; diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityItems/RanScriptActivityItem/index.ts b/frontend/pages/hosts/details/cards/Activity/ActivityItems/RanScriptActivityItem/index.ts new file mode 100644 index 0000000000..ea03a04cad --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/ActivityItems/RanScriptActivityItem/index.ts @@ -0,0 +1 @@ +export { default } from "./RanScriptActivityItem"; diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityItems/UnlockedHostActivityItem/UnlockedHostActivityItem.tsx b/frontend/pages/hosts/details/cards/Activity/ActivityItems/UnlockedHostActivityItem/UnlockedHostActivityItem.tsx new file mode 100644 index 0000000000..079f4463b7 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/ActivityItems/UnlockedHostActivityItem/UnlockedHostActivityItem.tsx @@ -0,0 +1,18 @@ +import React from "react"; + +import { IHostActivityItemComponentProps } from "../../ActivityConfig"; +import HostActivityItem from "../../HostActivityItem"; + +const baseClass = "unlocked-host-activity-item"; + +const UnlockedHostActivityItem = ({ + activity, +}: IHostActivityItemComponentProps) => { + return ( + + {activity.actor_full_name} unlocked this host. + + ); +}; + +export default UnlockedHostActivityItem; diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityItems/UnlockedHostActivityItem/index.ts b/frontend/pages/hosts/details/cards/Activity/ActivityItems/UnlockedHostActivityItem/index.ts new file mode 100644 index 0000000000..7e5a20b070 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/ActivityItems/UnlockedHostActivityItem/index.ts @@ -0,0 +1 @@ +export { default } from "./UnlockedHostActivityItem"; diff --git a/frontend/pages/hosts/details/cards/Activity/HostActivityItem/HostActivityItem.tsx b/frontend/pages/hosts/details/cards/Activity/HostActivityItem/HostActivityItem.tsx new file mode 100644 index 0000000000..44cc1267c0 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/HostActivityItem/HostActivityItem.tsx @@ -0,0 +1,94 @@ +import React from "react"; +import ReactTooltip from "react-tooltip"; +import { formatDistanceToNowStrict } from "date-fns"; +import classnames from "classnames"; + +import { IActivity } from "interfaces/activity"; +import { + addGravatarUrlToResource, + internationalTimeFormat, +} from "utilities/helpers"; +import { DEFAULT_GRAVATAR_LINK } from "utilities/constants"; + +import Avatar from "components/Avatar"; + +import { COLORS } from "styles/var/colors"; + +const baseClass = "host-activity-item"; + +interface IHostActivityItemProps { + activity: IActivity; + children: React.ReactNode; + className?: string; +} + +/** + * A wrapper that will render all the common elements of a host activity item. + * This includes the avatar, the created at timestamp, and a dash to separate + * the activity items. The `children` will be the specific details of the activity + * implemented in the component that uses this wrapper. + */ +const HostActivityItem = ({ + activity, + children, + className, +}: IHostActivityItemProps) => { + const { actor_email } = activity; + const { gravatar_url } = actor_email + ? addGravatarUrlToResource({ email: actor_email }) + : { gravatar_url: DEFAULT_GRAVATAR_LINK }; + + // wrapped just in case the date string does not parse correctly + let activityCreatedAt: Date | null = null; + try { + activityCreatedAt = new Date(activity.created_at); + } catch (e) { + activityCreatedAt = null; + } + + const classNames = classnames(baseClass, className); + + return ( +
+ +
+
+ + {children} + +
+ + {activityCreatedAt && + formatDistanceToNowStrict(activityCreatedAt, { + addSuffix: true, + })} + + {activityCreatedAt && ( + + {internationalTimeFormat(activityCreatedAt)} + + )} +
+
+
+
+ ); +}; + +export default HostActivityItem; diff --git a/frontend/pages/hosts/details/cards/Activity/PastActivity/_styles.scss b/frontend/pages/hosts/details/cards/Activity/HostActivityItem/_styles.scss similarity index 98% rename from frontend/pages/hosts/details/cards/Activity/PastActivity/_styles.scss rename to frontend/pages/hosts/details/cards/Activity/HostActivityItem/_styles.scss index 04173fdab9..cfb2fcd282 100644 --- a/frontend/pages/hosts/details/cards/Activity/PastActivity/_styles.scss +++ b/frontend/pages/hosts/details/cards/Activity/HostActivityItem/_styles.scss @@ -1,4 +1,4 @@ -.past-activity { +.host-activity-item { display: grid; // Grid system is used to create variable dashed line lengths grid-template-columns: 16px 16px 1fr; grid-template-rows: 32px max-content; @@ -62,4 +62,5 @@ padding-bottom: $pad-xxlarge; } } + } diff --git a/frontend/pages/hosts/details/cards/Activity/HostActivityItem/index.ts b/frontend/pages/hosts/details/cards/Activity/HostActivityItem/index.ts new file mode 100644 index 0000000000..b711dde773 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/HostActivityItem/index.ts @@ -0,0 +1 @@ +export { default } from "./HostActivityItem"; diff --git a/frontend/pages/hosts/details/cards/Activity/PastActivity/PastActivity.tsx b/frontend/pages/hosts/details/cards/Activity/PastActivity/PastActivity.tsx deleted file mode 100644 index 8dadc1328c..0000000000 --- a/frontend/pages/hosts/details/cards/Activity/PastActivity/PastActivity.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import React from "react"; -import ReactTooltip from "react-tooltip"; -import { formatDistanceToNowStrict } from "date-fns"; - -import Avatar from "components/Avatar"; -import Icon from "components/Icon"; -import Button from "components/buttons/Button"; - -import { COLORS } from "styles/var/colors"; -import { DEFAULT_GRAVATAR_LINK } from "utilities/constants"; -import { - addGravatarUrlToResource, - formatScriptNameForActivityItem, - internationalTimeFormat, -} from "utilities/helpers"; -import { IActivity } from "interfaces/activity"; -import { ShowActivityDetailsHandler } from "../Activity"; - -const baseClass = "past-activity"; - -interface IPastActivityProps { - activity: IActivity; - // TODO: To handle clicks for different activity types, this could be refactored as a reducer that - // takes the activity and dispatches the relevant show details action based on the activity type - onDetailsClick: ShowActivityDetailsHandler; -} - -const RanScriptActivityDetails = ({ - activity, - onDetailsClick, -}: Pick) => ( - - {activity.actor_full_name} - <> - {" "} - ran {formatScriptNameForActivityItem(activity.details?.script_name)} on - this host.{" "} - - - -); - -const LockedHostActivityDetails = ({ - activity, -}: Pick) => ( - - {activity.actor_full_name} locked this host. - -); - -const UnlockedHostActivityDetails = ({ - activity, -}: Pick) => ( - - {activity.actor_full_name}{" "} - {activity.details?.host_platform === "darwin" - ? "viewed the six-digit unlock PIN for" - : "unlocked"}{" "} - this host. - -); - -const PastActivityTopline = ({ - activity, - onDetailsClick, -}: IPastActivityProps) => { - switch (activity.type) { - case "ran_script": - return ( - - ); - case "locked_host": - return ; - case "unlocked_host": - return ; - default: - return null; - } -}; - -// TODO: Combine this with ./UpcomingActivity/UpcomingActivity.tsx and -// frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx -const PastActivity = ({ activity, onDetailsClick }: IPastActivityProps) => { - const { actor_email } = activity; - const { gravatar_url } = actor_email - ? addGravatarUrlToResource({ email: actor_email }) - : { gravatar_url: DEFAULT_GRAVATAR_LINK }; - const activityCreatedAt = new Date(activity.created_at); - - return ( -
- -
-
- -
- - {formatDistanceToNowStrict(activityCreatedAt, { - addSuffix: true, - })} - - - {internationalTimeFormat(activityCreatedAt)} - -
-
-
-
- ); -}; - -export default PastActivity; diff --git a/frontend/pages/hosts/details/cards/Activity/PastActivity/index.ts b/frontend/pages/hosts/details/cards/Activity/PastActivity/index.ts deleted file mode 100644 index 363a39834c..0000000000 --- a/frontend/pages/hosts/details/cards/Activity/PastActivity/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./PastActivity"; diff --git a/frontend/pages/hosts/details/cards/Activity/PastActivityFeed/PastActivityFeed.tsx b/frontend/pages/hosts/details/cards/Activity/PastActivityFeed/PastActivityFeed.tsx index 086f481b6a..05c7a790a8 100644 --- a/frontend/pages/hosts/details/cards/Activity/PastActivityFeed/PastActivityFeed.tsx +++ b/frontend/pages/hosts/details/cards/Activity/PastActivityFeed/PastActivityFeed.tsx @@ -1,7 +1,7 @@ import React from "react"; -import { IActivity } from "interfaces/activity"; -import { IActivitiesResponse } from "services/entities/activities"; +import { IPastActivity } from "interfaces/activity"; +import { IPastActivitiesResponse } from "services/entities/activities"; // @ts-ignore import FleetIcon from "components/icons/FleetIcon"; @@ -9,13 +9,14 @@ import Button from "components/buttons/Button"; import DataError from "components/DataError"; import EmptyFeed from "../EmptyFeed/EmptyFeed"; -import PastActivity from "../PastActivity/PastActivity"; import { ShowActivityDetailsHandler } from "../Activity"; +import { pastActivityComponentMap } from "../ActivityConfig"; + const baseClass = "past-activity-feed"; interface IPastActivityFeedProps { - activities?: IActivitiesResponse; + activities?: IPastActivitiesResponse; isError?: boolean; onDetailsClick: ShowActivityDetailsHandler; onNextPage: () => void; @@ -52,9 +53,16 @@ const PastActivityFeed = ({ return (
- {activitiesList.map((activity: IActivity) => ( - - ))} + {activitiesList.map((activity: IPastActivity) => { + const ActivityItemComponent = pastActivityComponentMap[activity.type]; + return ( + + ); + })}
+ ); +}; + +export default ShowDetailsButton; diff --git a/frontend/pages/hosts/details/cards/Activity/ShowDetailsButton/_styles.scss b/frontend/pages/hosts/details/cards/Activity/ShowDetailsButton/_styles.scss new file mode 100644 index 0000000000..c1f3a99a32 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/ShowDetailsButton/_styles.scss @@ -0,0 +1,5 @@ +.show-details-button { + &__show-details-icon { + margin-left: $pad-xsmall; + } +} diff --git a/frontend/pages/hosts/details/cards/Activity/ShowDetailsButton/index.ts b/frontend/pages/hosts/details/cards/Activity/ShowDetailsButton/index.ts new file mode 100644 index 0000000000..533c76eddd --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/ShowDetailsButton/index.ts @@ -0,0 +1 @@ +export { default } from "./ShowDetailsButton"; diff --git a/frontend/pages/hosts/details/cards/Activity/_styles.scss b/frontend/pages/hosts/details/cards/Activity/_styles.scss index caad04a12b..7fc58cd091 100644 --- a/frontend/pages/hosts/details/cards/Activity/_styles.scss +++ b/frontend/pages/hosts/details/cards/Activity/_styles.scss @@ -1,5 +1,4 @@ .activity-card { - padding: $pad-xxlarge; position: relative; h2 { diff --git a/frontend/pages/hosts/details/cards/AgentOptions/AgentOptions.tsx b/frontend/pages/hosts/details/cards/AgentOptions/AgentOptions.tsx index c6a05c8ce2..d91cd53952 100644 --- a/frontend/pages/hosts/details/cards/AgentOptions/AgentOptions.tsx +++ b/frontend/pages/hosts/details/cards/AgentOptions/AgentOptions.tsx @@ -4,7 +4,10 @@ import React from "react"; import { secondsToHms } from "utilities/helpers"; -const baseClass = "agent-options"; +import DataSet from "components/DataSet"; +import Card from "components/Card"; + +const baseClass = "agent-options-card"; interface IAgentOptionsProps { osqueryData: { [key: string]: any }; wrapFleetHelper: (helperFn: (value: any) => string, value: string) => string; @@ -19,7 +22,9 @@ const AgentOptions = ({ wrapFleetHelper, isChromeOS = false, }: IAgentOptionsProps): JSX.Element => { - const classNames = classnames(baseClass, "section", "osquery"); + const classNames = classnames(baseClass, { + [`${baseClass}__chrome-os`]: isChromeOS, + }); let configTLSRefresh; let loggerTLSPeriod; @@ -47,36 +52,28 @@ const AgentOptions = ({ } return ( -
+ {isChromeOS ? ( Agent options ) : ( -

Agent options

+

Agent options

)} -
-
- Config TLS refresh - - {configTLSRefresh} - -
-
- Logger TLS period - - {loggerTLSPeriod} - -
-
- Distributed interval - {distributedInterval} -
+
+ + +
-
+
); }; diff --git a/frontend/pages/hosts/details/cards/AgentOptions/_styles.scss b/frontend/pages/hosts/details/cards/AgentOptions/_styles.scss index 83ba9e5df7..0819e393cf 100644 --- a/frontend/pages/hosts/details/cards/AgentOptions/_styles.scss +++ b/frontend/pages/hosts/details/cards/AgentOptions/_styles.scss @@ -1,9 +1,17 @@ -.agent-options { +.agent-options-card { .component__tooltip-wrapper__tip-text { max-width: 326px; } - .grey-text { - color: $ui-fleet-black-75; + &__data { + display: flex; + flex-direction: column; + gap: $pad-medium; + } + + &__chrome-os { + dd { + color: $ui-fleet-black-75; + } } } diff --git a/frontend/pages/hosts/details/cards/HostSummary/HostSummary.tsx b/frontend/pages/hosts/details/cards/HostSummary/HostSummary.tsx index 3eb76c9c82..afa54bb8e6 100644 --- a/frontend/pages/hosts/details/cards/HostSummary/HostSummary.tsx +++ b/frontend/pages/hosts/details/cards/HostSummary/HostSummary.tsx @@ -13,6 +13,8 @@ import getHostStatusTooltipText from "pages/hosts/helpers"; import TooltipWrapper from "components/TooltipWrapper"; import Button from "components/buttons/Button"; import Icon from "components/Icon/Icon"; +import Card from "components/Card"; +import DataSet from "components/DataSet"; import DiskSpaceGraph from "components/DiskSpaceGraph"; import { HumanTimeDiffWithFleetLaunchCutoff } from "components/HumanTimeDiffWithDateTip"; import PremiumFeatureIconWithTooltip from "components/PremiumFeatureIconWithTooltip"; @@ -218,50 +220,65 @@ const HostSummary = ({ }; const renderIssues = () => ( -
- - Issues{isSandboxMode && } - - - - - - - - Failing policies ({summaryData.issues.failing_policies_count}) + Issues{isSandboxMode && }} + value={ + <> + + - - - {summaryData.issues.total_issues_count} - - -
+ + + Failing policies ({summaryData.issues.failing_policies_count}) + + + {summaryData.issues.total_issues_count} + + } + /> ); const renderHostTeam = () => ( -
- Team - - {summaryData.team_name ? ( + No team - )} - -
+ No team + ) + } + /> ); + const renderDiskSpaceSummary = () => { + return ( + + } + /> + ); + }; const renderDiskEncryptionSummary = () => { // TODO: improve this typing, platforms! if (!["darwin", "windows", "chrome"].includes(platform)) { @@ -290,12 +307,14 @@ const HostSummary = ({ } return ( -
- Disk encryption - - {statusText} - -
+ + {statusText} + + } + /> ); }; @@ -318,18 +337,24 @@ const HostSummary = ({ } return ( -
-
- Status - -
- + + + } + /> {(summaryData.issues?.total_issues_count > 0 || isSandboxMode) && isPremiumTier && renderIssues()} @@ -361,43 +386,18 @@ const HostSummary = ({ )} - {platform !== "chrome" && ( -
- Disk space - -
- )} + {platform !== "chrome" && renderDiskSpaceSummary()} {renderDiskEncryptionSummary()} -
- Memory - - {wrapFleetHelper(humanHostMemory, summaryData.memory)} - -
-
- Processor type - {summaryData.cpu_type} -
-
- Operating system - {summaryData.os_version} -
-
- Osquery - {summaryData.osquery_version} -
-
+ + + + + ); }; @@ -460,9 +460,7 @@ const HostSummary = ({
{renderActionButtons()}
-
-
{renderSummary()}
-
+ {renderSummary()}
); }; diff --git a/frontend/pages/hosts/details/cards/HostSummary/_styles.scss b/frontend/pages/hosts/details/cards/HostSummary/_styles.scss index b95055e0b6..b88e0d4964 100644 --- a/frontend/pages/hosts/details/cards/HostSummary/_styles.scss +++ b/frontend/pages/hosts/details/cards/HostSummary/_styles.scss @@ -1,4 +1,8 @@ .host-summary { + display: flex; + flex-direction: column; + gap: $pad-medium; + &__device-status-tag { margin-left: $pad-small; background-color: $ui-warning; @@ -12,7 +16,8 @@ } &.error { - background-color: $ui-error; + color: $core-white; + background-color: $core-vibrant-red; } } @@ -68,7 +73,7 @@ } } - .info-flex { + .card { &__header { display: flex; align-items: center; @@ -82,5 +87,18 @@ .component__tooltip-wrapper__tip-text { max-width: 326px; } + + display: flex; + gap: $pad-medium $pad-xxlarge; + padding: $pad-xxlarge; + flex-wrap: wrap; + + .host-issue { + display: flex; + align-items: center; + } + .no-team { + color: $ui-fleet-black-50; + } } } diff --git a/frontend/pages/hosts/details/cards/HostSummary/helpers.tsx b/frontend/pages/hosts/details/cards/HostSummary/helpers.tsx index 0282416be7..668feffc6c 100644 --- a/frontend/pages/hosts/details/cards/HostSummary/helpers.tsx +++ b/frontend/pages/hosts/details/cards/HostSummary/helpers.tsx @@ -39,6 +39,20 @@ export const DEVICE_STATUS_TAGS: DeviceStatusTagConfig = { generateTooltip: (platform) => "Host will lock when it comes online. If the host is online, it will lock the next time it checks in to Fleet.", }, + wiped: { + title: "WIPED", + tagType: "error", + generateTooltip: (platform) => + platform === "darwin" + ? "Host is wiped. To prevent the host from automatically reenrolling to Fleet, first release the host from Apple Business Manager and then delete the host in Fleet." + : "Host is wiped.", + }, + wiping: { + title: "WIPE PENDING", + tagType: "error", + generateTooltip: () => + "Host will wipe when it comes online. If the host is online, it will wipe the next time it checks in to Fleet.", + }, }; // We exclude "unlocked" as we dont display a tooltip for it. @@ -66,4 +80,14 @@ export const REFETCH_TOOLTIP_MESSAGES: Record< You can't fetch data from
a locked host. ), + wiping: ( + <> + You can't fetch data from
a wiping host. + + ), + wiped: ( + <> + You can't fetch data from
a wiped host. + + ), } as const; diff --git a/frontend/pages/hosts/details/cards/Labels/Labels.tsx b/frontend/pages/hosts/details/cards/Labels/Labels.tsx index c1ef1075ab..728d88073b 100644 --- a/frontend/pages/hosts/details/cards/Labels/Labels.tsx +++ b/frontend/pages/hosts/details/cards/Labels/Labels.tsx @@ -5,6 +5,8 @@ import { ILabel } from "interfaces/label"; import { enforceFleetSentenceCasing } from "utilities/strings/stringUtils"; import classnames from "classnames"; +import Card from "components/Card"; + const baseClass = "labels-card"; interface ILabelsProps { @@ -13,7 +15,7 @@ interface ILabelsProps { } const Labels = ({ onLabelClick, labels }: ILabelsProps): JSX.Element => { - const classNames = classnames(baseClass, "section", "labels"); + const classNames = classnames(baseClass, "card", "labels"); const labelItems = labels.map((label: ILabel) => { return ( @@ -30,8 +32,13 @@ const Labels = ({ onLabelClick, labels }: ILabelsProps): JSX.Element => { }); return ( -
-

Labels

+ +

Labels

{labels.length === 0 ? (

No labels are associated with this host. @@ -39,7 +46,7 @@ const Labels = ({ onLabelClick, labels }: ILabelsProps): JSX.Element => { ) : (

    {labelItems}
)} -
+ ); }; diff --git a/frontend/pages/hosts/details/cards/MunkiIssues/MunkiIssues.tsx b/frontend/pages/hosts/details/cards/MunkiIssues/MunkiIssues.tsx index 9af139a73f..9be0041b57 100644 --- a/frontend/pages/hosts/details/cards/MunkiIssues/MunkiIssues.tsx +++ b/frontend/pages/hosts/details/cards/MunkiIssues/MunkiIssues.tsx @@ -1,13 +1,14 @@ import React from "react"; import { IMunkiIssue } from "interfaces/host"; -import TableContainer from "components/TableContainer"; +import TableContainer from "components/TableContainer"; import EmptyTable from "components/EmptyTable"; +import Card from "components/Card"; import { munkiIssuesTableHeaders } from "./MunkiIssuesTableConfig"; -const baseClass = "munki-issues"; +const baseClass = "munki-issues-card"; interface IMunkiIssuesTableProps { isLoading: boolean; @@ -24,8 +25,13 @@ const MunkiIssuesTable = ({ const tableHeaders = munkiIssuesTableHeaders; return ( -
-

Munki issues

+ +

Munki issues

{munkiIssues?.length ? (
@@ -53,7 +59,7 @@ const MunkiIssuesTable = ({ info="The last time Munki ran on this host, no issues were reported." /> )} -
+
); }; export default MunkiIssuesTable; diff --git a/frontend/pages/hosts/details/cards/MunkiIssues/_styles.scss b/frontend/pages/hosts/details/cards/MunkiIssues/_styles.scss index af77de0d1e..fd13499b70 100644 --- a/frontend/pages/hosts/details/cards/MunkiIssues/_styles.scss +++ b/frontend/pages/hosts/details/cards/MunkiIssues/_styles.scss @@ -1,4 +1,4 @@ -.section--munki-issues { +.munki-issues-card { .data-table-block { .issue_tooltip, .time_tooltip { diff --git a/frontend/pages/hosts/details/cards/Packs/Packs.tsx b/frontend/pages/hosts/details/cards/Packs/Packs.tsx index 81ee3f5847..cdc2558247 100644 --- a/frontend/pages/hosts/details/cards/Packs/Packs.tsx +++ b/frontend/pages/hosts/details/cards/Packs/Packs.tsx @@ -2,6 +2,7 @@ import React from "react"; import { IPackStats } from "interfaces/host"; import TableContainer from "components/TableContainer"; +import Card from "components/Card"; import { Accordion, @@ -16,7 +17,7 @@ import { generatePackDataSet, } from "./PackTable/PackTableConfig"; -const baseClass = "schedule"; +const baseClass = "schedule-card"; interface IPacksProps { packsState?: IPackStats[]; @@ -70,12 +71,17 @@ const Packs = ({ packsState, isLoading }: IPacksProps): JSX.Element => { return !packs || !packs.length ? ( <> ) : ( -
-

Packs

+ +

Packs

{packsAccordion} -
+ ); }; diff --git a/frontend/pages/hosts/details/cards/Packs/_styles.scss b/frontend/pages/hosts/details/cards/Packs/_styles.scss index e4f9cdc939..b4132d9cbd 100644 --- a/frontend/pages/hosts/details/cards/Packs/_styles.scss +++ b/frontend/pages/hosts/details/cards/Packs/_styles.scss @@ -1,4 +1,4 @@ -.section--packs { +.card--packs { .table-container__header { display: none; } diff --git a/frontend/pages/hosts/details/cards/Policies/Policies.tsx b/frontend/pages/hosts/details/cards/Policies/HostPolicies.tsx similarity index 81% rename from frontend/pages/hosts/details/cards/Policies/Policies.tsx rename to frontend/pages/hosts/details/cards/Policies/HostPolicies.tsx index a750a6db68..05dba4ad7d 100644 --- a/frontend/pages/hosts/details/cards/Policies/Policies.tsx +++ b/frontend/pages/hosts/details/cards/Policies/HostPolicies.tsx @@ -3,6 +3,7 @@ import React from "react"; import { IHostPolicy } from "interfaces/policy"; import TableContainer from "components/TableContainer"; import EmptyTable from "components/EmptyTable"; +import Card from "components/Card"; import { generatePolicyTableHeaders, @@ -10,6 +11,8 @@ import { } from "./HostPoliciesTable/HostPoliciesTableConfig"; import PolicyFailingCount from "./HostPoliciesTable/PolicyFailingCount"; +const baseClass = "policies-card"; + interface IPoliciesProps { policies: IHostPolicy[]; isLoading: boolean; @@ -25,8 +28,13 @@ const Policies = ({ }: IPoliciesProps): JSX.Element => { if (policies.length === 0) { return ( -
-

Policies

+ +

Policies

@@ -42,7 +50,7 @@ const Policies = ({ } /> -
+ ); } @@ -55,8 +63,13 @@ const Policies = ({ policies.filter((policy: IHostPolicy) => policy.response === "fail") || []; return ( -
-

Policies

+ +

Policies

{policies.length > 0 && ( <> @@ -74,10 +87,11 @@ const Policies = ({ isAllPagesSelected={false} disablePagination disableCount + disableMultiRowSelect /> )} -
+ ); }; diff --git a/frontend/pages/hosts/details/cards/Policies/_styles.scss b/frontend/pages/hosts/details/cards/Policies/_styles.scss index eb7e5d8f6a..e7d90c5676 100644 --- a/frontend/pages/hosts/details/cards/Policies/_styles.scss +++ b/frontend/pages/hosts/details/cards/Policies/_styles.scss @@ -1,4 +1,4 @@ -.section--policies { +.policies-card { .info-banner { margin-bottom: 1rem; } diff --git a/frontend/pages/hosts/details/cards/Policies/index.ts b/frontend/pages/hosts/details/cards/Policies/index.ts index a7a0bb785a..5e0a9cb87f 100644 --- a/frontend/pages/hosts/details/cards/Policies/index.ts +++ b/frontend/pages/hosts/details/cards/Policies/index.ts @@ -1 +1 @@ -export { default } from "./Policies"; +export { default } from "./HostPolicies"; diff --git a/frontend/pages/hosts/details/cards/Queries/HostQueries.tsx b/frontend/pages/hosts/details/cards/Queries/HostQueries.tsx index dc9030215e..8b3d4e96d9 100644 --- a/frontend/pages/hosts/details/cards/Queries/HostQueries.tsx +++ b/frontend/pages/hosts/details/cards/Queries/HostQueries.tsx @@ -4,6 +4,7 @@ import { IQueryStats } from "interfaces/query_stats"; import TableContainer from "components/TableContainer"; import EmptyTable from "components/EmptyTable"; import CustomLink from "components/CustomLink"; +import Card from "components/Card"; import PATHS from "router/paths"; import { InjectedRouter } from "react-router"; import { Row } from "react-table"; @@ -13,7 +14,7 @@ import { generateDataSet, } from "./HostQueriesTableConfig"; -const baseClass = "host-queries"; +const baseClass = "host-queries-card"; interface IHostQueriesProps { hostId: number; @@ -87,8 +88,13 @@ const HostQueries = ({ ); return ( -
-

Queries

+ +

Queries

{!schedule || !schedule.length || isChromeOSHost ? ( renderEmptyQueriesTab() ) : ( @@ -111,7 +117,7 @@ const HostQueries = ({ />
)} -
+ ); }; diff --git a/frontend/pages/hosts/details/cards/Queries/_styles.scss b/frontend/pages/hosts/details/cards/Queries/_styles.scss index bffcdac122..b2cacfa9a6 100644 --- a/frontend/pages/hosts/details/cards/Queries/_styles.scss +++ b/frontend/pages/hosts/details/cards/Queries/_styles.scss @@ -1,8 +1,4 @@ -.section--host-queries { - margin-top: $pad-medium; - .section__header { - margin-bottom: $pad-medium; - } +.host-queries-card { .table-container__header { display: none; } diff --git a/frontend/pages/hosts/details/cards/Software/Software.tsx b/frontend/pages/hosts/details/cards/Software/Software.tsx index 55fd2ef2c5..74f7305585 100644 --- a/frontend/pages/hosts/details/cards/Software/Software.tsx +++ b/frontend/pages/hosts/details/cards/Software/Software.tsx @@ -13,6 +13,7 @@ import { buildQueryStringFromParams } from "utilities/url"; import Dropdown from "components/forms/fields/Dropdown"; import TableContainer from "components/TableContainer"; import { ITableQueryData } from "components/TableContainer/TableContainer"; +import Card from "components/Card"; import EmptySoftwareTable from "pages/SoftwarePage/components/EmptySoftwareTable"; import { getNextLocationPath } from "utilities/helpers"; @@ -23,7 +24,7 @@ import { generateSoftwareTableData, } from "./SoftwareTableConfig"; -const baseClass = "host-details"; +const baseClass = "software-card"; export interface ITableSoftware extends Omit { vulnerabilities: string[]; // for client-side search purposes, we only want an array of cve strings @@ -206,8 +207,13 @@ const SoftwareTable = ({ }; return ( -
-

Software

+ +

Software

{software?.length ? ( <> @@ -261,7 +267,7 @@ const SoftwareTable = ({ isFilterVulnerable={filterVuln} /> )} -
+ ); }; export default SoftwareTable; diff --git a/frontend/pages/hosts/details/cards/Software/_styles.scss b/frontend/pages/hosts/details/cards/Software/_styles.scss index ceaae0b407..1935884ee3 100644 --- a/frontend/pages/hosts/details/cards/Software/_styles.scss +++ b/frontend/pages/hosts/details/cards/Software/_styles.scss @@ -1,4 +1,4 @@ -.section--software { +.software-card { .info-banner { margin-bottom: 1rem; } diff --git a/frontend/pages/hosts/details/cards/Users/Users.tsx b/frontend/pages/hosts/details/cards/Users/Users.tsx index a5d85de51e..fc738949ee 100644 --- a/frontend/pages/hosts/details/cards/Users/Users.tsx +++ b/frontend/pages/hosts/details/cards/Users/Users.tsx @@ -5,6 +5,7 @@ import TableContainer from "components/TableContainer"; import { ITableQueryData } from "components/TableContainer/TableContainer"; import EmptyTable from "components/EmptyTable"; import CustomLink from "components/CustomLink"; +import Card from "components/Card"; import generateUsersTableHeaders from "./UsersTable/UsersTableConfig"; @@ -16,6 +17,8 @@ interface IUsersProps { hostUsersEnabled?: boolean; } +const baseClass = "users-card"; + const Users = ({ users, usersState, @@ -27,8 +30,13 @@ const Users = ({ if (!hostUsersEnabled) { return ( -
-

Users

+ +

Users

} /> -
+ ); } return ( -
-

Users

- {users?.length ? ( - ( - - )} - showMarkAllPages={false} - isAllPagesSelected={false} - searchable - wideSearch - filteredCount={usersState.length} - isClientSidePagination - /> - ) : ( - + <> +

Users

+ {users?.length ? ( + ( + + )} + showMarkAllPages={false} + isAllPagesSelected={false} + searchable + wideSearch + filteredCount={usersState.length} + isClientSidePagination + /> + ) : ( + - )} -
+ /> + )} + + ); }; diff --git a/frontend/pages/hosts/details/cards/Users/_styles.scss b/frontend/pages/hosts/details/cards/Users/_styles.scss index c760063173..87ca0c231c 100644 --- a/frontend/pages/hosts/details/cards/Users/_styles.scss +++ b/frontend/pages/hosts/details/cards/Users/_styles.scss @@ -1,12 +1,4 @@ -.section--users { - margin-top: $pad-medium; - - .table-container { - &__header { - padding-top: $pad-large; - } - } - +.users-card { .data-table-block { .data-table__table { thead { diff --git a/frontend/pages/hosts/details/helpers.ts b/frontend/pages/hosts/details/helpers.ts index c0a3a9a316..52a0efd6dd 100644 --- a/frontend/pages/hosts/details/helpers.ts +++ b/frontend/pages/hosts/details/helpers.ts @@ -1,5 +1,4 @@ /** Helpers used across the host details and my device pages and components. */ -import { is } from "date-fns/locale"; import { HostMdmDeviceStatus, HostMdmPendingAction } from "interfaces/host"; import { IHostMdmProfile, @@ -39,7 +38,9 @@ export type HostMdmDeviceStatusUIState = | "unlocked" | "locked" | "unlocking" - | "locking"; + | "locking" + | "wiped" + | "wiping"; // Exclude the empty string from HostPendingAction as that doesn't represent a // valid device status. @@ -51,9 +52,11 @@ const API_TO_UI_DEVICE_STATUS_MAP: Record< locked: "locked", unlock: "unlocking", lock: "locking", + wiped: "wiped", + wipe: "wiping", }; -const deviceUpdatingStates = ["unlocking", "locking"] as const; +const deviceUpdatingStates = ["unlocking", "locking", "wiping"] as const; /** * Gets the current UI state for the host device status. This helps us know what @@ -74,7 +77,7 @@ export const getHostDeviceStatusUIState = ( }; /** - * Helps check if our device status UI state is in an updating state. + * Checks if our device status UI state is in an updating state. */ export const isDeviceStatusUpdating = ( deviceStatus: HostMdmDeviceStatusUIState diff --git a/frontend/pages/policies/PolicyPage/components/PolicyForm/PolicyForm.tsx b/frontend/pages/policies/PolicyPage/components/PolicyForm/PolicyForm.tsx index 7e9173a6e9..c76bdfd263 100644 --- a/frontend/pages/policies/PolicyPage/components/PolicyForm/PolicyForm.tsx +++ b/frontend/pages/policies/PolicyPage/components/PolicyForm/PolicyForm.tsx @@ -327,7 +327,7 @@ const PolicyForm = ({ value={lastEditedQueryName} hasError={errors && errors.name} inputClassName={`${baseClass}__policy-name`} - maxLength="160" + maxLength={160} onChange={setLastEditedQueryName} onFocus={() => setIsEditingName(true)} onBlur={() => setIsEditingName(false)} @@ -368,7 +368,7 @@ const PolicyForm = ({ placeholder="Add description here." value={lastEditedQueryDescription} inputClassName={`${baseClass}__policy-description`} - maxLength="250" + maxLength={250} onChange={setLastEditedQueryDescription} onFocus={() => setIsEditingDescription(true)} onBlur={() => setIsEditingDescription(false)} @@ -404,7 +404,7 @@ const PolicyForm = ({ placeholder="Add resolution here." value={lastEditedQueryResolution} inputClassName={`${baseClass}__policy-resolution`} - maxLength="500" + maxLength={500} onChange={setLastEditedQueryResolution} onFocus={() => setIsEditingResolution(true)} onBlur={() => setIsEditingResolution(false)} diff --git a/frontend/pages/queries/edit/components/EditQueryForm/EditQueryForm.tsx b/frontend/pages/queries/edit/components/EditQueryForm/EditQueryForm.tsx index 07d8284198..bb687e11b7 100644 --- a/frontend/pages/queries/edit/components/EditQueryForm/EditQueryForm.tsx +++ b/frontend/pages/queries/edit/components/EditQueryForm/EditQueryForm.tsx @@ -470,79 +470,103 @@ const EditQueryForm = ({ return platformCompatibility.render(); }; - const queryNameClasses = classnames("query-name-wrapper", { - [`${baseClass}--editing`]: isEditingName, - }); + const editName = () => { + if (!isEditingName) { + setIsEditingName(true); + } + }; - const queryDescriptionClasses = classnames("query-description-wrapper", { - [`${baseClass}--editing`]: isEditingDescription, + const queryNameWrapperClasses = classnames("query-name-wrapper", { + "query-name-wrapper__editing": isEditingName, }); const renderName = () => { if (savedQueryMode) { return ( - <> -
- setIsEditingName(true)} - onBlur={() => setIsEditingName(false)} - onKeyPress={onInputKeypress} - isFocused={isEditingName} - /> - -
- + + ) : ( + + )} +
); } return

New query

; }; + const editDescription = () => { + if (!isEditingDescription) { + setIsEditingDescription(true); + } + }; + const renderDescription = () => { if (savedQueryMode) { return ( - <> -
- setIsEditingDescription(true)} - onBlur={() => setIsEditingDescription(false)} - onKeyPress={onInputKeypress} - isFocused={isEditingDescription} - /> - -
- + + ) : ( + + )} +
); } return null; diff --git a/frontend/pages/queries/edit/components/EditQueryForm/_styles.scss b/frontend/pages/queries/edit/components/EditQueryForm/_styles.scss index a84ec1b7f3..fb19204dfe 100644 --- a/frontend/pages/queries/edit/components/EditQueryForm/_styles.scss +++ b/frontend/pages/queries/edit/components/EditQueryForm/_styles.scss @@ -9,6 +9,7 @@ &__title-bar { display: flex; justify-content: space-between; + gap: 1.5rem; .form-field { margin-bottom: 0px; @@ -35,7 +36,6 @@ .edit-link { margin: 0; // override margin intended for buttons being used as form-fields, which these are not cursor: pointer; - padding-left: $pad-small; height: 18px; } @@ -44,43 +44,52 @@ flex-direction: column; gap: 0.5rem; - .query-name-wrapper { + .query-name-wrapper, + .query-description-wrapper { display: flex; - align-items: center; - - &:not(.edit-query-form--editing) { - textarea:hover { - cursor: pointer; - color: $core-vibrant-blue; - } + align-items: baseline; + gap: 0.5rem; + outline: 0; + &:hover:not(.focus-visible):not(.no-hover) { + color: $core-vibrant-blue; + cursor: pointer; } + .hide { + opacity: 0; + } + .icon { + align-self: initial; + } + + button { + all: unset; + display: flex; + // must match gap of wrappers + gap: 0.5rem; + align-items: baseline; + } + } + .query-name-wrapper { + width: fit-content; + .no-value { + min-width: 170px; + } + .edit-query-form__query-name, .input-sizer::after { font-size: $large; - } - .component__auto-size-input-field { letter-spacing: -0.5px; line-height: 2.3rem; } - } - .query-description-wrapper { - display: flex; - &:not(.edit-query-form--editing) { - textarea:hover { - cursor: pointer; - color: $core-vibrant-blue; + + // compensate for FF weirdness with textarea line-height calculations + @-moz-document url-prefix() { + line-height: 2.25rem; + &__editing { + line-height: 2.3rem; } } } - .edit-icon { - width: 14px; - height: 14px; - opacity: 1; - transition: opacity 0.2s; - &.hide { - opacity: 0; - } - } } .author { @@ -105,24 +114,29 @@ } } + &__textarea-content-display { + display: flex; + align-items: center; + } + &__query-name, &__query-description { - width: 100%; margin: 0; padding: 0; border: 0; resize: none; - white-space: normal; + // collapse not supported on Firefox, so pre-wrap for consistency across browsers + white-space: pre-wrap; background-color: transparent; overflow: hidden; - &:hover:not(.focus-visible):not(.no-hover) { - color: $core-vibrant-blue; - cursor: pointer; - } + text-align: left; &.focus-visible { outline: 0; cursor: text; } + .placeholder { + @include placeholder; + } } &__query-name { diff --git a/frontend/services/entities/activities.ts b/frontend/services/entities/activities.ts index d63e90fae1..7a45e52b83 100644 --- a/frontend/services/entities/activities.ts +++ b/frontend/services/entities/activities.ts @@ -1,5 +1,5 @@ import endpoints from "utilities/endpoints"; -import { IActivity } from "interfaces/activity"; +import { IActivity, IPastActivity } from "interfaces/activity"; import sendRequest from "services"; import { buildQueryStringFromParams } from "utilities/url"; @@ -16,6 +16,14 @@ export interface IActivitiesResponse { }; } +export interface IPastActivitiesResponse { + activities: IPastActivity[] | null; + meta: { + has_next_results: boolean; + has_previous_results: boolean; + }; +} + export interface IUpcomingActivitiesResponse extends IActivitiesResponse { count: number; } @@ -45,7 +53,7 @@ export default { id: number, page = DEFAULT_PAGE, perPage = DEFAULT_PAGE_SIZE - ): Promise => { + ): Promise => { const { HOST_PAST_ACTIVITIES } = endpoints; const queryParams = { diff --git a/frontend/services/entities/hosts.ts b/frontend/services/entities/hosts.ts index 2104fa2469..749373149f 100644 --- a/frontend/services/entities/hosts.ts +++ b/frontend/services/entities/hosts.ts @@ -116,6 +116,7 @@ export interface IActionByFilter { query: string; status: string; labelId?: number; + currentTeam?: number | null; } export type ILoadHostDetailsExtension = "device_mapping" | "macadmins"; @@ -359,6 +360,7 @@ export default { query, status, labelId, + currentTeam, }: IActionByFilter) => { const { HOSTS_TRANSFER_BY_FILTER } = endpoints; return sendRequest("POST", HOSTS_TRANSFER_BY_FILTER, { @@ -367,6 +369,7 @@ export default { query, status, label_id: labelId, + team_id: currentTeam, }, }); }, @@ -397,8 +400,14 @@ export default { const { HOST_LOCK } = endpoints; return sendRequest("POST", HOST_LOCK(id)); }, + unlockHost: (id: number): Promise => { const { HOST_UNLOCK } = endpoints; return sendRequest("POST", HOST_UNLOCK(id)); }, + + wipeHost: (id: number) => { + const { HOST_WIPE } = endpoints; + return sendRequest("POST", HOST_WIPE(id)); + }, }; diff --git a/frontend/styles/var/icon_sizes.ts b/frontend/styles/var/icon_sizes.ts index 91ff6f2a69..fea5764a9b 100644 --- a/frontend/styles/var/icon_sizes.ts +++ b/frontend/styles/var/icon_sizes.ts @@ -2,6 +2,7 @@ export type IconSizes = keyof typeof ICON_SIZES; export const ICON_SIZES = { small: "12", + "small-medium": "14", medium: "16", large: "24", "extra-large": "48", diff --git a/frontend/styles/var/mixins.scss b/frontend/styles/var/mixins.scss index a600c5c848..eca0a689fe 100644 --- a/frontend/styles/var/mixins.scss +++ b/frontend/styles/var/mixins.scss @@ -220,3 +220,8 @@ $max-width: 2560px; // compensate in layout for extra clickable area button height margin: -8px 0; } + +@mixin placeholder { + color: $ui-fleet-black-50; + opacity: 0.75; +} diff --git a/frontend/utilities/constants.tsx b/frontend/utilities/constants.tsx index 2e0c193282..6a79247239 100644 --- a/frontend/utilities/constants.tsx +++ b/frontend/utilities/constants.tsx @@ -360,3 +360,8 @@ export const HOST_OSQUERY_DATA = [ "logger_tls_period", "distributed_interval", ]; + +export const DEFAULT_USE_QUERY_OPTIONS = { + retry: 3, + refetchOnWindowFocus: false, +}; diff --git a/frontend/utilities/endpoints.ts b/frontend/utilities/endpoints.ts index 491c14690b..b54ec4fd97 100644 --- a/frontend/utilities/endpoints.ts +++ b/frontend/utilities/endpoints.ts @@ -43,6 +43,7 @@ export default { HOSTS_TRANSFER_BY_FILTER: `/${API_VERSION}/fleet/hosts/transfer/filter`, HOST_LOCK: (id: number) => `/${API_VERSION}/fleet/hosts/${id}/lock`, HOST_UNLOCK: (id: number) => `/${API_VERSION}/fleet/hosts/${id}/unlock`, + HOST_WIPE: (id: number) => `/${API_VERSION}/fleet/hosts/${id}/wipe`, INVITES: `/${API_VERSION}/fleet/invites`, LABELS: `/${API_VERSION}/fleet/labels`, diff --git a/frontend/utilities/permissions/permissions.ts b/frontend/utilities/permissions/permissions.ts index 8a394c4732..5ec2c2d35e 100644 --- a/frontend/utilities/permissions/permissions.ts +++ b/frontend/utilities/permissions/permissions.ts @@ -13,10 +13,14 @@ export const isPremiumTier = (config: IConfig): boolean => { return config.license.tier === "premium"; }; -export const isMdmEnabledAndConfigured = (config: IConfig): boolean => { +export const isMacMdmEnabledAndConfigured = (config: IConfig): boolean => { return Boolean(config.mdm.enabled_and_configured); }; +export const isWindowsMdmEnabledAndConfigured = (config: IConfig): boolean => { + return Boolean(config.mdm.windows_enabled_and_configured); +}; + export const isGlobalAdmin = (user: IUser): boolean => { return user.global_role === "admin"; }; @@ -142,7 +146,8 @@ export default { isSandboxMode, isFreeTier, isPremiumTier, - isMdmEnabledAndConfigured, + isMacMdmEnabledAndConfigured, + isWindowsMdmEnabledAndConfigured, isGlobalAdmin, isGlobalMaintainer, isGlobalObserver, diff --git a/go.mod b/go.mod index 20e9c30ac0..e4e78b47d7 100644 --- a/go.mod +++ b/go.mod @@ -17,7 +17,6 @@ require ( github.com/aws/aws-sdk-go v1.44.288 github.com/beevik/etree v1.1.0 github.com/beevik/ntp v0.3.0 - github.com/boltdb/bolt v1.3.1 github.com/briandowns/spinner v1.13.0 github.com/cenkalti/backoff v2.2.1+incompatible github.com/cenkalti/backoff/v4 v4.2.1 @@ -104,6 +103,7 @@ require ( go.elastic.co/apm/module/apmgorilla/v2 v2.3.0 go.elastic.co/apm/module/apmsql/v2 v2.4.3 go.elastic.co/apm/v2 v2.4.3 + go.etcd.io/bbolt v1.3.6 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.19.0 diff --git a/go.sum b/go.sum index 414f57041d..866a29d973 100644 --- a/go.sum +++ b/go.sum @@ -287,8 +287,6 @@ github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kB github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84= github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb h1:m935MPodAbYS46DG4pJSv7WO+VECIWUQ7OJYSoTrMh4= github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb/go.mod h1:PkYb9DJNAwrSvRx5DYA+gUcOIgTGVMNkfSCbZM8cWpI= -github.com/boltdb/bolt v1.3.1 h1:JQmyP4ZBrce+ZQu0dY660FMfatumYDLun9hBCUVIkF4= -github.com/boltdb/bolt v1.3.1/go.mod h1:clJnj/oiGkjum5o1McbSZDSLxVThjynRyGBgiAx27Ps= github.com/briandowns/spinner v1.13.0 h1:q/Y9LtpwtvL0CRzXrAMj0keVXqNhBYUFg6tBOUiY8ek= github.com/briandowns/spinner v1.13.0/go.mod h1:QOuQk7x+EaDASo80FEXwlwiA+j/PPIcX3FScO+3/ZPQ= github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= @@ -1234,6 +1232,8 @@ go.elastic.co/apm/v2 v2.4.3/go.mod h1:+CiBUdrrAGnGCL9TNx7tQz3BrfYV23L8Ljvotoc87s go.elastic.co/fastjson v1.1.0 h1:3MrGBWWVIxe/xvsbpghtkFoPciPhOCmjsR/HfwEeQR4= go.elastic.co/fastjson v1.1.0/go.mod h1:boNGISWMjQsUPy/t6yqt2/1Wx4YNPSe+mZjlyw9vKKI= go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= +go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU= +go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4= go.etcd.io/etcd/api/v3 v3.5.1/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs= go.etcd.io/etcd/client/pkg/v3 v3.5.1/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g= go.etcd.io/etcd/client/v2 v2.305.1/go.mod h1:pMEacxZW7o8pg4CrFE7pquyCJJzZvkvdD2RibOCCCGs= @@ -1522,6 +1522,7 @@ golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200828194041-157a740278f4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201109165425-215b40eba54c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/handbook/business-operations/README.md b/handbook/business-operations/README.md index 9b67e8e627..6bea61ddf0 100644 --- a/handbook/business-operations/README.md +++ b/handbook/business-operations/README.md @@ -86,25 +86,20 @@ Every quarter, payroll and tax filings are due for each state. Gusto can handle Every Friday at 1:00pm CT, we gather hours worked for anyone who gets paid hourly by Fleet. This includes core team members and consultants, regardless of employment classification, and regardless whether inside or outside of the United States. Here's how: -- For every hourly core team member in Gusto or Pilot.co, look up their manager ([who they report to](https://fleetdm.com/handbook/company#org-chart)). -- If any direct report is hourly in Pilot.co and does not submit their hours until the end of the month, still list them, but explain. (See example below.) -- [Consultants](https://fleetdm.com/handbook/business-operations#hiring) don't have a formal reporting structure or manager. Instead, send their hours worked to the CEO, no matter who the consultant is. +- For every hourly core team member in Gusto or Plane.com, find the DRI by looking up ([who they report to](https://fleetdm.com/handbook/company#org-chart)). +- If any direct report is hourly in Plane.com and does not submit their hours until the end of the month, still list them, but explain. +- [Consultants](https://fleetdm.com/handbook/business-operations#hiring) don't have a formal reporting structure. They submit their hours through Gusto (US consultants) or Plane.com (international consultants), which require approval from a DRI. You can find the DRI by looking at the Business Operations [KPIs](https://docs.google.com/spreadsheets/d/1Hso0LxqwrRVINCyW_n436bNHmoqhoLhC8bcbvLPOs9A/edit#gid=0) -Then, send **the CEO** and **each manager** a direct message in Slack by copying and pasting the following template: +Then, send the DRI a direct message in Slack with a screenshot of the HRIS portal, showing hours logged since last Saturday at midnight and tasks undertaken during those hours (if provided). Ensure the screenshot does not contain any compensation information. -> Here are the hours worked by your direct reports since last Saturday at midnight (YYYY-MM-DD): -> - šŸ§‘ā€šŸš€ Alice Bobberson: 21.25 -> - šŸ§‘ā€šŸš€ Charles David: 3.5 -> - šŸ§‘ā€šŸš€ Philippe Timebender: (hours not available until they invoice at the end of the month) -> -> And here are the hours worked by consultants: -> - šŸ’ Bombalurina: 0 -> - šŸ’ Jennyanydots: 0 -> - šŸ’ Skimbleshanks: 19 -> - šŸ’ Griza Bella: 0 > > More info: https://fleetdm.com/handbook/business-operations#inform-managers-about-hours-worked +### Change the DRI of a consultant + +1. In the [KPIs](https://docs.google.com/spreadsheets/d/1Hso0LxqwrRVINCyW_n436bNHmoqhoLhC8bcbvLPOs9A/edit#gid=0) sheet, find the consultant's column. +2. Change the DRI documented there to the new DRI who will receive information about the consultant's hours. + ### Run US contractor payroll For Fleet's US contractors, running payroll is a manual process: @@ -147,6 +142,37 @@ When a Fleetie, consultant or advisor requests an update to their personnel deta - If required, BizOps also makes changes to other core systems (e.g: creating a new email alias in google workspace; updating details in Carta; etc). - The change is now actioned, notify the team member and close the issue. + +### Change a Fleetie's job title +When BizOps receives notification of a Fleetie's job title changing, follow these steps to ensure accurate recording of the change across our systems. +- Update the ["šŸ§‘ā€šŸš€ Fleeties"](https://docs.google.com/spreadsheets/d/1OSLn-ZCbGSjPusHPiR5dwQhheH1K8-xqyZdsOe9y7qc/edit#gid=0) spreadsheet: + - Search the spreadsheet for the Fleetie in need of a job title change. + - Input the new job title in the Fleetie's row in the "Job title" cell. + - Navigate to the "Org chart" tab of the spreadsheet, and verify that the Fleetie's title appears correctly in the org chart. +- Update the relevant HRIS system. + - For updating Gusto (US-based Fleeties): + - Login to Gusto and navigate to "People > Team members". + - Find the Fleetie and select them to see their profile page. + - Under the "Compensation" heading, select edit and update the "Job title" and input the specific date the change happened. Save the changes. + - For updating Plane (non-US Fleeties): + - Login to Plane and navigate to "People > Team". + - Find the Fleetie and select them to see their profile page. + - Use the "Help" function, or email support@plane.com to notify Plane of the need to change the job title for the Fleetie. Include the Fleetie's name, current title, new title, and effective date. + - Take any relevant steps as directed by Plane in order to make the required changes to the Fleetie's profile. + + +### Change a Fleetie's manager +When BizOps receives notification of a Fleetie's manager changing, follow these steps to ensure correct recording in our systems. +- Update the [Fleeties](https://docs.google.com/spreadsheets/d/1OSLn-ZCbGSjPusHPiR5dwQhheH1K8-xqyZdsOe9y7qc/edit#gid=0). + - Search for the Fleetie's new manager, and copy the new manager's unique ID from the far left "Unique ID" column. + - Search for the Fleetie who's manager is changing, and paste (without formatting) their new manager's unique ID in the "Reports to: (manager unique ID)" cell in the Fleetie's row. + - Verify that the "Reports to (auto: manager name and job title)" cell in the Fleetie's row reflects the new manager's details. + - Verify that in the new manager's row, the "# direct reports" cell reflect the correct number. + - Navigate to the "Org chart" tab in the spreadsheet, and verify that the Fleetie now appears in the correct place in the org chart. +> **Note:** The Fleeties spreadsheet is the source of truth for this information, and any other systems reflecting reporting lines should be disregarded. + + + ### Prepare salary benchmarking information - Use the relevant template text in the README section of the [¶¶ šŸ’Œ Compensation decisions document](https://docs.google.com/document/d/1NQ-IjcOTbyFluCWqsFLMfP4SvnopoXDcX0civ-STS5c/edit?usp=sharing) for a current Fleetie, a new role, a prospective hire, or other benchmarking use case. - Copy the template text and paste at the end of the document. diff --git a/handbook/company/communications.md b/handbook/company/communications.md index 5d07fd9a88..9a65a98a09 100644 --- a/handbook/company/communications.md +++ b/handbook/company/communications.md @@ -253,6 +253,7 @@ Labels with a `#g-` prefix refer to a kanban board. Since it is best practice to > - `bug-` Defect category. For example, `bug-enrollment`, `bug-profiles-sync`, `bug-policies`. This allows us to track the areas of the product producing the most bugs. > - `story` A user story. > - `prospect-` A customer prospect. +> - `P-` A [priority level](https://fleetdm.com/handbook/company/product-groups#high-priority-user-stories-and-bugs). > - `Epic` Do not use. _(TODO: ZenHub automatically recreates this label when we group sub-tasks inside of a user story. Find a way to remove this. It is an artifact from Zenhub and not something we actually want to exist or use, as it is confusing.)_ ### Process new requests diff --git a/handbook/company/pricing-features-table.yml b/handbook/company/pricing-features-table.yml index 21d7d3cecb..eea9427e37 100644 --- a/handbook/company/pricing-features-table.yml +++ b/handbook/company/pricing-features-table.yml @@ -253,8 +253,8 @@ waysToUse: - description: - industryName: Incident response - friendlyName: # TODO: live query, triage, figuring out scope of impact, remediate using scripts, MDM commands (e.g. remote wipe), and quarantine or reimage using other systems and APIs (e.g. remove from network, decommission container) - description: + friendlyName: Interrogate hosts in real time + description: Live query, triage, figuring out scope of impact, remediate using scripts or MDM commands (e.g. remote wipe), and quarantine or reimage using other systems and APIs (e.g. remove from network, decommission container) documentationUrl: tier: Free dri: mikermcneil @@ -276,7 +276,7 @@ usualDepartment: Security productCategories: [Endpoint operations] pricingTableCategories: [Endpoint operations] - comingSoonOn: YYYY-MM-DD + comingSoonOn: 2025-06-30 buzzwords: [Mandatory Access Control (MAC),Privilege confinement,Binary authorization,Santa,Binary allowlisting,Binary whitelisting] demos: - description: @@ -838,6 +838,7 @@ usualDepartment: Security productCategories: [Endpoint operations] pricingTableCategories: [Endpoint operations] + buzzwords: [Real-time export,Ship logs] waysToUse: - description: Choose different file sizes for automated query results and agent logs. Coming soon (2024-01-26) #Customer-blanco moreInfoUrl: https://github.com/fleetdm/fleet/issues/11999 diff --git a/handbook/company/product-groups.md b/handbook/company/product-groups.md index 72b3cecd80..9e04e7264d 100644 --- a/handbook/company/product-groups.md +++ b/handbook/company/product-groups.md @@ -6,7 +6,6 @@ When creating software, handoffs between teams or contributors are one of the mo > - Write down philosophies and show how the pieces of the development process fit together on this "šŸ›©ļø Product groups" page. > - Use the dedicated [departmental](https://fleetdm.com/handbook/company#org-chart) handbook pages for [šŸš€ Engineering](https://fleetdm.com/handbook/engineering) and [🦢 Product Design](https://fleetdm.com/handbook/product) to keep track of specific, rote responsibilities and recurring rituals designed to be read and used only by people within those departments. - ## Product roadmap Fleet team members can read [Fleet's high-level product goals and planned releases for the current quarter and the next quarter](https://docs.google.com/document/d/11XEb__EJoGQJE9hXwaLrN45_5_k1NCi-zlJKH-OlKKk/edit#heading=h.33k3ii7z7ubc) (confidential Google Doc). @@ -26,7 +25,6 @@ At Fleet, [anyone can contribute](https://fleetdm.com/handbook/company#openness) | [Endpoint ops](#endpoint-ops-group) | Increase and exceed maturity in the "Endpoint operations" category. | 74 | | [MDM](#mdm-group) | Reach maturity in the "MDM" product category. | 52 | - \* The number of estimated story points this group can take on per-sprint under ideal circumstances, used as a baseline number for planning and prioritizing user stories for drafting. In reality, capacity will vary as engineers are on-call, out-of-office, filling in for other product groups, etc. > _**What happened to "CX"?** The customer experience (CX) group at Fleet is now [`#g-endpoint-ops`](#endpoint-ops-group)._ @@ -141,17 +139,6 @@ User stories are small and independently valuable. - Is it small enough? Will this task be likely to fit in 1 sprint when estimated? - Is it valuable enough? Will this task drive business value when released, independent of other tasks? - -#### Engineering-initiated stories - -Engineering-initiated stories are types of user stories created by engineers to make technical changes to Fleet. Technical changes should improve the user experience or contributor experience. For example, optimizing SQL that improves the response time of an API endpoint improves user experience by reducing latency. A script that generates common boilerplate, or automated tests to cover important business logic, improves the quality of life for contributors, making them happier and more productive, resulting in faster delivery of features to our customers. - -It is important to frame engineering-initiated user stories the same way we frame all user stories. Stay focused on how this technical change will drive value for our users. - -To [create an engineering-initiated user story](https://fleetdm.com/handbook/engineering#creating-an-engineering-initiated-story), follow the [user story drafting process](https://fleetdm.com/handbook/company/development-groups#drafting). Once your user story is created using the [new story template](https://github.com/fleetdm/fleet/issues/new?assignees=&labels=story,~engineering-initiated&projects=&template=story.md&title=), add the `~engineering-initiated` label, assign it to yourself, and bring to your EM to be considered for future prioritization into a sprint. The engineering output and architecture DRI is responsible for prioritizing engineering-initiated stories. - -> We prefer the term engineering-initiated stories over technical debt because the user story format helps keep us focused on our users. - #### Defining "done" To successfully deliver a user story, the people working on it need to know what "done" means. @@ -287,6 +274,21 @@ All unreleased bugs are addressed before publishing a release. Released bugs tha - Causes irreversible damage, such as data loss - Introduces a security vulnerability +### Notify the community about a critical bug +We inform customers and the community about critical bugs immediately so they don’t trigger it themselves. When a bug meeting the definition of critical is found, the bug finder is responsible for raising an alarm. Raising an alarm means pinging @here in the #help-product-design channel with the filed bug. + +If the bug finder is not a Fleetie (e.g., a member of the community), then whoever sees the critical bug should raise the alarm. Note that the bug finder here is NOT necessarily the **first** person who sees the bug. If you come across a bug you think is critical, but it has not been escalated, raise the alarm! + +Once raised, product design confirms whether or not it's critical and defines expected behavior. When outside of working hours for the product design team or if no one from product design responds within 1 hour, then fall back to the #help-p1 channel. + +Once the critical bug is confirmed, a [priority label](https://fleetdm.com/handbook/company/product-groups#high-priority-user-stories-and-bugs) is applied and the priority response process begins. Customer Success notifies impacted customers and the community if community features are impacted. If Customer Success is not available, the on-call engineer or infrastructure on-call engineer is responsible for this. If a quick fix workaround exists, that should be communicated as well for those who are already upgraded. + +The relevant release page on GitHub is updated to indicate that the release contains a critical bug, as shown on the [fleet-v4.45.0 release page](https://github.com/fleetdm/fleet/releases/tag/fleet-v4.45.0). + +When a critical bug is identified, we will then follow the patch release process in [our documentation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Releasing-Fleet.md#patch-releases). + +> After a critical bug is fixed, [an incident postmortem](https://fleetdm.com/handbook/engineering#preform-an-incident-postmortem) is scheduled by the EM of the product group that fixed the bug. + ## Feature fest To stay in-sync with our customers' needs, Fleet accepts feature requests from customers and community members on a sprint-by-sprint basis in the regular šŸŽšŸ—£ Feature Fest meeting. Anyone in the company is invited to submit requests or simply listen in on the šŸŽšŸ—£ Feature Fest meeting. Folks from the wider community can also [request an invite](https://fleetdm.com/contact). @@ -428,6 +430,28 @@ Fleet [always prioritizes bugs](https://fleetdm.com/handbook/product#prioritizin #### Awaiting QA Bugs will be verified as fixed by QA when they are placed in the "Awaiting QA" column of the relevant product group's sprint board. If the bug is verified as fixed, it is moved to the "Ready for release" column of the sprint board. Otherwise, the remaining issues are noted in a comment, and it is moved back to the "In progress" column of the sprint board. +## High priority user stories and bugs +All issues are treated as standard priority by default. Some issues are assigned a priority label to indicate urgency for the business. + +1. Emergency: `P0` +- Examples: Customer outage, confirmed security vulnerability ([critical bug](https://fleetdm.com/handbook/company/product-groups#release-testing)), a new feature is needed to address an immediate business emergency. +- Response: Immediately stop other work to swarm the issue. Work 24/7 in shifts until resolved. +- Impact: Significant impact. May void current sprint. + +2. Critical: `P1` +- Examples: A supported workflow is broken ([critical bug](https://fleetdm.com/handbook/company/product-groups#release-testing)), a potential security vulnerability, a new feature is required to address an immediate critical business need. +- Response: Issue brought to next standup for estimation and immediately brought into the sprint. Necessary team members are assigned as their top priority. +- Impact: High impact. Does not void sprint, but reduces overall velocity and requires deprioritizing other work. + +3. Urgent: `P2` +- Examples: A supported workflow is not functioning as intended, a newly drafted feature has an associated urgent business need. +- Response: Issue is prioritized at the top of the next sprint. If opporunity cost of waiting for the next sprint is too high, it may be considered for current sprint. +- Impact: Low to medium impact. If prioritized into current sprint, may reduce overall velocity and require deprioritizing other work. + +Add as much context as possible to the issue description and assign labels to help the team understand the problem and what is driving the urgency. All issues with a `P0`, `P1`, or `P2` label should be assigned to the [DRI for what goes in a release](https://fleetdm.com/handbook/company/communications#directly-responsible-individuals-dris). For immediate action, follow up on Slack or by phone. + +Once the release DRI is aware of the issue, they will adjust the labels as needed and assign to the PM and EM of the appropriate product group. If they disagree with the priority label applied to the issue, they will contact the requestor to discuss further. + ## How to reach the developer on-call Oncall engineers do not need to actively monitor Slack channels, except when called in by the Community or Customer teams. Members of those teams are instructed to `@oncall` in `#help-engineering` to get the attention of the on-call engineer to continue discussing any issues that come up. In some cases, the Community or Customer representative will continue to communicate with the requestor. In others, the on-call engineer will communicate directly (team members should use their judgment and discuss on a case-by-case basis how to best communicate with community members and customers). @@ -464,13 +488,11 @@ The on-call developer is encouraged to attend some of the customer success meeti This has a dual purpose of providing more context for how our customers use Fleet. The developer should actively participate and provide input where appropriate (if not sure, please ask your manager or organizer of the call). - - **Documentation for contributors** Fleet's documentation for contributors can be found in the [Fleet GitHub repo](https://github.com/fleetdm/fleet/tree/main/docs/Contributing). The on-call developer is asked to read, understand, test, correct, and improve at least one doc page per week. Our goal is to 1, ensure accuracy and verify that our deployment guides and tutorials are up to date and work as expected. And 2, improve the readability, consistency, and simplicity of our documentation – with empathy towards first-time users. See [Writing documentation](https://fleetdm.com/handbook/marketing#writing-documentation) for writing guidelines, and don't hesitate to reach out to [#g-digital-experience](https://fleetdm.slack.com/archives/C01GQUZ91TN) on Slack for writing support. A backlog of documentation improvement needs is kept [here](https://github.com/fleetdm/fleet/issues?q=is%3Aopen+is%3Aissue+label%3A%22%3Aimprove+documentation%22). - ### Escalations When the on-call developer is unsure of the answer, they should follow this process for escalation. @@ -482,7 +504,6 @@ How to escalate: 2. Create a new thread in the [#help-engineering channel](https://fleetdm.slack.com/archives/C019WG4GH0A), tagging `@zwass` and provide the information turned up in your research. Please include possibly relevant links (even if you didn't find what you were looking for there). Zach will work with you to craft an appropriate answer or find another team member who can help. - ### Changing of the guard The on-call developer changes each week on Wednesday. @@ -509,8 +530,7 @@ In the Slack reminder thread, the on-call developer includes their retrospective ## Wireframes - Showing these principles and ideas, to help remember the pros and cons and conceptualize the above visually. - - - Figma: [āš—ļø Fleet product project](https://www.figma.com/files/project/17318630/%E2%9A%97%EF%B8%8F-Fleet-product?fuid=1234929285759903870) +- Figma: [āš—ļø Fleet product project](https://www.figma.com/files/project/17318630/%E2%9A%97%EF%B8%8F-Fleet-product?fuid=1234929285759903870) We have certain design conventions that we include in Fleet. We will document more of these over time. @@ -581,22 +601,6 @@ OPTIONS ## Meetings - - ### Design consultation Design consultations are scheduled as needed with the relevant participants, typically product designers and frontend engineers. It is an opportunity to collaborate and discuss design, implementation, and story requirements. The meeting is scheduled as needed by the product designer or frontend engineer when a user story is in the "Prioritized" column on the [drafting board](https://app.zenhub.com/workspaces/-drafting-ships-in-6-weeks-6192dd66ea2562000faea25c/board). @@ -662,6 +654,30 @@ QA has weekly check-in with product to go over the inbox items. QA is responsibl QA may also propose that a reported bug is not actually a bug. A bug is defined as ā€œbehavior that is not according to spec or implied by spec.ā€ If agreed that it is not a bug, then it's assigned to the relevant product manager to determine its priority. +### Group weeklies +A chance for deeper, synchronous discussion on topics relevant across product groups like ā€œFrontend weeklyā€, ā€œBackend weeklyā€, etc. + +**Participants:** Anyone who wishes to participate. + +**Sample agenda from frontend weekly** +- Discuss common patterns and conventions in the codebase +- Review difficult frontend bugs +- Write engineering-initiated stories + +### Eng Together +This meeting is to disseminate engineering-wide announcements, promote cohesion across groups within the engineering team, and connect with engineers (and the "engineering-curious") in other departments. Held monthly for one hour. + +**Participants:** Everyone at the company is welcome to attend. All engineers are asked to attend. The subject matter is focused on engineering. + +**Agenda:** +- Announcements +- Engineering KPIs review +- ā€œTech talksā€ + - At least one member from each product group demos or discusses a technical subject relevant to engineering at Fleet. + - Everyone is welcome to present on a technical topic. Add your name and tech talk subject in the agenda doc included in the Eng Together calendar event. +- Social + - Structured and/or unstructured social activities + ## Development best practices - Remember the user. What would you do if you saw that error message? [šŸ”“](https://fleetdm.com/handbook/company#empathy) - Communicate any blockers ASAP in your group Slack channel or standup. [🟠](https://fleetdm.com/handbook/company#ownership) diff --git a/handbook/company/testimonials.yml b/handbook/company/testimonials.yml index a8038d8ec4..60570cd430 100644 --- a/handbook/company/testimonials.yml +++ b/handbook/company/testimonials.yml @@ -149,4 +149,12 @@ quoteAuthorName: Chandra Majumdar quoteAuthorProfileImageFilename: testimonial-author-chandra-majumdar-48x48@2x.png quoteAuthorJobTitle: Partner - Cyber and Strategic Risk - productCategories: [Vulnerability management, Endpoint operations] \ No newline at end of file + productCategories: [Vulnerability management, Endpoint operations] +- + quote: This is not just production osquery, but actually a way bigger opportunity than even something like Airwatch or Jamf. + quoteImageFilename: logo-flock-safety-907x132@2x.png + quoteLinkUrl: https://www.linkedin.com/in/mrerictan/ + quoteAuthorName: Eric Tan + quoteAuthorProfileImageFilename: testimonial-author-eric-tan-99x99@2x.png + quoteAuthorJobTitle: CIO & Chief Security Officer at Flock Safety + productCategories: [Device management, Endpoint operations] diff --git a/handbook/demand/README.md b/handbook/demand/README.md index 058bc93977..61a1051075 100644 --- a/handbook/demand/README.md +++ b/handbook/demand/README.md @@ -61,15 +61,11 @@ The Head of Demand is the DRI for deploying Fleet's event budget, and events are ### Optimize ads through experimentation Fleet improves click-through rates in their campaigns to make the most of their advertising budget and attract more engaged users, boosting product adoption and community participation. -[**Google Ads**:](https://ads.google.com/home/) Ad experimentation on Google Ads for better click-through rate (CTR) -1. Duplicate the ad with the highest CTR. (If there are already two ads running, pause the ad with the lower CTR.) -2. Test one headline change for two weeks -3. Pause the ad with the lower CTR -4. Duplicate best-performing ad -5. Test one description change for two weeks -6. Pause the ad with the lower CTR -7. Repeat this process +Here's how: +1. Remove all but the top 5 performing ads in [each LinkedIn campaign in the "Evergreen awareness" campaign group](https://www.linkedin.com/campaignmanager/accounts/509911695/campaigns?campaignGroupIds=%5B678329963%5D). +2. Replace the ad in [each Linkedin campaign in the "ABM maneuvers" campaign group](https://www.linkedin.com/campaignmanager/accounts/509911695/campaigns?campaignGroupIds=%5B633848103%5D) so that it matches the top performer. + + ### Process pending swag requests from the website On a weekly basis, log in to [Printful](https://www.printful.com/) and complete any draft orders so that they are sent. We currently only ship within the 48 contiguous United States and approve one free order per household under normal circumstances. diff --git a/handbook/engineering/README.md b/handbook/engineering/README.md index 41c24079ff..aa8a6c470c 100644 --- a/handbook/engineering/README.md +++ b/handbook/engineering/README.md @@ -27,6 +27,15 @@ The metrics are: Each week these are tracked and shared in the weekly KPI sheet by Luke Heath. +#### Create an engineering-initiated story +Engineering-initiated stories are types of user stories created by engineers to make technical changes to Fleet. Technical changes should improve the user experience or contributor experience. For example, optimizing SQL that improves the response time of an API endpoint improves user experience by reducing latency. A script that generates common boilerplate, or automated tests to cover important business logic, improves the quality of life for contributors, making them happier and more productive, resulting in faster delivery of features to our customers. + +It is important to frame engineering-initiated user stories the same way we frame all user stories. Stay focused on how this technical change will drive value for our users. + +To [create an engineering-initiated user story](https://fleetdm.com/handbook/engineering#creating-an-engineering-initiated-story), follow the [user story drafting process](https://fleetdm.com/handbook/company/development-groups#drafting). Once your user story is created using the [new story template](https://github.com/fleetdm/fleet/issues/new?assignees=&labels=story,~engineering-initiated&projects=&template=story.md&title=), add the `~engineering-initiated` label, assign it to yourself, and bring to your EM to be considered for future prioritization into a sprint. The engineering output and architecture DRI is responsible for prioritizing engineering-initiated stories. + +> We prefer the term engineering-initiated stories over technical debt because the user story format helps keep us focused on our users and contributors. + ### Begin a merge freeze To ensure release quality, Fleet has a freeze period for testing beginning the Tuesday before the release at 9:00 AM Pacific. Effective at the start of the freeze period, new feature work will not be merged into `main`. @@ -180,7 +189,6 @@ When merging a pull request from a community contributor: - Thank and congratulate the contributor. - Share the merged PR with the team in the #help-promote channel of Fleet Slack to be publicized on social media. Those who contribute to Fleet and are recognized for their contributions often become great champions for the project. - ### Schedule developer on-call workload Engineering managers are asked to be aware of the [on-call rotation](https://docs.google.com/document/d/1FNQdu23wc1S9Yo6x5k04uxT2RwT77CIMzLLeEI2U7JA/edit#) and schedule a light workload for engineers while they are on-call. While it varies week to week considerably, the on-call responsibilities can sometimes take up a substantial portion of the engineer's time. @@ -203,21 +211,6 @@ The on-call developer is responsible for: - [Escalating community questions and issues](https://fleetdm.com/handbook/company/product-groups#escalations). - Successfully [transferring the on-call persona to the next developer](https://fleetdm.com/handbook/company/product-groups#changing-of-the-guard). -### Notify community members about a critical bug - -We inform customers and the community about critical bugs immediately so they don’t trigger it themselves. When a bug meeting the definition of critical is found, the bug finder is responsible for raising an alarm. Raising an alarm means pinging @here in the #help-product-design channel with the filed bug. - -If the bug finder is not a Fleetie (e.g., a member of the community), then whoever sees the critical bug should raise the alarm. (We would expect this to be Customer success in the community Slack or QA in the bug inbox, though it could be anyone.) Note that the bug finder here is NOT necessarily the **first** person who sees the bug. If you come across a bug you think is critical, but it has not been escalated, raise the alarm! - -Once raised, product confirms whether or not it's critical and defines expected behavior. -When outside of working hours for the product team or if no one from product responds within 1 hour, then fall back to the #help-p1. - -Once the critical bug is confirmed, Customer success needs to ping both customers and the community to warn them. If Customer success is not available, the on-call engineer is responsible for doing this. If a quick fix workaround exists, that should be communicated as well for those who are already upgraded. - -When a critical bug is identified, we will then follow the patch release process in [our documentation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Releasing-Fleet.md#patch-releases). - -> After a critical bug is fixed, [an incident postmortem](https://fleetdm.com/handbook/engineering#preform-an-incident-postmortem) is scheduled by the EM of the product group that fixed the bug. - ### Notify stakeholders when a user story is pushed to the next release [User stories](https://fleetdm.com/handbook/company/product-groups#scrum-items) are intended to be completed in a single sprint. When a user story selected for a release has not merged into `main` by the time the [merge freeze](https://fleetdm.com/handbook/engineering#begin-a-merge-freeze) begins, it is the product group EM's responsibility to notify stakeholders: @@ -334,40 +327,40 @@ Please see [handbook/engineering#notify-community-members-about-a-critical-bug]( Please see [handbook/engineering#run-fleet-locally-for-qa-purposes](https://fleetdm.com/handbook/engineering#run-fleet-localy-for-qa-purposes) ##### Scrum at Fleet -Please see [handbook/company/product-groups#engineering-initiated-stories](https://fleetdm.com/handbook/company/product-groups#scrum-at-fleet) +Please see [handbook/company/engineering#create-an-engineering-initiated-story](https://fleetdm.com/handbook/company/product-groups#scrum-at-fleet) ##### Scrum items -Please see [handbook/company/product-groups#engineering-initiated-stories](https://fleetdm.com/handbook/company/product-groups#scrum-items) +Please see [handbook/company/engineering#create-an-engineering-initiated-story](https://fleetdm.com/handbook/company/product-groups#scrum-items) ##### Sprint ceremonies -Please see [handbook/company/product-groups#engineering-initiated-stories](https://fleetdm.com/handbook/company/product-groups#sprint-ceremonies) +Please see [handbook/company/engineering#create-an-engineering-initiated-story](https://fleetdm.com/handbook/company/product-groups#sprint-ceremonies) ##### Meetings -Please see [handbook/company/product-groups#engineering-initiated-stories](https://fleetdm.com/handbook/company/product-groups#meetings) +Please see [handbook/company/engineering#create-an-engineering-initiated-story](https://fleetdm.com/handbook/company/product-groups#meetings) ##### Principles -Please see [handbook/company/product-groups#engineering-initiated-stories](https://fleetdm.com/handbook/company/product-groups#principles) +Please see [handbook/company/engineering#create-an-engineering-initiated-story](https://fleetdm.com/handbook/company/product-groups#principles) -Please see [handbook/company/product-groups#engineering-initiated-stories](https://fleetdm.com/handbook/company/product-groups#eng-together) for **below** +Please see [handbook/company/engineering#create-an-engineering-initiated-story](https://fleetdm.com/handbook/company/product-groups#eng-together) for **below** ##### Eng Together ##### Participants ##### Agenda -Please see [handbook/company/product-groups#engineering-initiated-stories](https://fleetdm.com/handbook/company/product-groups#eng-together) for **above** +Please see [handbook/company/engineering#create-an-engineering-initiated-story](https://fleetdm.com/handbook/company/product-groups#eng-together) for **above** -Please see [handbook/company/product-groups#engineering-initiated-stories](https://fleetdm.com/handbook/company/product-groups#group-weeklies) for **below** +Please see [handbook/company/engineering#create-an-engineering-initiated-story](https://fleetdm.com/handbook/company/product-groups#group-weeklies) for **below** ##### User story discovery ##### Participants ##### Agenda -Please see [handbook/company/product-groups#engineering-initiated-stories](https://fleetdm.com/handbook/company/product-groups#group-weeklies) for **above** +Please see [handbook/company/engineering#create-an-engineering-initiated-story](https://fleetdm.com/handbook/company/product-groups#group-weeklies) for **above** -Please see [handbook/company/product-groups#engineering-initiated-stories](https://fleetdm.com/handbook/company/product-groups#group-weeklies) for **below** +Please see [handbook/company/engineering#create-an-engineering-initiated-story](https://fleetdm.com/handbook/company/product-groups#group-weeklies) for **below** ##### Group weeklies ##### Participants ##### Sample agenda (Frontend weekly) -Please see [handbook/company/product-groups#engineering-initiated-stories](https://fleetdm.com/handbook/company/product-groups#group-weeklies) for **above** +Please see [handbook/company/engineering#create-an-engineering-initiated-story](https://fleetdm.com/handbook/company/product-groups#group-weeklies) for **above** ##### Engineering-initiated stories -Please see [handbook/company/product-groups#engineering-initiated-stories](https://fleetdm.com/handbook/company/product-groups#engineering-initiated-stories) +Please see [handbook/company/engineering#create-an-engineering-initiated-story](https://fleetdm.com/handbook/company/engineering#create-an-engineering-initiated-story) ##### Creating an engineering-initiated story Please see [handbook/engineering#create-an-engineering-initiated-user-story](https://fleetdm.com/handbook/engineering#create-an-engineering-initiated-user-story) diff --git a/handbook/product-design/README.md b/handbook/product-design/README.md index 5d09c497d4..21e61fe871 100644 --- a/handbook/product-design/README.md +++ b/handbook/product-design/README.md @@ -185,35 +185,6 @@ Some of the data is forwarded to [Datadog](https://us5.datadoghq.com/dashboard/7 ## Rituals - - #### Stubs The following stubs are included only to make links backward compatible. diff --git a/handbook/product-design/product-design.rituals.yml b/handbook/product-design/product-design.rituals.yml index a00a6ef9e0..020b1e4949 100644 --- a/handbook/product-design/product-design.rituals.yml +++ b/handbook/product-design/product-design.rituals.yml @@ -8,3 +8,67 @@ autoIssue: # Enables automation of GitHub issues labels: [ "#g-endpoint-ops" ] # label to be applied to issue repo: "confidential" +- + task: "Design sprint review" + startedOn: "2024-03-07" + frequency: "Triweekly" + description: "Clear out the drafting board of all issues that are not estimated but leave the items we want to take in the next sprint on the drafting board. Record the number of dropped stories for KPIs (all user stories that did not meet the 3 week drafting timeline)." + moreInfoUrl: + dri: "noahtalerman" +- + task: "šŸŽ Feature fest" + startedOn: "2024-03-07" + frequency: "Triweekly" + description: "We make a decision regarding which customer and community feature requests can be committed to in the next six weeks." + moreInfoUrl: + dri: "noahtalerman" +- + task: "Design sprint kickoff" + startedOn: "2024-03-07" + frequency: "Triweekly" + description: "Add stories prioritized during Feature fest to Drafting board, assign stories to product designers, and align on priorities." + moreInfoUrl: + dri: "noahtalerman" +- + task: "Sprint kickoff review" + startedOn: "2024-03-07" + frequency: "Triweekly" + description: "Identify stories that did not make it into this sprint and remove them from the board. Notify relevant requesters/stakeholders. Ensure bugs have been effectively prioritized across teams. Recommend highlights for next release notes. Record the number of drops for KPI reporting. Consider product group staffing. Are we scheduling what we prioritized? Did we finish what we scheduled in the sprint? (Look at org chart.)" + moreInfoUrl: + dri: "noahtalerman" +- + task: "šŸ¦¢šŸ—£ Design review (#g-mdm)" + startedOn: "2024-03-07" + frequency: "Daily" + description: "Contributors present wireframes (UI changes) that are ā€œReady for reviewā€. Head of Product Design provides feedback on UI/CLI/API changes." + moreInfoUrl: "https://docs.google.com/document/d/1AduqZ9yuMQ8uvC5Z6GJFJtE0pbdqdX9zHIau_VCOqGI/edit#" + dri: "marko-lisica" +- + task: "šŸ¦¢šŸ—£ Design review (#g-endpoint-ops)" + startedOn: "2024-03-07" + frequency: "Daily" + description: "Contributors present wireframes (UI changes) that are ā€œReady for reviewā€. Head of Product Design provides feedback on UI/CLI/API changes." + moreInfoUrl: "https://docs.google.com/document/d/1_lIlhaIpR7yYZz-sp8iE5OIipDgrq7AdZTkeoz3Mf9c/edit#" + dri: "rachaelshaw" +- + task: "Maintenance" + 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 #help-product-design Slack channel." + moreInfoUrl: + dri: "noahtalerman" +- + task: "Product confirm and celebrate" + startedOn: "2024-02-27" + frequency: "Weekly" + description: "Review user stories we shipped but haven't closed/ Confirm all the loose ends are tied up: docs, internal and external comms, guides, pricing page, transparency page, user permissions." + moreInfoUrl: + dri: "noahtalerman" +- + task: "Pre-sprint prioritization" + startedOn: "2024-02-27" + frequency: "Triweekly" + description: "The Head of Product Design and each group's EM meet before each sprint to align on priorities and discuss what stories weren't completed in the previous sprint. Head of Product Design records number of stories that weren't completed in the ā€œStories droppedā€ product KPI." + moreInfoUrl: + dri: "noahtalerman" + diff --git a/infrastructure/dogfood/terraform/aws/variables.tf b/infrastructure/dogfood/terraform/aws/variables.tf index dc5e1f6af9..629417a5bb 100644 --- a/infrastructure/dogfood/terraform/aws/variables.tf +++ b/infrastructure/dogfood/terraform/aws/variables.tf @@ -56,7 +56,7 @@ variable "database_name" { variable "fleet_image" { description = "the name of the container image to run" - default = "fleetdm/fleet:v4.45.0" + default = "fleetdm/fleet:v4.46.1" } variable "software_inventory" { diff --git a/infrastructure/dogfood/terraform/gcp/variables.tf b/infrastructure/dogfood/terraform/gcp/variables.tf index c859c1a39a..fd2c64f6e4 100644 --- a/infrastructure/dogfood/terraform/gcp/variables.tf +++ b/infrastructure/dogfood/terraform/gcp/variables.tf @@ -68,5 +68,5 @@ variable "redis_mem" { } variable "image" { - default = "fleet:v4.45.0" + default = "fleet:v4.46.1" } diff --git a/infrastructure/loadtesting/terraform/enroll.sh b/infrastructure/loadtesting/terraform/enroll.sh new file mode 100755 index 0000000000..c1db645f27 --- /dev/null +++ b/infrastructure/loadtesting/terraform/enroll.sh @@ -0,0 +1,33 @@ +#!/bin/bash + +# Script for enrolling osquery-perf hosts by `terraform apply`ing in increments of 8 `loadtest` containers. +# NOTE(lucas): This is the currently known configuration that won't tip the loadtest environment, +# but maybe in the future we can be more aggressive (and reduce enroll time). +# +# ./enroll.sh my-branch 8 240 + +BRANCH_NAME=$1 +START_INDEX=$2 +END_INDEX=$3 +INCREMENT=8 +SLEEP_TIME_SECONDS=60 + +if [ -z "$BRANCH_NAME" ]; then + echo "Missing BRANCH_NAME" +fi +if [ -z "$START_INDEX" ]; then + echo "Missing START_INDEX" +fi +if [ -z "$END_INDEX" ]; then + echo "Missing END_INDEX" +fi + +# We add this check to avoid terraform (error-prone) locking in case of typos. +read -p "You will use BRANCH_NAME=$BRANCH_NAME. Continue? " + +set -x + +for (( c=$START_INDEX; c<=$END_INDEX; c+=$INCREMENT )); do + terraform apply -var tag=$BRANCH_NAME -var loadtest_containers=$c -auto-approve + sleep $SLEEP_TIME_SECONDS +done diff --git a/it-and-security/default.yml b/it-and-security/default.yml new file mode 100644 index 0000000000..5a5a170fef --- /dev/null +++ b/it-and-security/default.yml @@ -0,0 +1,86 @@ +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 + enable_software_inventory: true + fleet_desktop: + transparency_url: https://fleetdm.com/transparency + host_expiry_settings: + host_expiry_enabled: false + host_expiry_window: 7 + integrations: + jira: [ ] + zendesk: [ ] + mdm: + apple_bm_default_team: $DOGFOOD_APPLE_BM_DEFAULT_TEAM + org_info: + contact_url: https://fleetdm.com/company/contact + org_logo_url: "" + org_logo_url_light_background: "" + org_name: Fleet Device Management + secrets: + - secret: $DOGFOOD_GLOBAL_ENROLL_SECRET + server_settings: + debug_host_ids: + - 1 + - 3 + deferred_save_host: false + enable_analytics: true + live_query_disabled: false + query_reports_disabled: false + scripts_disabled: false + server_url: https://dogfood.fleetdm.com + sso_settings: + enable_jit_provisioning: true + enable_jit_role_sync: false + enable_sso: true + enable_sso_idp_login: false + entity_id: dogfood.fleetdm.com + idp_image_url: "" + idp_name: Google + issuer_uri: $DOGFOOD_SSO_ISSUER_URI + metadata: |- + $DOGFOOD_SSO_METADATA + metadata_url: "" + webhook_settings: + failing_policies_webhook: + destination_url: $DOGFOOD_FAILING_POLICIES_WEBHOOK_URL + enable_failing_policies_webhook: true + host_batch_size: 0 + policy_ids: [] + host_status_webhook: + days_count: 1 + destination_url: "" + enable_host_status_webhook: false + host_percentage: 25 + interval: 1m0s + vulnerabilities_webhook: + destination_url: $DOGFOOD_VULNERABILITIES_WEBHOOK_URL + enable_vulnerabilities_webhook: true + host_batch_size: 0 +policies: +queries: + - path: ./lib/collect-fleetd-update-channels.queries.yml diff --git a/it-and-security/lib/agent-options.yml b/it-and-security/lib/agent-options.yml new file mode 100644 index 0000000000..61559952c0 --- /dev/null +++ b/it-and-security/lib/agent-options.yml @@ -0,0 +1,13 @@ +config: + decorators: + load: + - SELECT uuid AS host_uuid FROM system_info; + - SELECT hostname AS hostname FROM system_info; + options: + disable_distributed: false + distributed_interval: 10 + distributed_plugin: tls + distributed_tls_max_attempts: 3 + logger_tls_endpoint: /api/osquery/log + logger_tls_period: 10 + pack_delimiter: / diff --git a/mdm_profiles/automatic_enrollment.json b/it-and-security/lib/automatic-enrollment.dep.json similarity index 100% rename from mdm_profiles/automatic_enrollment.json rename to it-and-security/lib/automatic-enrollment.dep.json diff --git a/it-and-security/lib/collect-failed-login-attempts.queries.yml b/it-and-security/lib/collect-failed-login-attempts.queries.yml new file mode 100644 index 0000000000..a2242b7e74 --- /dev/null +++ b/it-and-security/lib/collect-failed-login-attempts.queries.yml @@ -0,0 +1,14 @@ +- name: Collect failed login attempts + automations_enabled: true + description: Lists the users at least one failed login attempt and timestamp of + failed login. Number of failed login attempts reset to zero after a user successfully + logs in. + discard_data: false + interval: 300 + logging: snapshot + min_osquery_version: "" + observer_can_run: false + platform: "" + query: SELECT users.username, account_policy_data.failed_login_count, account_policy_data.failed_login_timestamp + FROM users INNER JOIN account_policy_data using (uid) WHERE account_policy_data.failed_login_count + > 0; \ No newline at end of file diff --git a/it-and-security/lib/collect-fleetd-logs.sh b/it-and-security/lib/collect-fleetd-logs.sh new file mode 100644 index 0000000000..887af2ace6 --- /dev/null +++ b/it-and-security/lib/collect-fleetd-logs.sh @@ -0,0 +1,7 @@ +cp /var/log/orbit/orbit.stderr.log ~/Library/Logs/Fleet/fleet-desktop.log /Users/Shared + +echo "Successfully copied fleetd logs to the /Users/Shared folder." + +echo "To retrieve logs, ask the end user to open Finder and in the menu bar select Go > Go to Folder." + +echo "Then, ask the end user to type in /Users/Shared, press Return, and locate orbit.stderr.log (Orbit logs) and fleet-desktop.log (Fleet Desktop logs) files." \ No newline at end of file diff --git a/it-and-security/lib/collect-fleetd-update-channels.queries.yml b/it-and-security/lib/collect-fleetd-update-channels.queries.yml new file mode 100644 index 0000000000..a90ea083ab --- /dev/null +++ b/it-and-security/lib/collect-fleetd-update-channels.queries.yml @@ -0,0 +1,7 @@ +- name: Collect fleetd update channels + description: "Collects the update channels for all fleetd components: osquery, Orbit, and Fleet Desktop. To see which version number each channel is on, ask in #help-engineering." + query: SELECT desktop_channel, orbit_channel, osqueryd_channel FROM orbit_info; + interval: 300 # 5 minutes + observer_can_run: true + automations_enabled: false + platform: darwin,linux,windows diff --git a/it-and-security/lib/collect-usb-devices.queries.yml b/it-and-security/lib/collect-usb-devices.queries.yml new file mode 100644 index 0000000000..ab2b40732e --- /dev/null +++ b/it-and-security/lib/collect-usb-devices.queries.yml @@ -0,0 +1,10 @@ +- name: Collect USB devices + automations_enabled: false + description: Collects the USB devices that are currently connected to macOS and Linux hosts. + discard_data: false + interval: 300 + logging: snapshot + min_osquery_version: "" + observer_can_run: true + platform: "" + query: SELECT model, vendor FROM usb_devices; \ No newline at end of file diff --git a/it-and-security/lib/collect-vs-code-extensions.queries.yml b/it-and-security/lib/collect-vs-code-extensions.queries.yml new file mode 100644 index 0000000000..29f97a2f7f --- /dev/null +++ b/it-and-security/lib/collect-vs-code-extensions.queries.yml @@ -0,0 +1,12 @@ +- name: Collect Visual Studio (VS) Code extensions + automations_enabled: false + description: Collects the name, publisher, and version of the VS Code extensions + installed on hosts. + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + observer_can_run: false + platform: "" + query: SELECT extension.name, extension.publisher, extension.version FROM users + JOIN vscode_extensions extension USING (uid); \ No newline at end of file diff --git a/mdm_profiles/automatic_updates.mobileconfig b/it-and-security/lib/configuration-profiles/macos-automatic-updates.mobileconfig similarity index 100% rename from mdm_profiles/automatic_updates.mobileconfig rename to it-and-security/lib/configuration-profiles/macos-automatic-updates.mobileconfig diff --git a/mdm_profiles/chrome_enrollment.mobileconfig b/it-and-security/lib/configuration-profiles/macos-chrome-enrollment.mobileconfig similarity index 96% rename from mdm_profiles/chrome_enrollment.mobileconfig rename to it-and-security/lib/configuration-profiles/macos-chrome-enrollment.mobileconfig index af28f4690a..ba4f3a09da 100644 --- a/mdm_profiles/chrome_enrollment.mobileconfig +++ b/it-and-security/lib/configuration-profiles/macos-chrome-enrollment.mobileconfig @@ -6,7 +6,7 @@ CloudManagementEnrollmentToken - $CLOUD_MANAGEMENT_ENROLLMENT_TOKEN + $MANAGED_CHROME_ENROLLMENT_TOKEN CloudReportingEnabled PayloadDisplayName diff --git a/mdm_profiles/time_and_date.mobileconfig b/it-and-security/lib/configuration-profiles/macos-date-time.mobileconfig similarity index 100% rename from mdm_profiles/time_and_date.mobileconfig rename to it-and-security/lib/configuration-profiles/macos-date-time.mobileconfig diff --git a/mdm_profiles/disable_bluetooth_file_sharing.mobileconfig b/it-and-security/lib/configuration-profiles/macos-disable-bluetooth-file-sharing.mobileconfig similarity index 100% rename from mdm_profiles/disable_bluetooth_file_sharing.mobileconfig rename to it-and-security/lib/configuration-profiles/macos-disable-bluetooth-file-sharing.mobileconfig diff --git a/mdm_profiles/disable_content_caching.mobileconfig b/it-and-security/lib/configuration-profiles/macos-disable-content-caching.mobileconfig similarity index 100% rename from mdm_profiles/disable_content_caching.mobileconfig rename to it-and-security/lib/configuration-profiles/macos-disable-content-caching.mobileconfig diff --git a/mdm_profiles/disable_guest_account.mobileconfig b/it-and-security/lib/configuration-profiles/macos-disable-guest-account.mobileconfig similarity index 100% rename from mdm_profiles/disable_guest_account.mobileconfig rename to it-and-security/lib/configuration-profiles/macos-disable-guest-account.mobileconfig diff --git a/mdm_profiles/disable_guest_shares.mobileconfig b/it-and-security/lib/configuration-profiles/macos-disable-guest-shares.mobileconfig similarity index 100% rename from mdm_profiles/disable_guest_shares.mobileconfig rename to it-and-security/lib/configuration-profiles/macos-disable-guest-shares.mobileconfig diff --git a/mdm_profiles/disable_internet_sharing.mobileconfig b/it-and-security/lib/configuration-profiles/macos-disable-internet-sharing.mobileconfig similarity index 100% rename from mdm_profiles/disable_internet_sharing.mobileconfig rename to it-and-security/lib/configuration-profiles/macos-disable-internet-sharing.mobileconfig diff --git a/mdm_profiles/disable_media_sharing.mobileconfig b/it-and-security/lib/configuration-profiles/macos-disable-media-sharing.mobileconfig similarity index 100% rename from mdm_profiles/disable_media_sharing.mobileconfig rename to it-and-security/lib/configuration-profiles/macos-disable-media-sharing.mobileconfig diff --git a/mdm_profiles/disable_safari_safefiles.mobileconfig b/it-and-security/lib/configuration-profiles/macos-disable-safari-safefiles.mobileconfig similarity index 100% rename from mdm_profiles/disable_safari_safefiles.mobileconfig rename to it-and-security/lib/configuration-profiles/macos-disable-safari-safefiles.mobileconfig diff --git a/mdm_profiles/enable_doh.mobileconfig b/it-and-security/lib/configuration-profiles/macos-enable-doh.mobileconfig similarity index 100% rename from mdm_profiles/enable_doh.mobileconfig rename to it-and-security/lib/configuration-profiles/macos-enable-doh.mobileconfig diff --git a/mdm_profiles/enable_firewall_logging.mobileconfig b/it-and-security/lib/configuration-profiles/macos-enable-firewall-logging.mobileconfig similarity index 100% rename from mdm_profiles/enable_firewall_logging.mobileconfig rename to it-and-security/lib/configuration-profiles/macos-enable-firewall-logging.mobileconfig diff --git a/mdm_profiles/enable_gatekeeper.mobileconfig b/it-and-security/lib/configuration-profiles/macos-enable-gatekeeper.mobileconfig similarity index 100% rename from mdm_profiles/enable_gatekeeper.mobileconfig rename to it-and-security/lib/configuration-profiles/macos-enable-gatekeeper.mobileconfig diff --git a/mdm_profiles/enforce_library_validation.mobileconfig b/it-and-security/lib/configuration-profiles/macos-enforce-library-validation.mobileconfig similarity index 100% rename from mdm_profiles/enforce_library_validation.mobileconfig rename to it-and-security/lib/configuration-profiles/macos-enforce-library-validation.mobileconfig diff --git a/mdm_profiles/firewall.mobileconfig b/it-and-security/lib/configuration-profiles/macos-firewall.mobileconfig similarity index 100% rename from mdm_profiles/firewall.mobileconfig rename to it-and-security/lib/configuration-profiles/macos-firewall.mobileconfig diff --git a/mdm_profiles/full_disk_access_for_orbit.mobileconfig b/it-and-security/lib/configuration-profiles/macos-full-disk-access-for-fleetd.mobileconfig similarity index 100% rename from mdm_profiles/full_disk_access_for_orbit.mobileconfig rename to it-and-security/lib/configuration-profiles/macos-full-disk-access-for-fleetd.mobileconfig diff --git a/mdm_profiles/limit_ad_tracking.mobileconfig b/it-and-security/lib/configuration-profiles/macos-limit-ad-tracking.mobileconfig similarity index 100% rename from mdm_profiles/limit_ad_tracking.mobileconfig rename to it-and-security/lib/configuration-profiles/macos-limit-ad-tracking.mobileconfig diff --git a/mdm_profiles/misc.mobileconfig b/it-and-security/lib/configuration-profiles/macos-misc.mobileconfig similarity index 100% rename from mdm_profiles/misc.mobileconfig rename to it-and-security/lib/configuration-profiles/macos-misc.mobileconfig diff --git a/mdm_profiles/password_policy.mobileconfig b/it-and-security/lib/configuration-profiles/macos-password.mobileconfig similarity index 100% rename from mdm_profiles/password_policy.mobileconfig rename to it-and-security/lib/configuration-profiles/macos-password.mobileconfig diff --git a/mdm_profiles/prevent_autologon.mobileconfig b/it-and-security/lib/configuration-profiles/macos-prevent-autologon.mobileconfig similarity index 100% rename from mdm_profiles/prevent_autologon.mobileconfig rename to it-and-security/lib/configuration-profiles/macos-prevent-autologon.mobileconfig diff --git a/mdm_profiles/secure_terminal_keyboard.mobileconfig b/it-and-security/lib/configuration-profiles/macos-secure-terminal-keyboard.mobileconfig similarity index 100% rename from mdm_profiles/secure_terminal_keyboard.mobileconfig rename to it-and-security/lib/configuration-profiles/macos-secure-terminal-keyboard.mobileconfig diff --git a/it-and-security/lib/explore-data.queries.yml b/it-and-security/lib/explore-data.queries.yml new file mode 100644 index 0000000000..deb070644d --- /dev/null +++ b/it-and-security/lib/explore-data.queries.yml @@ -0,0 +1,3210 @@ +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - account_policy_data' + observer_can_run: false + platform: "" + query: SELECT * FROM account_policy_data; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - ad_config' + observer_can_run: false + platform: "" + query: SELECT * FROM ad_config; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - alf' + observer_can_run: false + platform: "" + query: SELECT * FROM alf; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - alf_exceptions' + observer_can_run: false + platform: "" + query: SELECT * FROM alf_exceptions; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - alf_explicit_auths' + observer_can_run: false + platform: "" + query: SELECT * FROM alf_explicit_auths; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - apfs_physical_stores' + observer_can_run: false + platform: "" + query: SELECT * FROM apfs_physical_stores; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - apfs_volumes' + observer_can_run: false + platform: "" + query: SELECT * FROM apfs_volumes; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - app_icons' + observer_can_run: false + platform: "" + query: SELECT * FROM app_icons; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - app_schemes' + observer_can_run: false + platform: "" + query: SELECT * FROM app_schemes; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - apparmor_events' + observer_can_run: false + platform: "" + query: SELECT * FROM apparmor_events; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - apparmor_profiles' + observer_can_run: false + platform: "" + query: SELECT * FROM apparmor_profiles; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - appcompat_shims' + observer_can_run: false + platform: "" + query: SELECT * FROM appcompat_shims; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - apps' + observer_can_run: false + platform: "" + query: SELECT * FROM apps; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - apt_sources' + observer_can_run: false + platform: "" + query: SELECT * FROM apt_sources; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - arp_cache' + observer_can_run: false + platform: "" + query: SELECT * FROM arp_cache; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - asl' + observer_can_run: false + platform: "" + query: SELECT * FROM asl; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - atom_packages' + observer_can_run: false + platform: "" + query: SELECT * FROM atom_packages; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - augeas' + observer_can_run: false + platform: "" + query: SELECT * FROM augeas; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - authdb' + observer_can_run: false + platform: "" + query: SELECT * FROM authdb; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - authenticode' + observer_can_run: false + platform: "" + query: SELECT * FROM authenticode; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - authorization_mechanisms' + observer_can_run: false + platform: "" + query: SELECT * FROM authorization_mechanisms; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - authorizations' + observer_can_run: false + platform: "" + query: SELECT * FROM authorizations; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - authorized_keys' + observer_can_run: false + platform: "" + query: SELECT * FROM authorized_keys; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - autoexec' + observer_can_run: false + platform: "" + query: SELECT * FROM autoexec; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - azure_instance_metadata' + observer_can_run: false + platform: "" + query: SELECT * FROM azure_instance_metadata; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - azure_instance_tags' + observer_can_run: false + platform: "" + query: SELECT * FROM azure_instance_tags; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - background_activities_moderator' + observer_can_run: false + platform: "" + query: SELECT * FROM background_activities_moderator; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - battery' + observer_can_run: false + platform: "" + query: SELECT * FROM battery; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - bitlocker_info' + observer_can_run: false + platform: "" + query: SELECT * FROM bitlocker_info; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - block_devices' + observer_can_run: false + platform: "" + query: SELECT * FROM block_devices; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - bpf_process_events' + observer_can_run: false + platform: "" + query: SELECT * FROM bpf_process_events; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - bpf_socket_events' + observer_can_run: false + platform: "" + query: SELECT * FROM bpf_socket_events; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - carbon_black_info' + observer_can_run: false + platform: "" + query: SELECT * FROM carbon_black_info; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - carves' + observer_can_run: false + platform: "" + query: SELECT * FROM carves; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - certificates' + observer_can_run: false + platform: "" + query: SELECT * FROM certificates; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - chassis_info' + observer_can_run: false + platform: "" + query: SELECT * FROM chassis_info; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - chocolatey_packages' + observer_can_run: false + platform: "" + query: SELECT * FROM chocolatey_packages; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - chrome_extension_content_scripts' + observer_can_run: false + platform: "" + query: SELECT * FROM chrome_extension_content_scripts; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - chrome_extensions' + observer_can_run: false + platform: "" + query: SELECT * FROM chrome_extensions; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - cis_audit' + observer_can_run: false + platform: "" + query: SELECT * FROM cis_audit; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - connected_displays' + observer_can_run: false + platform: "" + query: SELECT * FROM connected_displays; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - connectivity' + observer_can_run: false + platform: "" + query: SELECT * FROM connectivity; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - corestorage_logical_volume_families' + observer_can_run: false + platform: "" + query: SELECT * FROM corestorage_logical_volume_families; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - corestorage_logical_volumes' + observer_can_run: false + platform: "" + query: SELECT * FROM corestorage_logical_volumes; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - cpu_info' + observer_can_run: false + platform: "" + query: SELECT * FROM cpu_info; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - cpu_time' + observer_can_run: false + platform: "" + query: SELECT * FROM cpu_time; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - cpuid' + observer_can_run: false + platform: "" + query: SELECT * FROM cpuid; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - crashes' + observer_can_run: false + platform: "" + query: SELECT * FROM crashes; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - crontab' + observer_can_run: false + platform: "" + query: SELECT * FROM crontab; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - cryptoinfo' + observer_can_run: false + platform: "" + query: SELECT * FROM cryptoinfo; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - cryptsetup_status' + observer_can_run: false + platform: "" + query: SELECT * FROM cryptsetup_status; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - csrutil_info' + observer_can_run: false + platform: "" + query: SELECT * FROM csrutil_info; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - cups_destinations' + observer_can_run: false + platform: "" + query: SELECT * FROM cups_destinations; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - cups_jobs' + observer_can_run: false + platform: "" + query: SELECT * FROM cups_jobs; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - curl' + observer_can_run: false + platform: "" + query: SELECT * FROM curl; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - curl_certificate' + observer_can_run: false + platform: "" + query: SELECT * FROM curl_certificate; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - deb_packages' + observer_can_run: false + platform: "" + query: SELECT * FROM deb_packages; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - default_environment' + observer_can_run: false + platform: "" + query: SELECT * FROM default_environment; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - device_file' + observer_can_run: false + platform: "" + query: SELECT * FROM device_file; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - device_firmware' + observer_can_run: false + platform: "" + query: SELECT * FROM device_firmware; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - device_hash' + observer_can_run: false + platform: "" + query: SELECT * FROM device_hash; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - device_partitions' + observer_can_run: false + platform: "" + query: SELECT * FROM device_partitions; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - disk_encryption' + observer_can_run: false + platform: "" + query: SELECT * FROM disk_encryption; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - disk_events' + observer_can_run: false + platform: "" + query: SELECT * FROM disk_events; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - disk_info' + observer_can_run: false + platform: "" + query: SELECT * FROM disk_info; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - dns_cache' + observer_can_run: false + platform: "" + query: SELECT * FROM dns_cache; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - dns_resolvers' + observer_can_run: false + platform: "" + query: SELECT * FROM dns_resolvers; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - docker_container_envs' + observer_can_run: false + platform: "" + query: SELECT * FROM docker_container_envs; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - docker_container_fs_changes' + observer_can_run: false + platform: "" + query: SELECT * FROM docker_container_fs_changes; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - docker_container_labels' + observer_can_run: false + platform: "" + query: SELECT * FROM docker_container_labels; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - docker_container_mounts' + observer_can_run: false + platform: "" + query: SELECT * FROM docker_container_mounts; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - docker_container_networks' + observer_can_run: false + platform: "" + query: SELECT * FROM docker_container_networks; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - docker_container_ports' + observer_can_run: false + platform: "" + query: SELECT * FROM docker_container_ports; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - docker_container_processes' + observer_can_run: false + platform: "" + query: SELECT * FROM docker_container_processes; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - docker_container_stats' + observer_can_run: false + platform: "" + query: SELECT * FROM docker_container_stats; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - docker_containers' + observer_can_run: false + platform: "" + query: SELECT * FROM docker_containers; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - docker_image_history' + observer_can_run: false + platform: "" + query: SELECT * FROM docker_image_history; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - docker_image_labels' + observer_can_run: false + platform: "" + query: SELECT * FROM docker_image_labels; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - docker_image_layers' + observer_can_run: false + platform: "" + query: SELECT * FROM docker_image_layers; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - docker_images' + observer_can_run: false + platform: "" + query: SELECT * FROM docker_images; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - docker_info' + observer_can_run: false + platform: "" + query: SELECT * FROM docker_info; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - docker_network_labels' + observer_can_run: false + platform: "" + query: SELECT * FROM docker_network_labels; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - docker_networks' + observer_can_run: false + platform: "" + query: SELECT * FROM docker_networks; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - docker_version' + observer_can_run: false + platform: "" + query: SELECT * FROM docker_version; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - docker_volume_labels' + observer_can_run: false + platform: "" + query: SELECT * FROM docker_volume_labels; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - docker_volumes' + observer_can_run: false + platform: "" + query: SELECT * FROM docker_volumes; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - drivers' + observer_can_run: false + platform: "" + query: SELECT * FROM drivers; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - dscl' + observer_can_run: false + platform: "" + query: SELECT * FROM dscl; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - ec2_instance_metadata' + observer_can_run: false + platform: "" + query: SELECT * FROM ec2_instance_metadata; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - ec2_instance_tags' + observer_can_run: false + platform: "" + query: SELECT * FROM ec2_instance_tags; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - es_process_events' + observer_can_run: false + platform: "" + query: SELECT * FROM es_process_events; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - es_process_file_events' + observer_can_run: false + platform: "" + query: SELECT * FROM es_process_file_events; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - etc_hosts' + observer_can_run: false + platform: "" + query: SELECT * FROM etc_hosts; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - etc_protocols' + observer_can_run: false + platform: "" + query: SELECT * FROM etc_protocols; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - etc_services' + observer_can_run: false + platform: "" + query: SELECT * FROM etc_services; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - event_taps' + observer_can_run: false + platform: "" + query: SELECT * FROM event_taps; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - extended_attributes' + observer_can_run: false + platform: "" + query: SELECT * FROM extended_attributes; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - falcon_kernel_check' + observer_can_run: false + platform: "" + query: SELECT * FROM falcon_kernel_check; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - falconctl_options' + observer_can_run: false + platform: "" + query: SELECT * FROM falconctl_options; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - fan_speed_sensors' + observer_can_run: false + platform: "" + query: SELECT * FROM fan_speed_sensors; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - file' + observer_can_run: false + platform: "" + query: SELECT * FROM file; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - file_events' + observer_can_run: false + platform: "" + query: SELECT * FROM file_events; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - file_lines' + observer_can_run: false + platform: "" + query: SELECT * FROM file_lines; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - filevault_prk' + observer_can_run: false + platform: "" + query: SELECT * FROM filevault_prk; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - filevault_status' + observer_can_run: false + platform: "" + query: SELECT * FROM filevault_status; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - filevault_users' + observer_can_run: false + platform: "" + query: SELECT * FROM filevault_users; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - find_cmd' + observer_can_run: false + platform: "" + query: SELECT * FROM find_cmd; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - firefox_addons' + observer_can_run: false + platform: "" + query: SELECT * FROM firefox_addons; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - firefox_preferences' + observer_can_run: false + platform: "" + query: SELECT * FROM firefox_preferences; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - firmware_eficheck_integrity_check' + observer_can_run: false + platform: "" + query: SELECT * FROM firmware_eficheck_integrity_check; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - firmwarepasswd' + observer_can_run: false + platform: "" + query: SELECT * FROM firmwarepasswd; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - gatekeeper' + observer_can_run: false + platform: "" + query: SELECT * FROM gatekeeper; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - gatekeeper_approved_apps' + observer_can_run: false + platform: "" + query: SELECT * FROM gatekeeper_approved_apps; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - geolocation' + observer_can_run: false + platform: "" + query: SELECT * FROM geolocation; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - google_chrome_profiles' + observer_can_run: false + platform: "" + query: SELECT * FROM google_chrome_profiles; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - groups' + observer_can_run: false + platform: "" + query: SELECT * FROM groups; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - hardware_events' + observer_can_run: false + platform: "" + query: SELECT * FROM hardware_events; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - hash' + observer_can_run: false + platform: "" + query: SELECT * FROM hash; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - homebrew_packages' + observer_can_run: false + platform: "" + query: SELECT * FROM homebrew_packages; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - hvci_status' + observer_can_run: false + platform: "" + query: SELECT * FROM hvci_status; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - ibridge_info' + observer_can_run: false + platform: "" + query: SELECT * FROM ibridge_info; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - icloud_private_relay' + observer_can_run: false + platform: "" + query: SELECT * FROM icloud_private_relay; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - ie_extensions' + observer_can_run: false + platform: "" + query: SELECT * FROM ie_extensions; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - intel_me_info' + observer_can_run: false + platform: "" + query: SELECT * FROM intel_me_info; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - interface_addresses' + observer_can_run: false + platform: "" + query: SELECT * FROM interface_addresses; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - interface_details' + observer_can_run: false + platform: "" + query: SELECT * FROM interface_details; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - interface_ipv6' + observer_can_run: false + platform: "" + query: SELECT * FROM interface_ipv6; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - iokit_devicetree' + observer_can_run: false + platform: "" + query: SELECT * FROM iokit_devicetree; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - iokit_registry' + observer_can_run: false + platform: "" + query: SELECT * FROM iokit_registry; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - ioreg' + observer_can_run: false + platform: "" + query: SELECT * FROM ioreg; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - kernel_extensions' + observer_can_run: false + platform: "" + query: SELECT * FROM kernel_extensions; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - kernel_info' + observer_can_run: false + platform: "" + query: SELECT * FROM kernel_info; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - kernel_keys' + observer_can_run: false + platform: "" + query: SELECT * FROM kernel_keys; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - kernel_modules' + observer_can_run: false + platform: "" + query: SELECT * FROM kernel_modules; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - kernel_panics' + observer_can_run: false + platform: "" + query: SELECT * FROM kernel_panics; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - keychain_acls' + observer_can_run: false + platform: "" + query: SELECT * FROM keychain_acls; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - keychain_items' + observer_can_run: false + platform: "" + query: SELECT * FROM keychain_items; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - known_hosts' + observer_can_run: false + platform: "" + query: SELECT * FROM known_hosts; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - kva_speculative_info' + observer_can_run: false + platform: "" + query: SELECT * FROM kva_speculative_info; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - last' + observer_can_run: false + platform: "" + query: SELECT * FROM last; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - launchd' + observer_can_run: false + platform: "" + query: SELECT * FROM launchd; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - launchd_overrides' + observer_can_run: false + platform: "" + query: SELECT * FROM launchd_overrides; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - listening_ports' + observer_can_run: false + platform: "" + query: SELECT * FROM listening_ports; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - load_average' + observer_can_run: false + platform: "" + query: SELECT * FROM load_average; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - location_services' + observer_can_run: false + platform: "" + query: SELECT * FROM location_services; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - logged_in_users' + observer_can_run: false + platform: "" + query: SELECT * FROM logged_in_users; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - logical_drives' + observer_can_run: false + platform: "" + query: SELECT * FROM logical_drives; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - logon_sessions' + observer_can_run: false + platform: "" + query: SELECT * FROM logon_sessions; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - lxd_certificates' + observer_can_run: false + platform: "" + query: SELECT * FROM lxd_certificates; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - lxd_cluster' + observer_can_run: false + platform: "" + query: SELECT * FROM lxd_cluster; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - lxd_cluster_members' + observer_can_run: false + platform: "" + query: SELECT * FROM lxd_cluster_members; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - lxd_images' + observer_can_run: false + platform: "" + query: SELECT * FROM lxd_images; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - lxd_instance_config' + observer_can_run: false + platform: "" + query: SELECT * FROM lxd_instance_config; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - lxd_instance_devices' + observer_can_run: false + platform: "" + query: SELECT * FROM lxd_instance_devices; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - lxd_instances' + observer_can_run: false + platform: "" + query: SELECT * FROM lxd_instances; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - lxd_networks' + observer_can_run: false + platform: "" + query: SELECT * FROM lxd_networks; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - lxd_storage_pools' + observer_can_run: false + platform: "" + query: SELECT * FROM lxd_storage_pools; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - macadmins_unified_log' + observer_can_run: false + platform: "" + query: SELECT * FROM macadmins_unified_log; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - macos_profiles' + observer_can_run: false + platform: "" + query: SELECT * FROM macos_profiles; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - macos_rsr' + observer_can_run: false + platform: "" + query: SELECT * FROM macos_rsr; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - magic' + observer_can_run: false + platform: "" + query: SELECT * FROM magic; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - managed_policies' + observer_can_run: false + platform: "" + query: SELECT * FROM managed_policies; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - md_devices' + observer_can_run: false + platform: "" + query: SELECT * FROM md_devices; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - md_drives' + observer_can_run: false + platform: "" + query: SELECT * FROM md_drives; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - md_personalities' + observer_can_run: false + platform: "" + query: SELECT * FROM md_personalities; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - mdfind' + observer_can_run: false + platform: "" + query: SELECT * FROM mdfind; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - mdls' + observer_can_run: false + platform: "" + query: SELECT * FROM mdls; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - mdm' + observer_can_run: false + platform: "" + query: SELECT * FROM mdm; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - mdm_bridge' + observer_can_run: false + platform: "" + query: SELECT * FROM mdm_bridge; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - memory_array_mapped_addresses' + observer_can_run: false + platform: "" + query: SELECT * FROM memory_array_mapped_addresses; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - memory_arrays' + observer_can_run: false + platform: "" + query: SELECT * FROM memory_arrays; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - memory_device_mapped_addresses' + observer_can_run: false + platform: "" + query: SELECT * FROM memory_device_mapped_addresses; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - memory_devices' + observer_can_run: false + platform: "" + query: SELECT * FROM memory_devices; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - memory_error_info' + observer_can_run: false + platform: "" + query: SELECT * FROM memory_error_info; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - memory_info' + observer_can_run: false + platform: "" + query: SELECT * FROM memory_info; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - memory_map' + observer_can_run: false + platform: "" + query: SELECT * FROM memory_map; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - mounts' + observer_can_run: false + platform: "" + query: SELECT * FROM mounts; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - msr' + observer_can_run: false + platform: "" + query: SELECT * FROM msr; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - munki_info' + observer_can_run: false + platform: "" + query: SELECT * FROM munki_info; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - munki_installs' + observer_can_run: false + platform: "" + query: SELECT * FROM munki_installs; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - network_interfaces' + observer_can_run: false + platform: "" + query: SELECT * FROM network_interfaces; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - nfs_shares' + observer_can_run: false + platform: "" + query: SELECT * FROM nfs_shares; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - npm_packages' + observer_can_run: false + platform: "" + query: SELECT * FROM npm_packages; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - ntdomains' + observer_can_run: false + platform: "" + query: SELECT * FROM ntdomains; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - ntfs_acl_permissions' + observer_can_run: false + platform: "" + query: SELECT * FROM ntfs_acl_permissions; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - ntfs_journal_events' + observer_can_run: false + platform: "" + query: SELECT * FROM ntfs_journal_events; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - nvram' + observer_can_run: false + platform: "" + query: SELECT * FROM nvram; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - nvram_info' + observer_can_run: false + platform: "" + query: SELECT * FROM nvram_info; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - oem_strings' + observer_can_run: false + platform: "" + query: SELECT * FROM oem_strings; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - office_mru' + observer_can_run: false + platform: "" + query: SELECT * FROM office_mru; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - orbit_info' + observer_can_run: false + platform: "" + query: SELECT * FROM orbit_info; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - os_version' + observer_can_run: false + platform: "" + query: SELECT * FROM os_version; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - osquery_events' + observer_can_run: false + platform: "" + query: SELECT * FROM osquery_events; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - osquery_extensions' + observer_can_run: false + platform: "" + query: SELECT * FROM osquery_extensions; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - osquery_flags' + observer_can_run: false + platform: "" + query: SELECT * FROM osquery_flags; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - osquery_info' + observer_can_run: false + platform: "" + query: SELECT * FROM osquery_info; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - osquery_packs' + observer_can_run: false + platform: "" + query: SELECT * FROM osquery_packs; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - osquery_registry' + observer_can_run: false + platform: "" + query: SELECT * FROM osquery_registry; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - osquery_schedule' + observer_can_run: false + platform: "" + query: SELECT * FROM osquery_schedule; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - package_bom' + observer_can_run: false + platform: "" + query: SELECT * FROM package_bom; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - package_install_history' + observer_can_run: false + platform: "" + query: SELECT * FROM package_install_history; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - package_receipts' + observer_can_run: false + platform: "" + query: SELECT * FROM package_receipts; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - password_policy' + observer_can_run: false + platform: "" + query: SELECT * FROM password_policy; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - patches' + observer_can_run: false + platform: "" + query: SELECT * FROM patches; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - pci_devices' + observer_can_run: false + platform: "" + query: SELECT * FROM pci_devices; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - physical_disk_performance' + observer_can_run: false + platform: "" + query: SELECT * FROM physical_disk_performance; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - pipes' + observer_can_run: false + platform: "" + query: SELECT * FROM pipes; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - platform_info' + observer_can_run: false + platform: "" + query: SELECT * FROM platform_info; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - plist' + observer_can_run: false + platform: "" + query: SELECT * FROM plist; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - pmset' + observer_can_run: false + platform: "" + query: SELECT * FROM pmset; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - portage_keywords' + observer_can_run: false + platform: "" + query: SELECT * FROM portage_keywords; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - portage_packages' + observer_can_run: false + platform: "" + query: SELECT * FROM portage_packages; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - portage_use' + observer_can_run: false + platform: "" + query: SELECT * FROM portage_use; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - power_sensors' + observer_can_run: false + platform: "" + query: SELECT * FROM power_sensors; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - powershell_events' + observer_can_run: false + platform: "" + query: SELECT * FROM powershell_events; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - preferences' + observer_can_run: false + platform: "" + query: SELECT * FROM preferences; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - prefetch' + observer_can_run: false + platform: "" + query: SELECT * FROM prefetch; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - privacy_preferences' + observer_can_run: false + platform: "" + query: SELECT * FROM privacy_preferences; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - process_envs' + observer_can_run: false + platform: "" + query: SELECT * FROM process_envs; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - process_etw_events' + observer_can_run: false + platform: "" + query: SELECT * FROM process_etw_events; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - process_events' + observer_can_run: false + platform: "" + query: SELECT * FROM process_events; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - process_file_events' + observer_can_run: false + platform: "" + query: SELECT * FROM process_file_events; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - process_memory_map' + observer_can_run: false + platform: "" + query: SELECT * FROM process_memory_map; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - process_namespaces' + observer_can_run: false + platform: "" + query: SELECT * FROM process_namespaces; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - process_open_files' + observer_can_run: false + platform: "" + query: SELECT * FROM process_open_files; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - process_open_pipes' + observer_can_run: false + platform: "" + query: SELECT * FROM process_open_pipes; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - process_open_sockets' + observer_can_run: false + platform: "" + query: SELECT * FROM process_open_sockets; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - processes' + observer_can_run: false + platform: "" + query: SELECT * FROM processes; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - programs' + observer_can_run: false + platform: "" + query: SELECT * FROM programs; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - prometheus_metrics' + observer_can_run: false + platform: "" + query: SELECT * FROM prometheus_metrics; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - puppet_info' + observer_can_run: false + platform: "" + query: SELECT * FROM puppet_info; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - puppet_logs' + observer_can_run: false + platform: "" + query: SELECT * FROM puppet_logs; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - puppet_state' + observer_can_run: false + platform: "" + query: SELECT * FROM puppet_state; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - pwd_policy' + observer_can_run: false + platform: "" + query: SELECT * FROM pwd_policy; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - python_packages' + observer_can_run: false + platform: "" + query: SELECT * FROM python_packages; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - quicklook_cache' + observer_can_run: false + platform: "" + query: SELECT * FROM quicklook_cache; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - registry' + observer_can_run: false + platform: "" + query: SELECT * FROM registry; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - routes' + observer_can_run: false + platform: "" + query: SELECT * FROM routes; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - rpm_package_files' + observer_can_run: false + platform: "" + query: SELECT * FROM rpm_package_files; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - rpm_packages' + observer_can_run: false + platform: "" + query: SELECT * FROM rpm_packages; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - running_apps' + observer_can_run: false + platform: "" + query: SELECT * FROM running_apps; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - safari_extensions' + observer_can_run: false + platform: "" + query: SELECT * FROM safari_extensions; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - sandboxes' + observer_can_run: false + platform: "" + query: SELECT * FROM sandboxes; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - scheduled_tasks' + observer_can_run: false + platform: "" + query: SELECT * FROM scheduled_tasks; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - screenlock' + observer_can_run: false + platform: "" + query: SELECT * FROM screenlock; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - seccomp_events' + observer_can_run: false + platform: "" + query: SELECT * FROM seccomp_events; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - secureboot' + observer_can_run: false + platform: "" + query: SELECT * FROM secureboot; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - security_profile_info' + observer_can_run: false + platform: "" + query: SELECT * FROM security_profile_info; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - selinux_events' + observer_can_run: false + platform: "" + query: SELECT * FROM selinux_events; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - selinux_settings' + observer_can_run: false + platform: "" + query: SELECT * FROM selinux_settings; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - services' + observer_can_run: false + platform: "" + query: SELECT * FROM services; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - shadow' + observer_can_run: false + platform: "" + query: SELECT * FROM shadow; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - shared_folders' + observer_can_run: false + platform: "" + query: SELECT * FROM shared_folders; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - shared_memory' + observer_can_run: false + platform: "" + query: SELECT * FROM shared_memory; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - shared_resources' + observer_can_run: false + platform: "" + query: SELECT * FROM shared_resources; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - sharing_preferences' + observer_can_run: false + platform: "" + query: SELECT * FROM sharing_preferences; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - shell_history' + observer_can_run: false + platform: "" + query: SELECT * FROM shell_history; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - shellbags' + observer_can_run: false + platform: "" + query: SELECT * FROM shellbags; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - shimcache' + observer_can_run: false + platform: "" + query: SELECT * FROM shimcache; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - signature' + observer_can_run: false + platform: "" + query: SELECT * FROM signature; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - sip_config' + observer_can_run: false + platform: "" + query: SELECT * FROM sip_config; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - smbios_tables' + observer_can_run: false + platform: "" + query: SELECT * FROM smbios_tables; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - smc_keys' + observer_can_run: false + platform: "" + query: SELECT * FROM smc_keys; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - sntp_request' + observer_can_run: false + platform: "" + query: SELECT * FROM sntp_request; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - socket_events' + observer_can_run: false + platform: "" + query: SELECT * FROM socket_events; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - software_update' + observer_can_run: false + platform: "" + query: SELECT * FROM software_update; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - ssh_configs' + observer_can_run: false + platform: "" + query: SELECT * FROM ssh_configs; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - startup_items' + observer_can_run: false + platform: "" + query: SELECT * FROM startup_items; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - sudo_info' + observer_can_run: false + platform: "" + query: SELECT * FROM sudo_info; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - sudoers' + observer_can_run: false + platform: "" + query: SELECT * FROM sudoers; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - suid_bin' + observer_can_run: false + platform: "" + query: SELECT * FROM suid_bin; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - syslog_events' + observer_can_run: false + platform: "" + query: SELECT * FROM syslog_events; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - system_controls' + observer_can_run: false + platform: "" + query: SELECT * FROM system_controls; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - system_extensions' + observer_can_run: false + platform: "" + query: SELECT * FROM system_extensions; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - system_info' + observer_can_run: false + platform: "" + query: SELECT * FROM system_info; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - system_state' + observer_can_run: false + platform: "" + query: SELECT * FROM system_state; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - systemd_units' + observer_can_run: false + platform: "" + query: SELECT * FROM systemd_units; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - temperature_sensors' + observer_can_run: false + platform: "" + query: SELECT * FROM temperature_sensors; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - time' + observer_can_run: false + platform: "" + query: SELECT * FROM time; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - time_machine_backups' + observer_can_run: false + platform: "" + query: SELECT * FROM time_machine_backups; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - time_machine_destinations' + observer_can_run: false + platform: "" + query: SELECT * FROM time_machine_destinations; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - tpm_info' + observer_can_run: false + platform: "" + query: SELECT * FROM tpm_info; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - ulimit_info' + observer_can_run: false + platform: "" + query: SELECT * FROM ulimit_info; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - unified_log' + observer_can_run: false + platform: "" + query: SELECT * FROM unified_log; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - uptime' + observer_can_run: false + platform: "" + query: SELECT * FROM uptime; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - usb_devices' + observer_can_run: false + platform: "" + query: SELECT * FROM usb_devices; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - user_events' + observer_can_run: false + platform: "" + query: SELECT * FROM user_events; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - user_groups' + observer_can_run: false + platform: "" + query: SELECT * FROM user_groups; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - user_interaction_events' + observer_can_run: false + platform: "" + query: SELECT * FROM user_interaction_events; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - user_login_settings' + observer_can_run: false + platform: "" + query: SELECT * FROM user_login_settings; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - user_ssh_keys' + observer_can_run: false + platform: "" + query: SELECT * FROM user_ssh_keys; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - userassist' + observer_can_run: false + platform: "" + query: SELECT * FROM userassist; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - users' + observer_can_run: false + platform: "" + query: SELECT * FROM users; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - video_info' + observer_can_run: false + platform: "" + query: SELECT * FROM video_info; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - virtual_memory_info' + observer_can_run: false + platform: "" + query: SELECT * FROM virtual_memory_info; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - wifi_networks' + observer_can_run: false + platform: "" + query: SELECT * FROM wifi_networks; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - wifi_status' + observer_can_run: false + platform: "" + query: SELECT * FROM wifi_status; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - wifi_survey' + observer_can_run: false + platform: "" + query: SELECT * FROM wifi_survey; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - winbaseobj' + observer_can_run: false + platform: "" + query: SELECT * FROM winbaseobj; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - windows_crashes' + observer_can_run: false + platform: "" + query: SELECT * FROM windows_crashes; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - windows_eventlog' + observer_can_run: false + platform: "" + query: SELECT * FROM windows_eventlog; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - windows_events' + observer_can_run: false + platform: "" + query: SELECT * FROM windows_events; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - windows_firewall_rules' + observer_can_run: false + platform: "" + query: SELECT * FROM windows_firewall_rules; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - windows_optional_features' + observer_can_run: false + platform: "" + query: SELECT * FROM windows_optional_features; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - windows_search' + observer_can_run: false + platform: "" + query: SELECT * FROM windows_search; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - windows_security_center' + observer_can_run: false + platform: "" + query: SELECT * FROM windows_security_center; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - windows_security_products' + observer_can_run: false + platform: "" + query: SELECT * FROM windows_security_products; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - windows_update_history' + observer_can_run: false + platform: "" + query: SELECT * FROM windows_update_history; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - windows_updates' + observer_can_run: false + platform: "" + query: SELECT * FROM windows_updates; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - wmi_bios_info' + observer_can_run: false + platform: "" + query: SELECT * FROM wmi_bios_info; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - wmi_cli_event_consumers' + observer_can_run: false + platform: "" + query: SELECT * FROM wmi_cli_event_consumers; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - wmi_event_filters' + observer_can_run: false + platform: "" + query: SELECT * FROM wmi_event_filters; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - wmi_filter_consumer_binding' + observer_can_run: false + platform: "" + query: SELECT * FROM wmi_filter_consumer_binding; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - wmi_script_event_consumers' + observer_can_run: false + platform: "" + query: SELECT * FROM wmi_script_event_consumers; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - xprotect_entries' + observer_can_run: false + platform: "" + query: SELECT * FROM xprotect_entries; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - xprotect_meta' + observer_can_run: false + platform: "" + query: SELECT * FROM xprotect_meta; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - xprotect_reports' + observer_can_run: false + platform: "" + query: SELECT * FROM xprotect_reports; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - yara' + observer_can_run: false + platform: "" + query: SELECT * FROM yara; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - yara_events' + observer_can_run: false + platform: "" + query: SELECT * FROM yara_events; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - ycloud_instance_metadata' + observer_can_run: false + platform: "" + query: SELECT * FROM ycloud_instance_metadata; +- automations_enabled: true + description: "" + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + name: '[Explore data] - yum_sources' + observer_can_run: false + platform: "" + query: SELECT * FROM yum_sources; diff --git a/it-and-security/lib/linux-device-health.policies.yml b/it-and-security/lib/linux-device-health.policies.yml new file mode 100644 index 0000000000..0d9e2f8aa2 --- /dev/null +++ b/it-and-security/lib/linux-device-health.policies.yml @@ -0,0 +1,6 @@ +- name: Linux - Enable disk encryption + query: SELECT 1 FROM disk_encryption WHERE encrypted=1 AND name LIKE '/dev/dm-1'; + critical: false + description: This policy checks if disk encryption is enabled. + resolution: As an IT admin, deploy an image that includes disk encryption. + platform: linux \ No newline at end of file diff --git a/it-and-security/lib/macos-device-health.policies.yml b/it-and-security/lib/macos-device-health.policies.yml new file mode 100644 index 0000000000..85d046a84f --- /dev/null +++ b/it-and-security/lib/macos-device-health.policies.yml @@ -0,0 +1,55 @@ +- name: macOS - Enable FileVault + query: SELECT 1 FROM filevault_status WHERE status = 'FileVault is On.'; + critical: false + description: This policy checks if FileVault (disk encryption) is enabled. + resolution: As an IT admin, turn on disk encryption in Fleet. + platform: darwin +- name: macOS - Enable Firewall + query: SELECT 1 FROM managed_policies WHERE domain='com.apple.security.firewall' AND username = '' AND name='EnableFirewall' AND CAST(value AS INT) = 1; + critical: false + description: This policy checks if Firewall is enabled. + resolution: An an IT admin, deploy a macOS, Firewall profile with the EnableFirewall option set to true. + platform: darwin +- name: macOS - Disable guest account + query: SELECT 1 FROM managed_policies WHERE domain='com.apple.loginwindow' AND username = '' AND name='DisableGuestAccount' AND CAST(value AS INT) = 1; + critical: false + description: This policy checks if the guest account is disabled. + resolution: An an IT admin, deploy a macOS, login window profile with the DisableGuestAccount option set to true. + platform: darwin +- name: macOS - Require 10 character password + query: SELECT 1 WHERE + EXISTS ( + SELECT 1 FROM managed_policies WHERE + domain='com.apple.screensaver' AND + name='askForPassword' AND + CAST(value AS INT) + ) + AND EXISTS ( + SELECT 1 FROM managed_policies WHERE + domain='com.apple.screensaver' AND + name='minLength' AND + CAST(value AS INT) <= 10 + ); + critical: false + description: This policy checks if the end user is required to enter a password, with at least 10 characters, to unlock the host. + resolution: An an IT admin, deploy a macOS, screensaver profile with the askForPassword option set to true and minLength option set to 10. + platform: darwin +- name: macOS - Enable screen saver after 20 minutes + query: SELECT 1 WHERE + EXISTS ( + SELECT 1 FROM managed_policies WHERE + domain='com.apple.screensaver' AND + name='idleTime' AND + CAST(value AS INT) <= 1200 AND + username = '' + ) + AND NOT EXISTS ( + SELECT 1 FROM managed_policies WHERE + domain='com.apple.screensaver' AND + name='idleTime' AND + CAST(value AS INT) > 1200 + ); + critical: false + description: This policy checks if maximum amount of time (in minutes) the device is allowed to sit idle before the screen is locked. End users can select any value less than the specified maximum. + resolution: An an IT admin, deploy a macOS, screen saver profile with the maxInactivity option set to 20 minutes. + platform: darwin \ No newline at end of file diff --git a/it-and-security/lib/macos-see-automatic-enrollment-profile.sh b/it-and-security/lib/macos-see-automatic-enrollment-profile.sh new file mode 100644 index 0000000000..1960b0264e --- /dev/null +++ b/it-and-security/lib/macos-see-automatic-enrollment-profile.sh @@ -0,0 +1 @@ +profiles show -type enrollment \ No newline at end of file diff --git a/it-and-security/lib/servers.agent-options.yml b/it-and-security/lib/servers.agent-options.yml new file mode 100644 index 0000000000..61559952c0 --- /dev/null +++ b/it-and-security/lib/servers.agent-options.yml @@ -0,0 +1,13 @@ +config: + decorators: + load: + - SELECT uuid AS host_uuid FROM system_info; + - SELECT hostname AS hostname FROM system_info; + options: + disable_distributed: false + distributed_interval: 10 + distributed_plugin: tls + distributed_tls_max_attempts: 3 + logger_tls_endpoint: /api/osquery/log + logger_tls_period: 10 + pack_delimiter: / diff --git a/it-and-security/lib/windows-device-health.policies.yml b/it-and-security/lib/windows-device-health.policies.yml new file mode 100644 index 0000000000..4b92f5841e --- /dev/null +++ b/it-and-security/lib/windows-device-health.policies.yml @@ -0,0 +1,24 @@ +- name: Windows - Enable screen saver after 20 minutes + query: SELECT 1 FROM mdm_bridge where mdm_command_input = "1./Device/Vendor/MSFT/Policy/Result/DeviceLock/MaxInactivityTimeDeviceLock" and CAST(mdm_command_output AS INT) <= 20; + critical: false + description: This policy checks if maximum amount of time (in minutes) the device is allowed to sit idle before the screen is locked. End users can select any value less than the specified maximum. + resolution: "As an IT admin, to deploy a Windows profile with the MaxInactivityTimeDeviceLock option documented here: https://learn.microsoft.com/en-us/windows/client-management/mdm/policy-csp-devicelock#maxinactivitytimedevicelock" + platform: windows +- name: Windows - Enable BitLocker + query: SELECT * FROM bitlocker_info WHERE drive_letter='C:' AND protection_status = 1; + critical: false + description: As an IT admin, turn on disk encryption in Fleet. + resolution: Ask your system administrator to turn on disk encryption in Fleet + platform: windows +- name: Windows - Disable guest account + query: SELECT 1 FROM mdm_bridge where mdm_command_input = "1./Device/Vendor/MSFT/Policy/Result/LocalPoliciesSecurityOptions/Accounts_EnableGuestAccountStatus" and CAST(mdm_command_output AS INT) = 0; + critical: false + description: This policy checks if the guest account is disabled. The Guest account allows unauthenticated network users to gain access to the system. + resolution: "As an IT admin, deploy a Windows profile with the Accounts_EnableGuestAccountStatus option documented here: https://learn.microsoft.com/en-us/windows/client-management/mdm/policy-csp-localpoliciessecurityoptions#accounts_enableguestaccountstatus" + platform: windows +- name: Windows - Require 10 character password + query: SELECT 1 FROM mdm_bridge where mdm_command_input = "1./Device/Vendor/MSFT/Policy/Result/DeviceLock/DevicePasswordEnabled" and CAST(mdm_command_output AS INT) = 0; + critical: false + description: This policy checks if the end user is required to enter a password, with at least 10 characters, to unlock the host. + resolution: "As an IT admin, deploy a Windows profile with the DevicePasswordEnabled and MinDevicePasswordLength option documented here: https://learn.microsoft.com/en-us/windows/client-management/mdm/policy-csp-devicelock" + platform: windows \ No newline at end of file diff --git a/it-and-security/lib/windows-remove-fleetd.ps1 b/it-and-security/lib/windows-remove-fleetd.ps1 new file mode 100644 index 0000000000..f532843c09 --- /dev/null +++ b/it-and-security/lib/windows-remove-fleetd.ps1 @@ -0,0 +1,110 @@ +function Test-Administrator +{ + [OutputType([bool])] + param() + process { + [Security.Principal.WindowsPrincipal]$user = [Security.Principal.WindowsIdentity]::GetCurrent(); + return $user.IsInRole([Security.Principal.WindowsBuiltinRole]::Administrator); + } +} + +# borrowed from Jeffrey Snover http://blogs.msdn.com/powershell/archive/2006/12/07/resolve-error.aspx +function Resolve-Error-Detailed($ErrorRecord = $Error[0]) { + $error_message = "========== ErrorRecord:{0}ErrorRecord.InvocationInfo:{1}Exception:{2}" + $formatted_errorRecord = $ErrorRecord | format-list * -force | out-string + $formatted_invocationInfo = $ErrorRecord.InvocationInfo | format-list * -force | out-string + $formatted_exception = "" + $Exception = $ErrorRecord.Exception + for ($i = 0; $Exception; $i++, ($Exception = $Exception.InnerException)) { + $formatted_exception += ("$i" * 70) + "-----" + $formatted_exception += $Exception | format-list * -force | out-string + $formatted_exception += "-----" + } + + return $error_message -f $formatted_errorRecord, $formatted_invocationInfo, $formatted_exception +} + +#Stops Orbit service and related processes +function Stop-Orbit { + + # Stop Service + Stop-Service -Name "Fleet osquery" -ErrorAction "Continue" + Start-Sleep -Milliseconds 1000 + + # Ensure that no process left running + Get-Process -Name "orbit" -ErrorAction "SilentlyContinue" | Stop-Process -Force + Get-Process -Name "osqueryd" -ErrorAction "SilentlyContinue" | Stop-Process -Force + Get-Process -Name "fleet-desktop" -ErrorAction "SilentlyContinue" | Stop-Process -Force + Start-Sleep -Milliseconds 1000 +} + +#Remove Orbit footprint from registry and disk +function Force-Remove-Orbit { + + try { + + #Stoping Orbit + Stop-Orbit + + #Remove Service + $service = Get-WmiObject -Class Win32_Service -Filter "Name='Fleet osquery'" + if ($service) { + $service.delete() | Out-Null + } + + #Removing Program files entries + $targetPath = $Env:Programfiles + "\\Orbit" + Remove-Item -LiteralPath $targetPath -Force -Recurse -ErrorAction "Continue" + + #Remove HKLM registry entries + Get-ChildItem "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" -Recurse -ErrorAction "SilentlyContinue" | Where-Object {($_.ValueCount -gt 0)} | ForEach-Object { + + # Filter for osquery entries + $properties = Get-ItemProperty $_.PSPath -ErrorAction "SilentlyContinue" | Where-Object {($_.DisplayName -eq "Fleet osquery")} + if ($properties) { + + #Remove Registry Entries + $regKey = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\" + $_.PSChildName + + Get-Item $regKey -ErrorAction "SilentlyContinue" | Remove-Item -Force -ErrorAction "SilentlyContinue" + + return + } + } + } + catch { + Write-Host "There was a problem running Force-Remove-Orbit" + Write-Host "$(Resolve-Error-Detailed)" + return $false + } + + return $true +} + +function Main { + + try { + # Is Administrator check + if (-not (Test-Administrator)) { + Write-Host "Please run this script with adming privileges." + Exit -1 + } + + Write-Host "About to uninstall fleetd..." + + if (Force-Remove-Orbit) { + Write-Host "fleetd was uninstalled." + Exit 0 + } else { + Write-Host "There was a problem uninstalling fleetd." + Exit -1 + } + + } catch { + Write-Host "Errorr: Entry point" + Write-Host "$(Resolve-Error-Detailed)" + Exit -1 + } +} + +$null = Main diff --git a/it-and-security/lib/windows-turn-off-mdm.ps1 b/it-and-security/lib/windows-turn-off-mdm.ps1 new file mode 100644 index 0000000000..2ffc3be05d --- /dev/null +++ b/it-and-security/lib/windows-turn-off-mdm.ps1 @@ -0,0 +1,27 @@ +Add-Type -TypeDefinition @" +using System; +using System.Runtime.InteropServices; + +public class MdmRegistration +{ + [DllImport("mdmregistration.dll", SetLastError = true)] + public static extern int UnregisterDeviceWithManagement(IntPtr pDeviceID); + + public static int UnregisterDevice() + { + return UnregisterDeviceWithManagement(IntPtr.Zero); + } +} +"@ -Language CSharp + +try { + $result = [MdmRegistration]::UnregisterDevice() + + if ($result -ne 0) { + throw "UnregisterDeviceWithManagement failed with error code: $result" + } + + Write-Host "Device unregistration called successfully." +} catch { + Write-Error "Error calling UnregisterDeviceWithManagement: $_" +} diff --git a/it-and-security/teams/explore-data.yml b/it-and-security/teams/explore-data.yml new file mode 100644 index 0000000000..4fef4ef021 --- /dev/null +++ b/it-and-security/teams/explore-data.yml @@ -0,0 +1,43 @@ +name: "Explore data (fleetdm.com) [DO NOT DELETE]" +team_settings: + features: + enable_host_users: true + enable_software_inventory: true + host_expiry_settings: + host_expiry_enabled: false + host_expiry_window: 0 + secrets: + - secret: $DOGFOOD_EXPLORE_DATA_ENROLL_SECRET +agent_options: + config: + decorators: + load: + - SELECT uuid AS host_uuid FROM system_info; + - SELECT hostname AS hostname FROM system_info; + options: + disable_distributed: false + distributed_interval: 5 + distributed_plugin: tls + distributed_tls_max_attempts: 3 + logger_tls_endpoint: /api/v1/osquery/log + pack_delimiter: / +controls: + enable_disk_encryption: false + macos_settings: + custom_settings: + macos_setup: + bootstrap_package: null + enable_end_user_authentication: false + macos_setup_assistant: null + macos_updates: + deadline: null + minimum_version: null + windows_settings: + custom_settings: null + windows_updates: + deadline_days: null + grace_period_days: null + scripts: +policies: +queries: + - path: ../lib/explore-data.queries.yml diff --git a/it-and-security/teams/servers-canary.yml b/it-and-security/teams/servers-canary.yml new file mode 100644 index 0000000000..a009d9dcf0 --- /dev/null +++ b/it-and-security/teams/servers-canary.yml @@ -0,0 +1,31 @@ +name: "Servers (canary)" +team_settings: + features: + enable_host_users: false + enable_software_inventory: false + host_expiry_settings: + host_expiry_enabled: false + host_expiry_window: 0 + secrets: + - secret: $DOGFOOD_SERVERS_CANARY_ENROLL_SECRET +agent_options: + path: ../lib/servers.agent-options.yml +controls: + enable_disk_encryption: false + macos_settings: + custom_settings: + macos_setup: + bootstrap_package: null + enable_end_user_authentication: false + macos_setup_assistant: null + macos_updates: + deadline: null + minimum_version: null + windows_settings: + custom_settings: null + windows_updates: + deadline_days: null + grace_period_days: null + scripts: +policies: +queries: diff --git a/it-and-security/teams/servers.yml b/it-and-security/teams/servers.yml new file mode 100644 index 0000000000..b10d25d3e3 --- /dev/null +++ b/it-and-security/teams/servers.yml @@ -0,0 +1,31 @@ +name: "Servers" +team_settings: + features: + enable_host_users: true + enable_software_inventory: true + host_expiry_settings: + host_expiry_enabled: false + host_expiry_window: 0 + secrets: + - secret: $DOGFOOD_SERVERS_ENROLL_SECRET +agent_options: + path: ../lib/servers.agent-options.yml +controls: + enable_disk_encryption: false + macos_settings: + custom_settings: + macos_setup: + bootstrap_package: null + enable_end_user_authentication: false + macos_setup_assistant: null + macos_updates: + deadline: null + minimum_version: null + windows_settings: + custom_settings: null + windows_updates: + deadline_days: null + grace_period_days: null + scripts: +policies: +queries: diff --git a/it-and-security/teams/workstations-canary.yml b/it-and-security/teams/workstations-canary.yml new file mode 100644 index 0000000000..1a94399ae7 --- /dev/null +++ b/it-and-security/teams/workstations-canary.yml @@ -0,0 +1,68 @@ +name: "Workstations (canary)" +team_settings: + features: + enable_host_users: true + enable_software_inventory: true + host_expiry_settings: + host_expiry_enabled: false + host_expiry_window: 0 + secrets: + - secret: $DOGFOOD_WORKSTATIONS_CANARY_ENROLL_SECRET +agent_options: + path: ../lib/agent-options.yml +controls: + enable_disk_encryption: true + macos_settings: + custom_settings: + - path: ../lib/configuration-profiles/macos-automatic-updates.mobileconfig + - path: ../lib/configuration-profiles/macos-chrome-enrollment.mobileconfig + - path: ../lib/configuration-profiles/macos-date-time.mobileconfig + - path: ../lib/configuration-profiles/macos-disable-bluetooth-file-sharing.mobileconfig + - path: ../lib/configuration-profiles/macos-disable-content-caching.mobileconfig + - path: ../lib/configuration-profiles/macos-disable-guest-account.mobileconfig + - path: ../lib/configuration-profiles/macos-disable-guest-shares.mobileconfig + - path: ../lib/configuration-profiles/macos-disable-internet-sharing.mobileconfig + - path: ../lib/configuration-profiles/macos-disable-media-sharing.mobileconfig + - path: ../lib/configuration-profiles/macos-disable-safari-safefiles.mobileconfig + - path: ../lib/configuration-profiles/macos-enable-doh.mobileconfig + - path: ../lib/configuration-profiles/macos-enable-firewall-logging.mobileconfig + - path: ../lib/configuration-profiles/macos-enable-gatekeeper.mobileconfig + - path: ../lib/configuration-profiles/macos-enforce-library-validation.mobileconfig + - path: ../lib/configuration-profiles/macos-firewall.mobileconfig + - path: ../lib/configuration-profiles/macos-full-disk-access-for-fleetd.mobileconfig + - path: ../lib/configuration-profiles/macos-limit-ad-tracking.mobileconfig + - path: ../lib/configuration-profiles/macos-misc.mobileconfig + - path: ../lib/configuration-profiles/macos-password.mobileconfig + - path: ../lib/configuration-profiles/macos-prevent-autologon.mobileconfig + - path: ../lib/configuration-profiles/macos-secure-terminal-keyboard.mobileconfig + macos_setup: + bootstrap_package: "" + enable_end_user_authentication: true + macos_setup_assistant: null + macos_updates: + deadline: "2023-12-15" + minimum_version: "14.2" + windows_settings: + custom_settings: null + windows_updates: + deadline_days: 7 + grace_period_days: 2 + scripts: + - path: ../lib/collect-fleetd-logs.sh + - path: ../lib/macos-see-automatic-enrollment-profile.sh + - path: ../lib/windows-remove-fleetd.ps1 + - path: ../lib/windows-turn-off-mdm.ps1 +policies: + - path: ../lib/macos-device-health.policies.yml + - path: ../lib/windows-device-health.policies.yml + - path: ../lib/linux-device-health.policies.yml + - name: chromeOS/macOS - Screenlock enabled + query: SELECT 1 FROM screenlock WHERE enabled = 1; + critical: false + description: "" + resolution: "" + platform: darwin,chrome +queries: + - path: ../lib/collect-failed-login-attempts.queries.yml + - path: ../lib/collect-usb-devices.queries.yml + - path: ../lib/collect-vs-code-extensions.queries.yml diff --git a/it-and-security/teams/workstations.yml b/it-and-security/teams/workstations.yml new file mode 100644 index 0000000000..445166ce95 --- /dev/null +++ b/it-and-security/teams/workstations.yml @@ -0,0 +1,62 @@ +name: "Workstations" +team_settings: + features: + enable_host_users: true + enable_software_inventory: true + host_expiry_settings: + host_expiry_enabled: false + host_expiry_window: 0 + secrets: + - secret: $DOGFOOD_WORKSTATIONS_ENROLL_SECRET +agent_options: + path: ../lib/agent-options.yml +controls: + enable_disk_encryption: true + macos_settings: + custom_settings: + - path: ../lib/configuration-profiles/macos-automatic-updates.mobileconfig + - path: ../lib/configuration-profiles/macos-date-time.mobileconfig + - path: ../lib/configuration-profiles/macos-chrome-enrollment.mobileconfig + - path: ../lib/configuration-profiles/macos-disable-bluetooth-file-sharing.mobileconfig + - path: ../lib/configuration-profiles/macos-disable-content-caching.mobileconfig + - path: ../lib/configuration-profiles/macos-disable-guest-account.mobileconfig + - path: ../lib/configuration-profiles/macos-disable-guest-shares.mobileconfig + - path: ../lib/configuration-profiles/macos-disable-internet-sharing.mobileconfig + - path: ../lib/configuration-profiles/macos-disable-media-sharing.mobileconfig + - path: ../lib/configuration-profiles/macos-disable-safari-safefiles.mobileconfig + - path: ../lib/configuration-profiles/macos-enable-doh.mobileconfig + - path: ../lib/configuration-profiles/macos-enable-firewall-logging.mobileconfig + - path: ../lib/configuration-profiles/macos-enable-gatekeeper.mobileconfig + - path: ../lib/configuration-profiles/macos-enforce-library-validation.mobileconfig + - path: ../lib/configuration-profiles/macos-firewall.mobileconfig + - path: ../lib/configuration-profiles/macos-full-disk-access-for-fleetd.mobileconfig + - path: ../lib/configuration-profiles/macos-limit-ad-tracking.mobileconfig + - path: ../lib/configuration-profiles/macos-misc.mobileconfig + - path: ../lib/configuration-profiles/macos-password.mobileconfig + - path: ../lib/configuration-profiles/macos-prevent-autologon.mobileconfig + - path: ../lib/configuration-profiles/macos-secure-terminal-keyboard.mobileconfig + macos_setup: + bootstrap_package: "" + enable_end_user_authentication: true + macos_setup_assistant: null + macos_updates: + deadline: "2023-12-19" + minimum_version: "14.2" + windows_settings: + custom_settings: null + windows_updates: + deadline_days: 7 + grace_period_days: 2 + scripts: + - path: ../lib/collect-fleetd-logs.sh + - path: ../lib/macos-see-automatic-enrollment-profile.sh + - path: ../lib/windows-remove-fleetd.ps1 + - path: ../lib/windows-turn-off-mdm.ps1 +policies: + - path: ../lib/macos-device-health.policies.yml + - path: ../lib/windows-device-health.policies.yml + - path: ../lib/linux-device-health.policies.yml +queries: + - path: ../lib/collect-failed-login-attempts.queries.yml + - path: ../lib/collect-usb-devices.queries.yml + - path: ../lib/collect-vs-code-extensions.queries.yml diff --git a/schema/osquery_fleet_schema.json b/schema/osquery_fleet_schema.json index cb2ae7e24a..c6f9096d43 100644 --- a/schema/osquery_fleet_schema.json +++ b/schema/osquery_fleet_schema.json @@ -3698,9 +3698,7 @@ "evented": false, "cacheable": false, "notes": "", - "examples": [ - "SELECT chrome_extension_content_scripts.* FROM users JOIN chrome_extension_content_scripts USING (uid) GROUP BY identifier, match" - ], + "examples": "```\nSELECT chrome_extension_content_scripts.* FROM users JOIN chrome_extension_content_scripts USING (uid) GROUP BY identifier, match\n```", "columns": [ { "name": "browser_type", @@ -8933,10 +8931,7 @@ "evented": false, "cacheable": false, "notes": "", - "examples": [ - "select * from docker_volumes", - "select * from docker_volumes where name = 'btrfs'" - ], + "examples": "```\nselect * from docker_volumes where name = 'btrfs'\n```", "columns": [ { "name": "name", @@ -11881,11 +11876,7 @@ "evented": false, "cacheable": true, "notes": "", - "examples": [ - "select interface, mac, type, idrops as input_drops from interface_details;", - "select interface, mac, type, flags, (1<<8) as promisc_flag from interface_details where (flags & promisc_flag) > 0;", - "select interface, mac, type, flags, (1<<3) as loopback_flag from interface_details where (flags & loopback_flag) > 0;" - ], + "examples": "```\nselect interface, mac, type, flags, (1<<3) as loopback_flag from interface_details where (flags & loopback_flag) > 0;\n```", "columns": [ { "name": "interface", @@ -13343,9 +13334,7 @@ "evented": false, "cacheable": false, "notes": "", - "examples": [ - "select * from users join known_hosts using (uid)" - ], + "examples": "```\nselect * from users join known_hosts using (uid)\n```", "columns": [ { "name": "uid", @@ -22907,10 +22896,7 @@ "evented": false, "cacheable": false, "notes": "- Includes installed extensions for all system users.", - "examples": [ - "select * from safari_extensions where uid=501", - "select count(*) from users JOIN safari_extensions using (uid)" - ], + "examples": "```\nselect count(*) from users JOIN safari_extensions using (uid)\n```", "columns": [ { "name": "uid", diff --git a/server/config/config.go b/server/config/config.go index e26831ec30..7861729762 100644 --- a/server/config/config.go +++ b/server/config/config.go @@ -94,6 +94,7 @@ type ServerConfig struct { Keepalive bool `yaml:"keepalive"` SandboxEnabled bool `yaml:"sandbox_enabled"` WebsocketsAllowUnsafeOrigin bool `yaml:"websockets_allow_unsafe_origin"` + FrequentCleanupsEnabled bool `yaml:"frequent_cleanups_enabled"` } func (s *ServerConfig) DefaultHTTPServer(ctx context.Context, handler http.Handler) *http.Server { @@ -841,6 +842,7 @@ func (man Manager) addConfigs() { man.addConfigBool("server.sandbox_enabled", false, "When enabled, Fleet limits some features for the Sandbox") man.addConfigBool("server.websockets_allow_unsafe_origin", false, "Disable checking the origin header on websocket connections, this is sometimes necessary when proxies rewrite origin headers between the client and the Fleet webserver") + man.addConfigBool("server.frequent_cleanups_enabled", false, "Enable frequent cleanups of expired data (15 minute interval)") // Hide the sandbox flag as we don't want it to be discoverable for users for now sandboxFlag := man.command.PersistentFlags().Lookup(flagNameFromConfigKey("server.sandbox_enabled")) @@ -1191,6 +1193,7 @@ func (man Manager) LoadConfig() FleetConfig { Keepalive: man.getConfigBool("server.keepalive"), SandboxEnabled: man.getConfigBool("server.sandbox_enabled"), WebsocketsAllowUnsafeOrigin: man.getConfigBool("server.websockets_allow_unsafe_origin"), + FrequentCleanupsEnabled: man.getConfigBool("server.frequent_cleanups_enabled"), }, Auth: AuthConfig{ BcryptCost: man.getConfigInt("auth.bcrypt_cost"), diff --git a/server/datastore/mysql/apple_mdm.go b/server/datastore/mysql/apple_mdm.go index 8f81f61e03..9c3cd5b0bd 100644 --- a/server/datastore/mysql/apple_mdm.go +++ b/server/datastore/mysql/apple_mdm.go @@ -653,6 +653,11 @@ func updateMDMAppleHostDB( return ctxerr.Wrap(ctx, err, "update mdm apple host") } + // clear any host_mdm_actions following re-enrollment here + if _, err := tx.ExecContext(ctx, `DELETE FROM host_mdm_actions WHERE host_id = ?`, hostID); err != nil { + return ctxerr.Wrap(ctx, err, "error clearing mdm apple host_mdm_actions") + } + if err := upsertMDMAppleHostMDMInfoDB(ctx, tx, appCfg.ServerSettings, false, hostID); err != nil { return ctxerr.Wrap(ctx, err, "ingest mdm apple host upsert MDM info") } @@ -1960,186 +1965,179 @@ func (ds *Datastore) UpdateOrDeleteHostMDMAppleProfile(ctx context.Context, prof return err } -func subqueryHostsMacOSSettingsStatusFailed() (string, []interface{}) { - sql := ` - SELECT - 1 FROM host_mdm_apple_profiles hmap - WHERE - h.uuid = hmap.host_uuid - AND hmap.status = ?` - args := []interface{}{fleet.MDMDeliveryFailed} +const ( + appleMDMFailedProfilesStmt = ` + h.uuid = hmap.host_uuid AND + hmap.status = :failed` - return sql, args -} + appleMDMPendingProfilesStmt = ` + h.uuid = hmap.host_uuid AND + ( + hmap.status IS NULL OR + hmap.status = :pending OR + -- 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) + ) + ) + )` -func subqueryHostsMacOSSettingsStatusPending() (string, []interface{}) { - sql := ` - SELECT - 1 FROM host_mdm_apple_profiles hmap - WHERE - h.uuid = hmap.host_uuid - AND (hmap.status IS NULL - OR hmap.status = ? - OR(hmap.profile_identifier = ? - AND hmap.status IN (?, ?) - AND hmap.operation_type = ? - AND NOT EXISTS ( - SELECT - 1 FROM host_disk_encryption_keys hdek - WHERE - h.id = hdek.host_id - AND hdek.decryptable = 1))) - AND NOT EXISTS ( - SELECT - 1 FROM host_mdm_apple_profiles hmap2 - WHERE - h.uuid = hmap2.host_uuid - AND hmap2.status = ?)` - args := []interface{}{ - fleet.MDMDeliveryPending, - mobileconfig.FleetFileVaultPayloadIdentifier, - fleet.MDMDeliveryVerifying, - fleet.MDMDeliveryVerified, - fleet.MDMOperationTypeInstall, - fleet.MDMDeliveryFailed, + 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) } - return sql, args -} -func subqueryHostsMacOSSetttingsStatusVerifying() (string, []interface{}) { - sql := ` - SELECT - 1 FROM host_mdm_apple_profiles hmap - WHERE - h.uuid = hmap.host_uuid - AND hmap.operation_type = ? - AND hmap.status = ? - AND(hmap.profile_identifier != ? - OR EXISTS ( - SELECT - 1 FROM host_disk_encryption_keys hdek - WHERE - h.id = hdek.host_id - AND hdek.decryptable = 1)) - AND NOT EXISTS ( - SELECT - 1 FROM host_mdm_apple_profiles hmap2 - WHERE (h.uuid = hmap2.host_uuid - AND hmap2.operation_type = ? - AND(hmap2.status IS NULL - OR hmap2.status NOT IN(?, ?) - OR(hmap2.profile_identifier = ? - AND hmap2.status IN(?, ?) - AND NOT EXISTS ( - SELECT - 1 FROM host_disk_encryption_keys hdek - WHERE - h.id = hdek.host_id - AND hdek.decryptable = 1)))) - OR(h.uuid = hmap2.host_uuid - AND hmap2.operation_type = ? - AND(hmap2.status IS NULL - OR hmap2.status NOT IN(?, ?))))` + 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) - args := []interface{}{ - fleet.MDMOperationTypeInstall, - fleet.MDMDeliveryVerifying, - mobileconfig.FleetFileVaultPayloadIdentifier, - fleet.MDMOperationTypeInstall, - fleet.MDMDeliveryVerifying, - fleet.MDMDeliveryVerified, - mobileconfig.FleetFileVaultPayloadIdentifier, - fleet.MDMDeliveryVerifying, - fleet.MDMDeliveryVerified, - fleet.MDMOperationTypeRemove, - fleet.MDMDeliveryVerifying, - fleet.MDMDeliveryVerified, + arg := map[string]any{ + "install": fleet.MDMOperationTypeInstall, + "remove": fleet.MDMOperationTypeRemove, + "verifying": fleet.MDMDeliveryVerifying, + "failed": fleet.MDMDeliveryFailed, + "verified": fleet.MDMDeliveryVerified, + "pending": fleet.MDMDeliveryPending, + "filevault": mobileconfig.FleetFileVaultPayloadIdentifier, } - return sql, args -} - -func subqueryHostsMacOSSetttingsStatusVerified() (string, []interface{}) { - sql := ` - SELECT - 1 FROM host_mdm_apple_profiles hmap - WHERE - h.uuid = hmap.host_uuid - AND hmap.operation_type = ? - AND hmap.status = ? - AND(hmap.profile_identifier != ? - OR EXISTS ( - SELECT - 1 FROM host_disk_encryption_keys hdek - WHERE - h.id = hdek.host_id - AND hdek.decryptable = 1)) - AND NOT EXISTS ( - SELECT - 1 FROM host_mdm_apple_profiles hmap2 - WHERE (h.uuid = hmap2.host_uuid - AND hmap2.operation_type = ? - AND (hmap2.status IS NULL - OR hmap2.status != ? - OR(hmap2.profile_identifier = ? - AND hmap2.status = ? - AND NOT EXISTS ( - SELECT - 1 FROM host_disk_encryption_keys hdek - WHERE - h.id = hdek.host_id - AND hdek.decryptable = 1)))) - OR(h.uuid = hmap2.host_uuid - AND hmap2.operation_type = ? - AND (hmap2.status IS NULL - OR hmap2.status NOT IN(?, ?))))` - args := []interface{}{ - fleet.MDMOperationTypeInstall, - fleet.MDMDeliveryVerified, - mobileconfig.FleetFileVaultPayloadIdentifier, - fleet.MDMOperationTypeInstall, - fleet.MDMDeliveryVerified, - mobileconfig.FleetFileVaultPayloadIdentifier, - fleet.MDMDeliveryVerified, - fleet.MDMOperationTypeRemove, - fleet.MDMDeliveryVerifying, - fleet.MDMDeliveryVerified, + query, args, err := sqlx.Named(sql, arg) + if err != nil { + return "", nil, fmt.Errorf("subqueryAppleProfileStatus %s: %w", status, err) } - return sql, args + + return query, args, nil } func (ds *Datastore) GetMDMAppleProfilesSummary(ctx context.Context, teamID *uint) (*fleet.MDMProfilesSummary, error) { var args []interface{} - subqueryFailed, subqueryFailedArgs := subqueryHostsMacOSSettingsStatusFailed() + + subqueryFailed, subqueryFailedArgs, err := subqueryAppleProfileStatus(fleet.MDMDeliveryFailed) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "building failed subquery") + } args = append(args, subqueryFailedArgs...) - subqueryPending, subqueryPendingArgs := subqueryHostsMacOSSettingsStatusPending() + + subqueryPending, subqueryPendingArgs, err := subqueryAppleProfileStatus(fleet.MDMDeliveryPending) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "building pending subquery") + } args = append(args, subqueryPendingArgs...) - subqueryVerifying, subqueryVeryingingArgs := subqueryHostsMacOSSetttingsStatusVerifying() - args = append(args, subqueryVeryingingArgs...) - subqueryVerified, subqueryVerifiedArgs := subqueryHostsMacOSSetttingsStatusVerified() + + subqueryVerifying, subqueryVerifyingArgs, err := subqueryAppleProfileStatus(fleet.MDMDeliveryVerifying) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "building verifying subquery") + } + args = append(args, subqueryVerifyingArgs...) + + subqueryVerified, subqueryVerifiedArgs, err := subqueryAppleProfileStatus(fleet.MDMDeliveryVerified) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "building verified subquery") + } args = append(args, subqueryVerifiedArgs...) sqlFmt := ` -SELECT - COUNT( - CASE WHEN EXISTS (%s) - THEN 1 - END) AS failed, - COUNT( - CASE WHEN EXISTS (%s) - THEN 1 - END) AS pending, - COUNT( - CASE WHEN EXISTS (%s) - THEN 1 - END) AS verifying, - COUNT( - CASE WHEN EXISTS (%s) - THEN 1 - END) AS verified -FROM - hosts h -WHERE - h.platform = 'darwin' AND %s` + SELECT + COUNT(CASE WHEN EXISTS (%s) THEN 1 END) AS failed, + COUNT(CASE WHEN EXISTS (%s) THEN 1 END) AS pending, + COUNT(CASE WHEN EXISTS (%s) THEN 1 END) AS verifying, + COUNT(CASE WHEN EXISTS (%s) THEN 1 END) AS verified + FROM + hosts h + WHERE + h.platform = 'darwin' AND %s` teamFilter := "h.team_id IS NULL" if teamID != nil && *teamID > 0 { @@ -2148,9 +2146,8 @@ WHERE } stmt := fmt.Sprintf(sqlFmt, subqueryFailed, subqueryPending, subqueryVerifying, subqueryVerified, teamFilter) - var res fleet.MDMProfilesSummary - err := sqlx.GetContext(ctx, ds.reader(ctx), &res, stmt, args...) + err = sqlx.GetContext(ctx, ds.reader(ctx), &res, stmt, args...) if err != nil { return nil, err } @@ -2204,14 +2201,18 @@ func subqueryFileVaultVerifying() (string, []interface{}) { 1 FROM host_mdm_apple_profiles hmap WHERE h.uuid = hmap.host_uuid - AND hdek.decryptable = 1 AND hmap.profile_identifier = ? - AND hmap.status = ? - AND hmap.operation_type = ?` + AND hmap.operation_type = ? + AND ( + (hmap.status = ? AND hdek.decryptable IS NULL) + OR + (hmap.status = ? AND hdek.decryptable = 1) + )` args := []interface{}{ mobileconfig.FleetFileVaultPayloadIdentifier, - fleet.MDMDeliveryVerifying, fleet.MDMOperationTypeInstall, + fleet.MDMDeliveryVerified, + fleet.MDMDeliveryVerifying, } return sql, args } @@ -2263,23 +2264,11 @@ func subqueryFileVaultEnforcing() (string, []interface{}) { AND hmap.profile_identifier = ? AND (hmap.status IS NULL OR hmap.status = ?) AND hmap.operation_type = ? - UNION SELECT - 1 FROM host_mdm_apple_profiles hmap - WHERE - h.uuid = hmap.host_uuid - AND hmap.profile_identifier = ? - AND (hmap.status IS NOT NULL AND (hmap.status = ? OR hmap.status = ?)) - AND hmap.operation_type = ? - AND hdek.decryptable IS NULL - AND hdek.host_id IS NOT NULL` + ` args := []interface{}{ mobileconfig.FleetFileVaultPayloadIdentifier, fleet.MDMDeliveryPending, fleet.MDMOperationTypeInstall, - mobileconfig.FleetFileVaultPayloadIdentifier, - fleet.MDMDeliveryVerifying, - fleet.MDMDeliveryVerified, - fleet.MDMOperationTypeInstall, } return sql, args } @@ -2868,6 +2857,189 @@ func (ds *Datastore) GetMDMAppleDefaultSetupAssistant(ctx context.Context, teamI return asst.ProfileUUID, asst.UploadedAt, nil } +func (ds *Datastore) UpdateHostDEPAssignProfileResponses(ctx context.Context, payload *godep.ProfileResponse) error { + if payload == nil { + // caller should ensure this does not happen + level.Debug(ds.logger).Log("msg", "update host dep assign profiles responses received nil payload") + return nil + } + + // we expect all devices to success so pre-allocate just the success slice + success := make([]string, 0, len(payload.Devices)) + var ( + notAccessible []string + failed []string + ) + + for serial, status := range payload.Devices { + switch status { + case string(fleet.DEPAssignProfileResponseSuccess): + success = append(success, serial) + case string(fleet.DEPAssignProfileResponseNotAccessible): + notAccessible = append(notAccessible, serial) + case string(fleet.DEPAssignProfileResponseFailed): + failed = append(failed, serial) + default: + // this should never happen unless Apple changes the response format, so we log it for + // future debugging + level.Debug(ds.logger).Log("msg", "unrecognized assign profile response", "serial", serial, "status", status) + } + } + + return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + if err := updateHostDEPAssignProfileResponses(ctx, tx, ds.logger, payload.ProfileUUID, success, string(fleet.DEPAssignProfileResponseSuccess)); err != nil { + return err + } + if err := updateHostDEPAssignProfileResponses(ctx, tx, ds.logger, payload.ProfileUUID, notAccessible, string(fleet.DEPAssignProfileResponseNotAccessible)); err != nil { + return err + } + if err := updateHostDEPAssignProfileResponses(ctx, tx, ds.logger, payload.ProfileUUID, failed, string(fleet.DEPAssignProfileResponseFailed)); err != nil { + return err + } + return nil + }) +} + +func updateHostDEPAssignProfileResponses(ctx context.Context, tx sqlx.ExtContext, logger log.Logger, profileUUID string, serials []string, status string) error { + if len(serials) == 0 { + return nil + } + + stmt := ` +UPDATE + host_dep_assignments +JOIN + hosts ON id = host_id +SET + profile_uuid = ?, + assign_profile_response = ?, + response_updated_at = CURRENT_TIMESTAMP, + retry_job_id = 0 +WHERE + hardware_serial IN (?) +` + stmt, args, err := sqlx.In(stmt, profileUUID, status, serials) + if err != nil { + return ctxerr.Wrap(ctx, err, "prepare statement arguments") + } + res, err := tx.ExecContext(ctx, stmt, args...) + if err != nil { + return ctxerr.Wrap(ctx, err, "update host dep assignments") + } + + n, _ := res.RowsAffected() + level.Info(logger).Log("msg", "update host dep assign profile responses", "profile_uuid", profileUUID, "status", status, "devices", n, "serials", fmt.Sprintf("%s", serials)) + + return nil +} + +// depCooldownPeriod is the waiting period following a failed DEP assign profile request for a host. +const depCooldownPeriod = 1 * time.Hour // TODO: Make this a test config option? + +func (ds *Datastore) ScreenDEPAssignProfileSerialsForCooldown(ctx context.Context, serials []string) (skipSerials []string, assignSerials []string, err error) { + stmt := ` +SELECT + CASE WHEN assign_profile_response = ? AND (response_updated_at > DATE_SUB(NOW(), INTERVAL ? SECOND) OR retry_job_id != 0) THEN + 'skip' + ELSE + 'assign' + END AS status, + hardware_serial +FROM + host_dep_assignments + JOIN hosts ON id = host_id +WHERE + hardware_serial IN (?) +` + + stmt, args, err := sqlx.In(stmt, string(fleet.DEPAssignProfileResponseFailed), depCooldownPeriod.Seconds(), serials) + if err != nil { + return nil, nil, ctxerr.Wrap(ctx, err, "screen dep serials: prepare statement arguments") + } + + var rows []struct { + Status string `db:"status"` + HardwareSerial string `db:"hardware_serial"` + } + if err := sqlx.SelectContext(ctx, ds.reader(ctx), &rows, stmt, args...); err != nil { + return nil, nil, ctxerr.Wrap(ctx, err, "screen dep serials: get rows") + } + + for _, r := range rows { + switch r.Status { + case "assign": + assignSerials = append(assignSerials, r.HardwareSerial) + case "skip": + skipSerials = append(skipSerials, r.HardwareSerial) + default: + return nil, nil, ctxerr.New(ctx, fmt.Sprintf("screen dep serials: %s unrecognized status: %s", r.HardwareSerial, r.Status)) + } + } + + return skipSerials, assignSerials, nil +} + +func (ds *Datastore) GetDEPAssignProfileExpiredCooldowns(ctx context.Context) (map[uint][]string, error) { + const stmt = ` +SELECT + COALESCE(team_id, 0) AS team_id, + hardware_serial +FROM + host_dep_assignments + JOIN hosts h ON h.id = host_id + LEFT JOIN jobs j ON j.id = retry_job_id +WHERE + assign_profile_response = ? + AND(retry_job_id = 0 OR j.state = ?) + AND(response_updated_at IS NULL + OR response_updated_at <= DATE_SUB(NOW(), INTERVAL ? SECOND))` + + var rows []struct { + TeamID uint `db:"team_id"` + HardwareSerial string `db:"hardware_serial"` + } + if err := sqlx.SelectContext(ctx, ds.reader(ctx), &rows, stmt, string(fleet.DEPAssignProfileResponseFailed), string(fleet.JobStateFailure), depCooldownPeriod.Seconds()); err != nil { + return nil, ctxerr.Wrap(ctx, err, "get host dep assign profile expired cooldowns") + } + + serialsByTeamID := make(map[uint][]string, len(rows)) + for _, r := range rows { + serialsByTeamID[r.TeamID] = append(serialsByTeamID[r.TeamID], r.HardwareSerial) + } + return serialsByTeamID, nil +} + +func (ds *Datastore) UpdateDEPAssignProfileRetryPending(ctx context.Context, jobID uint, serials []string) error { + if len(serials) == 0 { + return nil + } + + stmt := ` +UPDATE + host_dep_assignments +JOIN + hosts ON id = host_id +SET + retry_job_id = ? +WHERE + hardware_serial IN (?)` + + stmt, args, err := sqlx.In(stmt, jobID, serials) + if err != nil { + return ctxerr.Wrap(ctx, err, "prepare statement arguments") + } + + res, err := ds.writer(ctx).ExecContext(ctx, stmt, args...) + if err != nil { + return ctxerr.Wrap(ctx, err, "update dep assign profile retry pending") + } + + n, _ := res.RowsAffected() + level.Info(ds.logger).Log("msg", "update dep assign profile retry pending", "job_id", jobID, "devices", n, "serials", fmt.Sprintf("%s", serials)) + + return nil +} + func (ds *Datastore) ResetMDMAppleEnrollment(ctx context.Context, hostUUID string) error { return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { // it's okay if we didn't update any rows, `nano_enrollments` entries diff --git a/server/datastore/mysql/apple_mdm_test.go b/server/datastore/mysql/apple_mdm_test.go index ec1e0b134d..77da42fc92 100644 --- a/server/datastore/mysql/apple_mdm_test.go +++ b/server/datastore/mysql/apple_mdm_test.go @@ -67,7 +67,7 @@ func TestMDMApple(t *testing.T) { {"TestMDMAppleConfigProfileHash", testMDMAppleConfigProfileHash}, {"TestResetMDMAppleEnrollment", testResetMDMAppleEnrollment}, {"TestMDMAppleDeleteHostDEPAssignments", testMDMAppleDeleteHostDEPAssignments}, - {"CleanMacOSMDMLock", testCleanMacOSMDMLock}, + {"LockUnlockWipeMacOS", testLockUnlockWipeMacOS}, } for _, c := range cases { @@ -1769,9 +1769,11 @@ func testAggregateMacOSSettingsStatusWithFileVault(t *testing.T, ds *Datastore) res, err = ds.GetMDMAppleProfilesSummary(ctx, nil) require.NoError(t, err) require.NotNil(t, res) - require.Equal(t, uint(len(hosts)), res.Pending) // still pending because disk encryption key decryptable is not set + // hosts still pending because disk encryption key decryptable is not set + require.Equal(t, uint(len(hosts)-1), res.Pending) require.Equal(t, uint(0), res.Failed) - require.Equal(t, uint(0), res.Verifying) + // one host is verifying because the disk is encrypted and we're verifying the key + require.Equal(t, uint(1), res.Verifying) require.Equal(t, uint(0), res.Verified) err = ds.SetHostsDiskEncryptionKeyStatus(ctx, []uint{hosts[0].ID}, false, time.Now().Add(1*time.Hour)) @@ -2434,7 +2436,13 @@ func TestMDMAppleFileVaultSummary(t *testing.T) { // verifying status verifyingHost := hosts[0] - upsertHostCPs([]*fleet.Host{verifyingHost}, []*fleet.MDMAppleConfigProfile{noTeamFVProfile}, fleet.MDMOperationTypeInstall, &fleet.MDMDeliveryVerifying, ctx, ds, t) + upsertHostCPs( + []*fleet.Host{verifyingHost}, + []*fleet.MDMAppleConfigProfile{noTeamFVProfile}, + fleet.MDMOperationTypeInstall, + &fleet.MDMDeliveryVerifying, + ctx, ds, t, + ) oneMinuteAfterThreshold := time.Now().Add(+1 * time.Minute) createDiskEncryptionRecord(ctx, ds, t, verifyingHost.ID, "key-1", true, oneMinuteAfterThreshold) @@ -2643,7 +2651,13 @@ func TestMDMAppleFileVaultSummary(t *testing.T) { require.Equal(t, uint(0), allProfilesSummary.Verified) // verified status - upsertHostCPs([]*fleet.Host{verifyingTeam1Host}, []*fleet.MDMAppleConfigProfile{team1FVProfile}, fleet.MDMOperationTypeInstall, &fleet.MDMDeliveryVerified, ctx, ds, t) + upsertHostCPs( + []*fleet.Host{verifyingTeam1Host}, + []*fleet.MDMAppleConfigProfile{team1FVProfile}, + fleet.MDMOperationTypeInstall, + &fleet.MDMDeliveryVerified, + ctx, ds, t, + ) fvProfileSummary, err = ds.GetMDMAppleFileVaultSummary(ctx, &tm.ID) require.NoError(t, err) require.NotNil(t, fvProfileSummary) @@ -4402,18 +4416,9 @@ func testMDMAppleDeleteHostDEPAssignments(t *testing.T, ds *Datastore) { } } -func testCleanMacOSMDMLock(t *testing.T, ds *Datastore) { +func testLockUnlockWipeMacOS(t *testing.T, ds *Datastore) { ctx := context.Background() - checkState := func(t *testing.T, status *fleet.HostLockWipeStatus, unlocked, locked, wiped, pendingUnlock, pendingLock, pendingWipe bool) { - require.Equal(t, unlocked, status.IsUnlocked()) - require.Equal(t, locked, status.IsLocked()) - require.Equal(t, wiped, status.IsWiped()) - require.Equal(t, pendingLock, status.IsPendingLock()) - require.Equal(t, pendingUnlock, status.IsPendingUnlock()) - require.Equal(t, pendingWipe, status.IsPendingWipe()) - } - host, err := ds.NewHost(ctx, &fleet.Host{ Hostname: "test-host1-name", OsqueryHostID: ptr.String("1337"), @@ -4425,11 +4430,11 @@ func testCleanMacOSMDMLock(t *testing.T, ds *Datastore) { require.NoError(t, err) nanoEnroll(t, ds, host, false) - status, err := ds.GetHostLockWipeStatus(ctx, host.ID, "macos") + status, err := ds.GetHostLockWipeStatus(ctx, host) require.NoError(t, err) // default state - checkState(t, status, true, false, false, false, false, false) + checkLockWipeState(t, status, true, false, false, false, false, false) appleStore, err := ds.NewMDMAppleMDMStorage(nil, nil) require.NoError(t, err) @@ -4443,18 +4448,117 @@ func testCleanMacOSMDMLock(t *testing.T, ds *Datastore) { err = appleStore.EnqueueDeviceLockCommand(ctx, host, cmd, "123456") require.NoError(t, err) - status, err = ds.GetHostLockWipeStatus(ctx, host.ID, host.FleetPlatform()) + // it is now pending lock + status, err = ds.GetHostLockWipeStatus(ctx, host) require.NoError(t, err) - checkState(t, status, true, false, false, false, true, false) + checkLockWipeState(t, status, true, false, false, false, true, false) + + // record a command result to simulate locked state + err = appleStore.StoreCommandReport(&mdm.Request{ + EnrollID: &mdm.EnrollID{ID: host.UUID}, + Context: ctx, + }, &mdm.CommandResults{ + CommandUUID: cmd.CommandUUID, + Status: "Acknowledged", + RequestType: "DeviceLock", + Raw: cmd.Raw, + }) + require.NoError(t, err) + + err = ds.UpdateHostLockWipeStatusFromAppleMDMResult(ctx, host.UUID, cmd.CommandUUID, "DeviceLock", true) + require.NoError(t, err) + + // it is now locked + status, err = ds.GetHostLockWipeStatus(ctx, host) + require.NoError(t, err) + checkLockWipeState(t, status, false, true, false, false, false, false) + + // request an unlock, to make it pending unlock + err = ds.UnlockHostManually(ctx, host.ID, host.FleetPlatform(), time.Now().UTC()) + require.NoError(t, err) + + // it is now locked pending unlock + status, err = ds.GetHostLockWipeStatus(ctx, host) + require.NoError(t, err) + checkLockWipeState(t, status, false, true, false, true, false, false) // execute CleanMacOSMDMLock to simulate successful unlock err = ds.CleanMacOSMDMLock(ctx, host.UUID) require.NoError(t, err) - status, err = ds.GetHostLockWipeStatus(ctx, host.ID, "macos") + // it is back to unlocked state + status, err = ds.GetHostLockWipeStatus(ctx, host) require.NoError(t, err) - checkState(t, status, true, false, false, false, false, false) + checkLockWipeState(t, status, true, false, false, false, false, false) require.Empty(t, status.UnlockPIN) + + // record a request to wipe the host + cmd = &mdm.Command{ + CommandUUID: uuid.NewString(), + Raw: []byte("= ?, 1, 0)) as unseen - FROM hosts h - LEFT JOIN host_seen_times hst - ON h.id = hst.host_id`, - unseenSeconds, - ) + err = sqlx.GetContext(ctx, ds.reader(ctx), &total, totalQuery, args...) if err != nil { - return 0, 0, ctxerr.Wrap(ctx, err, "getting total and unseen host counts") + return 0, nil, ctxerr.Wrap(ctx, err, "getting total host counts") } - return counts.Total, counts.Unseen, nil + unseenSeconds := daysCount * 24 * 60 * 60 + args = []interface{}{unseenSeconds} + unseenQuery := `SELECT id + FROM hosts h + LEFT JOIN host_seen_times hst + ON h.id = hst.host_id + WHERE TIMESTAMPDIFF(SECOND, COALESCE(hst.seen_time, h.created_at), CURRENT_TIMESTAMP) >= ?` + + if teamID != nil { + unseenQuery += " AND team_id = ?" + args = append(args, *teamID) + } + + err = sqlx.SelectContext(ctx, ds.reader(ctx), &unseen, unseenQuery, args...) + + if err != nil { + return total, nil, ctxerr.Wrap(ctx, err, "getting unseen host counts") + } + + return } func (ds *Datastore) DeleteHosts(ctx context.Context, ids []uint) error { @@ -3740,7 +3777,8 @@ func (ds *Datastore) GetHostMDMCheckinInfo(ctx context.Context, hostUUID string) hd.display_name, COALESCE(h.team_id, 0) as team_id, hda.host_id IS NOT NULL AND hda.deleted_at IS NULL as dep_assigned_to_fleet, - h.node_key IS NOT NULL as osquery_enrolled + h.node_key IS NOT NULL as osquery_enrolled, + ncaa.renew_command_uuid IS NOT NULL as scep_renewal_in_progress FROM hosts h LEFT JOIN @@ -3752,6 +3790,9 @@ func (ds *Datastore) GetHostMDMCheckinInfo(ctx context.Context, hostUUID string) LEFT JOIN host_dep_assignments hda ON h.id = hda.host_id + LEFT JOIN + nano_cert_auth_associations ncaa + ON h.uuid = ncaa.id WHERE h.uuid = ? LIMIT 1`, hostUUID) if err != nil { if err == sql.ErrNoRows { diff --git a/server/datastore/mysql/hosts_test.go b/server/datastore/mysql/hosts_test.go index 8790daf494..fa699ecdc6 100644 --- a/server/datastore/mysql/hosts_test.go +++ b/server/datastore/mysql/hosts_test.go @@ -2726,7 +2726,7 @@ func testHostsSaveUsersWithoutUid(t *testing.T, ds *Datastore) { assert.Equal(t, host.Users[0].Uid, u2.Uid) } -func addHostSeenLast(t *testing.T, ds fleet.Datastore, i, days int) { +func addHostSeenLast(t *testing.T, ds fleet.Datastore, i, days int) *fleet.Host { host, err := ds.NewHost(context.Background(), &fleet.Host{ DetailUpdatedAt: time.Now(), LabelUpdatedAt: time.Now(), @@ -2741,41 +2741,61 @@ func addHostSeenLast(t *testing.T, ds fleet.Datastore, i, days int) { }) require.NoError(t, err) require.NotNil(t, host) + return host } func testHostsTotalAndUnseenSince(t *testing.T, ds *Datastore) { - addHostSeenLast(t, ds, 1, 0) + host1 := addHostSeenLast(t, ds, 1, 0) - total, unseen, err := ds.TotalAndUnseenHostsSince(context.Background(), 1) + total, unseen, err := ds.TotalAndUnseenHostsSince(context.Background(), nil, 1) require.NoError(t, err) assert.Equal(t, 1, total) - assert.Equal(t, 0, unseen) + assert.Len(t, unseen, 0) - addHostSeenLast(t, ds, 2, 2) - addHostSeenLast(t, ds, 3, 4) + host2 := addHostSeenLast(t, ds, 2, 2) + host3 := addHostSeenLast(t, ds, 3, 4) - total, unseen, err = ds.TotalAndUnseenHostsSince(context.Background(), 1) + total, unseen, err = ds.TotalAndUnseenHostsSince(context.Background(), nil, 1) require.NoError(t, err) assert.Equal(t, 3, total) - assert.Equal(t, 2, unseen) + assert.Len(t, unseen, 2) // host not counted as unseen if less than a full 24 hours has passed _, err = ds.writer(context.Background()).ExecContext(context.Background(), `UPDATE host_seen_times SET seen_time = ? WHERE host_id = 2`, time.Now().Add(-1*time.Duration(1)*86399*time.Second)) require.NoError(t, err) - total, unseen, err = ds.TotalAndUnseenHostsSince(context.Background(), 1) + total, unseen, err = ds.TotalAndUnseenHostsSince(context.Background(), nil, 1) require.NoError(t, err) assert.Equal(t, 3, total) - assert.Equal(t, 1, unseen) + assert.Len(t, unseen, 1) // host counted as unseen if more than 24 hours has passed _, err = ds.writer(context.Background()).ExecContext(context.Background(), `UPDATE host_seen_times SET seen_time = ? WHERE host_id = 2`, time.Now().Add(-1*time.Duration(1)*86401*time.Second)) require.NoError(t, err) - total, unseen, err = ds.TotalAndUnseenHostsSince(context.Background(), 1) + total, unseen, err = ds.TotalAndUnseenHostsSince(context.Background(), nil, 1) require.NoError(t, err) assert.Equal(t, 3, total) - assert.Equal(t, 2, unseen) + require.Len(t, unseen, 2) + assert.Equal(t, host2.ID, unseen[0]) + assert.Equal(t, host3.ID, unseen[1]) + + // Test team hosts + team1, err := ds.NewTeam(context.Background(), &fleet.Team{Name: "team1"}) + require.NoError(t, err) + + total, unseen, err = ds.TotalAndUnseenHostsSince(context.Background(), &team1.ID, 1) + require.NoError(t, err) + assert.Equal(t, 0, total) + assert.Len(t, unseen, 0) + + require.NoError(t, ds.AddHostsToTeam(context.Background(), &team1.ID, []uint{host1.ID, host3.ID})) + total, unseen, err = ds.TotalAndUnseenHostsSince(context.Background(), &team1.ID, 1) + require.NoError(t, err) + assert.Equal(t, 2, total) + require.Len(t, unseen, 1) + assert.Equal(t, host3.ID, unseen[0]) + } func testHostsListByPolicy(t *testing.T, ds *Datastore) { @@ -4920,10 +4940,10 @@ func testHostsNoSeenTime(t *testing.T, ds *Datastore) { require.Equal(t, h1.ID, foundHosts[1].ID) require.Equal(t, foundHosts[1].SeenTime, foundHosts[1].CreatedAt) - total, unseen, err := ds.TotalAndUnseenHostsSince(context.Background(), 1) + total, unseen, err := ds.TotalAndUnseenHostsSince(context.Background(), nil, 1) require.NoError(t, err) require.Equal(t, total, 2) - require.Equal(t, unseen, 0) + require.Len(t, unseen, 0) h3, err := ds.NewHost(context.Background(), &fleet.Host{ ID: 3, diff --git a/server/datastore/mysql/labels.go b/server/datastore/mysql/labels.go index 7cc3fbb58c..c2075b3a5f 100644 --- a/server/datastore/mysql/labels.go +++ b/server/datastore/mysql/labels.go @@ -584,10 +584,14 @@ func (ds *Datastore) applyHostLabelFilters(ctx context.Context, filter fleet.Tea params = append(params, *opt.LowDiskSpaceFilter) } + var err error query, params = filterHostsByStatus(ds.clock.Now(), query, opt, params) query, params = filterHostsByTeam(query, opt, params) query, params = filterHostsByMDM(query, opt, params) - query, params = filterHostsByMacOSSettingsStatus(query, opt, params) + query, params, err = filterHostsByMacOSSettingsStatus(query, opt, params) + if err != nil { + return "", nil, ctxerr.Wrap(ctx, err, "building macOS settings status filter") + } query, params = filterHostsByMacOSDiskEncryptionStatus(query, opt, params) query, params = filterHostsByMDMBootstrapPackageStatus(query, opt, params) if enableDiskEncryption, err := ds.getConfigEnableDiskEncryption(ctx, opt.TeamFilter); err != nil { diff --git a/server/datastore/mysql/mdm.go b/server/datastore/mysql/mdm.go index 73ab3a4d34..8960bb6287 100644 --- a/server/datastore/mysql/mdm.go +++ b/server/datastore/mysql/mdm.go @@ -1012,7 +1012,7 @@ func (ds *Datastore) SetCommandForPendingSCEPRenewal(ctx context.Context, assocs renew_command_uuid = VALUES(renew_command_uuid) `, strings.TrimSuffix(sb.String(), ",")) - return ds.withTx(ctx, func(tx sqlx.ExtContext) error { + return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { res, err := tx.ExecContext(ctx, stmt, args...) if err != nil { return fmt.Errorf("failed to update cert associations: %w", err) @@ -1030,3 +1030,21 @@ func (ds *Datastore) SetCommandForPendingSCEPRenewal(ctx context.Context, assocs return nil }) } + +func (ds *Datastore) CleanSCEPRenewRefs(ctx context.Context, hostUUID string) error { + stmt := ` + UPDATE nano_cert_auth_associations + SET renew_command_uuid = NULL + WHERE id = ?` + + res, err := ds.writer(ctx).ExecContext(ctx, stmt, hostUUID) + if err != nil { + return ctxerr.Wrap(ctx, err, "cleaning SCEP renew references") + } + + if rows, _ := res.RowsAffected(); rows == 0 { + return ctxerr.Errorf(ctx, "nano association for host.uuid %s doesn't exist", hostUUID) + } + + return nil +} diff --git a/server/datastore/mysql/mdm_test.go b/server/datastore/mysql/mdm_test.go index c1b686dfdd..737e930e36 100644 --- a/server/datastore/mysql/mdm_test.go +++ b/server/datastore/mysql/mdm_test.go @@ -3267,4 +3267,11 @@ func testSCEPRenewalHelpers(t *testing.T, ds *Datastore) { err = ds.SetCommandForPendingSCEPRenewal(ctx, []fleet.SCEPIdentityAssociation{{HostUUID: "foo", SHA256: "bar"}}, "bar") require.ErrorContains(t, err, "this function can only be used to update existing associations") + + err = ds.CleanSCEPRenewRefs(ctx, "does-not-exist") + require.Error(t, err) + + err = ds.CleanSCEPRenewRefs(ctx, h1.UUID) + require.NoError(t, err) + checkSCEPRenew(assocs[0], nil) } diff --git a/server/datastore/mysql/microsoft_mdm.go b/server/datastore/mysql/microsoft_mdm.go index 86fa77d299..b679ccd359 100644 --- a/server/datastore/mysql/microsoft_mdm.go +++ b/server/datastore/mysql/microsoft_mdm.go @@ -142,28 +142,32 @@ func (ds *Datastore) MDMWindowsInsertCommandForHosts(ctx context.Context, hostUU } return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { - // first, create the command entry - stmt := ` - INSERT INTO windows_mdm_commands (command_uuid, raw_command, target_loc_uri) - VALUES (?, ?, ?) - ` - if _, err := tx.ExecContext(ctx, stmt, cmd.CommandUUID, cmd.RawCommand, cmd.TargetLocURI); err != nil { - if isDuplicate(err) { - return ctxerr.Wrap(ctx, alreadyExists("MDMWindowsCommand", cmd.CommandUUID)) - } - return ctxerr.Wrap(ctx, err, "inserting MDMWindowsCommand") - } - - // create the command execution queue entries, one per host - for _, hostUUIDOrDeviceID := range hostUUIDsOrDeviceIDs { - if err := ds.mdmWindowsInsertHostCommandDB(ctx, tx, hostUUIDOrDeviceID, cmd.CommandUUID); err != nil { - return err - } - } - return nil + return ds.mdmWindowsInsertCommandForHostsDB(ctx, tx, hostUUIDsOrDeviceIDs, cmd) }) } +func (ds *Datastore) mdmWindowsInsertCommandForHostsDB(ctx context.Context, tx sqlx.ExecerContext, hostUUIDsOrDeviceIDs []string, cmd *fleet.MDMWindowsCommand) error { + // first, create the command entry + stmt := ` + INSERT INTO windows_mdm_commands (command_uuid, raw_command, target_loc_uri) + VALUES (?, ?, ?) + ` + if _, err := tx.ExecContext(ctx, stmt, cmd.CommandUUID, cmd.RawCommand, cmd.TargetLocURI); err != nil { + if isDuplicate(err) { + return ctxerr.Wrap(ctx, alreadyExists("MDMWindowsCommand", cmd.CommandUUID)) + } + return ctxerr.Wrap(ctx, err, "inserting MDMWindowsCommand") + } + + // create the command execution queue entries, one per host + for _, hostUUIDOrDeviceID := range hostUUIDsOrDeviceIDs { + if err := ds.mdmWindowsInsertHostCommandDB(ctx, tx, hostUUIDOrDeviceID, cmd.CommandUUID); err != nil { + return err + } + } + return nil +} + func (ds *Datastore) mdmWindowsInsertHostCommandDB(ctx context.Context, tx sqlx.ExecerContext, hostUUIDOrDeviceID, commandUUID string) error { stmt := ` INSERT INTO windows_mdm_command_queue (enrollment_id, command_uuid) @@ -228,20 +232,20 @@ func (ds *Datastore) MDMWindowsSaveResponse(ctx context.Context, deviceID string return ctxerr.New(ctx, "empty raw response") } - const findCommandsStmt = `SELECT command_uuid, raw_command FROM windows_mdm_commands WHERE command_uuid IN (?)` + const ( + findCommandsStmt = `SELECT command_uuid, raw_command, target_loc_uri FROM windows_mdm_commands WHERE command_uuid IN (?)` + saveFullRespStmt = `INSERT INTO windows_mdm_responses (enrollment_id, raw_response) VALUES (?, ?)` + dequeueCommandsStmt = `DELETE FROM windows_mdm_command_queue WHERE command_uuid IN (?)` - const saveFullRespStmt = `INSERT INTO windows_mdm_responses (enrollment_id, raw_response) VALUES (?, ?)` - - const dequeueCommandsStmt = `DELETE FROM windows_mdm_command_queue WHERE command_uuid IN (?)` - - const insertResultsStmt = ` + insertResultsStmt = ` INSERT INTO windows_mdm_command_results (enrollment_id, command_uuid, raw_result, response_id, status_code) VALUES %s ON DUPLICATE KEY UPDATE raw_result = COALESCE(VALUES(raw_result), raw_result), status_code = COALESCE(VALUES(status_code), status_code) - ` +` + ) enrollment, err := ds.MDMWindowsGetEnrolledDeviceWithDeviceID(ctx, deviceID) if err != nil { @@ -301,9 +305,15 @@ ON DUPLICATE KEY UPDATE // for all the matching UUIDs, try to find any or // entries to track them as responses. - var args []any - var sb strings.Builder - var potentialProfilePayloads []*fleet.MDMWindowsProfilePayload + var ( + args []any + sb strings.Builder + potentialProfilePayloads []*fleet.MDMWindowsProfilePayload + + wipeCmdUUID string + wipeCmdStatus string + ) + for _, cmd := range matchingCmds { statusCode := "" if status, ok := uuidsToStatus[cmd.CommandUUID]; ok && status.Data != nil { @@ -327,6 +337,13 @@ ON DUPLICATE KEY UPDATE } args = append(args, enrollment.ID, cmd.CommandUUID, rawResult, responseID, statusCode) sb.WriteString("(?, ?, ?, ?, ?),") + + // if the command is a Wipe, keep track of it so we can update + // host_mdm_actions accordingly. + if strings.Contains(cmd.TargetLocURI, "/Device/Vendor/MSFT/RemoteWipe/") { + wipeCmdUUID = cmd.CommandUUID + wipeCmdStatus = statusCode + } } if err := updateMDMWindowsHostProfileStatusFromResponseDB(ctx, tx, potentialProfilePayloads); err != nil { @@ -339,6 +356,14 @@ ON DUPLICATE KEY UPDATE return ctxerr.Wrap(ctx, err, "inserting command results") } + // if we received a Wipe command result, update the host's status + if wipeCmdUUID != "" { + if err := updateHostLockWipeStatusFromResultAndHostUUID(ctx, tx, enrollment.HostUUID, + "wipe_ref", wipeCmdUUID, strings.HasPrefix(wipeCmdStatus, "2")); err != nil { + return ctxerr.Wrap(ctx, err, "updating wipe command result in host_mdm_actions") + } + } + // dequeue the commands var matchingUUIDs []string for _, cmd := range matchingCmds { @@ -1874,3 +1899,27 @@ host_uuid = ? AND profile_name NOT IN(?) AND NOT (operation_type = '%s' AND COAL } return profiles, nil } + +func (ds *Datastore) WipeHostViaWindowsMDM(ctx context.Context, host *fleet.Host, cmd *fleet.MDMWindowsCommand) error { + return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + if err := ds.mdmWindowsInsertCommandForHostsDB(ctx, tx, []string{host.UUID}, cmd); err != nil { + return err + } + + stmt := ` + INSERT INTO host_mdm_actions ( + host_id, + wipe_ref, + fleet_platform + ) + VALUES (?, ?, ?) + ON DUPLICATE KEY UPDATE + wipe_ref = VALUES(wipe_ref)` + + if _, err := tx.ExecContext(ctx, stmt, host.ID, cmd.CommandUUID, host.FleetPlatform()); err != nil { + return ctxerr.Wrap(ctx, err, "modifying host_mdm_actions for wipe_ref") + } + + return nil + }) +} diff --git a/server/datastore/mysql/microsoft_mdm_test.go b/server/datastore/mysql/microsoft_mdm_test.go index 96cc36d5ef..dff2c124ea 100644 --- a/server/datastore/mysql/microsoft_mdm_test.go +++ b/server/datastore/mysql/microsoft_mdm_test.go @@ -1266,7 +1266,8 @@ func testMDMWindowsCommandResults(t *testing.T, ds *Datastore) { require.Empty(t, results) } -func windowsEnroll(t *testing.T, ds fleet.Datastore, h *fleet.Host) { +// enrolls the host in Windows MDM and returns the device's enrollment ID. +func windowsEnroll(t *testing.T, ds fleet.Datastore, h *fleet.Host) string { ctx := context.Background() d1 := &fleet.MDMWindowsEnrolledDevice{ MDMDeviceID: uuid.New().String(), @@ -1285,6 +1286,7 @@ func windowsEnroll(t *testing.T, ds fleet.Datastore, h *fleet.Host) { require.NoError(t, err) err = ds.UpdateMDMWindowsEnrollmentsHostUUID(ctx, d1.HostUUID, d1.MDMDeviceID) require.NoError(t, err) + return d1.MDMDeviceID } func testMDMWindowsProfileManagement(t *testing.T, ds *Datastore) { diff --git a/server/datastore/mysql/migrations/tables/20240228082706_AddHostDepAssignProfileResponses.go b/server/datastore/mysql/migrations/tables/20240228082706_AddHostDepAssignProfileResponses.go new file mode 100644 index 0000000000..936c1d857c --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240228082706_AddHostDepAssignProfileResponses.go @@ -0,0 +1,39 @@ +package tables + +import ( + "database/sql" + + "github.com/pkg/errors" +) + +func init() { + MigrationClient.AddMigration(Up_20240228082706, Down_20240228082706) +} + +func Up_20240228082706(tx *sql.Tx) error { + stmt := ` +ALTER TABLE host_dep_assignments + -- profile_uuid is the uuid of the enrollment profile that was assigned to the host (which should correspond to an entry in the mdm_apple_setup_assistants table) + ADD COLUMN profile_uuid VARCHAR(37) COLLATE utf8mb4_unicode_ci NULL, + + -- assign_profile_response is the response received for the DEP profile assignment request (e.g., 'SUCCESS', 'NOT_ACCESSIBLE', or 'FAILED') + ADD COLUMN assign_profile_response VARCHAR(15) COLLATE utf8mb4_unicode_ci NULL, + + -- response_updated_at is the time the most recent DEP profile assignment response was received + ADD COLUMN response_updated_at TIMESTAMP NULL, + + -- retry_job_id is the id of job to retry a failed DEP profile assignment + ADD COLUMN retry_job_id int(10) UNSIGNED NOT NULL DEFAULT 0, + + ADD INDEX idx_hdep_response (assign_profile_response, response_updated_at);` + + if _, err := tx.Exec(stmt); err != nil { + return errors.Wrap(err, "alter host_dep_assignments table") + } + + return nil +} + +func Down_20240228082706(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20240228082706_AddHostDepAssignProfileResponses_test.go b/server/datastore/mysql/migrations/tables/20240228082706_AddHostDepAssignProfileResponses_test.go new file mode 100644 index 0000000000..6668a4d518 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240228082706_AddHostDepAssignProfileResponses_test.go @@ -0,0 +1,54 @@ +package tables + +import ( + "testing" + "time" + + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/require" +) + +func TestUp_20240228082706(t *testing.T) { + db := applyUpToPrev(t) + insertStmt := "INSERT INTO host_dep_assignments (host_id) VALUES (?);" + execNoErr(t, db, insertStmt, 1337) + + // Apply current migration. + applyNext(t, db) + + // profile_uuid and assign_profile_response are now present and NULL + type hda struct { + HostID uint `db:"host_id"` + AddedAt time.Time `db:"added_at"` + DeletedAt *time.Time `db:"deleted_at"` + ProfileUUID *string `db:"profile_uuid"` + AssignProfileResponse *string `db:"assign_profile_response"` + ResponseUpdatedAt *time.Time `db:"response_updated_at"` + RetryJobID uint `db:"retry_job_id"` + } + var dest hda + err := sqlx.Get(db, &dest, `SELECT host_id, added_at, deleted_at, profile_uuid, assign_profile_response, response_updated_at, retry_job_id FROM host_dep_assignments WHERE host_id = ?`, 1337) + require.NoError(t, err) + require.Equal(t, uint(1337), dest.HostID) + require.NotZero(t, dest.AddedAt) + require.Nil(t, dest.DeletedAt) + require.Nil(t, dest.ProfileUUID) + require.Nil(t, dest.AssignProfileResponse) + require.Nil(t, dest.ResponseUpdatedAt) + require.Zero(t, dest.RetryJobID) + + // set profile_uuid and assign_profile_response to non-NULL values + execNoErr(t, db, `UPDATE host_dep_assignments SET profile_uuid = 'foo', assign_profile_response = 'bar', response_updated_at = NOW() WHERE host_id = ?`, 1337) + + dest = hda{} + err = sqlx.Get(db, &dest, `SELECT host_id, added_at, deleted_at, profile_uuid, assign_profile_response, response_updated_at, retry_job_id FROM host_dep_assignments WHERE host_id = ?`, 1337) + require.NoError(t, err) + require.Equal(t, uint(1337), dest.HostID) + require.NotZero(t, dest.AddedAt) + require.Nil(t, dest.DeletedAt) + require.Equal(t, "foo", *dest.ProfileUUID) + require.Equal(t, "bar", *dest.AssignProfileResponse) + require.NotNil(t, dest.ResponseUpdatedAt) + require.NotZero(t, dest.ResponseUpdatedAt) + require.Zero(t, dest.RetryJobID) +} diff --git a/server/datastore/mysql/migrations/tables/20240301173035_AddFleetPlatformToHostMDMActions.go b/server/datastore/mysql/migrations/tables/20240301173035_AddFleetPlatformToHostMDMActions.go new file mode 100644 index 0000000000..8a74ea9a33 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240301173035_AddFleetPlatformToHostMDMActions.go @@ -0,0 +1,25 @@ +package tables + +import ( + "database/sql" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20240301173035, Down_20240301173035) +} + +func Up_20240301173035(tx *sql.Tx) error { + _, err := tx.Exec(` +ALTER TABLE host_mdm_actions + ADD COLUMN fleet_platform VARCHAR(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' +`) + if err != nil { + return fmt.Errorf("failed to alter host_mdm_actions table: %w", err) + } + return nil +} + +func Down_20240301173035(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20240301173035_AddFleetPlatformToHostMDMActions_test.go b/server/datastore/mysql/migrations/tables/20240301173035_AddFleetPlatformToHostMDMActions_test.go new file mode 100644 index 0000000000..b177c1ab9c --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240301173035_AddFleetPlatformToHostMDMActions_test.go @@ -0,0 +1,33 @@ +package tables + +import ( + "testing" + + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/require" +) + +func TestUp_20240301173035(t *testing.T) { + db := applyUpToPrev(t) + + // create an existing host_mdm_actions row + _, err := db.Exec("INSERT INTO host_mdm_actions (host_id, lock_ref) VALUES (1, 'a')") + require.NoError(t, err) + + applyNext(t, db) + + var hostActions []struct { + HostID uint `db:"host_id"` + LockRef *string `db:"lock_ref"` + FleetPlatform string `db:"fleet_platform"` + } + + // fleet platform is left empty for pre-existing rows + err = sqlx.Select(db, &hostActions, `SELECT host_id, lock_ref, fleet_platform FROM host_mdm_actions`) + require.NoError(t, err) + require.Len(t, hostActions, 1) + require.Equal(t, uint(1), hostActions[0].HostID) + require.NotNil(t, hostActions[0].LockRef) + require.Equal(t, "a", *hostActions[0].LockRef) + require.Empty(t, hostActions[0].FleetPlatform) +} diff --git a/server/datastore/mysql/mysql.go b/server/datastore/mysql/mysql.go index ba03d27271..39faf5627e 100644 --- a/server/datastore/mysql/mysql.go +++ b/server/datastore/mysql/mysql.go @@ -1188,8 +1188,6 @@ func insertOnDuplicateDidUpdate(res sql.Result) bool { // time of the Exec call, and the result simply returns the integers it // already holds: // https://github.com/go-sql-driver/mysql/blob/bcc459a906419e2890a50fc2c99ea6dd927a88f2/result.go - // - // TODO(mna): would that work on mariadb too? lastID, _ := res.LastInsertId() aff, _ := res.RowsAffected() diff --git a/server/datastore/mysql/nanomdm_storage.go b/server/datastore/mysql/nanomdm_storage.go index 48c5d1d56c..c1929a4ba9 100644 --- a/server/datastore/mysql/nanomdm_storage.go +++ b/server/datastore/mysql/nanomdm_storage.go @@ -86,28 +86,26 @@ func (s *NanoMDMStorage) EnqueueDeviceLockCommand( cmd *mdm.Command, pin string, ) error { - return withRetryTxx(ctx, s.db, func(tx sqlx.ExtContext) error { if err := enqueueCommandDB(ctx, tx, []string{host.UUID}, cmd); err != nil { return err } - // TODO(roberto): call @mna's transactionable method to update - // these tables when it's ready. stmt := ` - INSERT INTO host_mdm_actions ( - host_id, - lock_ref, - unlock_pin - ) - VALUES (?, ?, ?) - ON DUPLICATE KEY UPDATE - wipe_ref = NULL, - unlock_ref = NULL, + INSERT INTO host_mdm_actions ( + host_id, + lock_ref, + unlock_pin, + fleet_platform + ) + VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + wipe_ref = NULL, + unlock_ref = NULL, unlock_pin = VALUES(unlock_pin), - lock_ref = VALUES(lock_ref)` + lock_ref = VALUES(lock_ref)` - if _, err := tx.ExecContext(ctx, stmt, host.ID, cmd.CommandUUID, pin); err != nil { + if _, err := tx.ExecContext(ctx, stmt, host.ID, cmd.CommandUUID, pin, host.FleetPlatform()); err != nil { return ctxerr.Wrap(ctx, err, "modifying host_mdm_actions for DeviceLock") } @@ -115,6 +113,31 @@ func (s *NanoMDMStorage) EnqueueDeviceLockCommand( }, s.logger) } +// EnqueueDeviceWipeCommand enqueues a EraseDevice command for the given host. +func (s *NanoMDMStorage) EnqueueDeviceWipeCommand(ctx context.Context, host *fleet.Host, cmd *mdm.Command) error { + return withRetryTxx(ctx, s.db, func(tx sqlx.ExtContext) error { + if err := enqueueCommandDB(ctx, tx, []string{host.UUID}, cmd); err != nil { + return err + } + + stmt := ` + INSERT INTO host_mdm_actions ( + host_id, + wipe_ref, + fleet_platform + ) + VALUES (?, ?, ?) + ON DUPLICATE KEY UPDATE + wipe_ref = VALUES(wipe_ref)` + + if _, err := tx.ExecContext(ctx, stmt, host.ID, cmd.CommandUUID, host.FleetPlatform()); err != nil { + return ctxerr.Wrap(ctx, err, "modifying host_mdm_actions for DeviceWipe") + } + + return nil + }, s.logger) +} + // NewMDMAppleDEPStorage returns a MySQL nanodep storage that uses the Datastore // underlying MySQL writer *sql.DB. func (ds *Datastore) NewMDMAppleDEPStorage(tok nanodep_client.OAuth1Tokens) (*NanoDEPStorage, error) { diff --git a/server/datastore/mysql/nanomdm_storage_test.go b/server/datastore/mysql/nanomdm_storage_test.go index c08ba8a00c..034fab16d7 100644 --- a/server/datastore/mysql/nanomdm_storage_test.go +++ b/server/datastore/mysql/nanomdm_storage_test.go @@ -78,7 +78,7 @@ func testEnqueueDeviceLockCommand(t *testing.T, ds *Datastore) { }, }, res) - status, err := ds.GetHostLockWipeStatus(ctx, host.ID, "darwin") + status, err := ds.GetHostLockWipeStatus(ctx, host) require.NoError(t, err) require.Equal(t, "cmd-uuid", status.LockMDMCommand.CommandUUID) require.Equal(t, "123456", status.UnlockPIN) diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index 253bcc9dfc..13ceba09ed 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -196,7 +196,12 @@ CREATE TABLE `host_dep_assignments` ( `host_id` int(10) unsigned NOT NULL, `added_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, `deleted_at` timestamp NULL DEFAULT NULL, - PRIMARY KEY (`host_id`) + `profile_uuid` varchar(37) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `assign_profile_response` varchar(15) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `response_updated_at` timestamp NULL DEFAULT NULL, + `retry_job_id` int(10) unsigned NOT NULL DEFAULT '0', + PRIMARY KEY (`host_id`), + KEY `idx_hdep_response` (`assign_profile_response`,`response_updated_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET @saved_cs_client = @@character_set_client */; @@ -284,6 +289,7 @@ CREATE TABLE `host_mdm_actions` ( `wipe_ref` varchar(36) COLLATE utf8mb4_unicode_ci DEFAULT NULL, `unlock_pin` varchar(6) COLLATE utf8mb4_unicode_ci DEFAULT NULL, `unlock_ref` varchar(36) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `fleet_platform` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', PRIMARY KEY (`host_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; @@ -773,9 +779,9 @@ CREATE TABLE `migration_status_tables` ( `tstamp` timestamp NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `id` (`id`) -) ENGINE=InnoDB AUTO_INCREMENT=253 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) ENGINE=InnoDB AUTO_INCREMENT=255 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,20240228111134,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,20240228111134,1,'2020-01-01 01:01:01'),(254,20240301173035,1,'2020-01-01 01:01:01'); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8 */; CREATE TABLE `mobile_device_management_solutions` ( diff --git a/server/datastore/mysql/scripts.go b/server/datastore/mysql/scripts.go index 34ab280ab7..6b10d86940 100644 --- a/server/datastore/mysql/scripts.go +++ b/server/datastore/mysql/scripts.go @@ -151,7 +151,7 @@ func (ds *Datastore) SetHostScriptExecutionResult(ctx context.Context, result *f return ctxerr.Wrap(ctx, err, "lookup host script corresponding mdm action") } if refCol != "" { - err = ds.updateHostLockWipeStatusFromResult(ctx, tx, result.HostID, refCol, result.ExitCode == 0) + err = updateHostLockWipeStatusFromResult(ctx, tx, result.HostID, refCol, result.ExitCode == 0) if err != nil { return ctxerr.Wrap(ctx, err, "update host mdm action based on script result") } @@ -629,13 +629,14 @@ ON DUPLICATE KEY UPDATE }) } -func (ds *Datastore) GetHostLockWipeStatus(ctx context.Context, hostID uint, fleetPlatform string) (*fleet.HostLockWipeStatus, error) { +func (ds *Datastore) GetHostLockWipeStatus(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) { const stmt = ` SELECT lock_ref, wipe_ref, unlock_ref, - unlock_pin + unlock_pin, + fleet_platform FROM host_mdm_actions WHERE @@ -643,16 +644,18 @@ func (ds *Datastore) GetHostLockWipeStatus(ctx context.Context, hostID uint, fle ` var mdmActions struct { - LockRef *string `db:"lock_ref"` - WipeRef *string `db:"wipe_ref"` - UnlockRef *string `db:"unlock_ref"` - UnlockPIN *string `db:"unlock_pin"` + LockRef *string `db:"lock_ref"` + WipeRef *string `db:"wipe_ref"` + UnlockRef *string `db:"unlock_ref"` + UnlockPIN *string `db:"unlock_pin"` + FleetPlatform string `db:"fleet_platform"` } + fleetPlatform := host.FleetPlatform() status := &fleet.HostLockWipeStatus{ HostFleetPlatform: fleetPlatform, } - if err := sqlx.GetContext(ctx, ds.reader(ctx), &mdmActions, stmt, hostID); err != nil { + if err := sqlx.GetContext(ctx, ds.reader(ctx), &mdmActions, stmt, host.ID); err != nil { if err == sql.ErrNoRows { // do not return a Not Found error, return the zero-value status, which // will report the correct states. @@ -661,6 +664,14 @@ func (ds *Datastore) GetHostLockWipeStatus(ctx context.Context, hostID uint, fle return nil, ctxerr.Wrap(ctx, err, "get host lock/wipe status") } + // if we have a fleet platform stored in host_mdm_actions, use it instead of + // the host.FleetPlatform() because the platform can be overwritten with an + // unknown OS name when a Wipe gets executed. + if mdmActions.FleetPlatform != "" { + fleetPlatform = mdmActions.FleetPlatform + status.HostFleetPlatform = fleetPlatform + } + switch fleetPlatform { case "darwin": if mdmActions.UnlockPIN != nil { @@ -680,34 +691,22 @@ func (ds *Datastore) GetHostLockWipeStatus(ctx context.Context, hostID uint, fle if mdmActions.LockRef != nil { // the lock reference is an MDM command - cmd, err := ds.getMDMCommand(ctx, ds.reader(ctx), *mdmActions.LockRef) + cmd, cmdRes, err := ds.getHostMDMAppleCommand(ctx, *mdmActions.LockRef, host.UUID) if err != nil { - return nil, ctxerr.Wrap(ctx, err, "get lock reference MDM command") + return nil, ctxerr.Wrap(ctx, err, "get lock reference") } status.LockMDMCommand = cmd + status.LockMDMCommandResult = cmdRes + } - // get the MDM command result, which may be not found (indicating the - // command is pending) - cmdRes, err := ds.GetMDMAppleCommandResults(ctx, *mdmActions.LockRef) + if mdmActions.WipeRef != nil { + // the wipe reference is an MDM command + cmd, cmdRes, err := ds.getHostMDMAppleCommand(ctx, *mdmActions.WipeRef, host.UUID) if err != nil { - return nil, ctxerr.Wrap(ctx, err, "get lock reference MDM command result") - } - // TODO: each item in the slice returned by - // GetMDMAppleCommandResults is a result for a - // different host. This only works because we're - // enqueuing the command with the given UUID for a - // single host, but it's the equivalent of doing - // cmdRes[0]. - // - // Ideally, and to be super safe, we should try to find - // a command with a matching r.HostUUID, but we don't - // have the host UUID available. - for _, r := range cmdRes { - if r.Status == fleet.MDMAppleStatusAcknowledged || r.Status == fleet.MDMAppleStatusError || r.Status == fleet.MDMAppleStatusCommandFormatError { - status.LockMDMCommandResult = r - break - } + return nil, ctxerr.Wrap(ctx, err, "get wipe reference") } + status.WipeMDMCommand = cmd + status.WipeMDMCommandResult = cmdRes } case "windows", "linux": @@ -727,13 +726,92 @@ func (ds *Datastore) GetHostLockWipeStatus(ctx context.Context, hostID uint, fle } status.UnlockScript = hsr } + + // wipe is an MDM command on Windows, a script on Linux + if mdmActions.WipeRef != nil { + if fleetPlatform == "windows" { + cmd, cmdRes, err := ds.getHostMDMWindowsCommand(ctx, *mdmActions.WipeRef, host.UUID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "get wipe reference") + } + status.WipeMDMCommand = cmd + status.WipeMDMCommandResult = cmdRes + } else { + hsr, err := ds.getHostScriptExecutionResultDB(ctx, ds.reader(ctx), *mdmActions.WipeRef) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "get wipe reference script result") + } + status.WipeScript = hsr + } + } } + return status, nil } +func (ds *Datastore) getHostMDMWindowsCommand(ctx context.Context, cmdUUID, hostUUID string) (*fleet.MDMCommand, *fleet.MDMCommandResult, error) { + cmd, err := ds.getMDMCommand(ctx, ds.reader(ctx), cmdUUID) + if err != nil { + return nil, nil, ctxerr.Wrap(ctx, err, "get Windows MDM command") + } + + // get the MDM command result, which may be not found (indicating the command + // is pending). Note that it doesn't return ErrNoRows if not found, it + // returns success and an empty cmdRes slice. + cmdResults, err := ds.GetMDMWindowsCommandResults(ctx, cmdUUID) + if err != nil { + return nil, nil, ctxerr.Wrap(ctx, err, "get Windows MDM command result") + } + + // each item in the slice returned by GetMDMWindowsCommandResults is + // potentially a result for a different host, we need to find the one for + // that specific host. + var cmdRes *fleet.MDMCommandResult + for _, r := range cmdResults { + if r.HostUUID != hostUUID { + continue + } + // all statuses for Windows indicate end of processing of the command + // (there is no equivalent of "NotNow" or "Idle" as for Apple). + cmdRes = r + break + } + return cmd, cmdRes, nil +} + +func (ds *Datastore) getHostMDMAppleCommand(ctx context.Context, cmdUUID, hostUUID string) (*fleet.MDMCommand, *fleet.MDMCommandResult, error) { + cmd, err := ds.getMDMCommand(ctx, ds.reader(ctx), cmdUUID) + if err != nil { + return nil, nil, ctxerr.Wrap(ctx, err, "get Apple MDM command") + } + + // get the MDM command result, which may be not found (indicating the command + // is pending). Note that it doesn't return ErrNoRows if not found, it + // returns success and an empty cmdRes slice. + cmdResults, err := ds.GetMDMAppleCommandResults(ctx, cmdUUID) + if err != nil { + return nil, nil, ctxerr.Wrap(ctx, err, "get Apple MDM command result") + } + + // each item in the slice returned by GetMDMAppleCommandResults is + // potentially a result for a different host, we need to find the one for + // that specific host. + var cmdRes *fleet.MDMCommandResult + for _, r := range cmdResults { + if r.HostUUID != hostUUID { + continue + } + if r.Status == fleet.MDMAppleStatusAcknowledged || r.Status == fleet.MDMAppleStatusError || r.Status == fleet.MDMAppleStatusCommandFormatError { + cmdRes = r + break + } + } + return cmd, cmdRes, nil +} + // LockHostViaScript will create the script execution request and update // host_mdm_actions in a single transaction. -func (ds *Datastore) LockHostViaScript(ctx context.Context, request *fleet.HostScriptRequestPayload) error { +func (ds *Datastore) LockHostViaScript(ctx context.Context, request *fleet.HostScriptRequestPayload, hostFleetPlatform string) error { var res *fleet.HostScriptResult return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { var err error @@ -761,9 +839,9 @@ func (ds *Datastore) LockHostViaScript(ctx context.Context, request *fleet.HostS ( host_id, lock_ref, - unlock_ref + fleet_platform ) - VALUES (?,?,NULL) + VALUES (?,?,?) ON DUPLICATE KEY UPDATE lock_ref = VALUES(lock_ref) ` @@ -771,6 +849,7 @@ func (ds *Datastore) LockHostViaScript(ctx context.Context, request *fleet.HostS _, err = tx.ExecContext(ctx, stmt, request.HostID, res.ExecutionID, + hostFleetPlatform, ) if err != nil { return ctxerr.Wrap(ctx, err, "lock host via script update mdm actions") @@ -782,7 +861,7 @@ func (ds *Datastore) LockHostViaScript(ctx context.Context, request *fleet.HostS // UnlockHostViaScript will create the script execution request and update // host_mdm_actions in a single transaction. -func (ds *Datastore) UnlockHostViaScript(ctx context.Context, request *fleet.HostScriptRequestPayload) error { +func (ds *Datastore) UnlockHostViaScript(ctx context.Context, request *fleet.HostScriptRequestPayload, hostFleetPlatform string) error { var res *fleet.HostScriptResult return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { var err error @@ -810,9 +889,9 @@ func (ds *Datastore) UnlockHostViaScript(ctx context.Context, request *fleet.Hos ( host_id, unlock_ref, - lock_ref + fleet_platform ) - VALUES (?,?,NULL) + VALUES (?,?,?) ON DUPLICATE KEY UPDATE unlock_ref = VALUES(unlock_ref), unlock_pin = NULL @@ -821,6 +900,7 @@ func (ds *Datastore) UnlockHostViaScript(ctx context.Context, request *fleet.Hos _, err = tx.ExecContext(ctx, stmt, request.HostID, res.ExecutionID, + hostFleetPlatform, ) if err != nil { return ctxerr.Wrap(ctx, err, "unlock host via script update mdm actions") @@ -830,14 +910,55 @@ func (ds *Datastore) UnlockHostViaScript(ctx context.Context, request *fleet.Hos }) } -func (ds *Datastore) UnlockHostManually(ctx context.Context, hostID uint, ts time.Time) error { +// WipeHostViaScript creates the script execution request and updates the +// host_mdm_actions table in a single transaction. +func (ds *Datastore) WipeHostViaScript(ctx context.Context, request *fleet.HostScriptRequestPayload, hostFleetPlatform string) error { + var res *fleet.HostScriptResult + return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + var err error + res, err = newHostScriptExecutionRequest(ctx, request, tx) + if err != nil { + return ctxerr.Wrap(ctx, err, "wipe host via script create execution") + } + + // on duplicate we don't clear any other existing state because at this + // point in time, this is just a request to wipe the host that is recorded, + // it is pending execution, so if it was locked, it is still locked (so the + // lock_ref info must still be there). + const stmt = ` + INSERT INTO host_mdm_actions + ( + host_id, + wipe_ref, + fleet_platform + ) + VALUES (?,?,?) + ON DUPLICATE KEY UPDATE + wipe_ref = VALUES(wipe_ref) + ` + + _, err = tx.ExecContext(ctx, stmt, + request.HostID, + res.ExecutionID, + hostFleetPlatform, + ) + if err != nil { + return ctxerr.Wrap(ctx, err, "wipe host via script update mdm actions") + } + + return err + }) +} + +func (ds *Datastore) UnlockHostManually(ctx context.Context, hostID uint, hostFleetPlatform string, ts time.Time) error { const stmt = ` INSERT INTO host_mdm_actions ( host_id, - unlock_ref + unlock_ref, + fleet_platform ) - VALUES (?, ?) + VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE -- do not overwrite if a value is already set unlock_ref = IF(unlock_ref IS NULL, VALUES(unlock_ref), unlock_ref) @@ -848,16 +969,22 @@ func (ds *Datastore) UnlockHostManually(ctx context.Context, hostID uint, ts tim // entering a PIN on the device). The /unlock endpoint can be called multiple // times, so we record the timestamp of the first time it was requested and // from then on, the host is marked as "pending unlock" until the device is - // actually unlocked with the PIN. - // TODO(mna): to be determined how we then get notified that it has been - // unlocked, so that it can transition to unlocked (not pending). + // actually unlocked with the PIN. The actual unlocking happens when the + // device sends an Idle MDM request. unlockRef := ts.Format(time.DateTime) - _, err := ds.writer(ctx).ExecContext(ctx, stmt, hostID, unlockRef) + _, err := ds.writer(ctx).ExecContext(ctx, stmt, hostID, unlockRef, hostFleetPlatform) return ctxerr.Wrap(ctx, err, "record manual unlock host request") } -func (ds *Datastore) updateHostLockWipeStatusFromResult(ctx context.Context, tx sqlx.ExtContext, hostID uint, refCol string, succeeded bool) error { - stmt := `UPDATE host_mdm_actions SET %s WHERE host_id = ?` +func buildHostLockWipeStatusUpdateStmt(refCol string, succeeded bool, joinPart string) string { + var alias string + + stmt := `UPDATE host_mdm_actions ` + if joinPart != "" { + stmt += `hma ` + joinPart + alias = "hma." + } + stmt += ` SET ` if succeeded { switch refCol { @@ -865,23 +992,49 @@ func (ds *Datastore) updateHostLockWipeStatusFromResult(ctx context.Context, tx // Note that this must not clear the unlock_pin, because recording the // lock request does generate the PIN and store it there to be used by an // eventual unlock. - stmt = fmt.Sprintf(stmt, "unlock_ref = NULL") + stmt += fmt.Sprintf("%sunlock_ref = NULL, %[1]swipe_ref = NULL", alias) case "unlock_ref": // a successful unlock clears itself as well as the lock ref, because // unlock is the default state so we don't need to keep its unlock_ref // around once it's confirmed. - stmt = fmt.Sprintf(stmt, "lock_ref = NULL, unlock_ref = NULL, unlock_pin = NULL") + stmt += fmt.Sprintf("%slock_ref = NULL, %[1]sunlock_ref = NULL, %[1]sunlock_pin = NULL, %[1]swipe_ref = NULL", alias) case "wipe_ref": - // TODO(mna): implement when implementing the wipe story - default: - return ctxerr.Errorf(ctx, "unknown reference column %q", refCol) + stmt += fmt.Sprintf("%slock_ref = NULL, %[1]sunlock_ref = NULL, %[1]sunlock_pin = NULL", alias) } } else { // if the action failed, then we clear the reference to that action itself so // the host stays in the previous state (it doesn't transition to the new // state). - stmt = fmt.Sprintf(stmt, refCol+" = NULL") + stmt += fmt.Sprintf("%s"+refCol+" = NULL", alias) } + return stmt +} + +func (ds *Datastore) UpdateHostLockWipeStatusFromAppleMDMResult(ctx context.Context, hostUUID, cmdUUID, requestType string, succeeded bool) error { + // a bit of MDM protocol leaking in the mysql layer, but it's either that or + // the other way around (MDM protocol would translate to database column) + var refCol string + switch requestType { + case "EraseDevice": + refCol = "wipe_ref" + case "DeviceLock": + refCol = "lock_ref" + default: + return nil + } + return updateHostLockWipeStatusFromResultAndHostUUID(ctx, ds.writer(ctx), hostUUID, refCol, cmdUUID, succeeded) +} + +func updateHostLockWipeStatusFromResultAndHostUUID(ctx context.Context, tx sqlx.ExtContext, hostUUID, refCol, cmdUUID string, succeeded bool) error { + stmt := buildHostLockWipeStatusUpdateStmt(refCol, succeeded, `JOIN hosts h ON hma.host_id = h.id`) + stmt += ` WHERE h.uuid = ? AND hma.` + refCol + ` = ?` + _, err := tx.ExecContext(ctx, stmt, hostUUID, cmdUUID) + return ctxerr.Wrap(ctx, err, "update host lock/wipe status from result via host uuid") +} + +func updateHostLockWipeStatusFromResult(ctx context.Context, tx sqlx.ExtContext, hostID uint, refCol string, succeeded bool) error { + stmt := buildHostLockWipeStatusUpdateStmt(refCol, succeeded, "") + stmt += ` WHERE host_id = ?` _, err := tx.ExecContext(ctx, stmt, hostID) return ctxerr.Wrap(ctx, err, "update host lock/wipe status from result") } diff --git a/server/datastore/mysql/scripts_test.go b/server/datastore/mysql/scripts_test.go index 659784c20a..5ad11770cd 100644 --- a/server/datastore/mysql/scripts_test.go +++ b/server/datastore/mysql/scripts_test.go @@ -11,6 +11,7 @@ import ( "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/test" + "github.com/google/uuid" "github.com/jmoiron/sqlx" "github.com/stretchr/testify/require" ) @@ -29,7 +30,7 @@ func TestScripts(t *testing.T) { {"BatchSetScripts", testBatchSetScripts}, {"TestLockHostViaScript", testLockHostViaScript}, {"TestUnlockHostViaScript", testUnlockHostViaScript}, - {"TestLockUnlockViaScripts", testLockUnlockViaScripts}, + {"TestLockUnlockWipeViaScripts", testLockUnlockWipeViaScripts}, {"TestLockUnlockManually", testLockUnlockManually}, {"TestInsertScriptContents", testInsertScriptContents}, } @@ -731,12 +732,12 @@ func testLockHostViaScript(t *testing.T, ds *Datastore) { ScriptContents: script, UserID: &user.ID, SyncRequest: false, - }) + }, "windows") require.NoError(t, err) // verify that we have created entries in host_mdm_actions and host_script_results - status, err := ds.GetHostLockWipeStatus(ctx, windowsHostID, "windows") + status, err := ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: windowsHostID, Platform: "windows", UUID: "uuid"}) require.NoError(t, err) require.Equal(t, "windows", status.HostFleetPlatform) require.NotNil(t, status.LockScript) @@ -757,7 +758,7 @@ func testLockHostViaScript(t *testing.T, ds *Datastore) { }) require.NoError(t, err) - status, err = ds.GetHostLockWipeStatus(ctx, windowsHostID, "windows") + status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: windowsHostID, Platform: "windows", UUID: "uuid"}) require.NoError(t, err) require.True(t, status.IsLocked()) require.False(t, status.IsPendingLock()) @@ -782,12 +783,12 @@ func testUnlockHostViaScript(t *testing.T, ds *Datastore) { ScriptContents: script, UserID: &user.ID, SyncRequest: false, - }) + }, "windows") require.NoError(t, err) // verify that we have created entries in host_mdm_actions and host_script_results - status, err := ds.GetHostLockWipeStatus(ctx, hostID, "windows") + status, err := ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: hostID, Platform: "windows", UUID: "uuid"}) require.NoError(t, err) require.Equal(t, "windows", status.HostFleetPlatform) require.NotNil(t, status.UnlockScript) @@ -808,14 +809,14 @@ func testUnlockHostViaScript(t *testing.T, ds *Datastore) { }) require.NoError(t, err) - status, err = ds.GetHostLockWipeStatus(ctx, hostID, "windows") + status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: hostID, Platform: "windows", UUID: "uuid"}) require.NoError(t, err) require.True(t, status.IsUnlocked()) require.False(t, status.IsPendingUnlock()) require.False(t, status.IsLocked()) } -func testLockUnlockViaScripts(t *testing.T, ds *Datastore) { +func testLockUnlockWipeViaScripts(t *testing.T, ds *Datastore) { ctx := context.Background() user := test.NewUser(t, ds, "Bob", "bob@example.com", true) @@ -823,7 +824,7 @@ func testLockUnlockViaScripts(t *testing.T, ds *Datastore) { hostID := uint(i + 1) t.Run(platform, func(t *testing.T) { - status, err := ds.GetHostLockWipeStatus(ctx, hostID, platform) + status, err := ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: hostID, Platform: platform, UUID: "uuid"}) require.NoError(t, err) // default state @@ -835,10 +836,10 @@ func testLockUnlockViaScripts(t *testing.T, ds *Datastore) { ScriptContents: "lock", UserID: &user.ID, SyncRequest: false, - }) + }, platform) require.NoError(t, err) - status, err = ds.GetHostLockWipeStatus(ctx, hostID, platform) + status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: hostID, Platform: platform, UUID: "uuid"}) require.NoError(t, err) checkLockWipeState(t, status, true, false, false, false, true, false) @@ -850,7 +851,7 @@ func testLockUnlockViaScripts(t *testing.T, ds *Datastore) { }) require.NoError(t, err) - status, err = ds.GetHostLockWipeStatus(ctx, hostID, platform) + status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: hostID, Platform: platform, UUID: "uuid"}) require.NoError(t, err) checkLockWipeState(t, status, false, true, false, false, false, false) @@ -860,10 +861,10 @@ func testLockUnlockViaScripts(t *testing.T, ds *Datastore) { ScriptContents: "unlock", UserID: &user.ID, SyncRequest: false, - }) + }, platform) require.NoError(t, err) - status, err = ds.GetHostLockWipeStatus(ctx, hostID, platform) + status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: hostID, Platform: platform, UUID: "uuid"}) require.NoError(t, err) checkLockWipeState(t, status, false, true, false, true, false, false) @@ -876,7 +877,7 @@ func testLockUnlockViaScripts(t *testing.T, ds *Datastore) { require.NoError(t, err) // still locked - status, err = ds.GetHostLockWipeStatus(ctx, hostID, platform) + status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: hostID, Platform: platform, UUID: "uuid"}) require.NoError(t, err) checkLockWipeState(t, status, false, true, false, false, false, false) @@ -886,10 +887,10 @@ func testLockUnlockViaScripts(t *testing.T, ds *Datastore) { ScriptContents: "unlock", UserID: &user.ID, SyncRequest: false, - }) + }, platform) require.NoError(t, err) - status, err = ds.GetHostLockWipeStatus(ctx, hostID, platform) + status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: hostID, Platform: platform, UUID: "uuid"}) require.NoError(t, err) checkLockWipeState(t, status, false, true, false, true, false, false) @@ -902,7 +903,7 @@ func testLockUnlockViaScripts(t *testing.T, ds *Datastore) { require.NoError(t, err) // host is now unlocked - status, err = ds.GetHostLockWipeStatus(ctx, hostID, platform) + status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: hostID, Platform: platform, UUID: "uuid"}) require.NoError(t, err) checkLockWipeState(t, status, true, false, false, false, false, false) @@ -912,10 +913,10 @@ func testLockUnlockViaScripts(t *testing.T, ds *Datastore) { ScriptContents: "lock", UserID: &user.ID, SyncRequest: false, - }) + }, platform) require.NoError(t, err) - status, err = ds.GetHostLockWipeStatus(ctx, hostID, platform) + status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: hostID, Platform: platform, UUID: "uuid"}) require.NoError(t, err) checkLockWipeState(t, status, true, false, false, false, true, false) @@ -927,9 +928,93 @@ func testLockUnlockViaScripts(t *testing.T, ds *Datastore) { }) require.NoError(t, err) - status, err = ds.GetHostLockWipeStatus(ctx, hostID, platform) + status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: hostID, Platform: platform, UUID: "uuid"}) require.NoError(t, err) checkLockWipeState(t, status, true, false, false, false, false, false) + + switch platform { + case "windows": + // need a real MDM-enrolled host for MDM commands + h, err := ds.NewHost(ctx, &fleet.Host{ + Hostname: "test-host-windows", + OsqueryHostID: ptr.String("osquery-windows"), + NodeKey: ptr.String("nodekey-windows"), + UUID: "test-uuid-windows", + Platform: "windows", + }) + require.NoError(t, err) + windowsEnroll(t, ds, h) + + // record a request to wipe the host + wipeCmdUUID := uuid.NewString() + wipeCmd := &fleet.MDMWindowsCommand{ + CommandUUID: wipeCmdUUID, + RawCommand: []byte(``), + TargetLocURI: "./Device/Vendor/MSFT/RemoteWipe/doWipeProtected", + } + err = ds.WipeHostViaWindowsMDM(ctx, h, wipeCmd) + require.NoError(t, err) + + status, err = ds.GetHostLockWipeStatus(ctx, h) + require.NoError(t, err) + checkLockWipeState(t, status, true, false, false, false, false, true) + + // TODO: we don't seem to have an easy way to simulate a Windows MDM + // protocol response, and there are lots of validations happening so we + // can't just send a simple XML. Will test the rest via integration + // tests. + + case "linux": + // record a request to wipe the host + err = ds.WipeHostViaScript(ctx, &fleet.HostScriptRequestPayload{ + HostID: hostID, + ScriptContents: "wipe", + UserID: &user.ID, + SyncRequest: false, + }, platform) + require.NoError(t, err) + + status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: hostID, Platform: platform, UUID: "uuid"}) + require.NoError(t, err) + checkLockWipeState(t, status, true, false, false, false, false, true) + + // simulate a failed result for the wipe script execution + _, err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{ + HostID: hostID, + ExecutionID: status.WipeScript.ExecutionID, + ExitCode: 1, + }) + require.NoError(t, err) + + status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: hostID, Platform: platform, UUID: "uuid"}) + require.NoError(t, err) + checkLockWipeState(t, status, true, false, false, false, false, false) + + // record another request to wipe the host + err = ds.WipeHostViaScript(ctx, &fleet.HostScriptRequestPayload{ + HostID: hostID, + ScriptContents: "wipe2", + UserID: &user.ID, + SyncRequest: false, + }, platform) + require.NoError(t, err) + + status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: hostID, Platform: platform, UUID: "uuid"}) + require.NoError(t, err) + checkLockWipeState(t, status, true, false, false, false, false, true) + + // simulate a successful result for the wipe script execution + _, err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{ + HostID: hostID, + ExecutionID: status.WipeScript.ExecutionID, + ExitCode: 0, + }) + require.NoError(t, err) + + status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: hostID, Platform: platform, UUID: "uuid"}) + require.NoError(t, err) + checkLockWipeState(t, status, false, false, true, false, false, false) + } }) } } @@ -939,19 +1024,19 @@ func testLockUnlockManually(t *testing.T, ds *Datastore) { twoDaysAgo := time.Now().AddDate(0, 0, -2).UTC() today := time.Now().UTC() - err := ds.UnlockHostManually(ctx, 1, twoDaysAgo) + err := ds.UnlockHostManually(ctx, 1, "darwin", twoDaysAgo) require.NoError(t, err) - status, err := ds.GetHostLockWipeStatus(ctx, 1, "darwin") + status, err := ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: 1, Platform: "darwin", UUID: "uuid"}) require.NoError(t, err) require.False(t, status.UnlockRequestedAt.IsZero()) require.WithinDuration(t, twoDaysAgo, status.UnlockRequestedAt, 1*time.Second) // if the unlock request already exists, it is not overwritten by subsequent // requests - err = ds.UnlockHostManually(ctx, 1, today) + err = ds.UnlockHostManually(ctx, 1, "darwin", today) require.NoError(t, err) - status, err = ds.GetHostLockWipeStatus(ctx, 1, "darwin") + status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: 1, Platform: "darwin", UUID: "uuid"}) require.NoError(t, err) require.False(t, status.UnlockRequestedAt.IsZero()) require.WithinDuration(t, twoDaysAgo, status.UnlockRequestedAt, 1*time.Second) @@ -962,9 +1047,9 @@ func testLockUnlockManually(t *testing.T, ds *Datastore) { _, err := tx.ExecContext(ctx, "INSERT INTO host_mdm_actions (host_id) VALUES (2)") return err }) - err = ds.UnlockHostManually(ctx, 2, today) + err = ds.UnlockHostManually(ctx, 2, "darwin", today) require.NoError(t, err) - status, err = ds.GetHostLockWipeStatus(ctx, 2, "darwin") + status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: 2, Platform: "darwin", UUID: "uuid"}) require.NoError(t, err) require.False(t, status.UnlockRequestedAt.IsZero()) require.WithinDuration(t, today, status.UnlockRequestedAt, 1*time.Second) diff --git a/server/datastore/mysql/targets.go b/server/datastore/mysql/targets.go index 04b618eb03..16c185ed38 100644 --- a/server/datastore/mysql/targets.go +++ b/server/datastore/mysql/targets.go @@ -85,7 +85,7 @@ func targetSQLCondAndArgs(targets fleet.HostTargets) (sql string, args []interfa AND /* A team filter was not specified OR if it was specified then the host must be a * member of one of the teams. */ - (? /* !teamsSpecified */ OR team_id IN (? /* queryTeamIDs */)) + (? /* !teamsSpecified */ OR team_id IN (? /* queryTeamIDs */) %s) ) )` @@ -102,14 +102,19 @@ func targetSQLCondAndArgs(targets fleet.HostTargets) (sql string, args []interfa queryHostIDs = append(queryHostIDs, int(id)) } queryTeamIDs := []int{-1} + extraTeamIDCondition := "" for _, id := range targets.TeamIDs { + if id == 0 { + extraTeamIDCondition = "OR team_id IS NULL" + continue + } queryTeamIDs = append(queryTeamIDs, int(id)) } labelsSpecified := len(queryLabelIDs) > 1 - teamsSpecified := len(queryTeamIDs) > 1 + teamsSpecified := len(queryTeamIDs) > 1 || extraTeamIDCondition != "" - return queryTargetLogicCondition, []interface{}{ + return fmt.Sprintf(queryTargetLogicCondition, extraTeamIDCondition), []interface{}{ queryHostIDs, queryLabelIDs, labelsSpecified, teamsSpecified, diff --git a/server/datastore/mysql/targets_test.go b/server/datastore/mysql/targets_test.go index 530e40a2c6..66e4bc14af 100644 --- a/server/datastore/mysql/targets_test.go +++ b/server/datastore/mysql/targets_test.go @@ -153,6 +153,23 @@ func testTargetsCountHosts(t *testing.T, ds *Datastore) { assert.Equal(t, uint(0), metrics.OfflineHosts) assert.Equal(t, uint(0), metrics.MissingInActionHosts) + // Get 'No team' hosts + metrics, err = ds.CountHostsInTargets(context.Background(), filter, fleet.HostTargets{TeamIDs: []uint{0}}, mockClock.Now()) + require.Nil(t, err) + assert.Equal(t, uint(2), metrics.TotalHosts) + assert.Equal(t, uint(1), metrics.OnlineHosts) + assert.Equal(t, uint(1), metrics.OfflineHosts) + assert.Equal(t, uint(1), metrics.MissingInActionHosts) + + metrics, err = ds.CountHostsInTargets( + context.Background(), filter, fleet.HostTargets{TeamIDs: []uint{team1.ID, team3.ID, 0}}, mockClock.Now(), + ) + require.Nil(t, err) + assert.Equal(t, uint(3), metrics.TotalHosts) + assert.Equal(t, uint(2), metrics.OnlineHosts) + assert.Equal(t, uint(1), metrics.OfflineHosts) + assert.Equal(t, uint(1), metrics.MissingInActionHosts) + metrics, err = ds.CountHostsInTargets(context.Background(), filter, fleet.HostTargets{TeamIDs: []uint{team1.ID, team3.ID}}, mockClock.Now()) require.Nil(t, err) assert.Equal(t, uint(1), metrics.TotalHosts) @@ -593,6 +610,16 @@ func testTargetsHostIDsInTargets(t *testing.T, ds *Datastore) { name: "No selection", expectedHostIDs: []uint{}, }, + { + name: "'No Team' team selection", + targetTeamIDs: []uint{0}, + expectedHostIDs: []uint{h5.ID, h6.ID}, + }, + { + name: "'No Team' team, one team, and an empty team selection", + targetTeamIDs: []uint{t1.ID, 0, t3.ID}, + expectedHostIDs: []uint{h1.ID, h5.ID, h6.ID}, + }, { name: "One team and an empty team selection", targetTeamIDs: []uint{t1.ID, t3.ID}, diff --git a/server/fleet/activities.go b/server/fleet/activities.go index ed4352110a..2fb7a4dbc0 100644 --- a/server/fleet/activities.go +++ b/server/fleet/activities.go @@ -83,6 +83,7 @@ var ActivityDetailsList = []ActivityDetails{ ActivityTypeLockedHost{}, ActivityTypeUnlockedHost{}, + ActivityTypeWipedHost{}, } type ActivityDetails interface { @@ -1234,6 +1235,20 @@ type ActivityTypeEditedWindowsProfile struct { TeamName *string `json:"team_name"` } +func (a ActivityTypeEditedWindowsProfile) ActivityName() string { + return "edited_windows_profile" +} + +func (a ActivityTypeEditedWindowsProfile) Documentation() (activity, details, detailsExample string) { + return `Generated when a user edits the Windows profiles of a team (or no team) via the fleetctl CLI.`, + `This activity contains the following fields: +- "team_id": The ID of the team that the profiles apply to, ` + "`null`" + ` if they apply to devices that are not in a team. +- "team_name": The name of the team that the profiles apply to, ` + "`null`" + ` if they apply to devices that are not in a team.`, `{ + "team_id": 123, + "team_name": "Workstations" +}` +} + type ActivityTypeLockedHost struct { HostID uint `json:"host_id"` HostDisplayName string `json:"host_display_name"` @@ -1283,17 +1298,22 @@ func (a ActivityTypeUnlockedHost) Documentation() (activity, details, detailsExa }` } -func (a ActivityTypeEditedWindowsProfile) ActivityName() string { - return "edited_windows_profile" +type ActivityTypeWipedHost struct { + HostID uint `json:"host_id"` + HostDisplayName string `json:"host_display_name"` } -func (a ActivityTypeEditedWindowsProfile) Documentation() (activity, details, detailsExample string) { - return `Generated when a user edits the Windows profiles of a team (or no team) via the fleetctl CLI.`, +func (a ActivityTypeWipedHost) ActivityName() string { + return "wiped_host" +} + +func (a ActivityTypeWipedHost) Documentation() (activity, details, detailsExample string) { + return `Generated when a user sends a request to wipe a host.`, `This activity contains the following fields: -- "team_id": The ID of the team that the profiles apply to, ` + "`null`" + ` if they apply to devices that are not in a team. -- "team_name": The name of the team that the profiles apply to, ` + "`null`" + ` if they apply to devices that are not in a team.`, `{ - "team_id": 123, - "team_name": "Workstations" +- "host_id": ID of the host. +- "host_display_name": Display name of the host.`, `{ + "host_id": 1, + "host_display_name": "Anna's MacBook Pro" }` } diff --git a/server/fleet/apple_mdm.go b/server/fleet/apple_mdm.go index a76465f5bb..02a0b65f47 100644 --- a/server/fleet/apple_mdm.go +++ b/server/fleet/apple_mdm.go @@ -19,7 +19,7 @@ type MDMAppleCommandIssuer interface { InstallProfile(ctx context.Context, hostUUIDs []string, profile mobileconfig.Mobileconfig, uuid string) error RemoveProfile(ctx context.Context, hostUUIDs []string, identifier string, uuid string) error DeviceLock(ctx context.Context, host *Host, uuid string) error - EraseDevice(ctx context.Context, hostUUIDs []string, uuid string) error + EraseDevice(ctx context.Context, host *Host, uuid string) error InstallEnterpriseApplication(ctx context.Context, hostUUIDs []string, uuid string, manifestURL string) error } @@ -442,6 +442,14 @@ func (h *HostDEPAssignment) IsDEPAssignedToFleet() bool { return h.HostID > 0 && !h.AddedAt.IsZero() && h.DeletedAt == nil } +type DEPAssignProfileResponseStatus string + +const ( + DEPAssignProfileResponseSuccess DEPAssignProfileResponseStatus = "SUCCESS" + DEPAssignProfileResponseNotAccessible DEPAssignProfileResponseStatus = "NOT_ACCESSIBLE" + DEPAssignProfileResponseFailed DEPAssignProfileResponseStatus = "FAILED" +) + // NanoEnrollment represents a row in the nano_enrollments table managed by // nanomdm. It is meant to be used internally by the server, not to be returned // as part of endpoints, and as a precaution its json-encoding is explicitly diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 001349d46f..26071c8471 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -272,7 +272,7 @@ type Datastore interface { // AddHostsToTeam adds hosts to an existing team, clearing their team settings if teamID is nil. AddHostsToTeam(ctx context.Context, teamID *uint, hostIDs []uint) error - TotalAndUnseenHostsSince(ctx context.Context, daysCount int) (total int, unseen int, err error) + TotalAndUnseenHostsSince(ctx context.Context, teamID *uint, daysCount int) (total int, unseen []uint, err error) // DeleteHosts deletes associated tables for multiple hosts. // @@ -794,6 +794,9 @@ type Datastore interface { // SetCommandForPendingSCEPRenewal tracks the command used to renew a scep certificate SetCommandForPendingSCEPRenewal(ctx context.Context, assocs []SCEPIdentityAssociation, cmdUUID string) error + // CleanSCEPRenewRefs cleans all references after a successful SCEP renewal. + CleanSCEPRenewRefs(ctx context.Context, hostUUID string) error + // UpdateVerificationHostMacOSProfiles updates status of macOS profiles installed on a given // host. The toVerify, toFail, and toRetry slices contain the identifiers of the profiles that // should be verified, failed, and retried, respectively. For each profile in the toRetry slice, @@ -1132,6 +1135,21 @@ type Datastore interface { // host_dep_assignments for host with matching serials. DeleteHostDEPAssignments(ctx context.Context, serials []string) error + // UpdateHostDEPAssignProfileResponses receives a profile UUID and threes lists of serials, each representing + // one of the three possible responses, and updates the host_dep_assignments table with the corresponding responses. + UpdateHostDEPAssignProfileResponses(ctx context.Context, resp *godep.ProfileResponse) error + + // ScreenDEPAssignProfileSerialsForCooldown returns the serials that are still in cooldown and the + // ones that are ready to be assigned a profile. If `screenRetryJobs` is true, it will also skip + // any serials that have a non-zero `retry_job_id`. + ScreenDEPAssignProfileSerialsForCooldown(ctx context.Context, serials []string) (skipSerials []string, assignSerials []string, err error) + // GetDEPAssignProfileExpiredCooldowns returns the serials of the hosts that have expired + // cooldowns, grouped by team. + GetDEPAssignProfileExpiredCooldowns(ctx context.Context) (map[uint][]string, error) + // UpdateDEPAssignProfileRetryPending sets the retry_pending flag for the hosts with the given + // serials. + UpdateDEPAssignProfileRetryPending(ctx context.Context, jobID uint, serials []string) error + /////////////////////////////////////////////////////////////////////////////// // Microsoft MDM @@ -1297,24 +1315,38 @@ type Datastore interface { BatchSetScripts(ctx context.Context, tmID *uint, scripts []*Script) error // GetHostLockWipeStatus gets the lock/unlock and wipe status for the host. - GetHostLockWipeStatus(ctx context.Context, hostID uint, fleetPlatform string) (*HostLockWipeStatus, error) + GetHostLockWipeStatus(ctx context.Context, host *Host) (*HostLockWipeStatus, error) // LockHostViaScript sends a script to lock a host and updates the // states in host_mdm_actions - LockHostViaScript(ctx context.Context, request *HostScriptRequestPayload) error + LockHostViaScript(ctx context.Context, request *HostScriptRequestPayload, hostFleetPlatform string) error // UnlockHostViaScript sends a script to unlock a host and updates the // states in host_mdm_actions - UnlockHostViaScript(ctx context.Context, request *HostScriptRequestPayload) error + UnlockHostViaScript(ctx context.Context, request *HostScriptRequestPayload, hostFleetPlatform string) error // UnlockHostmanually records a request to unlock a host that requires manual // intervention (such as for macOS). It indicates the an unlock request is // pending. - UnlockHostManually(ctx context.Context, hostID uint, ts time.Time) error + UnlockHostManually(ctx context.Context, hostID uint, hostFleetPlatform string, ts time.Time) error // CleanMacOSMDMLock cleans the lock status and pin for a macOS device // after it has been unlocked. CleanMacOSMDMLock(ctx context.Context, hostUUID string) error + + // WipeHostViaScript sends a script to wipe a host and updates the + // states in host_mdm_actions. + WipeHostViaScript(ctx context.Context, request *HostScriptRequestPayload, hostFleetPlatform string) error + + // WipeHostViaWindowsMDM sends a Windows MDM command to wipe a host and + // updates the states in host_mdm_actions. + WipeHostViaWindowsMDM(ctx context.Context, host *Host, cmd *MDMWindowsCommand) error + + // UpdateHostLockWipeStatusFromAppleMDMResult updates the host_mdm_actions + // table to reflect the result of the corresponding lock/wipe MDM command for + // Apple hosts. It is optimized to update using only the information + // available in the Apple MDM protocol. + UpdateHostLockWipeStatusFromAppleMDMResult(ctx context.Context, hostUUID, cmdUUID, requestType string, succeeded bool) error } // MDMAppleStore wraps nanomdm's storage and adds methods to deal with @@ -1322,6 +1354,7 @@ type Datastore interface { type MDMAppleStore interface { storage.AllStorage EnqueueDeviceLockCommand(ctx context.Context, host *Host, cmd *mdm.Command, pin string) error + EnqueueDeviceWipeCommand(ctx context.Context, host *Host, cmd *mdm.Command) error } // Cloner represents any type that can clone itself. Used for the cached_mysql diff --git a/server/fleet/hosts.go b/server/fleet/hosts.go index 8028ce5a35..f9815f7e4a 100644 --- a/server/fleet/hosts.go +++ b/server/fleet/hosts.go @@ -182,6 +182,9 @@ type HostListOptions struct { // PopulateSoftware adds the `Software` field to all Hosts returned. PopulateSoftware bool + // PopulatePolicies adds the `Policies` array field to all Hosts returned. + PopulatePolicies bool + // VulnerabilityFilter filters the hosts by the presence of a vulnerability (CVE) VulnerabilityFilter *string } @@ -339,6 +342,9 @@ type Host struct { // LastRestartedAt is a UNIX timestamp that indicates when the Host was last restarted. LastRestartedAt time.Time `json:"last_restarted_at" db:"last_restarted_at" csv:"last_restarted_at"` + + // Policies is the list of policies and whether it passes for the host + Policies *[]*HostPolicy `json:"policies,omitempty" csv:"-"` } // HostHealth contains a subset of Host data that indicates how healthy a Host is. For fields with @@ -364,6 +370,10 @@ type MDMHostData struct { // EnrollmentStatus is a string representation of state derived from // booleans stored in the host_mdm table, loaded by JOIN in datastore EnrollmentStatus *string `json:"enrollment_status" db:"-" csv:"mdm.enrollment_status"` + // DEPProfileError is a boolean representing whether Fleet received a "FAILED" response when + // attempting to assign a DEP profile for the host. + // See https://developer.apple.com/documentation/devicemanagement/assignprofileresponse + DEPProfileError bool `json:"dep_profile_error" db:"dep_profile_error" csv:"mdm.dep_profile_error"` // ServerURL is the server_url stored in the host_mdm table, loaded by // JOIN in datastore ServerURL *string `json:"server_url" db:"-" csv:"mdm.server_url"` @@ -505,10 +515,10 @@ func (d *MDMHostData) PopulateOSSettingsAndMacOSSettings(profiles []HostMDMApple if d.rawDecryptable != nil && *d.rawDecryptable == 1 { // if a FileVault profile has been successfully installed on the host // AND we have fetched and are able to decrypt the key - switch { - case *fvprof.Status == MDMDeliveryVerifying: + switch *fvprof.Status { + case MDMDeliveryVerifying: settings.DiskEncryption = DiskEncryptionVerifying.addrOf() - case *fvprof.Status == MDMDeliveryVerified: + case MDMDeliveryVerified: settings.DiskEncryption = DiskEncryptionVerified.addrOf() } } else if d.rawDecryptable != nil { @@ -525,7 +535,12 @@ func (d *MDMHostData) PopulateOSSettingsAndMacOSSettings(profiles []HostMDMApple // if [a FileVault profile is pending to be installed or] the // matching row in host_disk_encryption_keys has a field decryptable // = NULL - settings.DiskEncryption = DiskEncryptionEnforcing.addrOf() + switch *fvprof.Status { + case MDMDeliveryVerifying, MDMDeliveryVerified: + settings.DiskEncryption = DiskEncryptionVerifying.addrOf() + case MDMDeliveryPending: + settings.DiskEncryption = DiskEncryptionEnforcing.addrOf() + } } case fvprof.Status != nil && *fvprof.Status == MDMDeliveryFailed: @@ -713,8 +728,6 @@ type HostDetail struct { Labels []*Label `json:"labels"` // Packs is the list of packs the host is a member of. Packs []*Pack `json:"packs"` - // Policies is the list of policies and whether it passes for the host - Policies *[]*HostPolicy `json:"policies,omitempty"` // Batteries is the list of batteries for the host. It is a pointer to a // slice so that when set, it gets marhsaled even if the slice is empty, // but when unset, it doesn't get marshaled (e.g. we don't return that @@ -1172,12 +1185,13 @@ type EnrollHostLimiter interface { } type HostMDMCheckinInfo struct { - HardwareSerial string `json:"hardware_serial" db:"hardware_serial"` - InstalledFromDEP bool `json:"installed_from_dep" db:"installed_from_dep"` - DisplayName string `json:"display_name" db:"display_name"` - TeamID uint `json:"team_id" db:"team_id"` - DEPAssignedToFleet bool `json:"dep_assigned_to_fleet" db:"dep_assigned_to_fleet"` - OsqueryEnrolled bool `json:"osquery_enrolled" db:"osquery_enrolled"` + HardwareSerial string `json:"hardware_serial" db:"hardware_serial"` + InstalledFromDEP bool `json:"installed_from_dep" db:"installed_from_dep"` + DisplayName string `json:"display_name" db:"display_name"` + TeamID uint `json:"team_id" db:"team_id"` + DEPAssignedToFleet bool `json:"dep_assigned_to_fleet" db:"dep_assigned_to_fleet"` + OsqueryEnrolled bool `json:"osquery_enrolled" db:"osquery_enrolled"` + SCEPRenewalInProgress bool `json:"-" db:"scep_renewal_in_progress"` } type HostDiskEncryptionKey struct { diff --git a/server/fleet/mdm.go b/server/fleet/mdm.go index a4de548c3b..dbb3808aab 100644 --- a/server/fleet/mdm.go +++ b/server/fleet/mdm.go @@ -192,7 +192,8 @@ type MDMCommandResult struct { HostUUID string `json:"host_uuid" db:"host_uuid"` // CommandUUID is the unique identifier of the command. CommandUUID string `json:"command_uuid" db:"command_uuid"` - // Status is the command status. One of Acknowledged, Error, or NotNow. + // Status is the command status. One of Acknowledged, Error, or NotNow for + // Apple, or 200, 400, etc for Windows. Status string `json:"status" db:"status"` // UpdatedAt is the last update timestamp of the command result. UpdatedAt time.Time `json:"updated_at" db:"updated_at"` diff --git a/server/fleet/scripts.go b/server/fleet/scripts.go index 194b1eb9eb..9182a2a5c6 100644 --- a/server/fleet/scripts.go +++ b/server/fleet/scripts.go @@ -315,7 +315,12 @@ type HostLockWipeStatus struct { // windows and linux hosts use a script to unlock UnlockScript *HostScriptResult - // TODO: add wipe status when implementing the Wipe story. + // macOS and Windows use MDM commands for Wipe + WipeMDMCommand *MDMCommand + WipeMDMCommandResult *MDMCommandResult + + // Linux uses a script for Wipe + WipeScript *HostScriptResult } func (s *HostLockWipeStatus) IsPendingLock() bool { @@ -337,8 +342,12 @@ func (s HostLockWipeStatus) IsPendingUnlock() bool { } func (s HostLockWipeStatus) IsPendingWipe() bool { - // TODO(mna): implement when addressing Wipe story, for now wipe is never pending - return false + if s.HostFleetPlatform == "linux" { + // pending wipe if script execution request is queued but no result yet + return s.WipeScript != nil && s.WipeScript.ExitCode == nil + } + // pending wipe if an MDM command is queued but no result received yet + return s.WipeMDMCommand != nil && s.WipeMDMCommandResult == nil } func (s HostLockWipeStatus) IsLocked() bool { @@ -362,6 +371,20 @@ func (s HostLockWipeStatus) IsUnlocked() bool { } func (s HostLockWipeStatus) IsWiped() bool { - // TODO(mna): implement when addressing Wipe story, for now never wiped - return false + switch s.HostFleetPlatform { + case "linux": + // wiped if script was sent and succeeded + return s.WipeScript != nil && s.WipeScript.ExitCode != nil && + *s.WipeScript.ExitCode == 0 + case "windows": + // wiped if an MDM command was sent and succeeded + return s.WipeMDMCommand != nil && s.WipeMDMCommandResult != nil && + strings.HasPrefix(s.WipeMDMCommandResult.Status, "2") + case "darwin": + // wiped if an MDM command was sent and succeeded + return s.WipeMDMCommand != nil && s.WipeMDMCommandResult != nil && + s.WipeMDMCommandResult.Status == MDMAppleStatusAcknowledged + default: + return false + } } diff --git a/server/fleet/service.go b/server/fleet/service.go index 7984af7f5f..8d3f9720c5 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -956,4 +956,5 @@ type Service interface { // Script-based methods (at least for some platforms, MDM-based for others) LockHost(ctx context.Context, hostID uint) error UnlockHost(ctx context.Context, hostID uint) (unlockPIN string, err error) + WipeHost(ctx context.Context, hostID uint) error } diff --git a/server/fleet/teams.go b/server/fleet/teams.go index f0e124ab14..110bddb718 100644 --- a/server/fleet/teams.go +++ b/server/fleet/teams.go @@ -148,6 +148,7 @@ type TeamConfig struct { } type TeamWebhookSettings struct { + HostStatusWebhook HostStatusWebhookSettings `json:"host_status_webhook"` FailingPoliciesWebhook FailingPoliciesWebhookSettings `json:"failing_policies_webhook"` } @@ -395,12 +396,17 @@ type TeamSpec struct { // If the agent_options key is present but empty in the YAML, will be set to // "null" (JSON null). Otherwise, if the key is present and set, it will be // set to the agent options JSON object. - AgentOptions json.RawMessage `json:"agent_options,omitempty"` // marshals as "null" if omitempty is not set - HostExpirySettings *HostExpirySettings `json:"host_expiry_settings,omitempty"` - Secrets []EnrollSecret `json:"secrets,omitempty"` - Features *json.RawMessage `json:"features"` - MDM TeamSpecMDM `json:"mdm"` - Scripts optjson.Slice[string] `json:"scripts"` + AgentOptions json.RawMessage `json:"agent_options,omitempty"` // marshals as "null" if omitempty is not set + HostExpirySettings *HostExpirySettings `json:"host_expiry_settings,omitempty"` + Secrets []EnrollSecret `json:"secrets,omitempty"` + Features *json.RawMessage `json:"features"` + MDM TeamSpecMDM `json:"mdm"` + Scripts optjson.Slice[string] `json:"scripts"` + WebhookSettings TeamSpecWebhookSettings `json:"webhook_settings"` +} + +type TeamSpecWebhookSettings struct { + HostStatusWebhook *HostStatusWebhookSettings `json:"host_status_webhook"` } // TeamSpecFromTeam returns a TeamSpec constructed from the given Team. diff --git a/server/fleet/users.go b/server/fleet/users.go index 0549b86416..94f22292bd 100644 --- a/server/fleet/users.go +++ b/server/fleet/users.go @@ -404,6 +404,7 @@ func saltAndHashPassword(keySize int, plaintext string, cost int) (hashed []byte return nil, "", err } + salt = salt[:keySize] withSalt := []byte(fmt.Sprintf("%s%s", plaintext, salt)) hashed, err = bcrypt.GenerateFromPassword(withSalt, cost) if err != nil { diff --git a/server/fleet/users_test.go b/server/fleet/users_test.go index a1c9c12c78..1c4e45a655 100644 --- a/server/fleet/users_test.go +++ b/server/fleet/users_test.go @@ -164,11 +164,11 @@ func TestUserPasswordRequirements(t *testing.T) { } func TestSaltAndHashPassword(t *testing.T) { - passwordTests := []string{"foobar!!", "bazbing!!"} + goodTests := []string{"foobar!!", "bazbing!!", "foobarbaz!!!foobarbaz!!!foobarbaz!!!foobarbaz!!", "foobarbaz!!!foobarbaz!!!foobarbaz!!!foobarbaz!!!"} keySize := 24 cost := 10 - for _, pwd := range passwordTests { + for _, pwd := range goodTests { hashed, salt, err := saltAndHashPassword(keySize, pwd, cost) require.NoError(t, err) @@ -178,6 +178,14 @@ func TestSaltAndHashPassword(t *testing.T) { err = bcrypt.CompareHashAndPassword(hashed, []byte(fmt.Sprint("invalidpassword", salt))) require.Error(t, err) + + // too long + badTests := []string{"foobarbaz!!!foobarbaz!!!foobarbaz!!!foobarbaz!!!!"} + for _, pwd := range badTests { + _, _, err := saltAndHashPassword(keySize, pwd, cost) + require.Error(t, err) + + } } } diff --git a/server/fleet/windows_mdm.go b/server/fleet/windows_mdm.go index 427e705dd8..a83fa4d5f1 100644 --- a/server/fleet/windows_mdm.go +++ b/server/fleet/windows_mdm.go @@ -75,7 +75,7 @@ func (m *MDMWindowsConfigProfile) ValidateUserProvided() error { // NOTE: since we're only checking for well-formedness // we don't need to validate the required nesting // structure (Target>Item>LocURI) so we don't need to track all the tags. - var inReplace bool + var inValidNode bool var inLocURI bool for { @@ -96,24 +96,24 @@ func (m *MDMWindowsConfigProfile) ValidateUserProvided() error { case xml.StartElement: switch t.Name.Local { - case "Replace": - inReplace = true + case "Replace", "Add": + inValidNode = true case "LocURI": - if !inReplace { - return errors.New("Only supported as a top level element. Make sure you don't have other top level elements.") + if !inValidNode { + return errors.New("Windows configuration profiles can only have or top level elements.") } inLocURI = true default: - if !inReplace { - return errors.New("Only supported as a top level element. Make sure you don't have other top level elements.") + if !inValidNode { + return errors.New("Windows configuration profiles can only have or top level elements.") } } case xml.EndElement: switch t.Name.Local { - case "Replace": - inReplace = false + case "Replace", "Add": + inValidNode = false case "LocURI": inLocURI = false } diff --git a/server/fleet/windows_mdm_test.go b/server/fleet/windows_mdm_test.go index 3e407afdd5..0a16717546 100644 --- a/server/fleet/windows_mdm_test.go +++ b/server/fleet/windows_mdm_test.go @@ -39,10 +39,10 @@ func TestValidateUserProvided(t *testing.T) { `), }, - wantErr: "Only supported as a top level element. Make sure you don't have other top level elements", + wantErr: "Windows configuration profiles can only have or top level elements.", }, { - name: "Invalid top level element", + name: "Add top level element", profile: MDMWindowsConfigProfile{ SyncML: []byte(` @@ -52,7 +52,7 @@ func TestValidateUserProvided(t *testing.T) { `), }, - wantErr: "Only supported as a top level element. Make sure you don't have other top level elements.", + wantErr: "", }, { name: "Reserved LocURI", @@ -139,7 +139,7 @@ func TestValidateUserProvided(t *testing.T) { `), }, - wantErr: "Only supported as a top level element. Make sure you don't have other top level elements", + wantErr: "", }, { name: "XML with Replace and Alert", @@ -157,7 +157,7 @@ func TestValidateUserProvided(t *testing.T) { `), }, - wantErr: "Only supported as a top level element. Make sure you don't have other top level elements", + wantErr: "Windows configuration profiles can only have or top level elements.", }, { name: "XML with Replace and Atomic", @@ -175,7 +175,7 @@ func TestValidateUserProvided(t *testing.T) { `), }, - wantErr: "Only supported as a top level element. Make sure you don't have other top level elements", + wantErr: "Windows configuration profiles can only have or top level elements.", }, { name: "XML with Replace and Delete", @@ -193,7 +193,7 @@ func TestValidateUserProvided(t *testing.T) { `), }, - wantErr: "Only supported as a top level element. Make sure you don't have other top level elements", + wantErr: "Windows configuration profiles can only have or top level elements.", }, { name: "XML with Replace and Exec", @@ -211,7 +211,7 @@ func TestValidateUserProvided(t *testing.T) { `), }, - wantErr: "Only supported as a top level element. Make sure you don't have other top level elements", + wantErr: "Windows configuration profiles can only have or top level elements.", }, { name: "XML with Replace and Get", @@ -229,7 +229,7 @@ func TestValidateUserProvided(t *testing.T) { `), }, - wantErr: "Only supported as a top level element. Make sure you don't have other top level elements", + wantErr: "Windows configuration profiles can only have or top level elements.", }, { name: "XML with Replace and Results", @@ -247,7 +247,7 @@ func TestValidateUserProvided(t *testing.T) { `), }, - wantErr: "Only supported as a top level element. Make sure you don't have other top level elements", + wantErr: "Windows configuration profiles can only have or top level elements.", }, { name: "XML with Replace and Status", @@ -265,7 +265,7 @@ func TestValidateUserProvided(t *testing.T) { `), }, - wantErr: "Only supported as a top level element. Make sure you don't have other top level elements", + wantErr: "Windows configuration profiles can only have or top level elements.", }, { name: "XML with elements not defined in the protocol", @@ -283,7 +283,7 @@ func TestValidateUserProvided(t *testing.T) { `), }, - wantErr: "Only supported as a top level element. Make sure you don't have other top level elements", + wantErr: "Windows configuration profiles can only have or top level elements.", }, { name: "invalid XML with mismatched tags", @@ -359,7 +359,8 @@ func TestValidateUserProvided(t *testing.T) { Invalid & Data -`)}, +`), + }, wantErr: "The file should include valid XML", }, { diff --git a/server/mdm/apple/apple_mdm.go b/server/mdm/apple/apple_mdm.go index c777214530..b473e71b51 100644 --- a/server/mdm/apple/apple_mdm.go +++ b/server/mdm/apple/apple_mdm.go @@ -516,11 +516,26 @@ func (d *DEPService) processDeviceResponse(ctx context.Context, depClient *godep for profUUID, serials := range profileToSerials { logger := kitlog.With(d.logger, "profile_uuid", profUUID) level.Info(logger).Log("msg", "calling DEP client to assign profile", "profile_uuid", profUUID) - apiResp, err := depClient.AssignProfile(ctx, DEPName, profUUID, serials...) + + skipSerials, assignSerials, err := d.ds.ScreenDEPAssignProfileSerialsForCooldown(ctx, serials) + if err != nil { + return ctxerr.Wrap(ctx, err, "process device response") + } + if len(skipSerials) > 0 { + // NOTE: the `dep_cooldown` job of the `integrations`` cron picks up the assignments + // after the cooldown period is over + level.Debug(logger).Log("msg", "process device response: skipping assign profile for devices on cooldown", "serials", fmt.Sprintf("%s", skipSerials)) + } + if len(assignSerials) == 0 { + level.Debug(logger).Log("msg", "process device response: no devices to assign profile") + continue + } + + apiResp, err := depClient.AssignProfile(ctx, DEPName, profUUID, assignSerials...) if err != nil { level.Info(logger).Log( "msg", "assign profile", - "devices", len(serials), + "devices", len(assignSerials), "err", err, ) return fmt.Errorf("assign profile: %w", err) @@ -528,16 +543,14 @@ func (d *DEPService) processDeviceResponse(ctx context.Context, depClient *godep logs := []interface{}{ "msg", "profile assigned", - "devices", len(serials), + "devices", len(assignSerials), } logs = append(logs, logCountsForResults(apiResp.Devices)...) level.Info(logger).Log(logs...) - debugLogs := []interface{}{"msg", "assign profile responses by device"} - for k, v := range apiResp.Devices { - debugLogs = append(debugLogs, k, v) + if err := d.ds.UpdateHostDEPAssignProfileResponses(ctx, apiResp); err != nil { + return ctxerr.Wrap(ctx, err, "update host dep assign profile responses") } - level.Debug(logger).Log(debugLogs...) } return nil diff --git a/server/mdm/apple/commander.go b/server/mdm/apple/commander.go index ea39be26cb..280596a0cb 100644 --- a/server/mdm/apple/commander.go +++ b/server/mdm/apple/commander.go @@ -118,7 +118,7 @@ func (svc *MDMAppleCommander) DeviceLock(ctx context.Context, host *fleet.Host, return nil } -func (svc *MDMAppleCommander) EraseDevice(ctx context.Context, hostUUIDs []string, uuid string) error { +func (svc *MDMAppleCommander) EraseDevice(ctx context.Context, host *fleet.Host, uuid string) error { pin := GenerateRandomPin(6) raw := fmt.Sprintf(` @@ -132,10 +132,26 @@ func (svc *MDMAppleCommander) EraseDevice(ctx context.Context, hostUUIDs []strin EraseDevice PIN %s + ObliterationBehavior + Default `, uuid, pin) - return svc.EnqueueCommand(ctx, hostUUIDs, raw) + + cmd, err := mdm.DecodeCommand([]byte(raw)) + if err != nil { + return ctxerr.Wrap(ctx, err, "decoding command") + } + + if err := svc.storage.EnqueueDeviceWipeCommand(ctx, host, cmd); err != nil { + return ctxerr.Wrap(ctx, err, "enqueuing for DeviceWipe") + } + + if err := svc.sendNotifications(ctx, []string{host.UUID}); err != nil { + return ctxerr.Wrap(ctx, err, "sending notifications for DeviceWipe") + } + + return nil } func (svc *MDMAppleCommander) InstallEnterpriseApplication(ctx context.Context, hostUUIDs []string, uuid string, manifestURL string) error { diff --git a/server/mdm/apple/commander_test.go b/server/mdm/apple/commander_test.go index 05e2de999c..af7cc7dd33 100644 --- a/server/mdm/apple/commander_test.go +++ b/server/mdm/apple/commander_test.go @@ -104,7 +104,7 @@ func TestMDMAppleCommander(t *testing.T) { require.True(t, mdmStorage.RetrievePushInfoFuncInvoked) mdmStorage.RetrievePushInfoFuncInvoked = false - host := &fleet.Host{ID: 1, UUID: "A"} + host := &fleet.Host{ID: 1, UUID: "A", Platform: "darwin"} cmdUUID = uuid.New().String() mdmStorage.EnqueueDeviceLockCommandFunc = func(ctx context.Context, gotHost *fleet.Host, cmd *mdm.Command, pin string) error { require.NotNil(t, gotHost) @@ -112,6 +112,7 @@ func TestMDMAppleCommander(t *testing.T) { require.Equal(t, host.UUID, gotHost.UUID) require.Equal(t, "DeviceLock", cmd.Command.RequestType) require.Contains(t, string(cmd.Raw), cmdUUID) + require.Len(t, pin, 6) return nil } err = cmdr.DeviceLock(ctx, host, cmdUUID) @@ -120,6 +121,22 @@ func TestMDMAppleCommander(t *testing.T) { mdmStorage.EnqueueDeviceLockCommandFuncInvoked = false require.True(t, mdmStorage.RetrievePushInfoFuncInvoked) mdmStorage.RetrievePushInfoFuncInvoked = false + + cmdUUID = uuid.New().String() + mdmStorage.EnqueueDeviceWipeCommandFunc = func(ctx context.Context, gotHost *fleet.Host, cmd *mdm.Command) error { + require.NotNil(t, gotHost) + require.Equal(t, host.ID, gotHost.ID) + require.Equal(t, host.UUID, gotHost.UUID) + require.Equal(t, "EraseDevice", cmd.Command.RequestType) + require.Contains(t, string(cmd.Raw), cmdUUID) + return nil + } + err = cmdr.EraseDevice(ctx, host, cmdUUID) + require.NoError(t, err) + require.True(t, mdmStorage.EnqueueDeviceWipeCommandFuncInvoked) + mdmStorage.EnqueueDeviceWipeCommandFuncInvoked = false + require.True(t, mdmStorage.RetrievePushInfoFuncInvoked) + mdmStorage.RetrievePushInfoFuncInvoked = false } func newMockAPNSPushProviderFactory() (*svcmock.APNSPushProviderFactory, *svcmock.APNSPushProvider) { diff --git a/server/mdm/mdm.go b/server/mdm/mdm.go index 4e2b1c9485..00e515667b 100644 --- a/server/mdm/mdm.go +++ b/server/mdm/mdm.go @@ -35,8 +35,7 @@ func DecryptBase64CMS(p7Base64 string, cert *x509.Certificate, key crypto.Privat // // - Returns "darwin" if the profile starts with "= len(darwinPrefix) && bytes.EqualFold(darwinPrefix, trimmedProfile[:len(darwinPrefix)]) { + prefixMatches := func(prefix []byte) bool { + return len(trimmedProfile) >= len(prefix) && + bytes.EqualFold(prefix, trimmedProfile[:len(prefix)]) + } + + if prefixMatches([]byte("= len(windowsPrefix) && bytes.EqualFold(windowsPrefix, trimmedProfile[:len(windowsPrefix)]) { + if prefixMatches([]byte(""), expected: "windows", }, + { + name: "Windows case insensitive add ", + input: []byte(""), + expected: "windows", + }, + { + name: "Windows case sensitive add", + input: []byte(""), + expected: "windows", + }, { name: "Whitespace before prefix", input: []byte(" "), diff --git a/server/mdm/nanomdm/storage/mysql/certauth.go b/server/mdm/nanomdm/storage/mysql/certauth.go index b896022650..7e852df061 100644 --- a/server/mdm/nanomdm/storage/mysql/certauth.go +++ b/server/mdm/nanomdm/storage/mysql/certauth.go @@ -46,8 +46,7 @@ INSERT INTO nano_cert_auth_associations (id, sha256, cert_not_valid_after) VALUE ON DUPLICATE KEY UPDATE sha256 = VALUES(sha256), - cert_not_valid_after = VALUES(cert_not_valid_after), - renew_command_uuid = NULL;`, + cert_not_valid_after = VALUES(cert_not_valid_after)`, r.ID, strings.ToLower(hash), certNotValidAfter, diff --git a/server/mdm/scep/challenge/bolt/challenge.go b/server/mdm/scep/challenge/bolt/challenge.go index 77e2d091c7..bc528259b9 100644 --- a/server/mdm/scep/challenge/bolt/challenge.go +++ b/server/mdm/scep/challenge/bolt/challenge.go @@ -6,7 +6,7 @@ import ( "errors" "fmt" - "github.com/boltdb/bolt" + bolt "go.etcd.io/bbolt" ) type Depot struct { diff --git a/server/mdm/scep/challenge/challenge_bolt_test.go b/server/mdm/scep/challenge/challenge_bolt_test.go index 8a58719d61..4f6ae7e3fe 100644 --- a/server/mdm/scep/challenge/challenge_bolt_test.go +++ b/server/mdm/scep/challenge/challenge_bolt_test.go @@ -9,7 +9,7 @@ import ( "github.com/fleetdm/fleet/v4/server/mdm/scep/scep" scepserver "github.com/fleetdm/fleet/v4/server/mdm/scep/server" - "github.com/boltdb/bolt" + bolt "go.etcd.io/bbolt" ) func TestDynamicChallenge(t *testing.T) { diff --git a/server/mdm/scep/depot/bolt/depot.go b/server/mdm/scep/depot/bolt/depot.go index a078afadf1..eb85e4ec17 100644 --- a/server/mdm/scep/depot/bolt/depot.go +++ b/server/mdm/scep/depot/bolt/depot.go @@ -11,7 +11,7 @@ import ( "github.com/fleetdm/fleet/v4/server/mdm/scep/depot" - "github.com/boltdb/bolt" + bolt "go.etcd.io/bbolt" ) // Depot implements a SCEP certificate store using boltdb. diff --git a/server/mdm/scep/depot/bolt/depot_test.go b/server/mdm/scep/depot/bolt/depot_test.go index c19f6290ae..837df03310 100644 --- a/server/mdm/scep/depot/bolt/depot_test.go +++ b/server/mdm/scep/depot/bolt/depot_test.go @@ -7,7 +7,7 @@ import ( "reflect" "testing" - "github.com/boltdb/bolt" + bolt "go.etcd.io/bbolt" ) // createDepot creates a Bolt database in a temporary location. diff --git a/server/mdm/scep/server/service_bolt_test.go b/server/mdm/scep/server/service_bolt_test.go index f85ef1d652..e0c8db1fcd 100644 --- a/server/mdm/scep/server/service_bolt_test.go +++ b/server/mdm/scep/server/service_bolt_test.go @@ -19,7 +19,7 @@ import ( "github.com/fleetdm/fleet/v4/server/mdm/scep/scep" scepserver "github.com/fleetdm/fleet/v4/server/mdm/scep/server" - "github.com/boltdb/bolt" + bolt "go.etcd.io/bbolt" ) func TestCaCert(t *testing.T) { diff --git a/server/mock/datastore_mdm_mock.go b/server/mock/datastore_mdm_mock.go index 25d914921c..d6da34a7c2 100644 --- a/server/mock/datastore_mdm_mock.go +++ b/server/mock/datastore_mdm_mock.go @@ -56,6 +56,8 @@ type RetrieveTokenUpdateTallyFunc func(ctx context.Context, id string) (int, err type EnqueueDeviceLockCommandFunc func(ctx context.Context, host *fleet.Host, cmd *mdm.Command, pin string) error +type EnqueueDeviceWipeCommandFunc func(ctx context.Context, host *fleet.Host, cmd *mdm.Command) error + type MDMAppleStore struct { StoreAuthenticateFunc StoreAuthenticateFunc StoreAuthenticateFuncInvoked bool @@ -120,6 +122,9 @@ type MDMAppleStore struct { EnqueueDeviceLockCommandFunc EnqueueDeviceLockCommandFunc EnqueueDeviceLockCommandFuncInvoked bool + EnqueueDeviceWipeCommandFunc EnqueueDeviceWipeCommandFunc + EnqueueDeviceWipeCommandFuncInvoked bool + mu sync.Mutex } @@ -269,3 +274,10 @@ func (fs *MDMAppleStore) EnqueueDeviceLockCommand(ctx context.Context, host *fle fs.mu.Unlock() return fs.EnqueueDeviceLockCommandFunc(ctx, host, cmd, pin) } + +func (fs *MDMAppleStore) EnqueueDeviceWipeCommand(ctx context.Context, host *fleet.Host, cmd *mdm.Command) error { + fs.mu.Lock() + fs.EnqueueDeviceWipeCommandFuncInvoked = true + fs.mu.Unlock() + return fs.EnqueueDeviceWipeCommandFunc(ctx, host, cmd) +} diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index b83f13c41c..2823b3485a 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -200,7 +200,7 @@ type HostLiteByIDFunc func(ctx context.Context, id uint) (*fleet.HostLite, error type AddHostsToTeamFunc func(ctx context.Context, teamID *uint, hostIDs []uint) error -type TotalAndUnseenHostsSinceFunc func(ctx context.Context, daysCount int) (total int, unseen int, err error) +type TotalAndUnseenHostsSinceFunc func(ctx context.Context, teamID *uint, daysCount int) (total int, unseen []uint, err error) type DeleteHostsFunc func(ctx context.Context, ids []uint) error @@ -554,6 +554,8 @@ type GetHostCertAssociationsToExpireFunc func(ctx context.Context, expiryDays in type SetCommandForPendingSCEPRenewalFunc func(ctx context.Context, assocs []fleet.SCEPIdentityAssociation, cmdUUID string) error +type CleanSCEPRenewRefsFunc func(ctx context.Context, hostUUID string) error + type UpdateHostMDMProfilesVerificationFunc func(ctx context.Context, host *fleet.Host, toVerify []string, toFail []string, toRetry []string) error type GetHostMDMProfilesExpectedForVerificationFunc func(ctx context.Context, host *fleet.Host) (map[string]*fleet.ExpectedMDMProfile, error) @@ -746,6 +748,14 @@ type GetMatchingHostSerialsFunc func(ctx context.Context, serials []string) (map type DeleteHostDEPAssignmentsFunc func(ctx context.Context, serials []string) error +type UpdateHostDEPAssignProfileResponsesFunc func(ctx context.Context, resp *godep.ProfileResponse) error + +type ScreenDEPAssignProfileSerialsForCooldownFunc func(ctx context.Context, serials []string) (skipSerials []string, assignSerials []string, err error) + +type GetDEPAssignProfileExpiredCooldownsFunc func(ctx context.Context) (map[uint][]string, error) + +type UpdateDEPAssignProfileRetryPendingFunc func(ctx context.Context, jobID uint, serials []string) error + type WSTEPStoreCertificateFunc func(ctx context.Context, name string, crt *x509.Certificate) error type WSTEPNewSerialFunc func(ctx context.Context) (*big.Int, error) @@ -828,16 +838,22 @@ type GetHostScriptDetailsFunc func(ctx context.Context, hostID uint, teamID *uin type BatchSetScriptsFunc func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error -type GetHostLockWipeStatusFunc func(ctx context.Context, hostID uint, fleetPlatform string) (*fleet.HostLockWipeStatus, error) +type GetHostLockWipeStatusFunc func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) -type LockHostViaScriptFunc func(ctx context.Context, request *fleet.HostScriptRequestPayload) error +type LockHostViaScriptFunc func(ctx context.Context, request *fleet.HostScriptRequestPayload, hostFleetPlatform string) error -type UnlockHostViaScriptFunc func(ctx context.Context, request *fleet.HostScriptRequestPayload) error +type UnlockHostViaScriptFunc func(ctx context.Context, request *fleet.HostScriptRequestPayload, hostFleetPlatform string) error -type UnlockHostManuallyFunc func(ctx context.Context, hostID uint, ts time.Time) error +type UnlockHostManuallyFunc func(ctx context.Context, hostID uint, hostFleetPlatform string, ts time.Time) error type CleanMacOSMDMLockFunc func(ctx context.Context, hostUUID string) error +type WipeHostViaScriptFunc func(ctx context.Context, request *fleet.HostScriptRequestPayload, hostFleetPlatform string) error + +type WipeHostViaWindowsMDMFunc func(ctx context.Context, host *fleet.Host, cmd *fleet.MDMWindowsCommand) error + +type UpdateHostLockWipeStatusFromAppleMDMResultFunc func(ctx context.Context, hostUUID string, cmdUUID string, requestType string, succeeded bool) error + type DataStore struct { HealthCheckFunc HealthCheckFunc HealthCheckFuncInvoked bool @@ -1643,6 +1659,9 @@ type DataStore struct { SetCommandForPendingSCEPRenewalFunc SetCommandForPendingSCEPRenewalFunc SetCommandForPendingSCEPRenewalFuncInvoked bool + CleanSCEPRenewRefsFunc CleanSCEPRenewRefsFunc + CleanSCEPRenewRefsFuncInvoked bool + UpdateHostMDMProfilesVerificationFunc UpdateHostMDMProfilesVerificationFunc UpdateHostMDMProfilesVerificationFuncInvoked bool @@ -1931,6 +1950,18 @@ type DataStore struct { DeleteHostDEPAssignmentsFunc DeleteHostDEPAssignmentsFunc DeleteHostDEPAssignmentsFuncInvoked bool + UpdateHostDEPAssignProfileResponsesFunc UpdateHostDEPAssignProfileResponsesFunc + UpdateHostDEPAssignProfileResponsesFuncInvoked bool + + ScreenDEPAssignProfileSerialsForCooldownFunc ScreenDEPAssignProfileSerialsForCooldownFunc + ScreenDEPAssignProfileSerialsForCooldownFuncInvoked bool + + GetDEPAssignProfileExpiredCooldownsFunc GetDEPAssignProfileExpiredCooldownsFunc + GetDEPAssignProfileExpiredCooldownsFuncInvoked bool + + UpdateDEPAssignProfileRetryPendingFunc UpdateDEPAssignProfileRetryPendingFunc + UpdateDEPAssignProfileRetryPendingFuncInvoked bool + WSTEPStoreCertificateFunc WSTEPStoreCertificateFunc WSTEPStoreCertificateFuncInvoked bool @@ -2069,6 +2100,15 @@ type DataStore struct { CleanMacOSMDMLockFunc CleanMacOSMDMLockFunc CleanMacOSMDMLockFuncInvoked bool + WipeHostViaScriptFunc WipeHostViaScriptFunc + WipeHostViaScriptFuncInvoked bool + + WipeHostViaWindowsMDMFunc WipeHostViaWindowsMDMFunc + WipeHostViaWindowsMDMFuncInvoked bool + + UpdateHostLockWipeStatusFromAppleMDMResultFunc UpdateHostLockWipeStatusFromAppleMDMResultFunc + UpdateHostLockWipeStatusFromAppleMDMResultFuncInvoked bool + mu sync.Mutex } @@ -2709,11 +2749,11 @@ func (s *DataStore) AddHostsToTeam(ctx context.Context, teamID *uint, hostIDs [] return s.AddHostsToTeamFunc(ctx, teamID, hostIDs) } -func (s *DataStore) TotalAndUnseenHostsSince(ctx context.Context, daysCount int) (total int, unseen int, err error) { +func (s *DataStore) TotalAndUnseenHostsSince(ctx context.Context, teamID *uint, daysCount int) (total int, unseen []uint, err error) { s.mu.Lock() s.TotalAndUnseenHostsSinceFuncInvoked = true s.mu.Unlock() - return s.TotalAndUnseenHostsSinceFunc(ctx, daysCount) + return s.TotalAndUnseenHostsSinceFunc(ctx, teamID, daysCount) } func (s *DataStore) DeleteHosts(ctx context.Context, ids []uint) error { @@ -3948,6 +3988,13 @@ func (s *DataStore) SetCommandForPendingSCEPRenewal(ctx context.Context, assocs return s.SetCommandForPendingSCEPRenewalFunc(ctx, assocs, cmdUUID) } +func (s *DataStore) CleanSCEPRenewRefs(ctx context.Context, hostUUID string) error { + s.mu.Lock() + s.CleanSCEPRenewRefsFuncInvoked = true + s.mu.Unlock() + return s.CleanSCEPRenewRefsFunc(ctx, hostUUID) +} + func (s *DataStore) UpdateHostMDMProfilesVerification(ctx context.Context, host *fleet.Host, toVerify []string, toFail []string, toRetry []string) error { s.mu.Lock() s.UpdateHostMDMProfilesVerificationFuncInvoked = true @@ -4620,6 +4667,34 @@ func (s *DataStore) DeleteHostDEPAssignments(ctx context.Context, serials []stri return s.DeleteHostDEPAssignmentsFunc(ctx, serials) } +func (s *DataStore) UpdateHostDEPAssignProfileResponses(ctx context.Context, resp *godep.ProfileResponse) error { + s.mu.Lock() + s.UpdateHostDEPAssignProfileResponsesFuncInvoked = true + s.mu.Unlock() + return s.UpdateHostDEPAssignProfileResponsesFunc(ctx, resp) +} + +func (s *DataStore) ScreenDEPAssignProfileSerialsForCooldown(ctx context.Context, serials []string) (skipSerials []string, assignSerials []string, err error) { + s.mu.Lock() + s.ScreenDEPAssignProfileSerialsForCooldownFuncInvoked = true + s.mu.Unlock() + return s.ScreenDEPAssignProfileSerialsForCooldownFunc(ctx, serials) +} + +func (s *DataStore) GetDEPAssignProfileExpiredCooldowns(ctx context.Context) (map[uint][]string, error) { + s.mu.Lock() + s.GetDEPAssignProfileExpiredCooldownsFuncInvoked = true + s.mu.Unlock() + return s.GetDEPAssignProfileExpiredCooldownsFunc(ctx) +} + +func (s *DataStore) UpdateDEPAssignProfileRetryPending(ctx context.Context, jobID uint, serials []string) error { + s.mu.Lock() + s.UpdateDEPAssignProfileRetryPendingFuncInvoked = true + s.mu.Unlock() + return s.UpdateDEPAssignProfileRetryPendingFunc(ctx, jobID, serials) +} + func (s *DataStore) WSTEPStoreCertificate(ctx context.Context, name string, crt *x509.Certificate) error { s.mu.Lock() s.WSTEPStoreCertificateFuncInvoked = true @@ -4907,32 +4982,32 @@ func (s *DataStore) BatchSetScripts(ctx context.Context, tmID *uint, scripts []* return s.BatchSetScriptsFunc(ctx, tmID, scripts) } -func (s *DataStore) GetHostLockWipeStatus(ctx context.Context, hostID uint, fleetPlatform string) (*fleet.HostLockWipeStatus, error) { +func (s *DataStore) GetHostLockWipeStatus(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) { s.mu.Lock() s.GetHostLockWipeStatusFuncInvoked = true s.mu.Unlock() - return s.GetHostLockWipeStatusFunc(ctx, hostID, fleetPlatform) + return s.GetHostLockWipeStatusFunc(ctx, host) } -func (s *DataStore) LockHostViaScript(ctx context.Context, request *fleet.HostScriptRequestPayload) error { +func (s *DataStore) LockHostViaScript(ctx context.Context, request *fleet.HostScriptRequestPayload, hostFleetPlatform string) error { s.mu.Lock() s.LockHostViaScriptFuncInvoked = true s.mu.Unlock() - return s.LockHostViaScriptFunc(ctx, request) + return s.LockHostViaScriptFunc(ctx, request, hostFleetPlatform) } -func (s *DataStore) UnlockHostViaScript(ctx context.Context, request *fleet.HostScriptRequestPayload) error { +func (s *DataStore) UnlockHostViaScript(ctx context.Context, request *fleet.HostScriptRequestPayload, hostFleetPlatform string) error { s.mu.Lock() s.UnlockHostViaScriptFuncInvoked = true s.mu.Unlock() - return s.UnlockHostViaScriptFunc(ctx, request) + return s.UnlockHostViaScriptFunc(ctx, request, hostFleetPlatform) } -func (s *DataStore) UnlockHostManually(ctx context.Context, hostID uint, ts time.Time) error { +func (s *DataStore) UnlockHostManually(ctx context.Context, hostID uint, hostFleetPlatform string, ts time.Time) error { s.mu.Lock() s.UnlockHostManuallyFuncInvoked = true s.mu.Unlock() - return s.UnlockHostManuallyFunc(ctx, hostID, ts) + return s.UnlockHostManuallyFunc(ctx, hostID, hostFleetPlatform, ts) } func (s *DataStore) CleanMacOSMDMLock(ctx context.Context, hostUUID string) error { @@ -4941,3 +5016,24 @@ func (s *DataStore) CleanMacOSMDMLock(ctx context.Context, hostUUID string) erro s.mu.Unlock() return s.CleanMacOSMDMLockFunc(ctx, hostUUID) } + +func (s *DataStore) WipeHostViaScript(ctx context.Context, request *fleet.HostScriptRequestPayload, hostFleetPlatform string) error { + s.mu.Lock() + s.WipeHostViaScriptFuncInvoked = true + s.mu.Unlock() + return s.WipeHostViaScriptFunc(ctx, request, hostFleetPlatform) +} + +func (s *DataStore) WipeHostViaWindowsMDM(ctx context.Context, host *fleet.Host, cmd *fleet.MDMWindowsCommand) error { + s.mu.Lock() + s.WipeHostViaWindowsMDMFuncInvoked = true + s.mu.Unlock() + return s.WipeHostViaWindowsMDMFunc(ctx, host, cmd) +} + +func (s *DataStore) UpdateHostLockWipeStatusFromAppleMDMResult(ctx context.Context, hostUUID string, cmdUUID string, requestType string, succeeded bool) error { + s.mu.Lock() + s.UpdateHostLockWipeStatusFromAppleMDMResultFuncInvoked = true + s.mu.Unlock() + return s.UpdateHostLockWipeStatusFromAppleMDMResultFunc(ctx, hostUUID, cmdUUID, requestType, succeeded) +} diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go index 73c6a19479..534be43501 100644 --- a/server/service/apple_mdm.go +++ b/server/service/apple_mdm.go @@ -2217,6 +2217,18 @@ func (svc *MDMAppleCheckinAndCommandService) Authenticate(r *mdm.Request, m *mdm host.SerialNumber = m.SerialNumber host.UDID = m.UDID host.Model = m.Model + + existingDeviceInfo, err := svc.ds.GetHostMDMCheckinInfo(r.Context, m.Enrollment.UDID) + if err != nil { + var nfe fleet.NotFoundError + if !errors.As(err, &nfe) { + return ctxerr.Wrap(r.Context, err, "getting checkin info in Authenticate message") + } + } else if existingDeviceInfo.SCEPRenewalInProgress { + svc.logger.Log("info", "Authenticate message received for a SCEP renewal in process, skipping host ingestion and cleanups", "host_uuid", r.ID) + return nil + } + if err := svc.ds.IngestMDMAppleDeviceFromCheckin(r.Context, host); err != nil { return ctxerr.Wrap(r.Context, err, "ingesting device in Authenticate message") } @@ -2233,6 +2245,7 @@ func (svc *MDMAppleCheckinAndCommandService) Authenticate(r *mdm.Request, m *mdm InstalledFromDEP: info.DEPAssignedToFleet, MDMPlatform: fleet.MDMPlatformApple, }) + } // TokenUpdate handles MDM [TokenUpdate][1] requests. @@ -2241,19 +2254,28 @@ func (svc *MDMAppleCheckinAndCommandService) Authenticate(r *mdm.Request, m *mdm // // [1]: https://developer.apple.com/documentation/devicemanagement/token_update func (svc *MDMAppleCheckinAndCommandService) TokenUpdate(r *mdm.Request, m *mdm.TokenUpdate) error { + info, err := svc.ds.GetHostMDMCheckinInfo(r.Context, m.Enrollment.UDID) + if err != nil { + return ctxerr.Wrap(r.Context, err, "retrieving host checkin info on TokenUpdate") + } + + if info.SCEPRenewalInProgress { + svc.logger.Log("info", "TokenUpdate message received for a SCEP renewal in process", "host_uuid", r.ID) + err := svc.ds.CleanSCEPRenewRefs(r.Context, r.ID) + return ctxerr.Wrap(r.Context, err, "cleaning SCEP refs on TokenUpdate") + } + nanoEnroll, err := svc.ds.GetNanoMDMEnrollment(r.Context, r.ID) if err != nil { - return err + return ctxerr.Wrap(r.Context, err, "retrieving nano enrollment info on TokenUpdate") } + if nanoEnroll != nil && nanoEnroll.Enabled && nanoEnroll.Type == "Device" && nanoEnroll.TokenUpdateTally == 1 { // device is enrolled for the first time, not a token update - if err := svc.ds.BulkSetPendingMDMHostProfiles(r.Context, nil, nil, nil, []string{r.ID}); err != nil { - return err - } + svc.logger.Log("info", "TokenUpdate message received for a new enrollment", "host_uuid", r.ID) - info, err := svc.ds.GetHostMDMCheckinInfo(r.Context, m.Enrollment.UDID) - if err != nil { + if err := svc.ds.BulkSetPendingMDMHostProfiles(r.Context, nil, nil, nil, []string{r.ID}); err != nil { return err } @@ -2400,6 +2422,13 @@ func (svc *MDMAppleCheckinAndCommandService) CommandAndReportResults(r *mdm.Requ Detail: apple_mdm.FmtErrorChain(cmdResult.ErrorChain), OperationType: fleet.MDMOperationTypeRemove, }) + case "DeviceLock", "EraseDevice": + // call into our datastore to update host_mdm_actions if the status is terminal + if cmdResult.Status == fleet.MDMAppleStatusAcknowledged || + cmdResult.Status == fleet.MDMAppleStatusError || + cmdResult.Status == fleet.MDMAppleStatusCommandFormatError { + return nil, svc.ds.UpdateHostLockWipeStatusFromAppleMDMResult(r.Context, cmdResult.UDID, cmdResult.CommandUUID, requestType, cmdResult.Status == fleet.MDMAppleStatusAcknowledged) + } } return nil, nil } @@ -2787,7 +2816,7 @@ func (svc *Service) restorePendingDEPHost(ctx context.Context, host *fleet.Host, return ctxerr.Wrap(ctx, err, "restore pending dep host") } - if err := worker.QueueMacosSetupAssistantJob(ctx, svc.ds, svc.logger, + if _, err := worker.QueueMacosSetupAssistantJob(ctx, svc.ds, svc.logger, worker.MacosSetupAssistantHostsTransferred, tmID, host.HardwareSerial); err != nil { return ctxerr.Wrap(ctx, err, "restore pending dep host") } diff --git a/server/service/apple_mdm_test.go b/server/service/apple_mdm_test.go index f8d5da8a85..5c7a667824 100644 --- a/server/service/apple_mdm_test.go +++ b/server/service/apple_mdm_test.go @@ -760,7 +760,7 @@ func TestHostDetailsMDMProfiles(t *testing.T) { ds.GetHostMDMMacOSSetupFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDMMacOSSetup, error) { return nil, nil } - ds.GetHostLockWipeStatusFunc = func(ctx context.Context, hostID uint, fleetPlatform string) (*fleet.HostLockWipeStatus, error) { + ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) { return &fleet.HostLockWipeStatus{}, nil } @@ -1000,7 +1000,7 @@ func TestMDMCommandAuthz(t *testing.T) { } } -func TestMDMAuthenticate(t *testing.T) { +func TestMDMAuthenticateManualEnrollment(t *testing.T) { ds := new(mock.Store) svc := MDMAppleCheckinAndCommandService{ds: ds} ctx := context.Background() @@ -1015,7 +1015,11 @@ func TestMDMAuthenticate(t *testing.T) { ds.GetHostMDMCheckinInfoFunc = func(ct context.Context, hostUUID string) (*fleet.HostMDMCheckinInfo, error) { require.Equal(t, uuid, hostUUID) - return &fleet.HostMDMCheckinInfo{HardwareSerial: serial, DisplayName: fmt.Sprintf("%s (%s)", model, serial), InstalledFromDEP: false}, nil + return &fleet.HostMDMCheckinInfo{ + HardwareSerial: serial, + DisplayName: fmt.Sprintf("%s (%s)", model, serial), + InstalledFromDEP: false, + }, nil } ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error { @@ -1052,6 +1056,104 @@ func TestMDMAuthenticate(t *testing.T) { require.True(t, ds.ResetMDMAppleEnrollmentFuncInvoked) } +func TestMDMAuthenticateADE(t *testing.T) { + ds := new(mock.Store) + svc := MDMAppleCheckinAndCommandService{ds: ds} + ctx := context.Background() + uuid, serial, model := "ABC-DEF-GHI", "XYZABC", "MacBookPro 16,1" + + ds.IngestMDMAppleDeviceFromCheckinFunc = func(ctx context.Context, mdmHost fleet.MDMAppleHostDetails) error { + require.Equal(t, uuid, mdmHost.UDID) + require.Equal(t, serial, mdmHost.SerialNumber) + require.Equal(t, model, mdmHost.Model) + return nil + } + + ds.GetHostMDMCheckinInfoFunc = func(ct context.Context, hostUUID string) (*fleet.HostMDMCheckinInfo, error) { + require.Equal(t, uuid, hostUUID) + return &fleet.HostMDMCheckinInfo{ + HardwareSerial: serial, + DisplayName: fmt.Sprintf("%s (%s)", model, serial), + DEPAssignedToFleet: true, + }, nil + } + + ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error { + a, ok := activity.(*fleet.ActivityTypeMDMEnrolled) + require.True(t, ok) + require.Nil(t, user) + require.Equal(t, "mdm_enrolled", activity.ActivityName()) + require.Equal(t, serial, a.HostSerial) + require.Equal(t, a.HostDisplayName, fmt.Sprintf("%s (%s)", model, serial)) + require.True(t, a.InstalledFromDEP) + require.Equal(t, fleet.MDMPlatformApple, a.MDMPlatform) + return nil + } + + ds.ResetMDMAppleEnrollmentFunc = func(ctx context.Context, hostUUID string) error { + require.Equal(t, uuid, hostUUID) + return nil + } + + err := svc.Authenticate( + &mdm.Request{Context: ctx}, + &mdm.Authenticate{ + Enrollment: mdm.Enrollment{ + UDID: uuid, + }, + SerialNumber: serial, + Model: model, + }, + ) + require.NoError(t, err) + require.True(t, ds.IngestMDMAppleDeviceFromCheckinFuncInvoked) + require.True(t, ds.GetHostMDMCheckinInfoFuncInvoked) + require.True(t, ds.NewActivityFuncInvoked) + require.True(t, ds.ResetMDMAppleEnrollmentFuncInvoked) +} + +func TestMDMAuthenticateSCEPRenewal(t *testing.T) { + ds := new(mock.Store) + svc := MDMAppleCheckinAndCommandService{ds: ds, logger: kitlog.NewNopLogger()} + ctx := context.Background() + uuid, serial, model := "ABC-DEF-GHI", "XYZABC", "MacBookPro 16,1" + + ds.GetHostMDMCheckinInfoFunc = func(ct context.Context, hostUUID string) (*fleet.HostMDMCheckinInfo, error) { + require.Equal(t, uuid, hostUUID) + return &fleet.HostMDMCheckinInfo{ + HardwareSerial: serial, + DisplayName: fmt.Sprintf("%s (%s)", model, serial), + SCEPRenewalInProgress: true, + }, nil + } + + ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error { + return nil + } + ds.ResetMDMAppleEnrollmentFunc = func(ctx context.Context, hostUUID string) error { + return nil + } + ds.IngestMDMAppleDeviceFromCheckinFunc = func(ctx context.Context, mdmHost fleet.MDMAppleHostDetails) error { + return nil + } + + err := svc.Authenticate( + &mdm.Request{Context: ctx, EnrollID: &mdm.EnrollID{ID: uuid}}, + &mdm.Authenticate{ + Enrollment: mdm.Enrollment{ + UDID: uuid, + }, + SerialNumber: serial, + Model: model, + }, + ) + require.NoError(t, err) + require.False(t, ds.IngestMDMAppleDeviceFromCheckinFuncInvoked) + require.True(t, ds.GetHostMDMCheckinInfoFuncInvoked) + require.False(t, ds.NewActivityFuncInvoked) + require.False(t, ds.ResetMDMAppleEnrollmentFuncInvoked) +} + func TestMDMTokenUpdate(t *testing.T) { ctx := context.Background() ds := new(mock.Store) diff --git a/server/service/campaigns.go b/server/service/campaigns.go index efa82fbf89..cba57bd04e 100644 --- a/server/service/campaigns.go +++ b/server/service/campaigns.go @@ -156,17 +156,17 @@ func (svc *Service) NewDistributedQueryCampaign(ctx context.Context, queryString } } - err = svc.liveQueryStore.RunQuery(strconv.Itoa(int(campaign.ID)), queryString, hostIDs) - if err != nil { - return nil, ctxerr.Wrap(ctx, err, "run query") - } - // Metrics are used for total hosts targeted for the activity feed. campaign.Metrics, err = svc.ds.CountHostsInTargets(ctx, filter, targets, time.Now()) if err != nil { return nil, ctxerr.Wrap(ctx, err, "counting hosts") } + err = svc.liveQueryStore.RunQuery(strconv.Itoa(int(campaign.ID)), queryString, hostIDs) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "run query") + } + return campaign, nil } diff --git a/server/service/client.go b/server/service/client.go index a5ea7e4dab..eeaa245c7b 100644 --- a/server/service/client.go +++ b/server/service/client.go @@ -913,6 +913,18 @@ func (c *Client) DoGitOps( } team["scripts"] = scripts team["secrets"] = config.TeamSettings["secrets"] + team["webhook_settings"] = map[string]interface{}{} + clearHostStatusWebhook := true + if webhookSettings, ok := config.TeamSettings["webhook_settings"]; ok { + if hostStatusWebhook, ok := webhookSettings.(map[string]interface{})["host_status_webhook"]; ok { + clearHostStatusWebhook = false + team["webhook_settings"].(map[string]interface{})["host_status_webhook"] = hostStatusWebhook + } + } + if clearHostStatusWebhook { + // Clear out any existing host_status_webhook settings + team["webhook_settings"].(map[string]interface{})["host_status_webhook"] = map[string]interface{}{} + } team["mdm"] = map[string]interface{}{} mdmAppConfig = team["mdm"].(map[string]interface{}) } diff --git a/server/service/client_hosts.go b/server/service/client_hosts.go index 735743b8df..029916a3cb 100644 --- a/server/service/client_hosts.go +++ b/server/service/client_hosts.go @@ -126,7 +126,9 @@ func (c *Client) TransferHosts(hosts []string, label string, status, searchQuery MatchQuery string `json:"query"` Status fleet.HostStatus `json:"status"` LabelID *uint `json:"label_id"` - }{MatchQuery: searchQuery, Status: fleet.HostStatus(status), LabelID: labelIDPtr}} + TeamID *uint `json:"team_id"` + }{MatchQuery: searchQuery, Status: fleet.HostStatus(status), LabelID: labelIDPtr}, + } return c.authenticatedRequest(params, verb, path, &responseBody) } diff --git a/server/service/client_mdm.go b/server/service/client_mdm.go index 9a6980becd..f4b52c24d0 100644 --- a/server/service/client_mdm.go +++ b/server/service/client_mdm.go @@ -388,3 +388,11 @@ func (c *Client) MDMUnlockHost(hostID uint) (string, error) { } return response.UnlockPIN, nil } + +func (c *Client) MDMWipeHost(hostID uint) error { + var response wipeHostResponse + if err := c.authenticatedRequest(nil, "POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/wipe", hostID), &response); err != nil { + return fmt.Errorf("wipe host request: %w", err) + } + return nil +} diff --git a/server/service/handler.go b/server/service/handler.go index 5df68c0b8f..bf4d1176d6 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -479,6 +479,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/activities", listHostPastActivitiesEndpoint, listHostPastActivitiesRequest{}) ue.POST("/api/_version_/fleet/hosts/{id:[0-9]+}/lock", lockHostEndpoint, lockHostRequest{}) ue.POST("/api/_version_/fleet/hosts/{id:[0-9]+}/unlock", unlockHostEndpoint, unlockHostRequest{}) + ue.POST("/api/_version_/fleet/hosts/{id:[0-9]+}/wipe", wipeHostEndpoint, wipeHostRequest{}) // Only Fleet MDM specific endpoints should be within the root /mdm/ path. // NOTE: remember to update diff --git a/server/service/hosts.go b/server/service/hosts.go index db6e5baaec..8142be8614 100644 --- a/server/service/hosts.go +++ b/server/service/hosts.go @@ -194,6 +194,16 @@ func (svc *Service) ListHosts(ctx context.Context, opt fleet.HostListOptions) ([ } } + if opt.PopulatePolicies { + for _, host := range hosts { + hp, err := svc.ds.ListPoliciesForHost(ctx, host) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, fmt.Sprintf("get policies for host %d", host.ID)) + } + host.Policies = &hp + } + } + return hosts, nil } @@ -775,7 +785,7 @@ func (svc *Service) AddHostsToTeam(ctx context.Context, teamID *uint, hostIDs [] return ctxerr.Wrap(ctx, err, "list mdm dep serials in host ids") } if len(serials) > 0 { - if err := worker.QueueMacosSetupAssistantJob( + if _, err := worker.QueueMacosSetupAssistantJob( ctx, svc.ds, svc.logger, @@ -857,6 +867,7 @@ type addHostsToTeamByFilterRequest struct { MatchQuery string `json:"query"` Status fleet.HostStatus `json:"status"` LabelID *uint `json:"label_id"` + TeamID *uint `json:"team_id"` } `json:"filters"` } @@ -873,6 +884,7 @@ func addHostsToTeamByFilterEndpoint(ctx context.Context, request interface{}, sv MatchQuery: req.Filters.MatchQuery, }, StatusFilter: req.Filters.Status, + TeamFilter: req.Filters.TeamID, } err := svc.AddHostsToTeamByFilter(ctx, req.TeamID, listOpt, req.Filters.LabelID) if err != nil { @@ -911,7 +923,7 @@ func (svc *Service) AddHostsToTeamByFilter(ctx context.Context, teamID *uint, op return ctxerr.Wrap(ctx, err, "list mdm dep serials in host ids") } if len(serials) > 0 { - if err := worker.QueueMacosSetupAssistantJob( + if _, err := worker.QueueMacosSetupAssistantJob( ctx, svc.ds, svc.logger, @@ -1093,7 +1105,7 @@ func (svc *Service) getHostDetails(ctx context.Context, host *fleet.Host, opts f } host.MDM.MacOSSetup = macOSSetup - mdmActions, err := svc.ds.GetHostLockWipeStatus(ctx, host.ID, host.FleetPlatform()) + mdmActions, err := svc.ds.GetHostLockWipeStatus(ctx, host) if err != nil { return nil, ctxerr.Wrap(ctx, err, "get host mdm lock/wipe status") } @@ -1104,10 +1116,10 @@ func (svc *Service) getHostDetails(ctx context.Context, host *fleet.Host, opts f host.MDM.PendingAction = ptr.String("") // device status switch { - case mdmActions.IsLocked(): - host.MDM.DeviceStatus = ptr.String("locked") case mdmActions.IsWiped(): host.MDM.DeviceStatus = ptr.String("wiped") + case mdmActions.IsLocked(): + host.MDM.DeviceStatus = ptr.String("locked") } // pending action, if any @@ -1120,11 +1132,11 @@ func (svc *Service) getHostDetails(ctx context.Context, host *fleet.Host, opts f host.MDM.PendingAction = ptr.String("wipe") } + host.Policies = policies return &fleet.HostDetail{ Host: *host, Labels: labels, Packs: packs, - Policies: policies, Batteries: &bats, }, nil } diff --git a/server/service/hosts_test.go b/server/service/hosts_test.go index 6570d8e78d..1d3a5ac4c5 100644 --- a/server/service/hosts_test.go +++ b/server/service/hosts_test.go @@ -67,7 +67,7 @@ func TestHostDetails(t *testing.T) { ds.ListHostBatteriesFunc = func(ctx context.Context, hostID uint) ([]*fleet.HostBattery, error) { return dsBats, nil } - ds.GetHostLockWipeStatusFunc = func(ctx context.Context, hostID uint, fleetPlatform string) (*fleet.HostLockWipeStatus, error) { + ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) { return &fleet.HostLockWipeStatus{}, nil } // Health should be replaced at the service layer with custom values determined by the cycle count. See https://github.com/fleetdm/fleet/issues/6763. @@ -108,7 +108,7 @@ func TestHostDetailsMDMAppleDiskEncryption(t *testing.T) { ds.ListHostBatteriesFunc = func(ctx context.Context, hostID uint) ([]*fleet.HostBattery, error) { return nil, nil } - ds.GetHostLockWipeStatusFunc = func(ctx context.Context, hostID uint, fleetPlatform string) (*fleet.HostLockWipeStatus, error) { + ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) { return &fleet.HostLockWipeStatus{}, nil } @@ -144,9 +144,9 @@ func TestHostDetailsMDMAppleDiskEncryption(t *testing.T) { Status: &fleet.MDMDeliveryVerifying, OperationType: fleet.MDMOperationTypeInstall, }, - fleet.DiskEncryptionEnforcing, + fleet.DiskEncryptionVerifying, "", - &fleet.MDMDeliveryPending, + &fleet.MDMDeliveryVerifying, }, { "installed profile, not decryptable", @@ -385,7 +385,7 @@ func TestHostDetailsOSSettings(t *testing.T) { ds.GetHostMDMMacOSSetupFunc = func(ctx context.Context, hid uint) (*fleet.HostMDMMacOSSetup, error) { return nil, nil } - ds.GetHostLockWipeStatusFunc = func(ctx context.Context, hostID uint, fleetPlatform string) (*fleet.HostLockWipeStatus, error) { + ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) { return &fleet.HostLockWipeStatus{}, nil } @@ -497,7 +497,7 @@ func TestHostDetailsOSSettingsWindowsOnly(t *testing.T) { ds.GetHostMDMWindowsProfilesFunc = func(ctx context.Context, uuid string) ([]fleet.HostMDMWindowsProfile, error) { return nil, nil } - ds.GetHostLockWipeStatusFunc = func(ctx context.Context, hostID uint, fleetPlatform string) (*fleet.HostLockWipeStatus, error) { + ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) { return &fleet.HostLockWipeStatus{}, nil } @@ -600,7 +600,7 @@ func TestHostAuth(t *testing.T) { ds.ListHostUpcomingActivitiesFunc = func(ctx context.Context, hostID uint, opt fleet.ListOptions) ([]*fleet.Activity, *fleet.PaginationMetadata, error) { return nil, nil, nil } - ds.GetHostLockWipeStatusFunc = func(ctx context.Context, hostID uint, fleetPlatform string) (*fleet.HostLockWipeStatus, error) { + ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) { return &fleet.HostLockWipeStatus{}, nil } @@ -1383,7 +1383,7 @@ func TestHostMDMProfileDetail(t *testing.T) { ds.GetHostMDMMacOSSetupFunc = func(ctx context.Context, hid uint) (*fleet.HostMDMMacOSSetup, error) { return nil, nil } - ds.GetHostLockWipeStatusFunc = func(ctx context.Context, hostID uint, fleetPlatform string) (*fleet.HostLockWipeStatus, error) { + ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) { return &fleet.HostLockWipeStatus{}, nil } ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { @@ -1445,15 +1445,20 @@ func TestHostMDMProfileDetail(t *testing.T) { } } -func TestLockUnlockHostAuth(t *testing.T) { +func TestLockUnlockWipeHostAuth(t *testing.T) { ds := new(mock.Store) svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: &fleet.LicenseInfo{Tier: fleet.TierPremium}}) + const ( + teamHostID = 1 + globalHostID = 2 + ) + teamHost := &fleet.Host{TeamID: ptr.Uint(1), Platform: "darwin"} globalHost := &fleet.Host{Platform: "darwin"} ds.HostByIdentifierFunc = func(ctx context.Context, identifier string) (*fleet.Host, error) { - if identifier == "1" { + if identifier == fmt.Sprint(teamHostID) { return teamHost, nil } @@ -1483,14 +1488,14 @@ func TestLockUnlockHostAuth(t *testing.T) { ds.GetHostMDMMacOSSetupFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDMMacOSSetup, error) { return nil, nil } - ds.GetHostLockWipeStatusFunc = func(ctx context.Context, hostID uint, fleetPlatform string) (*fleet.HostLockWipeStatus, error) { + ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) { return &fleet.HostLockWipeStatus{}, nil } - ds.LockHostViaScriptFunc = func(ctx context.Context, request *fleet.HostScriptRequestPayload) error { + ds.LockHostViaScriptFunc = func(ctx context.Context, request *fleet.HostScriptRequestPayload, platform string) error { return nil } ds.HostLiteFunc = func(ctx context.Context, hostID uint) (*fleet.Host, error) { - if hostID == 1 { + if hostID == teamHostID { return teamHost, nil } @@ -1505,7 +1510,7 @@ func TestLockUnlockHostAuth(t *testing.T) { ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error { return nil } - ds.UnlockHostManuallyFunc = func(ctx context.Context, hostID uint, ts time.Time) error { + ds.UnlockHostManuallyFunc = func(ctx context.Context, hostID uint, platform string, ts time.Time) error { return nil } @@ -1596,25 +1601,30 @@ func TestLockUnlockHostAuth(t *testing.T) { } ctx := viewer.NewContext(ctx, viewer.Viewer{User: tt.user}) - err := svc.LockHost(ctx, 2) + err := svc.LockHost(ctx, globalHostID) checkAuthErr(t, tt.shouldFailGlobalWrite, err) - err = svc.LockHost(ctx, 1) + err = svc.LockHost(ctx, teamHostID) checkAuthErr(t, tt.shouldFailTeamWrite, err) // Pretend we locked the host - ds.GetHostLockWipeStatusFunc = func(ctx context.Context, hostID uint, fleetPlatform string) (*fleet.HostLockWipeStatus, error) { - return &fleet.HostLockWipeStatus{HostFleetPlatform: fleetPlatform, LockMDMCommand: &fleet.MDMCommand{}, LockMDMCommandResult: &fleet.MDMCommandResult{Status: fleet.MDMAppleStatusAcknowledged}}, nil + ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) { + return &fleet.HostLockWipeStatus{HostFleetPlatform: host.FleetPlatform(), LockMDMCommand: &fleet.MDMCommand{}, LockMDMCommandResult: &fleet.MDMCommandResult{Status: fleet.MDMAppleStatusAcknowledged}}, nil } - _, err = svc.UnlockHost(ctx, 2) + _, err = svc.UnlockHost(ctx, globalHostID) checkAuthErr(t, tt.shouldFailGlobalWrite, err) - _, err = svc.UnlockHost(ctx, 1) + _, err = svc.UnlockHost(ctx, teamHostID) checkAuthErr(t, tt.shouldFailTeamWrite, err) // Reset so we're now pretending host is unlocked - ds.GetHostLockWipeStatusFunc = func(ctx context.Context, hostID uint, fleetPlatform string) (*fleet.HostLockWipeStatus, error) { + ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) { return &fleet.HostLockWipeStatus{}, nil } + + err = svc.WipeHost(ctx, globalHostID) + checkAuthErr(t, tt.shouldFailGlobalWrite, err) + err = svc.WipeHost(ctx, teamHostID) + checkAuthErr(t, tt.shouldFailTeamWrite, err) }) } } diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index 9614bd5088..8e8462bb61 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -1495,12 +1495,16 @@ func (s *integrationTestSuite) TestListHosts() { user1 := test.NewUser(t, s.ds, "Alice", "alice@example.com", true) q := test.NewQuery(t, s.ds, nil, "query1", "select 1", 0, true) defer cleanupQuery(s, q.ID) - p, err := s.ds.NewGlobalPolicy(context.Background(), &user1.ID, fleet.PolicyPayload{ - QueryID: &q.ID, - }) + globalPolicy0, err := s.ds.NewGlobalPolicy( + context.Background(), &user1.ID, fleet.PolicyPayload{ + QueryID: &q.ID, + }) require.NoError(t, err) - require.NoError(t, s.ds.RecordPolicyQueryExecutions(context.Background(), host2, map[uint]*bool{p.ID: ptr.Bool(false)}, time.Now(), false)) + require.NoError( + t, + s.ds.RecordPolicyQueryExecutions(context.Background(), host2, map[uint]*bool{globalPolicy0.ID: ptr.Bool(false)}, time.Now(), false), + ) resp = listHostsResponse{} s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &resp, "software_id", fmt.Sprint(fooV1ID)) @@ -1720,13 +1724,54 @@ func (s *integrationTestSuite) TestListHosts() { require.Nil(t, h.Software[0].Vulnerabilities[0].Description) require.Nil(t, h.Software[0].Vulnerabilities[0].ResolvedInVersion) } + assert.Nil(t, h.Policies) } resp = listHostsResponse{} - s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &resp, "populate_software", "false") + s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &resp, "populate_software", "false", "populate_policies", "false") require.Len(t, resp.Hosts, 4) for _, h := range resp.Hosts { require.Empty(t, h.Software) + assert.Nil(t, h.Policies) + } + + // Populate policies for hosts. One policy was created earlier. + ctx := context.Background() + globalPolicy1, err := s.ds.NewGlobalPolicy( + ctx, &test.UserAdmin.ID, fleet.PolicyPayload{ + Name: "foobar0", + Query: "SELECT 0;", + }, + ) + require.NoError(t, err) + + for _, host := range hosts { + // All hosts pass the globalPolicy1 + err := s.ds.RecordPolicyQueryExecutions( + context.Background(), host, map[uint]*bool{globalPolicy1.ID: ptr.Bool(true)}, time.Now(), false, + ) + require.NoError(t, err) + } + + resp = listHostsResponse{} + s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &resp, "populate_policies", "true") + require.Len(t, resp.Hosts, len(hosts)+1) // +1 for the pending MDM host + for _, h := range resp.Hosts { + if h.ID == hosts[0].ID { + policies := *h.Policies + require.Len(t, policies, 2) + assert.Equal(t, globalPolicy0.Name, policies[0].Name) + assert.Equal(t, "", policies[0].Response) + assert.Equal(t, globalPolicy1.Name, policies[1].Name) + assert.Equal(t, "pass", policies[1].Response) + } else if h.ID == hosts[2].ID { + policies := *h.Policies + require.Len(t, policies, 2) + assert.Equal(t, globalPolicy0.Name, policies[0].Name) + assert.Equal(t, "fail", policies[0].Response) + assert.Equal(t, globalPolicy1.Name, policies[1].Name) + assert.Equal(t, "pass", policies[1].Response) + } } } @@ -5350,9 +5395,10 @@ func (s *integrationTestSuite) TestPremiumEndpointsWithoutLicense() { "team_id", "1", ) - // lock/unlock a host + // lock/unlock/wipe a host s.Do("POST", "/api/v1/fleet/hosts/123/lock", nil, http.StatusPaymentRequired) s.Do("POST", "/api/v1/fleet/hosts/123/unlock", nil, http.StatusPaymentRequired) + s.Do("POST", "/api/v1/fleet/hosts/123/wipe", nil, http.StatusPaymentRequired) } func (s *integrationTestSuite) TestScriptsEndpointsWithoutLicense() { @@ -6369,6 +6415,16 @@ func (s *integrationTestSuite) TestCountTargets() { require.Equal(t, uint(1), countResp.TargetsOnline) require.Equal(t, uint(0), countResp.TargetsOffline) + // 'No team' selected + countResp = countTargetsResponse{} + s.DoJSON( + "POST", "/api/latest/fleet/targets/count", countTargetsRequest{Selected: fleet.HostTargets{TeamIDs: []uint{0}}}, + http.StatusOK, &countResp, + ) + assert.Equal(t, uint(2), countResp.TargetsCount) + assert.Equal(t, uint(0), countResp.TargetsOnline) + assert.Equal(t, uint(2), countResp.TargetsOffline) + // host id selected countResp = countTargetsResponse{} s.DoJSON("POST", "/api/latest/fleet/targets/count", countTargetsRequest{Selected: fleet.HostTargets{HostIDs: []uint{hosts[1].ID}}}, http.StatusOK, &countResp) @@ -7047,7 +7103,7 @@ func (s *integrationTestSuite) TestHostsReportDownload() { res.Body.Close() require.NoError(t, err) require.Len(t, rows, len(hosts)+1) // all hosts + header row - assert.Len(t, rows[0], 50) // total number of cols + assert.Len(t, rows[0], 51) // total number of cols const ( idCol = 3 @@ -9149,7 +9205,7 @@ func (s *integrationTestSuite) TestHostsReportWithPolicyResults() { res.Body.Close() require.NoError(t, err) require.Len(t, rows1, len(hosts)+1) // all hosts + header row - assert.Len(t, rows1[0], 50) // total number of cols + assert.Len(t, rows1[0], 51) // total number of cols var ( idIdx int @@ -9176,7 +9232,7 @@ func (s *integrationTestSuite) TestHostsReportWithPolicyResults() { res.Body.Close() require.NoError(t, err) require.Len(t, rows2, len(hosts)+1) // all hosts + header row - assert.Len(t, rows2[0], 50) // total number of cols + assert.Len(t, rows2[0], 51) // total number of cols // Check that all hosts have 0 issues and that they match the previous call to `/hosts/report`. for i := 1; i < len(hosts)+1; i++ { diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index d510377418..b3f650deb3 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -1315,9 +1315,15 @@ func (s *integrationEnterpriseTestSuite) TestExternalIntegrationsTeamConfig() { Enable: true, DestinationURL: "http://example.com", }, + HostStatusWebhook: fleet.HostStatusWebhookSettings{ + Enable: true, + DestinationURL: "http://example.com/host_status_webhook", + }, }}, http.StatusOK, &tmResp) require.True(t, tmResp.Team.Config.WebhookSettings.FailingPoliciesWebhook.Enable) require.Equal(t, "http://example.com", tmResp.Team.Config.WebhookSettings.FailingPoliciesWebhook.DestinationURL) + require.True(t, tmResp.Team.Config.WebhookSettings.HostStatusWebhook.Enable) + require.Equal(t, "http://example.com/host_status_webhook", tmResp.Team.Config.WebhookSettings.HostStatusWebhook.DestinationURL) // add an unknown automation - does not exist at the global level s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), fleet.TeamPayload{Integrations: &fleet.TeamIntegrations{ @@ -6910,7 +6916,7 @@ func (s *integrationEnterpriseTestSuite) TestAllSoftwareTitles() { ) } -func (s *integrationEnterpriseTestSuite) TestLockUnlockWindowsLinux() { +func (s *integrationEnterpriseTestSuite) TestLockUnlockWipeWindowsLinux() { ctx := context.Background() t := s.T() @@ -6932,19 +6938,22 @@ func (s *integrationEnterpriseTestSuite) TestLockUnlockWindowsLinux() { require.NotNil(t, getHostResp.Host.MDM.PendingAction) require.Equal(t, "", *getHostResp.Host.MDM.PendingAction) - // try to lock/unlock the Windows host, fails because Windows MDM must be enabled + // try to lock/unlock/wipe the Windows host, fails because Windows MDM must be enabled res := s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/lock", winHost.ID), nil, http.StatusBadRequest) errMsg := extractServerErrorText(res.Body) require.Contains(t, errMsg, "Windows MDM isn't turned on.") res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/unlock", winHost.ID), nil, http.StatusBadRequest) errMsg = extractServerErrorText(res.Body) require.Contains(t, errMsg, "Windows MDM isn't turned on.") + res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/wipe", winHost.ID), nil, http.StatusBadRequest) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Windows MDM isn't turned on.") - // try to lock/unlock the Linux host succeeds, no MDM constraints + // try to lock/unlock/wipe the Linux host succeeds, no MDM constraints s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/lock", linuxHost.ID), nil, http.StatusNoContent) // simulate a successful script result for the lock command - status, err := s.ds.GetHostLockWipeStatus(ctx, linuxHost.ID, linuxHost.FleetPlatform()) + status, err := s.ds.GetHostLockWipeStatus(ctx, linuxHost) require.NoError(t, err) var orbitScriptResp orbitPostScriptResultResponse @@ -6966,6 +6975,12 @@ func (s *integrationEnterpriseTestSuite) TestLockUnlockWindowsLinux() { require.Equal(t, "locked", *getHostResp.Host.MDM.DeviceStatus) require.NotNil(t, getHostResp.Host.MDM.PendingAction) require.Equal(t, "unlock", *getHostResp.Host.MDM.PendingAction) + + // attempting to Wipe the linux host fails due to pending unlock, not because + // of MDM not enabled + res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/wipe", linuxHost.ID), nil, http.StatusUnprocessableEntity) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Host cannot be wiped until unlock is complete.") } // checks that the specified team/no-team has the Windows OS Updates profile with diff --git a/server/service/integration_live_queries_test.go b/server/service/integration_live_queries_test.go index 91bb4a2389..cc6b5b0659 100644 --- a/server/service/integration_live_queries_test.go +++ b/server/service/integration_live_queries_test.go @@ -961,8 +961,8 @@ func (s *liveQueriesTestSuite) TestCreateDistributedQueryCampaign() { } s.DoJSON("POST", "/api/latest/fleet/queries/run", req, http.StatusBadRequest, &createResp) - // wait a second to prevent duplicate name for new query - time.Sleep(time.Second) + // wait to prevent duplicate name for new query + time.Sleep(200 * time.Millisecond) // create with new query for specific hosts req = createDistributedQueryCampaignRequest{ @@ -975,8 +975,23 @@ func (s *liveQueriesTestSuite) TestCreateDistributedQueryCampaign() { camp1 := *createResp.Campaign assert.Equal(t, uint(2), createResp.Campaign.Metrics.TotalHosts) - // wait a second to prevent duplicate name for new query - time.Sleep(time.Second) + // wait to prevent duplicate name for new query + time.Sleep(200 * time.Millisecond) + + // create with new query for 'No team' + req = createDistributedQueryCampaignRequest{ + QuerySQL: "SELECT 2.5", + Selected: fleet.HostTargets{ + TeamIDs: []uint{0}, + }, + } + s.DoJSON("POST", "/api/latest/fleet/queries/run", req, http.StatusOK, &createResp) + assert.NotEqual(t, camp1.ID, createResp.Campaign.ID) + camp1 = *createResp.Campaign + assert.Equal(t, uint(len(s.hosts)), createResp.Campaign.Metrics.TotalHosts) + + // wait to prevent duplicate name for new query + time.Sleep(200 * time.Millisecond) // create by host name req2 := createDistributedQueryCampaignByNamesRequest{ @@ -989,8 +1004,8 @@ func (s *liveQueriesTestSuite) TestCreateDistributedQueryCampaign() { assert.NotEqual(t, camp1.ID, createResp.Campaign.ID) assert.Equal(t, uint(1), createResp.Campaign.Metrics.TotalHosts) - // wait a second to prevent duplicate name for new query - time.Sleep(time.Second) + // wait to prevent duplicate name for new query + time.Sleep(200 * time.Millisecond) // create by unknown host name - it ignores the unknown names. Must have at least 1 valid host req2 = createDistributedQueryCampaignByNamesRequest{ diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index 648ef866c9..f128fc9805 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -75,17 +75,19 @@ func TestIntegrationsMDM(t *testing.T) { type integrationMDMTestSuite struct { suite.Suite withServer - fleetCfg config.FleetConfig - fleetDMNextCSRStatus atomic.Value - pushProvider *mock.APNSPushProvider - depStorage nanodep_storage.AllDEPStorage - depSchedule *schedule.Schedule - profileSchedule *schedule.Schedule - onProfileJobDone func() // function called when profileSchedule.Trigger() job completed - onDEPScheduleDone func() // function called when depSchedule.Trigger() job completed - mdmStorage *mysql.NanoMDMStorage - worker *worker.Worker - mdmCommander *apple_mdm.MDMAppleCommander + fleetCfg config.FleetConfig + fleetDMNextCSRStatus atomic.Value + pushProvider *mock.APNSPushProvider + depStorage nanodep_storage.AllDEPStorage + depSchedule *schedule.Schedule + profileSchedule *schedule.Schedule + integrationsSchedule *schedule.Schedule + onProfileJobDone func() // function called when profileSchedule.Trigger() job completed + onDEPScheduleDone func() // function called when depSchedule.Trigger() job completed + onIntegrationsScheduleDone func() // function called when integrationsSchedule.Trigger() job completed + mdmStorage *mysql.NanoMDMStorage + worker *worker.Worker + mdmCommander *apple_mdm.MDMAppleCommander } func (s *integrationMDMTestSuite) SetupSuite() { @@ -126,7 +128,24 @@ func (s *integrationMDMTestSuite) SetupSuite() { redisPool := redistest.SetupRedis(s.T(), "zz", false, false, false) s.withServer.lq = live_query_mock.New(s.T()) + macosJob := &worker.MacosSetupAssistant{ + Datastore: s.ds, + Log: kitlog.NewJSONLogger(os.Stdout), + DEPService: apple_mdm.NewDEPService(s.ds, depStorage, kitlog.NewJSONLogger(os.Stdout)), + DEPClient: apple_mdm.NewDEPClient(depStorage, s.ds, kitlog.NewJSONLogger(os.Stdout)), + } + appleMDMJob := &worker.AppleMDM{ + Datastore: s.ds, + Log: kitlog.NewJSONLogger(os.Stdout), + Commander: mdmCommander, + } + workr := worker.NewWorker(s.ds, kitlog.NewJSONLogger(os.Stdout)) + workr.TestIgnoreUnknownJobs = true + workr.Register(macosJob, appleMDMJob) + s.worker = workr + var depSchedule *schedule.Schedule + var integrationsSchedule *schedule.Schedule var profileSchedule *schedule.Schedule config := TestServerOpts{ License: &fleet.LicenseInfo{ @@ -187,6 +206,27 @@ func (s *integrationMDMTestSuite) SetupSuite() { return profileSchedule, nil } }, + func(ctx context.Context, ds fleet.Datastore) fleet.NewCronScheduleFunc { + return func() (fleet.CronSchedule, error) { + const name = string(fleet.CronWorkerIntegrations) + logger := kitlog.NewJSONLogger(os.Stdout) + integrationsSchedule = schedule.New( + ctx, name, s.T().Name(), 1*time.Minute, ds, ds, + schedule.WithLogger(logger), + schedule.WithJob("integrations_worker", func(ctx context.Context) error { + return s.worker.ProcessJobs(ctx) + }), + schedule.WithJob("dep_cooldowns", func(ctx context.Context) error { + if s.onIntegrationsScheduleDone != nil { + defer s.onIntegrationsScheduleDone() + } + + return worker.ProcessDEPCooldowns(ctx, ds, logger) + }), + ) + return integrationsSchedule, nil + } + }, }, APNSTopic: "com.apple.mgmt.External.10ac3ce5-4668-4e58-b69a-b2b5ce667589", } @@ -199,26 +239,11 @@ func (s *integrationMDMTestSuite) SetupSuite() { s.pushProvider = pushProvider s.depStorage = depStorage s.depSchedule = depSchedule + s.integrationsSchedule = integrationsSchedule s.profileSchedule = profileSchedule s.mdmStorage = mdmStorage s.mdmCommander = mdmCommander - macosJob := &worker.MacosSetupAssistant{ - Datastore: s.ds, - Log: kitlog.NewJSONLogger(os.Stdout), - DEPService: apple_mdm.NewDEPService(s.ds, depStorage, kitlog.NewJSONLogger(os.Stdout)), - DEPClient: apple_mdm.NewDEPClient(depStorage, s.ds, kitlog.NewJSONLogger(os.Stdout)), - } - appleMDMJob := &worker.AppleMDM{ - Datastore: s.ds, - Log: kitlog.NewJSONLogger(os.Stdout), - Commander: mdmCommander, - } - workr := worker.NewWorker(s.ds, kitlog.NewJSONLogger(os.Stdout)) - workr.TestIgnoreUnknownJobs = true - workr.Register(macosJob, appleMDMJob) - s.worker = workr - fleetdmSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { status := s.fleetDMNextCSRStatus.Swap(http.StatusOK) w.WriteHeader(status.(int)) @@ -278,12 +303,19 @@ func (s *integrationMDMTestSuite) TearDownTest() { _, err := q.ExecContext(ctx, "DELETE FROM mdm_windows_configuration_profiles") return err }) + // clear any pending worker job mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { _, err := q.ExecContext(ctx, "DELETE FROM jobs") return err }) + // clear any host dep assignments + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, "DELETE FROM host_dep_assignments") + return err + }) + // clear any mdm windows enrollments mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { _, err := q.ExecContext(ctx, "DELETE FROM mdm_windows_enrollments") @@ -1973,6 +2005,104 @@ func (s *integrationMDMTestSuite) TestDEPProfileAssignment() { } } + checkAssignProfileRequests := func(serial string, profUUID *string) { + require.NotEmpty(t, profileAssignmentReqs) + require.Len(t, profileAssignmentReqs, 1) + require.Len(t, profileAssignmentReqs[0].Devices, 1) + require.Equal(t, serial, profileAssignmentReqs[0].Devices[0]) + if profUUID != nil { + require.Equal(t, *profUUID, profileAssignmentReqs[0].ProfileUUID) + } + } + + type hostDEPRow struct { + HostID uint `db:"host_id"` + ProfileUUID string `db:"profile_uuid"` + AssignProfileResponse string `db:"assign_profile_response"` + ResponseUpdatedAt time.Time `db:"response_updated_at"` + RetryJobID uint `db:"retry_job_id"` + } + checkHostDEPAssignProfileResponses := func(deviceSerials []string, expectedProfileUUID string, expectedStatus fleet.DEPAssignProfileResponseStatus) map[string]hostDEPRow { + bySerial := make(map[string]hostDEPRow, len(deviceSerials)) + for _, deviceSerial := range deviceSerials { + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + var dest hostDEPRow + err := sqlx.GetContext(ctx, q, &dest, "SELECT host_id, assign_profile_response, profile_uuid, response_updated_at, retry_job_id FROM host_dep_assignments WHERE profile_uuid = ? AND host_id = (SELECT id FROM hosts WHERE hardware_serial = ?)", expectedProfileUUID, deviceSerial) + require.NoError(t, err) + require.Equal(t, string(expectedStatus), dest.AssignProfileResponse) + bySerial[deviceSerial] = dest + return nil + }) + } + return bySerial + } + + checkPendingMacOSSetupAssistantJob := func(expectedTask string, expectedTeamID *uint, expectedSerials []string, expectedJobID uint) { + pending, err := s.ds.GetQueuedJobs(context.Background(), 1) + require.NoError(t, err) + require.Len(t, pending, 1) + require.Equal(t, "macos_setup_assistant", pending[0].Name) + require.NotNil(t, pending[0].Args) + var gotArgs struct { + Task string `json:"task"` + TeamID *uint `json:"team_id,omitempty"` + HostSerialNumbers []string `json:"host_serial_numbers,omitempty"` + } + require.NoError(t, json.Unmarshal(*pending[0].Args, &gotArgs)) + require.Equal(t, expectedTask, gotArgs.Task) + if expectedTeamID != nil { + require.NotNil(t, gotArgs.TeamID) + require.Equal(t, *expectedTeamID, *gotArgs.TeamID) + } else { + require.Nil(t, gotArgs.TeamID) + } + require.Equal(t, expectedSerials, gotArgs.HostSerialNumbers) + + if expectedJobID != 0 { + require.Equal(t, expectedJobID, pending[0].ID) + } + } + + checkNoJobsPending := func() { + pending, err := s.ds.GetQueuedJobs(context.Background(), 1) + require.NoError(t, err) + require.Empty(t, pending) + } + + expectNoJobID := ptr.Uint(0) // used when expect no retry job + checkHostCooldown := func(serial, profUUID string, status fleet.DEPAssignProfileResponseStatus, expectUpdatedAt *time.Time, expectRetryJobID *uint) hostDEPRow { + bySerial := checkHostDEPAssignProfileResponses([]string{serial}, profUUID, status) + d, ok := bySerial[serial] + require.True(t, ok) + if expectUpdatedAt != nil { + require.Equal(t, *expectUpdatedAt, d.ResponseUpdatedAt) + } + if expectRetryJobID != nil { + require.Equal(t, *expectRetryJobID, d.RetryJobID) + } + return d + } + + checkListHostDEPError := func(serial string, expectStatus string, expectError bool) *fleet.HostResponse { + listHostsRes := listHostsResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts?query=%s", serial), nil, http.StatusOK, &listHostsRes) + require.Len(t, listHostsRes.Hosts, 1) + require.Equal(t, serial, listHostsRes.Hosts[0].HardwareSerial) + require.Equal(t, expectStatus, *listHostsRes.Hosts[0].MDM.EnrollmentStatus) + require.Equal(t, expectError, listHostsRes.Hosts[0].MDM.DEPProfileError) + + return &listHostsRes.Hosts[0] + } + + setAssignProfileResponseUpdatedAt := func(serial string, updatedAt time.Time) { + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, `UPDATE host_dep_assignments SET response_updated_at = ? WHERE host_id = (SELECT id FROM hosts WHERE hardware_serial = ?)`, updatedAt, serial) + return err + }) + } + + expectAssignProfileResponseFailed := "" // set to device serial when testing the failed profile assignment flow + expectAssignProfileResponseNotAccessible := "" // set to device serial when testing the not accessible profile assignment flow s.mockDEPResponse(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) encoder := json.NewEncoder(w) @@ -1999,7 +2129,21 @@ func (s *integrationMDMTestSuite) TestDEPProfileAssignment() { var prof profileAssignmentReq require.NoError(t, json.Unmarshal(b, &prof)) profileAssignmentReqs = append(profileAssignmentReqs, prof) - _, _ = w.Write([]byte(`{}`)) + var resp godep.ProfileResponse + resp.ProfileUUID = prof.ProfileUUID + resp.Devices = make(map[string]string, len(prof.Devices)) + for _, device := range prof.Devices { + switch device { + case expectAssignProfileResponseNotAccessible: + resp.Devices[device] = string(fleet.DEPAssignProfileResponseNotAccessible) + case expectAssignProfileResponseFailed: + resp.Devices[device] = string(fleet.DEPAssignProfileResponseFailed) + default: + resp.Devices[device] = string(fleet.DEPAssignProfileResponseSuccess) + } + } + err = encoder.Encode(resp) + require.NoError(t, err) default: _, _ = w.Write([]byte(`{}`)) } @@ -2032,7 +2176,9 @@ func (s *integrationMDMTestSuite) TestDEPProfileAssignment() { // - one when we do the device sync (/device/sync) require.Len(t, profileAssignmentReqs, 2) require.Len(t, profileAssignmentReqs[0].Devices, 1) + checkHostDEPAssignProfileResponses(profileAssignmentReqs[0].Devices, profileAssignmentReqs[0].ProfileUUID, fleet.DEPAssignProfileResponseSuccess) require.Len(t, profileAssignmentReqs[1].Devices, len(devices)) + checkHostDEPAssignProfileResponses(profileAssignmentReqs[1].Devices, profileAssignmentReqs[1].ProfileUUID, fleet.DEPAssignProfileResponseSuccess) // create a new host nonDEPHost := createHostAndDeviceToken(t, s.ds, "not-dep") @@ -2156,6 +2302,7 @@ func (s *integrationMDMTestSuite) TestDEPProfileAssignment() { // TODO: seems like we're doing this request on each loop? require.Len(t, profileAssignmentReqs[0].Devices, 1) require.Equal(t, devices[0].SerialNumber, profileAssignmentReqs[0].Devices[0]) + checkHostDEPAssignProfileResponses(profileAssignmentReqs[0].Devices, profileAssignmentReqs[0].ProfileUUID, fleet.DEPAssignProfileResponseSuccess) // profileAssignmentReqs[1] and [2] can be in any order ix2Devices, ix1Device := 1, 2 @@ -2166,12 +2313,13 @@ func (s *integrationMDMTestSuite) TestDEPProfileAssignment() { // - existing device with "added" // - new device with "added" require.Len(t, profileAssignmentReqs[ix2Devices].Devices, 2, "%#+v", profileAssignmentReqs) - require.Equal(t, devices[0].SerialNumber, profileAssignmentReqs[ix2Devices].Devices[0]) - require.Equal(t, addedSerial, profileAssignmentReqs[ix2Devices].Devices[1]) + require.ElementsMatch(t, []string{devices[0].SerialNumber, addedSerial}, profileAssignmentReqs[ix2Devices].Devices) + checkHostDEPAssignProfileResponses(profileAssignmentReqs[ix2Devices].Devices, profileAssignmentReqs[ix2Devices].ProfileUUID, fleet.DEPAssignProfileResponseSuccess) // - existing device with "modified" and a different team (thus different profile request) require.Len(t, profileAssignmentReqs[ix1Device].Devices, 1) require.Equal(t, devices[1].SerialNumber, profileAssignmentReqs[ix1Device].Devices[0]) + checkHostDEPAssignProfileResponses(profileAssignmentReqs[ix1Device].Devices, profileAssignmentReqs[ix1Device].ProfileUUID, fleet.DEPAssignProfileResponseSuccess) // entries for all hosts except for the one with OpType = "deleted" assignment, err := s.ds.GetHostDEPAssignment(ctx, deletedHostID) @@ -2205,6 +2353,7 @@ func (s *integrationMDMTestSuite) TestDEPProfileAssignment() { profileAssignmentReqs = []profileAssignmentReq{} s.runWorker() require.Equal(t, mdmDevice.SerialNumber, profileAssignmentReqs[0].Devices[0]) + checkHostDEPAssignProfileResponses(profileAssignmentReqs[0].Devices, profileAssignmentReqs[0].ProfileUUID, fleet.DEPAssignProfileResponseSuccess) // it should get the post-enrollment commands require.NoError(t, mdmDevice.Enroll()) @@ -2272,6 +2421,11 @@ func (s *integrationMDMTestSuite) TestDEPProfileAssignment() { s.runDEPSchedule() require.NotEmpty(t, profileAssignmentReqs) require.Equal(t, eHost.HardwareSerial, profileAssignmentReqs[0].Devices[0]) + checkHostDEPAssignProfileResponses(profileAssignmentReqs[0].Devices, profileAssignmentReqs[0].ProfileUUID, fleet.DEPAssignProfileResponseSuccess) + + // report MDM info via osquery + require.NoError(t, s.ds.SetOrUpdateMDMData(ctx, eHost.ID, false, true, s.server.URL, true, fleet.WellKnownMDMFleet, "")) + checkListHostDEPError(eHost.HardwareSerial, "On (automatic)", false) // transfer to "no team", we assign a DEP profile to the device profileAssignmentReqs = []profileAssignmentReq{} @@ -2280,6 +2434,8 @@ func (s *integrationMDMTestSuite) TestDEPProfileAssignment() { s.runWorker() require.NotEmpty(t, profileAssignmentReqs) require.Equal(t, eHost.HardwareSerial, profileAssignmentReqs[0].Devices[0]) + checkHostDEPAssignProfileResponses(profileAssignmentReqs[0].Devices, profileAssignmentReqs[0].ProfileUUID, fleet.DEPAssignProfileResponseSuccess) + checkListHostDEPError(eHost.HardwareSerial, "On (automatic)", false) // transfer to the team back again, we assign a DEP profile to the device again s.Do("POST", "/api/v1/fleet/hosts/transfer", @@ -2288,6 +2444,220 @@ func (s *integrationMDMTestSuite) TestDEPProfileAssignment() { s.runWorker() require.NotEmpty(t, profileAssignmentReqs) require.Equal(t, eHost.HardwareSerial, profileAssignmentReqs[0].Devices[0]) + checkHostDEPAssignProfileResponses(profileAssignmentReqs[0].Devices, profileAssignmentReqs[0].ProfileUUID, fleet.DEPAssignProfileResponseSuccess) + checkListHostDEPError(eHost.HardwareSerial, "On (automatic)", false) + + // transfer to "no team", but simulate a failed profile assignment + expectAssignProfileResponseFailed = eHost.HardwareSerial + profileAssignmentReqs = []profileAssignmentReq{} + s.Do("POST", "/api/v1/fleet/hosts/transfer", + addHostsToTeamRequest{TeamID: nil, HostIDs: []uint{eHost.ID}}, http.StatusOK) + checkPendingMacOSSetupAssistantJob("hosts_transferred", nil, []string{eHost.HardwareSerial}, 0) + + s.runIntegrationsSchedule() + checkAssignProfileRequests(eHost.HardwareSerial, nil) + profUUID := profileAssignmentReqs[0].ProfileUUID + d := checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseFailed, nil, expectNoJobID) + require.NotZero(t, d.ResponseUpdatedAt) + failedAt := d.ResponseUpdatedAt + checkNoJobsPending() + // list hosts shows dep profile error + checkListHostDEPError(eHost.HardwareSerial, "On (automatic)", true) + + // run the integrations schedule during the cooldown period + profileAssignmentReqs = []profileAssignmentReq{} + s.runIntegrationsSchedule() + require.Empty(t, profileAssignmentReqs) // no new request during cooldown + checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, expectNoJobID) // no change + checkNoJobsPending() + + // create a new team + var tmResp teamResponse + s.DoJSON("POST", "/api/latest/fleet/teams", &fleet.Team{ + Name: t.Name() + "dummy", + Description: "desc dummy", + }, http.StatusOK, &tmResp) + require.NotZero(t, createTeamResp.Team.ID) + dummyTeam := tmResp.Team + s.Do("POST", "/api/v1/fleet/hosts/transfer", + addHostsToTeamRequest{TeamID: &dummyTeam.ID, HostIDs: []uint{eHost.ID}}, http.StatusOK) + checkPendingMacOSSetupAssistantJob("hosts_transferred", &dummyTeam.ID, []string{eHost.HardwareSerial}, 0) + + // expect no assign profile request during cooldown + profileAssignmentReqs = []profileAssignmentReq{} + s.runIntegrationsSchedule() + require.Empty(t, profileAssignmentReqs) // screened for cooldown + checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, expectNoJobID) // no change + checkNoJobsPending() + + // cooldown hosts are screened from update profile jobs that would assign profiles + _, err = worker.QueueMacosSetupAssistantJob(ctx, s.ds, kitlog.NewNopLogger(), worker.MacosSetupAssistantUpdateProfile, &dummyTeam.ID, eHost.HardwareSerial) + require.NoError(t, err) + checkPendingMacOSSetupAssistantJob("update_profile", &dummyTeam.ID, []string{eHost.HardwareSerial}, 0) + s.runIntegrationsSchedule() + require.Empty(t, profileAssignmentReqs) // screened for cooldown + checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, expectNoJobID) // no change + checkNoJobsPending() + + // cooldown hosts are screened from delete profile jobs that would assign profiles + _, err = worker.QueueMacosSetupAssistantJob(ctx, s.ds, kitlog.NewNopLogger(), worker.MacosSetupAssistantProfileDeleted, &dummyTeam.ID, eHost.HardwareSerial) + require.NoError(t, err) + checkPendingMacOSSetupAssistantJob("profile_deleted", &dummyTeam.ID, []string{eHost.HardwareSerial}, 0) + s.runIntegrationsSchedule() + require.Empty(t, profileAssignmentReqs) // screened for cooldown + checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, expectNoJobID) // no change + checkNoJobsPending() + + // // TODO: Restore this test when FIXME on DeleteTeam is addressed + // s.Do("DELETE", fmt.Sprintf("/api/v1/fleet/teams/%d", dummyTeam.ID), nil, http.StatusOK) + // checkPendingMacOSSetupAssistantJob("team_deleted", nil, []string{eHost.HardwareSerial}, 0) + // s.runIntegrationsSchedule() + // require.Empty(t, profileAssignmentReqs) // screened for cooldown + // bySerial = checkHostDEPAssignProfileResponses([]string{eHost.HardwareSerial}, profUUID, fleet.DEPAssignProfileResponseFailed) + // d, ok = bySerial[eHost.HardwareSerial] + // require.True(t, ok) + // require.Equal(t, failedAt, d.ResponseUpdatedAt) + // require.Zero(t, d.RetryJobID) // cooling down so no retry job + // checkNoJobsPending() + + // transfer back to no team, expect no assign profile request during cooldown + s.Do("POST", "/api/v1/fleet/hosts/transfer", + addHostsToTeamRequest{TeamID: nil, HostIDs: []uint{eHost.ID}}, http.StatusOK) + checkPendingMacOSSetupAssistantJob("hosts_transferred", nil, []string{eHost.HardwareSerial}, 0) + profileAssignmentReqs = []profileAssignmentReq{} + s.runIntegrationsSchedule() + require.Empty(t, profileAssignmentReqs) // screened for cooldown + checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, expectNoJobID) // no change + checkNoJobsPending() + + // simulate expired cooldown + failedAt = failedAt.Add(-2 * time.Hour) + setAssignProfileResponseUpdatedAt(eHost.HardwareSerial, failedAt) + profileAssignmentReqs = []profileAssignmentReq{} + s.runIntegrationsSchedule() + require.Empty(t, profileAssignmentReqs) // assign profile request will be made when the retry job is processed on the next worker run + d = checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, nil) + require.NotZero(t, d.RetryJobID) // retry job created + jobID := d.RetryJobID + checkPendingMacOSSetupAssistantJob("hosts_cooldown", nil, []string{eHost.HardwareSerial}, jobID) + + // running the DEP schedule should not trigger a profile assignment request when the retry job is pending + profileAssignmentReqs = []profileAssignmentReq{} + s.runDEPSchedule() + require.Empty(t, profileAssignmentReqs) // assign profile request will be made when the retry job is processed on the next worker run + checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, &jobID) // no change + checkPendingMacOSSetupAssistantJob("hosts_cooldown", nil, []string{eHost.HardwareSerial}, jobID) + checkListHostDEPError(eHost.HardwareSerial, "On (automatic)", true) + + // run the inregration schedule and expect success + expectAssignProfileResponseFailed = "" + profileAssignmentReqs = []profileAssignmentReq{} + s.runIntegrationsSchedule() + checkAssignProfileRequests(eHost.HardwareSerial, &profUUID) + d = checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseSuccess, nil, expectNoJobID) // retry job cleared + require.True(t, d.ResponseUpdatedAt.After(failedAt)) + succeededAt := d.ResponseUpdatedAt + checkNoJobsPending() + checkListHostDEPError(eHost.HardwareSerial, "On (automatic)", false) + + // run the integrations schedule and expect no changes + profileAssignmentReqs = []profileAssignmentReq{} + s.runIntegrationsSchedule() + require.Empty(t, profileAssignmentReqs) + checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseSuccess, &succeededAt, expectNoJobID) // no change + checkNoJobsPending() + + // ingest new device via DEP but the profile assignment fails + serial := uuid.NewString() + devices = []godep.Device{ + {SerialNumber: serial, Model: "MacBook Pro", OS: "osx", OpType: "added"}, + } + expectAssignProfileResponseFailed = serial + profileAssignmentReqs = []profileAssignmentReq{} + s.runDEPSchedule() + checkAssignProfileRequests(serial, nil) + profUUID = profileAssignmentReqs[0].ProfileUUID + d = checkHostCooldown(serial, profUUID, fleet.DEPAssignProfileResponseFailed, nil, expectNoJobID) + require.NotZero(t, d.ResponseUpdatedAt) + failedAt = d.ResponseUpdatedAt + checkNoJobsPending() + h := checkListHostDEPError(serial, "Pending", true) // list hosts shows device pending and dep profile error + + // transfer to team, no profile assignment request is made during the cooldown period + profileAssignmentReqs = []profileAssignmentReq{} + s.Do("POST", "/api/v1/fleet/hosts/transfer", + addHostsToTeamRequest{TeamID: &team.ID, HostIDs: []uint{h.ID}}, http.StatusOK) + checkPendingMacOSSetupAssistantJob("hosts_transferred", &team.ID, []string{serial}, 0) + s.runIntegrationsSchedule() + require.Empty(t, profileAssignmentReqs) // screened by cooldown + checkHostCooldown(serial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, expectNoJobID) // no change + checkNoJobsPending() + + // run the integrations schedule and expect no changes + profileAssignmentReqs = []profileAssignmentReq{} + s.runIntegrationsSchedule() + require.Empty(t, profileAssignmentReqs) + checkHostCooldown(serial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, expectNoJobID) // no change + checkNoJobsPending() + + // simulate expired cooldown + failedAt = failedAt.Add(-2 * time.Hour) + setAssignProfileResponseUpdatedAt(serial, failedAt) + profileAssignmentReqs = []profileAssignmentReq{} + s.runIntegrationsSchedule() + require.Empty(t, profileAssignmentReqs) // assign profile request will be made when the retry job is processed on the next worker run + d = checkHostCooldown(serial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, nil) + require.NotZero(t, d.RetryJobID) // retry job created + jobID = d.RetryJobID + checkPendingMacOSSetupAssistantJob("hosts_cooldown", &team.ID, []string{serial}, jobID) + + // run the inregration schedule and expect success + expectAssignProfileResponseFailed = "" + profileAssignmentReqs = []profileAssignmentReq{} + s.runIntegrationsSchedule() + checkAssignProfileRequests(serial, nil) + require.NotEqual(t, profUUID, profileAssignmentReqs[0].ProfileUUID) // retry job will use the current team profile instead + profUUID = profileAssignmentReqs[0].ProfileUUID + d = checkHostCooldown(serial, profUUID, fleet.DEPAssignProfileResponseSuccess, nil, expectNoJobID) // retry job cleared + require.True(t, d.ResponseUpdatedAt.After(failedAt)) + checkNoJobsPending() + // list hosts shows pending (because MDM detail query hasn't been reported) but dep profile + // error has been cleared + checkListHostDEPError(serial, "Pending", false) + + // ingest another device via DEP but the profile assignment is not accessible + serial = uuid.NewString() + devices = []godep.Device{ + {SerialNumber: serial, Model: "MacBook Pro", OS: "osx", OpType: "added"}, + } + expectAssignProfileResponseNotAccessible = serial + profileAssignmentReqs = []profileAssignmentReq{} + s.runDEPSchedule() + require.Len(t, profileAssignmentReqs, 2) // FIXME: When new device is added in ABM, we see two profile assign requests when device is not accessible: first during the "fetch" phase, then during the "sync" phase + expectProfileUUID := "" + for _, req := range profileAssignmentReqs { + require.Len(t, req.Devices, 1) + require.Equal(t, serial, req.Devices[0]) + if expectProfileUUID == "" { + expectProfileUUID = req.ProfileUUID + } else { + require.Equal(t, expectProfileUUID, req.ProfileUUID) + } + d := checkHostCooldown(serial, req.ProfileUUID, fleet.DEPAssignProfileResponseNotAccessible, nil, expectNoJobID) // not accessible responses aren't retried + require.NotZero(t, d.ResponseUpdatedAt) + failedAt = d.ResponseUpdatedAt + } + // list hosts shows device pending and no dep profile error for not accessible responses + checkListHostDEPError(serial, "Pending", false) + + // no retry job for not accessible responses even if cooldown expires + failedAt = failedAt.Add(-2 * time.Hour) + setAssignProfileResponseUpdatedAt(serial, failedAt) + profileAssignmentReqs = []profileAssignmentReq{} + s.runIntegrationsSchedule() + require.Empty(t, profileAssignmentReqs) + checkHostCooldown(serial, expectProfileUUID, fleet.DEPAssignProfileResponseNotAccessible, &failedAt, expectNoJobID) // no change + checkNoJobsPending() } func loadEnrollmentProfileDEPToken(t *testing.T, ds *mysql.Datastore) string { @@ -2963,15 +3333,15 @@ func (s *integrationMDMTestSuite) TestMDMAppleHostDiskEncryption() { require.NoError(t, err) // get that host - it has an encryption key with unknown decryptability, so - // it should report "enforcing" disk encryption. + // it should report "verifying" disk encryption. getHostResp = getHostResponse{} s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp) require.NotNil(t, getHostResp.Host.MDM.MacOSSettings.DiskEncryption) - require.Equal(t, fleet.DiskEncryptionEnforcing, *getHostResp.Host.MDM.MacOSSettings.DiskEncryption) + require.Equal(t, fleet.DiskEncryptionVerifying, *getHostResp.Host.MDM.MacOSSettings.DiskEncryption) require.Nil(t, getHostResp.Host.MDM.MacOSSettings.ActionRequired) require.NotNil(t, getHostResp.Host.MDM.OSSettings) require.NotNil(t, getHostResp.Host.MDM.OSSettings.DiskEncryption.Status) - require.Equal(t, fleet.DiskEncryptionEnforcing, *getHostResp.Host.MDM.OSSettings.DiskEncryption.Status) + require.Equal(t, fleet.DiskEncryptionVerifying, *getHostResp.Host.MDM.OSSettings.DiskEncryption.Status) require.Equal(t, "", getHostResp.Host.MDM.OSSettings.DiskEncryption.Detail) // request with no token @@ -8078,10 +8448,36 @@ func (s *integrationMDMTestSuite) TestWindowsMDM() { err = s.ds.MDMWindowsInsertCommandForHosts(context.Background(), []string{orbitHost.UUID}, commandThree) require.NoError(t, err) + cmdFourUUID := uuid.New().String() + commandFour := &fleet.MDMWindowsCommand{ + CommandUUID: cmdFourUUID, + RawCommand: []byte(fmt.Sprintf(` + + %s + + + ./Vendor/MSFT/WiFi/Profile/MyNetwork/WlanXml + + + text/plain + chr + + + <?xml version="1.0"?><WLANProfile + xmlns="http://contoso.com/provisioning/EapHostConfig"><EapMethod><Type + + + + `, cmdFourUUID)), + TargetLocURI: "./Vendor/MSFT/WiFi/Profile/MyNetwork/WlanXml", + } + err = s.ds.MDMWindowsInsertCommandForHosts(context.Background(), []string{orbitHost.UUID}, commandFour) + require.NoError(t, err) + cmds, err = d.StartManagementSession() require.NoError(t, err) - // two status + the two commands we enqueued - require.Len(t, cmds, 4) + // two status + the three commands we enqueued + require.Len(t, cmds, 5) receivedCmdTwo := cmds[cmdTwoUUID] require.NotNil(t, receivedCmdTwo) require.Equal(t, receivedCmdTwo.Verb, fleet.CmdGet) @@ -8094,6 +8490,12 @@ func (s *integrationMDMTestSuite) TestWindowsMDM() { require.Len(t, receivedCmdThree.Cmd.Items, 1) require.EqualValues(t, "./Device/Vendor/MSFT/DMClient/Provider/DEMO%20MDM/SignedEntDMID", *receivedCmdThree.Cmd.Items[0].Target) + receivedCmdFour := cmds[cmdFourUUID] + require.NotNil(t, receivedCmdFour) + require.Equal(t, receivedCmdFour.Verb, fleet.CmdAdd) + require.Len(t, receivedCmdFour.Cmd.Items, 1) + require.EqualValues(t, "./Vendor/MSFT/WiFi/Profile/MyNetwork/WlanXml", *receivedCmdFour.Cmd.Items[0].Target) + // status 200 for command Two (Get) d.AppendResponse(fleet.SyncMLCmd{ XMLName: xml.Name{Local: mdm_types.CmdStatus}, @@ -8130,8 +8532,19 @@ func (s *integrationMDMTestSuite) TestWindowsMDM() { Items: nil, CmdID: fleet.CmdID{Value: uuid.NewString()}, }) + // status 200 for command Four (Add) + d.AppendResponse(fleet.SyncMLCmd{ + XMLName: xml.Name{Local: mdm_types.CmdStatus}, + MsgRef: &msgID, + CmdRef: &cmdFourUUID, + Cmd: ptr.String("Add"), + Data: ptr.String("200"), + Items: nil, + CmdID: fleet.CmdID{Value: uuid.NewString()}, + }) cmds, err = d.SendResponse() require.NoError(t, err) + // the ack of the message should be the only returned command require.Len(t, cmds, 1) @@ -8192,6 +8605,20 @@ func (s *integrationMDMTestSuite) TestWindowsMDM() { Hostname: "TestIntegrationsMDM/TestWindowsMDMh1.local", Payload: commandThree.RawCommand, }, getMDMCmdResp.Results[0]) + + s.DoJSON("GET", "/api/latest/fleet/mdm/commandresults", nil, http.StatusOK, &getMDMCmdResp, "command_uuid", cmdFourUUID) + require.Len(t, getMDMCmdResp.Results, 1) + require.NotZero(t, getMDMCmdResp.Results[0].UpdatedAt) + getMDMCmdResp.Results[0].UpdatedAt = time.Time{} + require.Equal(t, &fleet.MDMCommandResult{ + HostUUID: orbitHost.UUID, + CommandUUID: cmdFourUUID, + Status: "200", + RequestType: "./Vendor/MSFT/WiFi/Profile/MyNetwork/WlanXml", + Result: getCommandFullResult(cmdFourUUID), + Hostname: "TestIntegrationsMDM/TestWindowsMDMh1.local", + Payload: commandFour.RawCommand, + }, getMDMCmdResp.Results[0]) } func (s *integrationMDMTestSuite) TestWindowsAutomaticEnrollmentCommands() { @@ -8730,7 +9157,7 @@ func (s *integrationMDMTestSuite) TestMDMConfigProfileCRUD() { body, headers := generateNewProfileMultipartRequest( t, filename, - []byte(fmt.Sprintf(`%s`, locURI)), + []byte(fmt.Sprintf(`%s%s`, locURI, locURI)), s.token, fields, ) @@ -9011,7 +9438,7 @@ func (s *integrationMDMTestSuite) TestListMDMConfigProfiles() { tm2ProfG, err := s.ds.NewMDMWindowsConfigProfile(ctx, fleet.MDMWindowsConfigProfile{ Name: "tG", TeamID: &tm2.ID, - SyncML: []byte(``), + SyncML: []byte(``), Labels: []mdm_types.ConfigurationProfileLabel{ {LabelID: lblFoo.ID, LabelName: lblFoo.Name}, {LabelID: lblBar.ID, LabelName: lblBar.Name}, @@ -9917,6 +10344,19 @@ func (s *integrationMDMTestSuite) runDEPSchedule() { <-ch } +func (s *integrationMDMTestSuite) runIntegrationsSchedule() { + // FIXME: This pattern (which is being used in testing other schedules as well) seems cause issues + // where a subsequent call attempts to trigger when the schedule's trigger channel is full and + // schedule ignored the subsquent call (which is the documented behavior of the trigger). + // In testing, this can cause the test to hang until the next scheduled run. It isn't a very + // noticeable issue here since the intervals for these schedules are short. + ch := make(chan bool) + s.onIntegrationsScheduleDone = func() { close(ch) } + _, err := s.integrationsSchedule.Trigger() + require.NoError(s.T(), err) + <-ch +} + func (s *integrationMDMTestSuite) getRawTokenValue(content string) string { // Create a regex object with the defined pattern pattern := `inputToken.value\s*=\s*'([^']*)'` @@ -10765,14 +11205,14 @@ func (s *integrationMDMTestSuite) TestBatchSetMDMProfiles() { {Name: "N3", Contents: []byte(``)}, }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID))) errMsg = extractServerErrorText(res.Body) - require.Contains(t, errMsg, "Only supported as a top level element") + require.Contains(t, errMsg, "Windows configuration profiles can only have or top level elements.") // invalid xml res = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{ {Name: "N3", Contents: []byte(`foo`)}, }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID))) errMsg = extractServerErrorText(res.Body) - require.Contains(t, errMsg, "Only supported as a top level element") + require.Contains(t, errMsg, "Windows configuration profiles can only have or top level elements.") // successfully apply windows and macOS a profiles for the team, but it's a dry run s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{ @@ -10892,14 +11332,14 @@ func (s *integrationMDMTestSuite) TestBatchSetMDMProfilesBackwardsCompat() { "N3": []byte(``), }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID))) errMsg = extractServerErrorText(res.Body) - require.Contains(t, errMsg, "Only supported as a top level element") + require.Contains(t, errMsg, "Windows configuration profiles can only have or top level elements.") // invalid xml res = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": map[string][]byte{ "N3": []byte(`foo`), }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID))) errMsg = extractServerErrorText(res.Body) - require.Contains(t, errMsg, "Only supported as a top level element") + require.Contains(t, errMsg, "Windows configuration profiles can only have or top level elements.") // successfully apply windows and macOS a profiles for the team, but it's a dry run s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": map[string][]byte{ @@ -11009,12 +11449,15 @@ func (s *integrationMDMTestSuite) TestManualEnrollmentCommands() { checkInstallFleetdCommandSent(mdmDevice, false) } -func (s *integrationMDMTestSuite) TestLockUnlockWindowsLinux() { +func (s *integrationMDMTestSuite) TestLockUnlockWipeWindowsLinux() { t := s.T() ctx := context.Background() // create an MDM-enrolled Windows host - winHost, _ := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t) + winHost, winMDMClient := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t) + // set its MDM data so it shows as MDM-enrolled in the backend + err := s.ds.SetOrUpdateMDMData(ctx, winHost.ID, false, true, s.server.URL, false, fleet.WellKnownMDMFleet, "") + require.NoError(t, err) linuxHost := createOrbitEnrolledHost(t, "linux", "lock_unlock_linux", s.ds) for _, host := range []*fleet.Host{winHost, linuxHost} { @@ -11047,7 +11490,7 @@ func (s *integrationMDMTestSuite) TestLockUnlockWindowsLinux() { require.Contains(t, errMsg, "Host has pending lock request.") // simulate a successful script result for the lock command - status, err := s.ds.GetHostLockWipeStatus(ctx, host.ID, host.FleetPlatform()) + status, err := s.ds.GetHostLockWipeStatus(ctx, host) require.NoError(t, err) var orbitScriptResp orbitPostScriptResultResponse @@ -11064,6 +11507,10 @@ func (s *integrationMDMTestSuite) TestLockUnlockWindowsLinux() { // try to lock the host again s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/lock", host.ID), nil, http.StatusConflict) + // try to wipe a locked host + res = s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/wipe", host.ID), nil, http.StatusUnprocessableEntity) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Host cannot be wiped until it is unlocked.") // unlock the host s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/unlock", host.ID), nil, http.StatusNoContent) @@ -11081,7 +11528,7 @@ func (s *integrationMDMTestSuite) TestLockUnlockWindowsLinux() { require.Contains(t, errMsg, "Host has pending unlock request.") // simulate a failed script result for the unlock command - status, err = s.ds.GetHostLockWipeStatus(ctx, host.ID, host.FleetPlatform()) + status, err = s.ds.GetHostLockWipeStatus(ctx, host) require.NoError(t, err) s.DoJSON("POST", "/api/fleet/orbit/scripts/result", @@ -11094,10 +11541,267 @@ func (s *integrationMDMTestSuite) TestLockUnlockWindowsLinux() { require.Equal(t, "locked", *getHostResp.Host.MDM.DeviceStatus) require.NotNil(t, getHostResp.Host.MDM.PendingAction) require.Equal(t, "", *getHostResp.Host.MDM.PendingAction) + + // unlock the host, simulate success + s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/unlock", host.ID), nil, http.StatusNoContent) + status, err = s.ds.GetHostLockWipeStatus(ctx, host) + require.NoError(t, err) + s.DoJSON("POST", "/api/fleet/orbit/scripts/result", + json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q, "execution_id": %q, "exit_code": 0, "output": "ok"}`, *host.OrbitNodeKey, status.UnlockScript.ExecutionID)), + http.StatusOK, &orbitScriptResp) + + // refresh the host's status, it is unlocked, no pending action + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp) + require.NotNil(t, getHostResp.Host.MDM.DeviceStatus) + require.Equal(t, "unlocked", *getHostResp.Host.MDM.DeviceStatus) + require.NotNil(t, getHostResp.Host.MDM.PendingAction) + require.Equal(t, "", *getHostResp.Host.MDM.PendingAction) + + // wipe the host + s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/wipe", host.ID), nil, http.StatusNoContent) + wipeActID := s.lastActivityOfTypeMatches(fleet.ActivityTypeWipedHost{}.ActivityName(), fmt.Sprintf(`{"host_id": %d, "host_display_name": %q}`, host.ID, host.DisplayName()), 0) + + // try to wipe the host again, already have it pending + res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/wipe", host.ID), nil, http.StatusUnprocessableEntity) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Host has pending wipe request.") + // no activity created + s.lastActivityOfTypeMatches(fleet.ActivityTypeWipedHost{}.ActivityName(), fmt.Sprintf(`{"host_id": %d, "host_display_name": %q}`, host.ID, host.DisplayName()), wipeActID) + + // refresh the host's status, it is unlocked, pending wipe + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp) + require.NotNil(t, getHostResp.Host.MDM.DeviceStatus) + require.Equal(t, "unlocked", *getHostResp.Host.MDM.DeviceStatus) + require.NotNil(t, getHostResp.Host.MDM.PendingAction) + require.Equal(t, "wipe", *getHostResp.Host.MDM.PendingAction) + + status, err = s.ds.GetHostLockWipeStatus(ctx, host) + require.NoError(t, err) + if host.FleetPlatform() == "linux" { + // simulate a successful wipe for the Linux host's script response + s.DoJSON("POST", "/api/fleet/orbit/scripts/result", + json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q, "execution_id": %q, "exit_code": 0, "output": "ok"}`, *host.OrbitNodeKey, status.WipeScript.ExecutionID)), + http.StatusOK, &orbitScriptResp) + } else { + // simulate a successful wipe from the Windows device's MDM response + cmds, err := winMDMClient.StartManagementSession() + require.NoError(t, err) + + // two status + the wipe command we enqueued + require.Len(t, cmds, 3) + wipeCmd := cmds[status.WipeMDMCommand.CommandUUID] + require.NotNil(t, wipeCmd) + require.Equal(t, wipeCmd.Verb, fleet.CmdExec) + require.Len(t, wipeCmd.Cmd.Items, 1) + require.EqualValues(t, "./Device/Vendor/MSFT/RemoteWipe/doWipeProtected", *wipeCmd.Cmd.Items[0].Target) + + msgID, err := winMDMClient.GetCurrentMsgID() + require.NoError(t, err) + + winMDMClient.AppendResponse(fleet.SyncMLCmd{ + XMLName: xml.Name{Local: mdm_types.CmdStatus}, + MsgRef: &msgID, + CmdRef: &status.WipeMDMCommand.CommandUUID, + Cmd: ptr.String("Exec"), + Data: ptr.String("200"), + Items: nil, + CmdID: fleet.CmdID{Value: uuid.NewString()}, + }) + cmds, err = winMDMClient.SendResponse() + require.NoError(t, err) + // the ack of the message should be the only returned command + require.Len(t, cmds, 1) + } + + // refresh the host's status, it is wiped + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp) + require.NotNil(t, getHostResp.Host.MDM.DeviceStatus) + require.Equal(t, "wiped", *getHostResp.Host.MDM.DeviceStatus) + require.NotNil(t, getHostResp.Host.MDM.PendingAction) + require.Equal(t, "", *getHostResp.Host.MDM.PendingAction) + + // try to lock/unlock the host fails + res = s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/lock", host.ID), nil, http.StatusUnprocessableEntity) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Cannot process lock requests once host is wiped.") + res = s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/unlock", host.ID), nil, http.StatusUnprocessableEntity) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Cannot process unlock requests once host is wiped.") + + // try to wipe the host again, conflict (already wiped) + s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/wipe", host.ID), nil, http.StatusConflict) + // no activity created + s.lastActivityOfTypeMatches(fleet.ActivityTypeWipedHost{}.ActivityName(), fmt.Sprintf(`{"host_id": %d, "host_display_name": %q}`, host.ID, host.DisplayName()), wipeActID) + + // re-enroll the host, simulating that another user received the wiped host + newOrbitKey := uuid.New().String() + newHost, err := s.ds.EnrollOrbit(ctx, true, fleet.OrbitHostInfo{ + HardwareUUID: *host.OsqueryHostID, + HardwareSerial: host.HardwareSerial, + }, newOrbitKey, nil) + require.NoError(t, err) + // it re-enrolled using the same host record + require.Equal(t, host.ID, newHost.ID) + + // refresh the host's status, it is back to unlocked + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp) + require.NotNil(t, getHostResp.Host.MDM.DeviceStatus) + require.Equal(t, "unlocked", *getHostResp.Host.MDM.DeviceStatus) + require.NotNil(t, getHostResp.Host.MDM.PendingAction) + require.Equal(t, "", *getHostResp.Host.MDM.PendingAction) }) } } +func (s *integrationMDMTestSuite) TestLockUnlockWipeMacOS() { + t := s.T() + host, mdmClient := createHostThenEnrollMDM(s.ds, s.server.URL, t) + + // get the host's information + var getHostResp getHostResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp) + require.NotNil(t, getHostResp.Host.MDM.DeviceStatus) + require.Equal(t, "unlocked", *getHostResp.Host.MDM.DeviceStatus) + require.NotNil(t, getHostResp.Host.MDM.PendingAction) + require.Equal(t, "", *getHostResp.Host.MDM.PendingAction) + + // try to unlock the host (which is already its status) + var unlockResp unlockHostResponse + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/unlock", host.ID), nil, http.StatusConflict, &unlockResp) + + // lock the host + s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/lock", host.ID), nil, http.StatusNoContent) + + // refresh the host's status, it is now pending lock + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp) + require.NotNil(t, getHostResp.Host.MDM.DeviceStatus) + require.Equal(t, "unlocked", *getHostResp.Host.MDM.DeviceStatus) + require.NotNil(t, getHostResp.Host.MDM.PendingAction) + require.Equal(t, "lock", *getHostResp.Host.MDM.PendingAction) + + // try locking the host while it is pending lock fails + res := s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/lock", host.ID), nil, http.StatusUnprocessableEntity) + errMsg := extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Host has pending lock request.") + + // simulate a successful MDM result for the lock command + cmd, err := mdmClient.Idle() + require.NoError(t, err) + require.NotNil(t, cmd) + require.Equal(t, "DeviceLock", cmd.Command.RequestType) + cmd, err = mdmClient.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + + // refresh the host's status, it is now locked + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp) + require.NotNil(t, getHostResp.Host.MDM.DeviceStatus) + require.Equal(t, "locked", *getHostResp.Host.MDM.DeviceStatus) + require.NotNil(t, getHostResp.Host.MDM.PendingAction) + require.Equal(t, "", *getHostResp.Host.MDM.PendingAction) + + // try to lock the host again + s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/lock", host.ID), nil, http.StatusConflict) + // try to wipe a locked host + res = s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/wipe", host.ID), nil, http.StatusUnprocessableEntity) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Host cannot be wiped until it is unlocked.") + + // unlock the host + unlockResp = unlockHostResponse{} + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/unlock", host.ID), nil, http.StatusOK, &unlockResp) + require.NotNil(t, unlockResp.HostID) + require.Equal(t, host.ID, *unlockResp.HostID) + require.Len(t, unlockResp.UnlockPIN, 6) + unlockPIN := unlockResp.UnlockPIN + unlockActID := s.lastActivityOfTypeMatches(fleet.ActivityTypeUnlockedHost{}.ActivityName(), + fmt.Sprintf(`{"host_id": %d, "host_display_name": %q, "host_platform": %q}`, host.ID, host.DisplayName(), host.FleetPlatform()), 0) + + // refresh the host's status, it is locked pending unlock + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp) + require.NotNil(t, getHostResp.Host.MDM.DeviceStatus) + require.Equal(t, "locked", *getHostResp.Host.MDM.DeviceStatus) + require.NotNil(t, getHostResp.Host.MDM.PendingAction) + require.Equal(t, "unlock", *getHostResp.Host.MDM.PendingAction) + + // try unlocking the host again simply returns the PIN again + unlockResp = unlockHostResponse{} + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/unlock", host.ID), nil, http.StatusOK, &unlockResp) + require.Equal(t, unlockPIN, unlockResp.UnlockPIN) + // a new unlock host activity is created every time the unlock PIN is viewed + newUnlockActID := s.lastActivityOfTypeMatches(fleet.ActivityTypeUnlockedHost{}.ActivityName(), + fmt.Sprintf(`{"host_id": %d, "host_display_name": %q, "host_platform": %q}`, host.ID, host.DisplayName(), host.FleetPlatform()), 0) + require.NotEqual(t, unlockActID, newUnlockActID) + + // as soon as the host sends an Idle MDM request, it is maked as unlocked + cmd, err = mdmClient.Idle() + require.NoError(t, err) + require.Nil(t, cmd) + + // refresh the host's status, it is unlocked + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp) + require.NotNil(t, getHostResp.Host.MDM.DeviceStatus) + require.Equal(t, "unlocked", *getHostResp.Host.MDM.DeviceStatus) + require.NotNil(t, getHostResp.Host.MDM.PendingAction) + require.Equal(t, "", *getHostResp.Host.MDM.PendingAction) + + // wipe the host + s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/wipe", host.ID), nil, http.StatusNoContent) + wipeActID := s.lastActivityOfTypeMatches(fleet.ActivityTypeWipedHost{}.ActivityName(), fmt.Sprintf(`{"host_id": %d, "host_display_name": %q}`, host.ID, host.DisplayName()), 0) + + // try to wipe the host again, already have it pending + res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/wipe", host.ID), nil, http.StatusUnprocessableEntity) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Host has pending wipe request.") + // no activity created + s.lastActivityOfTypeMatches(fleet.ActivityTypeWipedHost{}.ActivityName(), fmt.Sprintf(`{"host_id": %d, "host_display_name": %q}`, host.ID, host.DisplayName()), wipeActID) + + // refresh the host's status, it is unlocked, pending wipe + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp) + require.NotNil(t, getHostResp.Host.MDM.DeviceStatus) + require.Equal(t, "unlocked", *getHostResp.Host.MDM.DeviceStatus) + require.NotNil(t, getHostResp.Host.MDM.PendingAction) + require.Equal(t, "wipe", *getHostResp.Host.MDM.PendingAction) + + // simulate a successful MDM result for the wipe command + cmd, err = mdmClient.Idle() + require.NoError(t, err) + require.NotNil(t, cmd) + require.Equal(t, "EraseDevice", cmd.Command.RequestType) + cmd, err = mdmClient.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + + // refresh the host's status, it is wiped + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp) + require.NotNil(t, getHostResp.Host.MDM.DeviceStatus) + require.Equal(t, "wiped", *getHostResp.Host.MDM.DeviceStatus) + require.NotNil(t, getHostResp.Host.MDM.PendingAction) + require.Equal(t, "", *getHostResp.Host.MDM.PendingAction) + + // try to lock/unlock the host fails + res = s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/lock", host.ID), nil, http.StatusUnprocessableEntity) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Cannot process lock requests once host is wiped.") + res = s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/unlock", host.ID), nil, http.StatusUnprocessableEntity) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Cannot process unlock requests once host is wiped.") + + // try to wipe the host again, conflict (already wiped) + s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/wipe", host.ID), nil, http.StatusConflict) + // no activity created + s.lastActivityOfTypeMatches(fleet.ActivityTypeWipedHost{}.ActivityName(), fmt.Sprintf(`{"host_id": %d, "host_display_name": %q}`, host.ID, host.DisplayName()), wipeActID) + + // re-enroll the host, simulating that another user received the wiped host + err = mdmClient.Enroll() + require.NoError(t, err) + + // refresh the host's status, it is back to unlocked + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp) + require.NotNil(t, getHostResp.Host.MDM.DeviceStatus) + require.Equal(t, "unlocked", *getHostResp.Host.MDM.DeviceStatus) + require.NotNil(t, getHostResp.Host.MDM.PendingAction) + require.Equal(t, "", *getHostResp.Host.MDM.PendingAction) +} + func (s *integrationMDMTestSuite) TestZCustomConfigurationWebURL() { t := s.T() @@ -11453,6 +12157,32 @@ func (s *integrationMDMTestSuite) TestSCEPCertExpiration() { require.NoError(t, s.ds.SetOrUpdateMDMData(ctx, automaticHostWithRef.ID, false, true, s.server.URL, true, fleet.WellKnownMDMFleet, "foo")) require.NoError(t, err) + // add global profiles + globalProfiles := [][]byte{ + mobileconfigForTest("N1", "I1"), + mobileconfigForTest("N2", "I2"), + } + s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: globalProfiles}, http.StatusNoContent) + // ack all commands to install profiles + cmd, err := manualEnrolledDevice.Idle() + require.NoError(t, err) + for cmd != nil { + cmd, err = manualEnrolledDevice.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + } + cmd, err = automaticEnrolledDevice.Idle() + require.NoError(t, err) + for cmd != nil { + cmd, err = automaticEnrolledDevice.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + } + cmd, err = automaticEnrolledDeviceWithRef.Idle() + require.NoError(t, err) + for cmd != nil { + cmd, err = automaticEnrolledDeviceWithRef.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + } + cert, key, err := generateCertWithAPNsTopic() require.NoError(t, err) fleetCfg := config.TestConfig() @@ -11462,7 +12192,7 @@ func (s *integrationMDMTestSuite) TestSCEPCertExpiration() { // run without expired certs, no command enqueued err = RenewSCEPCertificates(ctx, logger, s.ds, &fleetCfg, s.mdmCommander) require.NoError(t, err) - cmd, err := manualEnrolledDevice.Idle() + cmd, err = manualEnrolledDevice.Idle() require.NoError(t, err) require.Nil(t, cmd) @@ -11522,6 +12252,25 @@ func (s *integrationMDMTestSuite) TestSCEPCertExpiration() { cmd, err = automaticEnrolledDeviceWithRef.Idle() require.NoError(t, err) require.Nil(t, cmd) + + // devices renew their SCEP cert by re-enrolling. + require.NoError(t, manualEnrolledDevice.Enroll()) + require.NoError(t, automaticEnrolledDevice.Enroll()) + require.NoError(t, automaticEnrolledDeviceWithRef.Enroll()) + + // no new commands are enqueued right after enrollment + cmd, err = manualEnrolledDevice.Idle() + require.NoError(t, err) + require.Nil(t, cmd) + + cmd, err = automaticEnrolledDevice.Idle() + require.NoError(t, err) + require.Nil(t, cmd) + + cmd, err = automaticEnrolledDeviceWithRef.Idle() + require.NoError(t, err) + require.Nil(t, cmd) + } func (s *integrationMDMTestSuite) TestMDMDiskEncryptionIssue16636() { diff --git a/server/service/live_queries.go b/server/service/live_queries.go index 7bbe6a79b1..11c46457b5 100644 --- a/server/service/live_queries.go +++ b/server/service/live_queries.go @@ -263,27 +263,30 @@ func (svc *Service) RunLiveQueryDeadline( queryIDPtr = nil queryString = query } + campaign, err := svc.NewDistributedQueryCampaign(ctx, queryString, queryIDPtr, fleet.HostTargets{HostIDs: hostIDs}) if err != nil { + level.Error(svc.logger).Log( + "msg", "new distributed query campaign", + "queryString", queryString, + "queryID", queryID, + "err", err, + ) resultsCh <- fleet.QueryCampaignResult{QueryID: queryID, Error: ptr.String(err.Error()), Err: err} return } queryID = campaign.QueryID - readChan, cancelFunc, err := svc.GetCampaignReader(ctx, campaign) - if err != nil { - resultsCh <- fleet.QueryCampaignResult{QueryID: queryID, Error: ptr.String(err.Error()), Err: err} - return - } - defer cancelFunc() - + // We do not want to use the outer `ctx` directly because we want to cleanup the campaign + // even if the outer `ctx` is canceled (e.g. a client terminating the connection). + // Also, we make sure stats and activity DB operations don't get killed after we return results. + ctxWithoutCancel := context.WithoutCancel(ctx) defer func() { - // We do not want to use the outer `ctx` directly because we want to cleanup the campaign - // even if the outer `ctx` is canceled (e.g. a client terminating the connection). - ctx := context.WithoutCancel(ctx) - err := svc.CompleteCampaign(ctx, campaign) + err := svc.CompleteCampaign(ctxWithoutCancel, campaign) if err != nil { - level.Error(svc.logger).Log("msg", "completing campaign (sync)", "query.id", campaign.QueryID, "err", err) + level.Error(svc.logger).Log( + "msg", "completing campaign (sync)", "query.id", campaign.QueryID, "campaign.id", campaign.ID, "err", err, + ) resultsCh <- fleet.QueryCampaignResult{ QueryID: queryID, Error: ptr.String(err.Error()), @@ -292,6 +295,16 @@ func (svc *Service) RunLiveQueryDeadline( } }() + readChan, cancelFunc, err := svc.GetCampaignReader(ctx, campaign) + if err != nil { + level.Error(svc.logger).Log( + "msg", "get campaign reader", "query.id", campaign.QueryID, "campaign.id", campaign.ID, "err", err, + ) + resultsCh <- fleet.QueryCampaignResult{QueryID: queryID, Error: ptr.String(err.Error()), Err: err} + return + } + defer cancelFunc() + var results []fleet.QueryResult timeout := time.After(deadline) @@ -305,8 +318,6 @@ func (svc *Service) RunLiveQueryDeadline( level.Error(svc.logger).Log("msg", "error checking saved query", "query.id", campaign.QueryID, "err", err) perfStatsTracker.saveStats = false } - // to make sure stats and activity DB operations don't get killed after we return results. - ctxWithoutCancel := context.WithoutCancel(ctx) totalHosts := campaign.Metrics.TotalHosts // We update aggregated stats and activity at the end asynchronously. defer func() { diff --git a/server/service/mdm.go b/server/service/mdm.go index da369740c9..44eead8dfc 100644 --- a/server/service/mdm.go +++ b/server/service/mdm.go @@ -545,24 +545,6 @@ func (svc *Service) enqueueAppleMDMCommand(ctx context.Context, rawXMLCmd []byte return nil, ctxerr.Wrap(ctx, err, "decode plist command") } - // TODO(mna): as per the story's spec: - // Make macOS and Windows MDM, low-level lock command available for free - // users. Remove validation where we check for Premium for custom MDM - // commands that contain the lock command - // - // So we'd need to not only remove this validation to allow DeviceLock (and - // eventually EraseDevice for the Wipe story), but it needs to behave - // similarly to how the /lock endpoint would've: - // - // see https://fleetdm.slack.com/archives/C03C41L5YEL/p1707169116154199?thread_ts=1707162619.655219&cid=C03C41L5YEL - // Regarding Free use of ā€œlockā€ command as custom command, remove the validation but does that behave the same as if /lock had been used? - // @Martin Angers - // that’s right. - // - // So it looks like we'd need to parse the command's XML to get the unlock - // PIN, and TBD how to behave if there is no PIN or if it's larger than - // supported. - if appleMDMPremiumCommands[strings.TrimSpace(cmd.Command.RequestType)] { lic, err := svc.License(ctx) if err != nil { @@ -621,15 +603,6 @@ func (svc *Service) enqueueMicrosoftMDMCommand(ctx context.Context, rawXMLCmd [] return nil, ctxerr.Wrap(ctx, err, "decode SyncML command") } - // TODO(mna): as per the story's spec: - // Make macOS and Windows MDM, low-level lock command available for Free - // users. Remove validation where we check for Premium for custom MDM - // commands that contain the lock command - // - // However for Windows, it looks like we only prevent the RemoteWipe command, - // nothing for lock, so looks like nothing to do here for now (will need a - // change for the wipe command). - if cmdMsg.IsPremium() { lic, err := svc.License(ctx) if err != nil { @@ -1722,7 +1695,7 @@ func validateProfiles(profiles []fleet.MDMProfileBatchPayload) error { platform := mdm.GetRawProfilePlatform(profile.Contents) if platform != "darwin" && platform != "windows" { // TODO(roberto): there's ongoing feedback with Marko about improving this message, as it's too windows specific - return fleet.NewInvalidArgumentError("mdm", "Only supported as a top level element. Make sure you don’t have other top level elements.") + return fleet.NewInvalidArgumentError("mdm", "Windows configuration profiles can only have or top level elements.") } } diff --git a/server/service/mdm_test.go b/server/service/mdm_test.go index d8a6f783e2..f566b78be8 100644 --- a/server/service/mdm_test.go +++ b/server/service/mdm_test.go @@ -1028,7 +1028,7 @@ func TestUploadWindowsMDMConfigProfileValidations(t *testing.T) { {"mdm not enabled", 0, ``, false, "Windows MDM isn't turned on."}, {"duplicate profile name", 0, `duplicate`, true, "configuration profile with this name already exists."}, {"multiple Replace", 0, `ab`, true, ""}, - {"Replace and non-Replace", 0, `ab`, true, "Only supported as a top level element."}, + {"Replace and non-Replace", 0, `ab`, true, "Windows configuration profiles can only have or top level elements."}, {"BitLocker profile", 0, `./Device/Vendor/MSFT/BitLocker/AllowStandardUserEncryption`, true, "Custom configuration profiles can't include BitLocker settings."}, {"Windows updates profile", 0, ` ./Device/Vendor/MSFT/Policy/Config/Update/ConfigureDeadlineNoAutoRebootForFeatureUpdates `, true, "Custom configuration profiles can't include Windows updates settings."}, @@ -1039,7 +1039,7 @@ func TestUploadWindowsMDMConfigProfileValidations(t *testing.T) { {"team mdm not enabled", 1, ``, false, "Windows MDM isn't turned on."}, {"team duplicate profile name", 1, `duplicate`, true, "configuration profile with this name already exists."}, {"team multiple Replace", 1, `ab`, true, ""}, - {"team Replace and non-Replace", 1, `ab`, true, "Only supported as a top level element."}, + {"team Replace and non-Replace", 1, `ab`, true, "Windows configuration profiles can only have or top level elements."}, {"team BitLocker profile", 1, `./Device/Vendor/MSFT/BitLocker/AllowStandardUserEncryption`, true, "Custom configuration profiles can't include BitLocker settings."}, {"team Windows updates profile", 1, ` ./Device/Vendor/MSFT/Policy/Config/Update/ConfigureDeadlineNoAutoRebootForFeatureUpdates `, true, "Custom configuration profiles can't include Windows updates settings."}, diff --git a/server/service/microsoft_mdm_test.go b/server/service/microsoft_mdm_test.go index a688f69735..f806e4da4f 100644 --- a/server/service/microsoft_mdm_test.go +++ b/server/service/microsoft_mdm_test.go @@ -407,11 +407,18 @@ func TestBuildCommandFromProfileBytes(t *testing.T) { func syncMLForTest(locURI string) []byte { return []byte(fmt.Sprintf(` + + + + %s + + + %s -`, locURI)) +`, locURI, locURI)) } diff --git a/server/service/osquery.go b/server/service/osquery.go index 94de381e95..8342d73f6c 100644 --- a/server/service/osquery.go +++ b/server/service/osquery.go @@ -1457,6 +1457,7 @@ func submitLogsEndpoint(ctx context.Context, request interface{}, svc fleet.Serv err = newOsqueryError("unmarshalling result logs: " + err.Error()) break } + logging.WithExtras(ctx, "results", len(results)) // We currently return errors to osqueryd if there are any issues submitting results // to the configured external destinations. diff --git a/server/service/scripts.go b/server/service/scripts.go index bca996f64b..ce121b3ac0 100644 --- a/server/service/scripts.go +++ b/server/service/scripts.go @@ -925,3 +925,34 @@ func (svc *Service) UnlockHost(ctx context.Context, hostID uint) (string, error) return "", fleet.ErrMissingLicense } + +//////////////////////////////////////////////////////////////////////////////// +// Wipe host +//////////////////////////////////////////////////////////////////////////////// + +type wipeHostRequest struct { + HostID uint `url:"id"` +} + +type wipeHostResponse struct { + Err error `json:"error,omitempty"` +} + +func (r wipeHostResponse) Status() int { return http.StatusNoContent } +func (r wipeHostResponse) error() error { return r.Err } + +func wipeHostEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + req := request.(*wipeHostRequest) + if err := svc.WipeHost(ctx, req.HostID); err != nil { + return wipeHostResponse{Err: err}, nil + } + return wipeHostResponse{}, nil +} + +func (svc *Service) WipeHost(ctx context.Context, hostID uint) error { + // skipauth: No authorization check needed due to implementation returning + // only license error. + svc.authz.SkipAuthorization(ctx) + + return fleet.ErrMissingLicense +} diff --git a/server/service/transport.go b/server/service/transport.go index a0ba0a1124..e7b2ece2e1 100644 --- a/server/service/transport.go +++ b/server/service/transport.go @@ -506,6 +506,16 @@ func hostListOptionsFromRequest(r *http.Request) (fleet.HostListOptions, error) } hopt.PopulateSoftware = ps } + populatePolicies := r.URL.Query().Get("populate_policies") + if populatePolicies != "" { + pp, err := strconv.ParseBool(populatePolicies) + if err != nil { + return hopt, ctxerr.Wrap( + r.Context(), badRequest(fmt.Sprintf("Invalid boolean parameter populate_policies: %s", populateSoftware)), + ) + } + hopt.PopulatePolicies = pp + } // cannot combine software_id, software_version_id, and software_title_id var softwareErrorLabel []string diff --git a/server/service/transport_test.go b/server/service/transport_test.go index ce2e605f7e..f253adccd1 100644 --- a/server/service/transport_test.go +++ b/server/service/transport_test.go @@ -158,7 +158,7 @@ func TestHostListOptionsFromRequest(t *testing.T) { "&os_name=osName&os_version=osVersion&os_version_id=5&disable_failing_policies=1&macos_settings=verified" + "&macos_settings_disk_encryption=enforcing&os_settings=pending&os_settings_disk_encryption=failed" + "&bootstrap_package=installed&mdm_id=6&mdm_name=mdmName&mdm_enrollment_status=automatic" + - "&munki_issue_id=7&low_disk_space=99&vulnerability=CVE-2023-42887", + "&munki_issue_id=7&low_disk_space=99&vulnerability=CVE-2023-42887&populate_policies=true", hostListOptions: fleet.HostListOptions{ ListOptions: fleet.ListOptions{ OrderKey: "foo", @@ -189,6 +189,7 @@ func TestHostListOptionsFromRequest(t *testing.T) { MunkiIssueIDFilter: ptr.Uint(7), LowDiskSpaceFilter: ptr.Int(99), VulnerabilityFilter: ptr.String("CVE-2023-42887"), + PopulatePolicies: true, }, }, "policy_id and policy_response params (for coverage)": { @@ -336,6 +337,10 @@ func TestHostListOptionsFromRequest(t *testing.T) { url: "/foo?software_id=1&software_version_id=2", errorMessage: "The combination of software_id and software_version_id is not allowed", }, + "invalid populate_policies": { + url: "/foo?populate_policies=foo", + errorMessage: "populate_policies", + }, } for name, tt := range hostListOptionsTests { diff --git a/server/vulnerabilities/nvd/cpe.go b/server/vulnerabilities/nvd/cpe.go index 0971016d25..528aa0a1f2 100644 --- a/server/vulnerabilities/nvd/cpe.go +++ b/server/vulnerabilities/nvd/cpe.go @@ -11,6 +11,7 @@ import ( "regexp" "strings" "time" + "unicode" "github.com/doug-martin/goqu/v9" "github.com/fleetdm/fleet/v4/pkg/download" @@ -184,6 +185,11 @@ func cpeGeneralSearchQuery(software *fleet.Software) (string, []interface{}, err // and is optimized for lookups, see `GenerateCPEDB`. `translations` are used to aid in cpe matching. When searching for cpes, we first check if it matches // any translations, and then lookup in the cpe database based on the title, product and vendor. func CPEFromSoftware(logger log.Logger, db *sqlx.DB, software *fleet.Software, translations CPETranslations, reCache *regexpCache) (string, error) { + if containsNonASCII(software.Name) { + level.Debug(logger).Log("msg", "skipping software with non-ascii characters", "software", software.Name, "version", software.Version, "source", software.Source) + return "", nil + } + translation, match, err := translations.Translate(reCache, software) if err != nil { return "", fmt.Errorf("translate software: %w", err) @@ -462,3 +468,12 @@ func TranslateSoftwareToCPE( return nil } + +func containsNonASCII(s string) bool { + for _, char := range s { + if char > unicode.MaxASCII { + return true + } + } + return false +} diff --git a/server/vulnerabilities/nvd/cpe_test.go b/server/vulnerabilities/nvd/cpe_test.go index 084d7f7c6b..224456a183 100644 --- a/server/vulnerabilities/nvd/cpe_test.go +++ b/server/vulnerabilities/nvd/cpe_test.go @@ -46,6 +46,10 @@ func TestCPEFromSoftware(t *testing.T) { cpe, err = CPEFromSoftware(log.NewNopLogger(), db, &fleet.Software{Name: "Vendor2 Product2.app", Version: "0.3", BundleIdentifier: "vendor2", Source: "apps"}, nil, reCache) require.NoError(t, err) require.Equal(t, "cpe:2.3:a:vendor2:product4:0.3:*:*:*:*:macos:*:*", cpe) + + // Does not error on Unicode Names + _, err = CPEFromSoftware(log.NewNopLogger(), db, &fleet.Software{Name: "Š”ŠµŠ²ŃƒŃˆŠŗŠ° Фонарём", Version: "1.2.3", BundleIdentifier: "vendor", Source: "apps"}, nil, reCache) + require.NoError(t, err) } func TestCPETranslations(t *testing.T) { @@ -652,7 +656,7 @@ func TestCPEFromSoftwareIntegration(t *testing.T) { }, { software: fleet.Software{ - Name: "1Password – Password Manager", + Name: "1Password - Password Manager", Source: "chrome_extensions", Version: "2.3.8", Vendor: "", @@ -670,7 +674,7 @@ func TestCPEFromSoftwareIntegration(t *testing.T) { }, { software: fleet.Software{ - Name: "AdBlock — best ad blocker", + Name: "AdBlock - best ad blocker", Source: "chrome_extensions", Version: "5.1.1", Vendor: "", @@ -679,7 +683,7 @@ func TestCPEFromSoftwareIntegration(t *testing.T) { }, { software: fleet.Software{ - Name: "AdBlock — best ad blocker", + Name: "AdBlock - best ad blocker", Source: "chrome_extensions", Version: "5.1.2", Vendor: "", @@ -1390,3 +1394,21 @@ func TestCPEFromSoftwareIntegration(t *testing.T) { assert.Equal(t, tt.cpe, cpe, tt.software.Name) } } + +func TestContainsNonASCII(t *testing.T) { + testCases := []struct { + input string + expected bool + }{ + {"hello", false}, + {"hello world", false}, + {"hello world!", false}, + {"šŸ˜ŠšŸ‘", true}, + {"hello world! šŸ˜ŠšŸ‘", true}, + {"Š”ŠµŠ²ŃƒŃˆŠŗŠ° Фонарём", true}, + } + + for _, tc := range testCases { + assert.Equal(t, tc.expected, containsNonASCII(tc.input)) + } +} diff --git a/server/vulnerabilities/nvd/cve.go b/server/vulnerabilities/nvd/cve.go index 59424b38ed..70fe99254d 100644 --- a/server/vulnerabilities/nvd/cve.go +++ b/server/vulnerabilities/nvd/cve.go @@ -225,11 +225,9 @@ func TranslateCPEToCVE( foundSoftwareVulns, foundOSVulns, err := checkCVEs( ctx, - ds, logger, interfaceParsed, file, - collectVulns, knownNVDBugRules, ) if err != nil { @@ -339,11 +337,9 @@ func matchesExactTargetSW(softwareCPETargetSW string, targetSWs []string, config func checkCVEs( ctx context.Context, - ds fleet.Datastore, logger kitlog.Logger, CPEItems []itemWithNVDMeta, jsonFile string, - collectVulns bool, knownNVDBugRules CPEMatchingRules, ) ([]fleet.SoftwareVulnerability, []fleet.OSVulnerability, error) { dict, err := cvefeed.LoadJSONDictionary(jsonFile) @@ -351,9 +347,30 @@ func checkCVEs( return nil, nil, err } - cache := cvefeed.NewCache(dict).SetRequireVersion(true).SetMaxSize(-1) - // This index consumes too much RAM - // cache.Idx = cvefeed.NewIndex(dict) + // Group dictionary by vendor using a map. + // This is done to speed up the matching process (PR https://github.com/fleetdm/fleet/pull/17298). + // A map uses a hash table to store the key-value pairs. By putting multiple vulnerabilities with the same vendor into a map, + // we reduce the number of comparisons needed to find the vulnerabilities that match the CPEs. Specifically, we no longer need to + // compare each CPE with each vulnerability, but only with the vulnerabilities that have the same vendor. + // Further optimization can be done by also using a map for product name comparison. + dictGrouped := make(map[string]cvefeed.Dictionary, len(dict)) + for key, vuln := range dict { + attrsArray := vuln.Config() + for _, attrs := range attrsArray { + subDict, ok := dictGrouped[attrs.Vendor] + if !ok { + subDict = make(cvefeed.Dictionary, 1) + dictGrouped[attrs.Vendor] = subDict + } + subDict[key] = vuln + } + } + + cacheGrouped := make(map[string]*cvefeed.Cache, len(dictGrouped)) + for vendor, subDict := range dictGrouped { + cache := cvefeed.NewCache(subDict).SetRequireVersion(true).SetMaxSize(-1) + cacheGrouped[vendor] = cache + } CPEItemCh := make(chan itemWithNVDMeta) var foundSoftwareVulns []fleet.SoftwareVulnerability @@ -382,6 +399,11 @@ func checkCVEs( return } + cache, ok := cacheGrouped[CPEItem.GetMeta().Vendor] + if !ok { + // No such vendor in the Vulnerability dictionary + continue + } cacheHits := cache.Get([]*wfn.Attributes{CPEItem.GetMeta()}) for _, matches := range cacheHits { if len(matches.CPEs) == 0 { diff --git a/server/webhooks/host_status.go b/server/webhooks/host_status.go index 37bb453040..5900dcddc7 100644 --- a/server/webhooks/host_status.go +++ b/server/webhooks/host_status.go @@ -3,12 +3,12 @@ package webhooks import ( "context" "fmt" - "github.com/fleetdm/fleet/v4/server" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/fleet" kitlog "github.com/go-kit/kit/log" "github.com/go-kit/kit/log/level" + "github.com/hashicorp/go-multierror" ) func TriggerHostStatusWebhook( @@ -16,6 +16,13 @@ func TriggerHostStatusWebhook( ds fleet.Datastore, logger kitlog.Logger, ) error { + multiErr := &multierror.Error{} + multiErr = multierror.Append(multiErr, triggerGlobalHostStatusWebhook(ctx, ds, logger)) + multiErr = multierror.Append(multiErr, triggerTeamHostStatusWebhook(ctx, ds, logger)) + return multiErr.ErrorOrNil() +} + +func triggerGlobalHostStatusWebhook(ctx context.Context, ds fleet.Datastore, logger kitlog.Logger) error { appConfig, err := ds.AppConfig(ctx) if err != nil { return ctxerr.Wrap(ctx, err, "getting app config") @@ -25,30 +32,39 @@ func TriggerHostStatusWebhook( return nil } - level.Debug(logger).Log("enabled", "true") + level.Debug(logger).Log("global", "true", "enable_host_status_webhook", "true") - total, unseen, err := ds.TotalAndUnseenHostsSince(ctx, appConfig.WebhookSettings.HostStatusWebhook.DaysCount) + return processWebhook(ctx, ds, nil, appConfig.WebhookSettings.HostStatusWebhook) +} + +func processWebhook(ctx context.Context, ds fleet.Datastore, teamID *uint, settings fleet.HostStatusWebhookSettings) error { + total, unseen, err := ds.TotalAndUnseenHostsSince(ctx, teamID, settings.DaysCount) if err != nil { return ctxerr.Wrap(ctx, err, "getting total and unseen hosts") } - percentUnseen := float64(unseen) * 100.0 / float64(total) - if percentUnseen >= appConfig.WebhookSettings.HostStatusWebhook.HostPercentage { - url := appConfig.WebhookSettings.HostStatusWebhook.DestinationURL + unseenCount := len(unseen) + percentUnseen := float64(unseenCount) * 100.0 / float64(total) + if percentUnseen >= settings.HostPercentage { + url := settings.DestinationURL message := fmt.Sprintf( "More than %.2f%% of your hosts have not checked into Fleet for more than %d days. "+ "You've been sent this message because the Host status webhook is enabled in your Fleet instance.", - percentUnseen, appConfig.WebhookSettings.HostStatusWebhook.DaysCount, + percentUnseen, settings.DaysCount, ) payload := map[string]interface{}{ "text": message, "data": map[string]interface{}{ - "unseen_hosts": unseen, + "unseen_hosts": unseenCount, "total_hosts": total, - "days_unseen": appConfig.WebhookSettings.HostStatusWebhook.DaysCount, + "days_unseen": settings.DaysCount, + "host_ids": unseen, }, } + if teamID != nil { + payload["data"].(map[string]interface{})["team_id"] = *teamID + } err = server.PostJSONWithTimeout(ctx, url, &payload) if err != nil { @@ -58,3 +74,31 @@ func TriggerHostStatusWebhook( return nil } + +func triggerTeamHostStatusWebhook(ctx context.Context, ds fleet.Datastore, logger kitlog.Logger) error { + + teams, err := ds.TeamsSummary(ctx) + if err != nil { + return ctxerr.Wrap(ctx, err, "getting teams summary") + } + // We try to send a webhook for each team. If one team fails, we continue. + multiErr := &multierror.Error{} + for _, teamSummary := range teams { + id := teamSummary.ID + team, err := ds.Team(ctx, id) + if err != nil { + multiErr = multierror.Append(multiErr, ctxerr.Wrap(ctx, err, "getting team")) + continue + } + if !team.Config.WebhookSettings.HostStatusWebhook.Enable { + continue + } + level.Debug(logger).Log("team", id, "enable_host_status_webhook", "true") + err = processWebhook(ctx, ds, &id, team.Config.WebhookSettings.HostStatusWebhook) + if err != nil { + multiErr = multierror.Append(multiErr, ctxerr.Wrap(ctx, err, "processing webhook")) + } + } + + return multiErr.ErrorOrNil() +} diff --git a/server/webhooks/host_status_test.go b/server/webhooks/host_status_test.go index d1da424f36..2f15bfc405 100644 --- a/server/webhooks/host_status_test.go +++ b/server/webhooks/host_status_test.go @@ -18,11 +18,13 @@ func TestTriggerHostStatusWebhook(t *testing.T) { ds := new(mock.Store) requestBody := "" + count := 0 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requestBodyBytes, err := io.ReadAll(r.Body) require.NoError(t, err) requestBody = string(requestBodyBytes) + count++ })) defer ts.Close() @@ -41,24 +43,110 @@ func TestTriggerHostStatusWebhook(t *testing.T) { return ac, nil } - ds.TotalAndUnseenHostsSinceFunc = func(ctx context.Context, daysCount int) (int, int, error) { + ds.TotalAndUnseenHostsSinceFunc = func(ctx context.Context, teamID *uint, daysCount int) (int, []uint, error) { assert.Equal(t, 2, daysCount) - return 10, 6, nil + return 10, []uint{1, 2, 3, 4, 5, 6}, nil + } + + ds.TeamsSummaryFunc = func(ctx context.Context) ([]*fleet.TeamSummary, error) { + return nil, nil } require.NoError(t, TriggerHostStatusWebhook(context.Background(), ds, kitlog.NewNopLogger())) assert.Equal( t, - `{"data":{"days_unseen":2,"total_hosts":10,"unseen_hosts":6},"text":"More than 60.00% of your hosts have not checked into Fleet for more than 2 days. You've been sent this message because the Host status webhook is enabled in your Fleet instance."}`, + `{"data":{"days_unseen":2,"host_ids":[1,2,3,4,5,6],"total_hosts":10,"unseen_hosts":6},"text":"More than 60.00% of your hosts have not checked into Fleet for more than 2 days. You've been sent this message because the Host status webhook is enabled in your Fleet instance."}`, requestBody, ) - requestBody = "" + assert.Equal(t, 1, count) - ds.TotalAndUnseenHostsSinceFunc = func(ctx context.Context, daysCount int) (int, int, error) { + requestBody = "" + ds.TotalAndUnseenHostsSinceFunc = func(ctx context.Context, teamID *uint, daysCount int) (int, []uint, error) { assert.Equal(t, 2, daysCount) - return 10, 1, nil + return 10, []uint{1}, nil } require.NoError(t, TriggerHostStatusWebhook(context.Background(), ds, kitlog.NewNopLogger())) assert.Equal(t, "", requestBody) + assert.Equal(t, 1, count) +} + +func TestTriggerHostStatusWebhookTeam(t *testing.T) { + ds := new(mock.Store) + + requestBody := "" + count := 0 + + ts := httptest.NewServer( + http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + requestBodyBytes, err := io.ReadAll(r.Body) + require.NoError(t, err) + requestBody = string(requestBodyBytes) + count++ + }, + ), + ) + defer ts.Close() + + ac := &fleet.AppConfig{ + WebhookSettings: fleet.WebhookSettings{ + HostStatusWebhook: fleet.HostStatusWebhookSettings{ + Enable: false, + DestinationURL: ts.URL, + HostPercentage: 43, + DaysCount: 3, + }, + }, + } + teamSettings := fleet.HostStatusWebhookSettings{ + Enable: true, + DestinationURL: ts.URL, + HostPercentage: 43, + DaysCount: 2, + } + + ds.AppConfigFunc = func(context.Context) (*fleet.AppConfig, error) { + return ac, nil + } + + ds.TotalAndUnseenHostsSinceFunc = func(ctx context.Context, teamID *uint, daysCount int) (int, []uint, error) { + assert.Equal(t, 2, daysCount) + assert.Equal(t, uint(1), *teamID) + return 10, []uint{1, 2, 3, 4, 5, 6}, nil + } + + ds.TeamsSummaryFunc = func(ctx context.Context) ([]*fleet.TeamSummary, error) { + return []*fleet.TeamSummary{{ID: 1}}, nil + } + ds.TeamFunc = func(ctx context.Context, id uint) (*fleet.Team, error) { + assert.Equal(t, uint(1), id) + return &fleet.Team{ + ID: 1, + Config: fleet.TeamConfig{ + WebhookSettings: fleet.TeamWebhookSettings{ + HostStatusWebhook: teamSettings, + }, + }, + }, nil + } + + require.NoError(t, TriggerHostStatusWebhook(context.Background(), ds, kitlog.NewNopLogger())) + assert.Equal( + t, + `{"data":{"days_unseen":2,"host_ids":[1,2,3,4,5,6],"team_id":1,"total_hosts":10,"unseen_hosts":6},"text":"More than 60.00% of your hosts have not checked into Fleet for more than 2 days. You've been sent this message because the Host status webhook is enabled in your Fleet instance."}`, + requestBody, + ) + assert.Equal(t, 1, count) + + requestBody = "" + ds.TotalAndUnseenHostsSinceFunc = func(ctx context.Context, teamID *uint, daysCount int) (int, []uint, error) { + assert.Equal(t, 2, daysCount) + assert.Equal(t, uint(1), *teamID) + return 10, []uint{1}, nil + } + + require.NoError(t, TriggerHostStatusWebhook(context.Background(), ds, kitlog.NewNopLogger())) + assert.Equal(t, "", requestBody) + assert.Equal(t, 1, count) } diff --git a/server/worker/macos_setup_assistant.go b/server/worker/macos_setup_assistant.go index 3a24a7fcbe..cf206c9ff5 100644 --- a/server/worker/macos_setup_assistant.go +++ b/server/worker/macos_setup_assistant.go @@ -3,6 +3,7 @@ package worker import ( "context" "encoding/json" + "fmt" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/fleet" @@ -27,6 +28,7 @@ const ( MacosSetupAssistantHostsTransferred MacosSetupAssistantTask = "hosts_transferred" MacosSetupAssistantUpdateAllProfiles MacosSetupAssistantTask = "update_all_profiles" MacosSetupAssistantUpdateProfile MacosSetupAssistantTask = "update_profile" + MacosSetupAssistantHostsCooldown MacosSetupAssistantTask = "hosts_cooldown" ) // MacosSetupAssistant is the job processor for the macos_setup_assistant job. @@ -71,7 +73,9 @@ func (m *MacosSetupAssistant) Run(ctx context.Context, argsJSON json.RawMessage) case MacosSetupAssistantTeamDeleted: return m.runTeamDeleted(ctx, args) case MacosSetupAssistantHostsTransferred: - return m.runHostsTransferred(ctx, args) + return m.runHostsTransferred(ctx, args, false) + case MacosSetupAssistantHostsCooldown: + return m.runHostsTransferred(ctx, args, true) case MacosSetupAssistantUpdateAllProfiles: return m.runUpdateAllProfiles(ctx, args) case MacosSetupAssistantUpdateProfile: @@ -113,9 +117,27 @@ func (m *MacosSetupAssistant) runProfileChanged(ctx context.Context, args macosS return ctxerr.Wrap(ctx, err, "list mdm dep serials in team") } if len(serials) > 0 { - if _, err := m.DEPClient.AssignProfile(ctx, apple_mdm.DEPName, profUUID, serials...); err != nil { + skipSerials, assignSerials, err := m.Datastore.ScreenDEPAssignProfileSerialsForCooldown(ctx, serials) + if err != nil { + return ctxerr.Wrap(ctx, err, "run profile changed") + } + if len(skipSerials) > 0 { + // NOTE: the `dep_cooldown` job of the `integrations`` cron picks up the assignments + // after the cooldown period is over + level.Info(m.Log).Log("msg", "run profile changed: skipping assign profile for devices on cooldown", "serials", fmt.Sprintf("%s", skipSerials)) + } + if len(assignSerials) == 0 { + level.Info(m.Log).Log("msg", "run profile changed: no devices to assign profile") + return nil + } + + resp, err := m.DEPClient.AssignProfile(ctx, apple_mdm.DEPName, profUUID, assignSerials...) + if err != nil { return ctxerr.Wrap(ctx, err, "assign profile") } + if err := m.Datastore.UpdateHostDEPAssignProfileResponses(ctx, resp); err != nil { + return ctxerr.Wrap(ctx, err, "worker: run profile changed") + } } return nil } @@ -163,9 +185,27 @@ func (m *MacosSetupAssistant) runProfileDeleted(ctx context.Context, args macosS return ctxerr.Wrap(ctx, err, "list mdm dep serials in team") } if len(serials) > 0 { - if _, err := m.DEPClient.AssignProfile(ctx, apple_mdm.DEPName, profUUID, serials...); err != nil { + skipSerials, assignSerials, err := m.Datastore.ScreenDEPAssignProfileSerialsForCooldown(ctx, serials) + if err != nil { + return ctxerr.Wrap(ctx, err, "run profile deleted") + } + if len(skipSerials) > 0 { + // NOTE: the `dep_cooldown` job of the `integrations`` cron picks up the assignments + // after the cooldown period is over + level.Info(m.Log).Log("msg", "run profile deleted: skipping assign profile for devices on cooldown", "serials", fmt.Sprintf("%s", skipSerials)) + } + if len(assignSerials) == 0 { + level.Info(m.Log).Log("msg", "run profile deleted: no devices to assign profile") + return nil + } + + resp, err := m.DEPClient.AssignProfile(ctx, apple_mdm.DEPName, profUUID, assignSerials...) + if err != nil { return ctxerr.Wrap(ctx, err, "assign profile") } + if err := m.Datastore.UpdateHostDEPAssignProfileResponses(ctx, resp); err != nil { + return ctxerr.Wrap(ctx, err, "worker: run profile deleted") + } } return nil } @@ -173,10 +213,10 @@ func (m *MacosSetupAssistant) runProfileDeleted(ctx context.Context, args macosS func (m *MacosSetupAssistant) runTeamDeleted(ctx context.Context, args macosSetupAssistantArgs) error { // team deletion is semantically equivalent to moving hosts to "no team" args.TeamID = nil // should already be this way, but just to make sure - return m.runHostsTransferred(ctx, args) + return m.runHostsTransferred(ctx, args, false) } -func (m *MacosSetupAssistant) runHostsTransferred(ctx context.Context, args macosSetupAssistantArgs) error { +func (m *MacosSetupAssistant) runHostsTransferred(ctx context.Context, args macosSetupAssistantArgs, fromCooldown bool) error { team, err := m.getTeamNoTeam(ctx, args.TeamID) if err != nil { if fleet.IsNotFound(err) { @@ -205,10 +245,33 @@ func (m *MacosSetupAssistant) runHostsTransferred(ctx context.Context, args maco } } - _, err = m.DEPClient.AssignProfile(ctx, apple_mdm.DEPName, profUUID, args.HostSerialNumbers...) + serials := args.HostSerialNumbers + if !fromCooldown { + // if not a retry, then we need to screen the serials for cooldown + skipSerials, assignSerials, err := m.Datastore.ScreenDEPAssignProfileSerialsForCooldown(ctx, serials) + if err != nil { + return ctxerr.Wrap(ctx, err, "run hosts transferred") + } + if len(skipSerials) > 0 { + // NOTE: the `dep_cooldown` job of the `integrations` cron picks up the assignments + // after the cooldown period is over + level.Info(m.Log).Log("msg", "run hosts transferred: skipping assign profile for devices on cooldown", "serials", fmt.Sprintf("%s", skipSerials)) + } + serials = assignSerials + } + + if len(serials) == 0 { + level.Info(m.Log).Log("msg", "run hosts transferred: no devices to assign profile") + return nil + } + + resp, err := m.DEPClient.AssignProfile(ctx, apple_mdm.DEPName, profUUID, serials...) if err != nil { return ctxerr.Wrap(ctx, err, "assign profile") } + if err := m.Datastore.UpdateHostDEPAssignProfileResponses(ctx, resp); err != nil { + return ctxerr.Wrap(ctx, err, "worker: run hosts transferred") + } return nil } @@ -225,7 +288,7 @@ func (m *MacosSetupAssistant) runUpdateAllProfiles(ctx context.Context, args mac teamID = &team.ID } - if err := QueueMacosSetupAssistantJob(ctx, m.Datastore, m.Log, MacosSetupAssistantUpdateProfile, teamID); err != nil { + if _, err := QueueMacosSetupAssistantJob(ctx, m.Datastore, m.Log, MacosSetupAssistantUpdateProfile, teamID); err != nil { return ctxerr.Wrap(ctx, err, "queue macos setup assistant update profile job") } return nil @@ -254,7 +317,7 @@ func (m *MacosSetupAssistant) runUpdateProfile(ctx context.Context, args macosSe if fleet.IsNotFound(err) { // no setup assistant for that team, enqueue a profile deleted task so // the default profile is assigned to the hosts. - if err := QueueMacosSetupAssistantJob(ctx, m.Datastore, m.Log, MacosSetupAssistantProfileDeleted, args.TeamID); err != nil { + if _, err := QueueMacosSetupAssistantJob(ctx, m.Datastore, m.Log, MacosSetupAssistantProfileDeleted, args.TeamID); err != nil { return ctxerr.Wrap(ctx, err, "queue macos setup assistant profile deleted job") } return nil @@ -264,7 +327,7 @@ func (m *MacosSetupAssistant) runUpdateProfile(ctx context.Context, args macosSe // no error means that the setup assistant existed for that team, enqueue a profile // changed task so the custom profile is assigned to the hosts. - if err := QueueMacosSetupAssistantJob(ctx, m.Datastore, m.Log, MacosSetupAssistantProfileChanged, args.TeamID); err != nil { + if _, err := QueueMacosSetupAssistantJob(ctx, m.Datastore, m.Log, MacosSetupAssistantProfileChanged, args.TeamID); err != nil { return ctxerr.Wrap(ctx, err, "queue macos setup assistant profile changed job") } return nil @@ -291,7 +354,7 @@ func QueueMacosSetupAssistantJob( task MacosSetupAssistantTask, teamID *uint, serialNumbers ...string, -) error { +) (uint, error) { attrs := []interface{}{ "enabled", "true", macosSetupAssistantJobName, task, @@ -309,8 +372,47 @@ func QueueMacosSetupAssistantJob( } job, err := QueueJob(ctx, ds, macosSetupAssistantJobName, args) if err != nil { - return ctxerr.Wrap(ctx, err, "queueing job") + return 0, ctxerr.Wrap(ctx, err, "queueing job") } level.Debug(logger).Log("job_id", job.ID) + return job.ID, nil +} + +func ProcessDEPCooldowns(ctx context.Context, ds fleet.Datastore, logger kitlog.Logger) error { + serialsByTeamId, err := ds.GetDEPAssignProfileExpiredCooldowns(ctx) + if err != nil { + return ctxerr.Wrap(ctx, err, "getting cooldowns") + } + if len(serialsByTeamId) == 0 { + logger.Log("msg", "no cooldowns to process") + return nil + } + + // queue job for each team so that macOS setup assistant worker can pick it up and process it + for teamID, serials := range serialsByTeamId { + if len(serials) == 0 { + logger.Log("msg", "no cooldowns", "team_id", teamID) + continue + } + logger.Log("msg", "processing cooldowns", "team_id", teamID, "serials", serials) + + var tid *uint + if teamID != 0 { + tid = &teamID + } + + id, err := QueueMacosSetupAssistantJob(ctx, ds, logger, + MacosSetupAssistantHostsCooldown, + tid, serials..., + ) + if err != nil { + return ctxerr.Wrap(ctx, err, "queue macos setup assistant job for cooldowns") + } + + if err := ds.UpdateDEPAssignProfileRetryPending(ctx, id, serials); err != nil { + return ctxerr.Wrap(ctx, err, "updating dep assign profile retry pending") + } + + } return nil } diff --git a/server/worker/macos_setup_assistant_test.go b/server/worker/macos_setup_assistant_test.go index 304f10ebb9..e2633363bf 100644 --- a/server/worker/macos_setup_assistant_test.go +++ b/server/worker/macos_setup_assistant_test.go @@ -58,7 +58,7 @@ func TestMacosSetupAssistant(t *testing.T) { err = ds.AddHostsToTeam(ctx, &tm2.ID, []uint{hosts[4].ID, hosts[5].ID}) require.NoError(t, err) - var testBMToken = nanodep_client.OAuth1Tokens{ + testBMToken := nanodep_client.OAuth1Tokens{ ConsumerKey: "test_consumer", ConsumerSecret: "test_secret", AccessToken: "test_access_token", @@ -155,7 +155,7 @@ func TestMacosSetupAssistant(t *testing.T) { start := time.Now().Truncate(time.Second) // enqueue a regenerate all and process the jobs - err = QueueMacosSetupAssistantJob(ctx, ds, logger, MacosSetupAssistantUpdateAllProfiles, nil) + _, err = QueueMacosSetupAssistantJob(ctx, ds, logger, MacosSetupAssistantUpdateAllProfiles, nil) require.NoError(t, err) runCheckDone() @@ -188,7 +188,7 @@ func TestMacosSetupAssistant(t *testing.T) { }) require.NoError(t, err) require.NotZero(t, tm1Asst.ID) - err = QueueMacosSetupAssistantJob(ctx, ds, logger, MacosSetupAssistantProfileChanged, &tm1.ID) + _, err = QueueMacosSetupAssistantJob(ctx, ds, logger, MacosSetupAssistantProfileChanged, &tm1.ID) require.NoError(t, err) runCheckDone() @@ -219,7 +219,7 @@ func TestMacosSetupAssistant(t *testing.T) { tm2, err = ds.SaveTeam(ctx, tm2) require.NoError(t, err) - err = QueueMacosSetupAssistantJob(ctx, ds, logger, MacosSetupAssistantUpdateProfile, &tm2.ID) + _, err = QueueMacosSetupAssistantJob(ctx, ds, logger, MacosSetupAssistantUpdateProfile, &tm2.ID) require.NoError(t, err) runCheckDone() @@ -249,11 +249,11 @@ func TestMacosSetupAssistant(t *testing.T) { require.NotZero(t, tm3Asst.ID) err = ds.DeleteMDMAppleSetupAssistant(ctx, &tm1.ID) require.NoError(t, err) - err = QueueMacosSetupAssistantJob(ctx, ds, logger, MacosSetupAssistantProfileChanged, &tm2.ID) + _, err = QueueMacosSetupAssistantJob(ctx, ds, logger, MacosSetupAssistantProfileChanged, &tm2.ID) require.NoError(t, err) - err = QueueMacosSetupAssistantJob(ctx, ds, logger, MacosSetupAssistantProfileChanged, &tm3.ID) + _, err = QueueMacosSetupAssistantJob(ctx, ds, logger, MacosSetupAssistantProfileChanged, &tm3.ID) require.NoError(t, err) - err = QueueMacosSetupAssistantJob(ctx, ds, logger, MacosSetupAssistantProfileDeleted, &tm1.ID) + _, err = QueueMacosSetupAssistantJob(ctx, ds, logger, MacosSetupAssistantProfileDeleted, &tm1.ID) require.NoError(t, err) runCheckDone() @@ -271,7 +271,7 @@ func TestMacosSetupAssistant(t *testing.T) { tm2, err = ds.SaveTeam(ctx, tm2) require.NoError(t, err) - err = QueueMacosSetupAssistantJob(ctx, ds, logger, MacosSetupAssistantUpdateProfile, &tm2.ID) + _, err = QueueMacosSetupAssistantJob(ctx, ds, logger, MacosSetupAssistantUpdateProfile, &tm2.ID) require.NoError(t, err) runCheckDone() @@ -290,9 +290,9 @@ func TestMacosSetupAssistant(t *testing.T) { err = ds.DeleteTeam(ctx, tm2.ID) require.NoError(t, err) - err = QueueMacosSetupAssistantJob(ctx, ds, logger, MacosSetupAssistantHostsTransferred, &tm3.ID, "serial-2", "serial-4") + _, err = QueueMacosSetupAssistantJob(ctx, ds, logger, MacosSetupAssistantHostsTransferred, &tm3.ID, "serial-2", "serial-4") require.NoError(t, err) - err = QueueMacosSetupAssistantJob(ctx, ds, logger, MacosSetupAssistantTeamDeleted, nil, "serial-5") // hosts[5] was in team 2 + _, err = QueueMacosSetupAssistantJob(ctx, ds, logger, MacosSetupAssistantTeamDeleted, nil, "serial-5") // hosts[5] was in team 2 require.NoError(t, err) runCheckDone() @@ -314,7 +314,7 @@ func TestMacosSetupAssistant(t *testing.T) { require.NoError(t, err) require.NotZero(t, noTmAsst.ID) - err = QueueMacosSetupAssistantJob(ctx, ds, logger, MacosSetupAssistantProfileChanged, nil) + _, err = QueueMacosSetupAssistantJob(ctx, ds, logger, MacosSetupAssistantProfileChanged, nil) require.NoError(t, err) runCheckDone() @@ -331,7 +331,7 @@ func TestMacosSetupAssistant(t *testing.T) { reset := time.Now().Truncate(time.Second) time.Sleep(time.Second) - err = QueueMacosSetupAssistantJob(ctx, ds, logger, MacosSetupAssistantUpdateAllProfiles, nil) + _, err = QueueMacosSetupAssistantJob(ctx, ds, logger, MacosSetupAssistantUpdateAllProfiles, nil) require.NoError(t, err) runCheckDone() diff --git a/terraform/byo-vpc/byo-db/byo-ecs/variables.tf b/terraform/byo-vpc/byo-db/byo-ecs/variables.tf index bf017d4a2b..f058c93072 100644 --- a/terraform/byo-vpc/byo-db/byo-ecs/variables.tf +++ b/terraform/byo-vpc/byo-db/byo-ecs/variables.tf @@ -13,7 +13,7 @@ variable "fleet_config" { type = object({ mem = optional(number, 4096) cpu = optional(number, 512) - image = optional(string, "fleetdm/fleet:v4.45.0") + image = optional(string, "fleetdm/fleet:v4.46.1") family = optional(string, "fleet") sidecars = optional(list(any), []) depends_on = optional(list(any), []) diff --git a/terraform/byo-vpc/byo-db/variables.tf b/terraform/byo-vpc/byo-db/variables.tf index 04ec0c443f..c14ade1061 100644 --- a/terraform/byo-vpc/byo-db/variables.tf +++ b/terraform/byo-vpc/byo-db/variables.tf @@ -74,7 +74,7 @@ variable "fleet_config" { type = object({ mem = optional(number, 4096) cpu = optional(number, 512) - image = optional(string, "fleetdm/fleet:v4.45.0") + image = optional(string, "fleetdm/fleet:v4.46.1") family = optional(string, "fleet") sidecars = optional(list(any), []) depends_on = optional(list(any), []) diff --git a/terraform/byo-vpc/example/main.tf b/terraform/byo-vpc/example/main.tf index 6b607c1fa8..ffc5b4845b 100644 --- a/terraform/byo-vpc/example/main.tf +++ b/terraform/byo-vpc/example/main.tf @@ -17,7 +17,7 @@ provider "aws" { } locals { - fleet_image = "fleetdm/fleet:v4.45.0" + fleet_image = "fleetdm/fleet:v4.46.1" domain_name = "example.com" } diff --git a/terraform/byo-vpc/variables.tf b/terraform/byo-vpc/variables.tf index 1e1434e4cb..3c1c5ee863 100644 --- a/terraform/byo-vpc/variables.tf +++ b/terraform/byo-vpc/variables.tf @@ -165,7 +165,7 @@ variable "fleet_config" { type = object({ mem = optional(number, 4096) cpu = optional(number, 512) - image = optional(string, "fleetdm/fleet:v4.45.0") + image = optional(string, "fleetdm/fleet:v4.46.1") family = optional(string, "fleet") sidecars = optional(list(any), []) depends_on = optional(list(any), []) diff --git a/terraform/example/.header.md b/terraform/example/.header.md index 62620abe09..64b279e6df 100644 --- a/terraform/example/.header.md +++ b/terraform/example/.header.md @@ -2,5 +2,6 @@ This code provides some example usage of the Fleet Terraform module, including how some addons can be used to extend functionality. Due to Terraform issues, this code requires 3 applies "from scratch": -- terraform apply -target random_pet.main -- terraform apply -target module.main.module.vpc +1. `terraform apply -target module.fleet.module.vpc` +2. `terraform apply -target module.fleet` +3. `terraform apply` diff --git a/terraform/example/README.md b/terraform/example/README.md index 986222f2cb..8a5cd3ddf6 100644 --- a/terraform/example/README.md +++ b/terraform/example/README.md @@ -2,41 +2,44 @@ This code provides some example usage of the Fleet Terraform module, including how some addons can be used to extend functionality. Due to Terraform issues, this code requires 3 applies "from scratch": -- terraform apply -target random\_pet.main -- terraform apply -target module.main.module.vpc +1. `terraform apply -target module.fleet.module.vpc` +2. `terraform apply -target module.fleet` +3. `terraform apply` ## Requirements | Name | Version | |------|---------| -| [aws](#requirement\_aws) | ~> 4.0 | +| [aws](#requirement\_aws) | 5.36.0 | ## Providers | Name | Version | |------|---------| -| [aws](#provider\_aws) | 4.48.0 | -| [random](#provider\_random) | 3.4.3 | +| [aws](#provider\_aws) | 5.36.0 | ## Modules | Name | Source | Version | |------|--------|---------| | [acm](#module\_acm) | terraform-aws-modules/acm/aws | 4.3.1 | -| [firehose-logging](#module\_firehose-logging) | ../addons/logging-destination-firehose | n/a | -| [main](#module\_main) | ../ | n/a | +| [fleet](#module\_fleet) | github.com/fleetdm/fleet//terraform | tf-mod-root-v1.7.1 | +| [migrations](#module\_migrations) | github.com/fleetdm/fleet//terraform/addons/migrations | tf-mod-addon-migrations-v2.0.0 | ## Resources | Name | Type | |------|------| -| [aws_route53_record.main](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route53_record) | resource | -| [random_pet.main](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/pet) | resource | -| [aws_route53_zone.main](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/route53_zone) | data source | +| [aws_route53_record.main](https://registry.terraform.io/providers/hashicorp/aws/5.36.0/docs/resources/route53_record) | resource | +| [aws_route53_zone.main](https://registry.terraform.io/providers/hashicorp/aws/5.36.0/docs/resources/route53_zone) | resource | ## Inputs -No inputs. +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [domain\_name](#input\_domain\_name) | domain name to host fleet under | `string` | n/a | yes | +| [vpc\_name](#input\_vpc\_name) | name of the vpc to provision | `string` | `"fleet"` | no | +| [zone\_name](#input\_zone\_name) | the name to give to your hosted zone | `string` | `"fleet"` | no | ## Outputs diff --git a/terraform/example/main.tf b/terraform/example/main.tf index b9f0bd1ba6..63ae90244c 100644 --- a/terraform/example/main.tf +++ b/terraform/example/main.tf @@ -2,94 +2,78 @@ terraform { required_providers { aws = { source = "hashicorp/aws" - version = "~> 4.0" + version = "5.36.0" } } } -provider "aws" { - default_tags { - tags = { - Example = "This is a demo of the Fleet terraform module" - } - } +variable "domain_name" { + type = string + description = "domain name to host fleet under" } -resource "random_pet" "main" {} +variable "vpc_name" { + type = string + description = "name of the vpc to provision" + default = "fleet" +} -module "main" { - source = "github.com/fleetdm/fleet//terraform?ref=tf-mod-root-v1.3.0" +variable "zone_name" { + type = string + description = "the name to give to your hosted zone" + default = "fleet" +} + +module "fleet" { + source = "github.com/fleetdm/fleet//terraform?ref=tf-mod-root-v1.7.1" certificate_arn = module.acm.acm_certificate_arn - vpc = { - name = random_pet.main.id - enable_dns_hostnames = module.vulnprocessing.enable_dns_hostnames + + vpc_config = { + name = var.vpc_name } + fleet_config = { - extra_environment_variables = concat(module.firehose-logging.fleet_extra_environment_variables, module.vulnprocessing.fleet_extra_environment_variables) - extra_iam_policies = module.firehose-logging.fleet_extra_iam_policies + image = "fleetdm/fleet:v4.46.1" # override default to deploy the image you desire + extra_environment_variables = { + # FLEET_LICENSE_KEY = "" + } } } +module "migrations" { + source = "github.com/fleetdm/fleet//terraform/addons/migrations?ref=tf-mod-addon-migrations-v2.0.0" + ecs_cluster = module.fleet.byo-vpc.byo-db.byo-ecs.service.cluster + task_definition = module.fleet.byo-vpc.byo-db.byo-ecs.task_definition.family + task_definition_revision = module.fleet.byo-vpc.byo-db.byo-ecs.task_definition.revision + subnets = module.fleet.byo-vpc.byo-db.byo-ecs.service.network_configuration[0].subnets + security_groups = module.fleet.byo-vpc.byo-db.byo-ecs.service.network_configuration[0].security_groups + ecs_service = module.fleet.byo-vpc.byo-db.byo-ecs.service.name + desired_count = module.fleet.byo-vpc.byo-db.byo-ecs.appautoscaling_target.min_capacity + min_capacity = module.fleet.byo-vpc.byo-db.byo-ecs.appautoscaling_target.min_capacity +} + module "acm" { source = "terraform-aws-modules/acm/aws" version = "4.3.1" - domain_name = "${random_pet.main.id}.example.com" - zone_id = data.aws_route53_zone.main.id + domain_name = var.domain_name + zone_id = aws_route53_zone.main.id wait_for_validation = true } +resource "aws_route53_zone" "main" { + name = var.zone_name +} + resource "aws_route53_record" "main" { - zone_id = data.aws_route53_zone.main.id - name = "${random_pet.main.id}.example.com" + zone_id = aws_route53_zone.main.id + name = var.domain_name type = "A" alias { - name = module.main.byo-vpc.byo-db.alb.lb_dns_name - zone_id = module.main.byo-vpc.byo-db.alb.lb_zone_id + name = module.fleet.byo-vpc.byo-db.alb.lb_dns_name + zone_id = module.fleet.byo-vpc.byo-db.alb.lb_zone_id evaluate_target_health = true } -} - -data "aws_route53_zone" "main" { - name = "example.com." - private_zone = false -} - -module "firehose-logging" { - source = "github.com/fleetdm/fleet//terraform/addons/logging-destination-firehose?ref=tf-mod-addon-logging-destination-firehose-v1.0.0" - osquery_results_s3_bucket = { - name = "${random_pet.main.id}-results" - } - osquery_status_s3_bucket = { - name = "${random_pet.main.id}-status" - } -} - -module "vulnprocessing" { - source = "github.com/fleetdm/fleet//terraform/addons/vuln-processing?ref=tf-mod-addon-vuln-processing-v1.0.0" - customer_prefix = "fleet" - ecs_cluster = module.main.byo-vpc.byo-db.byo-ecs.cluster.cluster_arn - vpc_id = module.main.vpc.vpc_id - fleet_config = { - image = "fleetdm/fleet:v4.38.0" - database = { - password_secret_arn = module.main.byo-vpc.secrets.secret_arns["${var.rds_config.name}-database-password"] - user = module.main.byo-vpc.rds.db_instance_username - address = "${module.main.byo-vpc.rds.db_instance_endpoint}:${module.main.byo-vpc.rds.db_instance_port}" - database = module.main.byo-vpc.rds.db_instance_name - } - extra_environment_variables = { - FLEET_LOGGING_DEBUG = "true" - FLEET_LOGGING_JSON = "true" - } - extra_secrets = { - // FLEET_LICENSE_KEY: "secret_manager_license_key_arn" // note needed for some feature of vuln processing - } - networking = { - subnets = module.main.byo-vpc.byo-db.byo-ecs.service.network_configuration[0].subnets - security_groups = module.main.byo-vpc.byo-db.byo-ecs.service.network_configuration[0].security_groups - } - } -} +} \ No newline at end of file diff --git a/terraform/variables.tf b/terraform/variables.tf index ebd0fb4c2b..1b05dc8aa3 100644 --- a/terraform/variables.tf +++ b/terraform/variables.tf @@ -215,7 +215,7 @@ variable "fleet_config" { type = object({ mem = optional(number, 4096) cpu = optional(number, 512) - image = optional(string, "fleetdm/fleet:v4.45.0") + image = optional(string, "fleetdm/fleet:v4.46.1") family = optional(string, "fleet") sidecars = optional(list(any), []) depends_on = optional(list(any), []) diff --git a/tools/fleetctl-npm/package.json b/tools/fleetctl-npm/package.json index 3ad54a3e9c..1bc0d0aec8 100644 --- a/tools/fleetctl-npm/package.json +++ b/tools/fleetctl-npm/package.json @@ -1,6 +1,6 @@ { "name": "fleetctl", - "version": "v4.45.0", + "version": "v4.46.1", "description": "Installer for the fleetctl CLI tool", "bin": { "fleetctl": "./run.js" @@ -16,7 +16,7 @@ }, "homepage": "https://fleetdm.com", "dependencies": { - "axios": "^0.21.2", + "axios": "^0.28.0", "rimraf": "3.0.2", "tar": "^6.1.9" }, diff --git a/tools/fleetctl-npm/yarn.lock b/tools/fleetctl-npm/yarn.lock index 043f635df6..3076f03fae 100644 --- a/tools/fleetctl-npm/yarn.lock +++ b/tools/fleetctl-npm/yarn.lock @@ -2,12 +2,19 @@ # yarn lockfile v1 -axios@^0.21.2: - version "0.21.4" - resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.4.tgz#c67b90dc0568e5c1cf2b0b858c43ba28e2eda575" - integrity sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg== +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== + +axios@^0.28.0: + version "0.28.0" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.28.0.tgz#801a4d991d0404961bccef46800e1170f8278c89" + integrity sha512-Tu7NYoGY4Yoc7I+Npf9HhUMtEEpV7ZiLH9yndTCoNhcpBH0kwcvFbzYN9/u5QKI5A6uefjsNNWaz5olJVYS62Q== dependencies: - follow-redirects "^1.14.0" + follow-redirects "^1.15.0" + form-data "^4.0.0" + proxy-from-env "^1.1.0" balanced-match@^1.0.0: version "1.0.2" @@ -27,15 +34,36 @@ chownr@^2.0.0: resolved "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz" integrity sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ== +combined-stream@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" + integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== + dependencies: + delayed-stream "~1.0.0" + concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== -follow-redirects@^1.14.0: - version "1.15.4" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.4.tgz#cdc7d308bf6493126b17ea2191ea0ccf3e535adf" - integrity sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw== +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== + +follow-redirects@^1.15.0: + version "1.15.5" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.5.tgz#54d4d6d062c0fa7d9d17feb008461550e3ba8020" + integrity sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw== + +form-data@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452" + integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + mime-types "^2.1.12" fs-minipass@^2.0.0: version "2.1.0" @@ -74,6 +102,18 @@ inherits@2: resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== +mime-db@1.52.0: + version "1.52.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" + integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== + +mime-types@^2.1.12: + version "2.1.35" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" + integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== + dependencies: + mime-db "1.52.0" + minimatch@^3.0.4: version "3.1.2" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" @@ -113,6 +153,11 @@ path-is-absolute@^1.0.0: resolved "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz" integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18= +proxy-from-env@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" + integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== + rimraf@3.0.2: version "3.0.2" resolved "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz" diff --git a/tools/tuf/test/README.md b/tools/tuf/test/README.md index 6b893e376b..dacac03e75 100644 --- a/tools/tuf/test/README.md +++ b/tools/tuf/test/README.md @@ -55,6 +55,14 @@ LINUX_TEST_EXTENSIONS="./tools/test_extensions/hello_world/linux/hello_world_lin ./tools/tuf/test/main.sh ``` +To build for a specific architecture, you can pass the `GOARCH` environment variable: +``` shell +[...] +GOARCH=arm64 # defaults to amd64 +[...] +./tools/tuf/test/main.sh +``` + # Add new updates To add new updates (osqueryd or orbit), use `push_target.sh`. diff --git a/tools/tuf/test/create_repository.sh b/tools/tuf/test/create_repository.sh index 42ca58ddc5..2633f096bb 100755 --- a/tools/tuf/test/create_repository.sh +++ b/tools/tuf/test/create_repository.sh @@ -61,13 +61,9 @@ for system in $SYSTEMS; do rm $osqueryd_path goose_value="$system" - goarch_value="" # leave it empty to use the default for the system + goarch_value=${GOARCH:-} if [[ $system == "macos" ]]; then goose_value="darwin" - # for all platforms except Darwin, GOARCH is hardcoded to amd64 to - # prevent cross compilation issues when building macOS arm64 binaries - # from Linux (CGO + libraries are required) - goarch_value="amd64" fi orbit_target=orbit-$system if [[ $system == "windows" ]]; then diff --git a/website/api/controllers/webhooks/receive-from-github.js b/website/api/controllers/webhooks/receive-from-github.js index 4131843b37..375e685b0e 100644 --- a/website/api/controllers/webhooks/receive-from-github.js +++ b/website/api/controllers/webhooks/receive-from-github.js @@ -88,6 +88,7 @@ module.exports = { 'pintomi1989', 'nonpunctual', 'hughestaylor', + 'dantecatalfamo', ]; 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/api/controllers/webhooks/receive-from-stripe.js b/website/api/controllers/webhooks/receive-from-stripe.js index 0af2abd168..394c17851e 100644 --- a/website/api/controllers/webhooks/receive-from-stripe.js +++ b/website/api/controllers/webhooks/receive-from-stripe.js @@ -95,7 +95,9 @@ module.exports = { } let userForThisSubscription = subscriptionForThisEvent.user; - + // ┬ ā”¬ā”Œā”€ā”ā”Œā”€ā”ā”Œā”€ā”ā”Œā”¬ā”ā”¬ā”Œā”ā”Œā”Œā”€ā” ā”¬ā”€ā”ā”Œā”€ā”ā”Œā”ā”Œā”Œā”€ā”ā”¬ ā”¬ā”Œā”€ā”ā”¬ + // │ ā”‚ā”œā”€ā”˜ā”‚ │ │││││││││ ┬ ā”œā”¬ā”˜ā”œā”¤ ā”‚ā”‚ā”‚ā”œā”¤ ā”‚ā”‚ā”‚ā”œā”€ā”¤ā”‚ + // ā””ā”€ā”˜ā”“ ā””ā”€ā”˜ā””ā”€ā”˜ā”“ ā”“ā”“ā”˜ā””ā”˜ā””ā”€ā”˜ ā”“ā””ā”€ā””ā”€ā”˜ā”˜ā””ā”˜ā””ā”€ā”˜ā””ā”“ā”˜ā”“ ā”“ā”“ā”€ā”˜ // If stripe thinks this subscription renews in 7 days, we'll send the user an subscription reminder email. if(type === 'invoice.upcoming' && stripeEventData.billing_reason === 'upcoming') { // Get the subscription cost per host for the Subscription renewal notification email. @@ -117,7 +119,9 @@ module.exports = { nextBillingAt: upcomingBillingAt, } }); - + // ā”Œā”€ā”ā”¬ ā”¬ā”Œā” ā”Œā”€ā”ā”Œā”€ā”ā”¬ā”€ā”ā”¬ā”Œā”€ā”ā”Œā”¬ā”ā”¬ā”Œā”€ā”ā”Œā”ā”Œ ā”¬ā”€ā”ā”Œā”€ā”ā”Œā”ā”Œā”Œā”€ā”ā”¬ ā”¬ā”Œā”€ā”ā”Œā”¬ā” + // └─┐│ ā”‚ā”œā”“ā”ā””ā”€ā”ā”‚ ā”œā”¬ā”˜ā”‚ā”œā”€ā”˜ │ ││ ││││ ā”œā”¬ā”˜ā”œā”¤ ā”‚ā”‚ā”‚ā”œā”¤ ā”‚ā”‚ā”‚ā”œā”¤ ││ + // ā””ā”€ā”˜ā””ā”€ā”˜ā””ā”€ā”˜ā””ā”€ā”˜ā””ā”€ā”˜ā”“ā””ā”€ā”“ā”“ ┓ ā”“ā””ā”€ā”˜ā”˜ā””ā”˜ ā”“ā””ā”€ā””ā”€ā”˜ā”˜ā””ā”˜ā””ā”€ā”˜ā””ā”“ā”˜ā””ā”€ā”˜ā”€ā”“ā”˜ } else if(type === 'invoice.paid' && stripeEventData.billing_reason === 'subscription_cycle') { // If the event was triggered by a user's card successfully being charged by Stripe, we'll generate a new license key, update the subscription's database record, and send the user a renewal confirmation email. @@ -156,7 +160,46 @@ module.exports = { lastName: userForThisSubscription.lastName, } }); + // ā”¬ā”Œā”ā”Œā”¬ ā”¬ā”Œā”€ā”ā”¬ā”Œā”€ā”ā”Œā”€ā” ā”Œā”€ā”ā”Œā”€ā”ā”¬ā”€ā” ┬ ā”¬ā”Œā”€ā”ā”Œā”¬ā”ā”Œā”€ā”ā”Œā”¬ā”ā”Œā”€ā”ā”Œā”¬ā” ā”Œā”€ā”ā”¬ ā”¬ā”Œā” ā”Œā”€ā”ā”Œā”€ā”ā”¬ā”€ā”ā”¬ā”Œā”¬ā”ā”Œā”€ā”ā”Œā”ā”Œ ā”Œā”€ā”ā”Œā”€ā”ā”¬ā”Œā”¬ā” + // ā”‚ā”‚ā”‚ā”‚ā””ā”ā”Œā”˜ā”‚ │││ ā”œā”¤ ā”œā”¤ │ ā”‚ā”œā”¬ā”˜ │ ā”‚ā”œā”€ā”˜ ā”‚ā”‚ā”œā”€ā”¤ │ ā”œā”¤ ││ └─┐│ ā”‚ā”œā”“ā”ā””ā”€ā”ā”‚ ā”œā”¬ā”˜ā”‚ │ │ ││││ ā”œā”€ā”˜ā”œā”€ā”¤ā”‚ ││ + // ā”“ā”˜ā””ā”˜ ā””ā”˜ ā””ā”€ā”˜ā”“ā””ā”€ā”˜ā””ā”€ā”˜ ā”” ā””ā”€ā”˜ā”“ā””ā”€ ā””ā”€ā”˜ā”“ ā”€ā”“ā”˜ā”“ ┓ ┓ ā””ā”€ā”˜ā”€ā”“ā”˜ ā””ā”€ā”˜ā””ā”€ā”˜ā””ā”€ā”˜ā””ā”€ā”˜ā””ā”€ā”˜ā”“ā””ā”€ā”“ ┓ ā””ā”€ā”˜ā”˜ā””ā”˜ ┓ ┓ ā”“ā”“ā”€ā”“ā”˜ + } else if (type === 'invoice.paid' && stripeEventData.billing_reason === 'subscription_update') { + // If the event was triggered by a customer paying an invoice that was sent to them after their subscription was updated, we'll generate a new license key with their updated informaton. + // Get the information about the paid invoice from the stripe event. + let itemsOnThisInvoice = stripeEventData.lines.data; + + // Find the line item in the new invoice that contains the new information about this subscription. + let updatedSubscriptionInfo = _.find(itemsOnThisInvoice, (item)=>{ + // Invoices for updated subscriptions list the new number of hosts with the remaining subscription period + // e.g., 'Remaining time on 9 Ɨ Fleet premium hosts after 17 Feb 2024' + return _.startsWith(item.description, 'Remaining'); + }); + // Convert the subscription cycle's period end timestamp from Stripe into a JS timestamp. + // Note: with most subscription changes, this value will be indentical to the existing license key's expiration + // timestamp. We do this here to handle situations where the subscription period has been adjusted in the Stripe UI. + let nextBillingAt = updatedSubscriptionInfo.period.end * 1000; + + // Use information from the nested plan object to determine the new price of this subscription. + let pricePerHost = updatedSubscriptionInfo.plan.amount / 100; + + // Get the updated number of hosts from the quantity of the invoice. + let newNumberOfHosts = updatedSubscriptionInfo.quantity; + + // Generate a new license key for this subscription + let newLicenseKeyForThisSubscription = await sails.helpers.createLicenseKey.with({ + numberOfHosts: newNumberOfHosts, + organization: subscriptionForThisEvent.user.organization, + expiresAt: nextBillingAt, + }); + + // Update the subscription record + await Subscription.updateOne({id: subscriptionForThisEvent.id}).set({ + numberOfHosts: newNumberOfHosts, + subscriptionPrice: Math.floor(pricePerHost * newNumberOfHosts), + fleetLicenseKey: newLicenseKeyForThisSubscription, + nextBillingAt: nextBillingAt + }); } // FUTURE: send emails about failed payments. (type === 'invoice.payment_failed' && stripeEventData.billing_reason === 'subscription_cycle') diff --git a/website/api/helpers/get-extended-osquery-schema.js b/website/api/helpers/get-extended-osquery-schema.js index e1a809f520..a589d2dbe0 100644 --- a/website/api/helpers/get-extended-osquery-schema.js +++ b/website/api/helpers/get-extended-osquery-schema.js @@ -159,6 +159,13 @@ module.exports = { } else { expandedTableToPush.examples = _.clone(fleetOverridesForTable.examples); } + } else { + // If the override file does not contain an 'examples' value, we'll use the last example from the osquery schema (See above for more information about the reasoning behind this) + let examplesFromOsquerySchema = expandedTableToPush.examples; + if (examplesFromOsquerySchema.length > 0) { + // Examples are parsed as markdown, so we wrap the example in a code fence so it renders as a code block. + expandedTableToPush.examples = '```\n' + examplesFromOsquerySchema[examplesFromOsquerySchema.length - 1] + '\n```'; + } } if(fleetOverridesForTable.notes !== undefined) { if(typeof fleetOverridesForTable.notes !== 'string') { diff --git a/website/api/helpers/github-automations/get-is-pr-preapproved.js b/website/api/helpers/github-automations/get-is-pr-preapproved.js index 8cb8cd281a..1c90da1f16 100644 --- a/website/api/helpers/github-automations/get-is-pr-preapproved.js +++ b/website/api/helpers/github-automations/get-is-pr-preapproved.js @@ -39,7 +39,7 @@ module.exports = { MAINTAINERS_BY_PATH = sails.config.custom.confidentialGithubRepoMaintainersByPath; } - if (repo === 'fleet-mdm-gitops') { + if (repo === 'fleet-gitops') { MAINTAINERS_BY_PATH = sails.config.custom.fleetMdmGitopsGithubRepoMaintainersByPath; } diff --git a/website/api/hooks/custom/index.js b/website/api/hooks/custom/index.js index 048886b599..fb774908a4 100644 --- a/website/api/hooks/custom/index.js +++ b/website/api/hooks/custom/index.js @@ -145,6 +145,23 @@ will be disabled and/or hidden in the UI. res.locals.me = undefined; }//fi + // Check for UTM parameters for website personalization. + // [?] https://en.wikipedia.org/wiki/UTM_parameters + // e.g. + // https://fleetdm.com/device-management?utm_source=linkedin&utm_campaign=evergreen+leadgen&utm_content=mdm + if (['eo-security', 'eo-it', 'mdm', 'vm'].includes(req.param('utm_content'))) { + // If this is set to something weird, then we silently ignore it. + // Modify the active session instance. (This will be persisted when the response is sent.) + req.session.primaryBuyingSituation = req.param('utm_content'); + // FUTURE: Auto-redirect without the querystring after absorbtion to make it prettier in the URL bar. + // (except this probably messes up analytics so before doing that, figure out how to solve that problem) + }//fi + if (req.method === 'GET' || req.method === 'HEAD') { + // Include information about the primary buying situation + // If set in the session (e.g. from an ad), use the primary buying situation for personalization. + res.locals.primaryBuyingSituation = req.session.primaryBuyingSituation || undefined; + }//fi + // Next, if we're running in our actual "production" or "staging" Sails // environment, check if this is a GET request via some other host, // for example a subdomain like `webhooks.` or `click.`. If so, we'll @@ -233,7 +250,6 @@ will be disabled and/or hidden in the UI. });//_āˆ_ (Meanwhile...) }//fi - // If this is a GET request, then also expose an extra view local (`<%= me %>`). // > Note that we make sure a local named `me` doesn't already exist first. // > Also note that we strip off any properties that correspond with protected attributes. @@ -260,6 +276,10 @@ will be disabled and/or hidden in the UI. res.locals.isBillingEnabled = sails.config.custom.enableBillingFeatures; res.locals.isEmailVerificationRequired = sails.config.custom.verifyEmailAddresses; + // Include information about the primary buying situation + // If set in the session (e.g. from an ad), use the primary buying situation for personalization. + res.locals.primaryBuyingSituation = req.session.primaryBuyingSituation || undefined; + }//fi return next(); diff --git a/website/assets/images/articles/the-device-security-tightrope-balancing-cost-and-protection-in-K-12-schools-1600x900@2x.png b/website/assets/images/articles/the-device-security-tightrope-balancing-cost-and-protection-in-K-12-schools-1600x900@2x.png new file mode 100644 index 0000000000..387c333460 Binary files /dev/null and b/website/assets/images/articles/the-device-security-tightrope-balancing-cost-and-protection-in-K-12-schools-1600x900@2x.png differ diff --git a/website/assets/images/endpoint-operations-hero-image-380x380@2x.png b/website/assets/images/endpoint-operations-hero-image-380x380@2x.png new file mode 100644 index 0000000000..001e9f77c5 Binary files /dev/null and b/website/assets/images/endpoint-operations-hero-image-380x380@2x.png differ diff --git a/website/assets/images/endpoint-operations-hero-image-380x383@2x.png b/website/assets/images/endpoint-operations-hero-image-380x383@2x.png deleted file mode 100644 index 4ee371fccd..0000000000 Binary files a/website/assets/images/endpoint-operations-hero-image-380x383@2x.png and /dev/null differ diff --git a/website/assets/images/icon-cis-benchmarks-48x48@2x.png b/website/assets/images/icon-cis-benchmarks-48x48@2x.png new file mode 100644 index 0000000000..8b6271be56 Binary files /dev/null and b/website/assets/images/icon-cis-benchmarks-48x48@2x.png differ diff --git a/website/assets/images/icon-live-connection-48x48@2x.png b/website/assets/images/icon-live-connection-48x48@2x.png new file mode 100644 index 0000000000..a1d628fe90 Binary files /dev/null and b/website/assets/images/icon-live-connection-48x48@2x.png differ diff --git a/website/assets/images/logo-flock-safety-907x132@2x.png b/website/assets/images/logo-flock-safety-907x132@2x.png new file mode 100644 index 0000000000..233e60f32a Binary files /dev/null and b/website/assets/images/logo-flock-safety-907x132@2x.png differ diff --git a/website/assets/images/testimonial-author-eric-tan-99x99@2x.png b/website/assets/images/testimonial-author-eric-tan-99x99@2x.png new file mode 100644 index 0000000000..1a18a05ed1 Binary files /dev/null and b/website/assets/images/testimonial-author-eric-tan-99x99@2x.png differ diff --git a/website/assets/resources/install-fleet.sh b/website/assets/resources/install-fleet.sh deleted file mode 100644 index 0717e46dd2..0000000000 --- a/website/assets/resources/install-fleet.sh +++ /dev/null @@ -1,119 +0,0 @@ -#!/bin/bash - -set -e - -FLEETCTL_INSTALL_DIR="${HOME}/.fleetctl/" -FLEETCTL_BINARY_NAME="fleetctl" - - -# Check for necessary commands -for cmd in curl tar grep sed; do - if ! command -v $cmd &> /dev/null; then - echo "Error: $cmd is not installed." >&2 - exit 1 - fi -done - -echo "Fetching the latest version of fleetctl..." - - -# Fetch the latest version number from NPM -latest_strippedVersion=$(curl -s "https://registry.npmjs.org/fleetctl/latest" | grep -o '"version": *"[^"]*"' | cut -d'"' -f4) -echo "Latest version available on NPM: $latest_strippedVersion" - -version_gt() { - test "$(printf '%s\n' "$@" | sort -V | head -n 1)" != "$1"; -} - -# Determine operating system (Linux or MacOS) -OS="$(uname -s)" - -case "${OS}" in - Linux*) OS='linux';; - Darwin*) OS='macos';; - *) echo "Unsupported operating system: ${OS}"; exit 1;; -esac - -# Download the fleetctl binary and extract it into the install directory -download_and_extract() { - echo "Downloading fleetctl ${latest_strippedVersion} for ${OS}..." - curl -sSL $DOWNLOAD_URL | tar -xz -C $FLEETCTL_INSTALL_DIR --strip-components=1 fleetctl_v${latest_strippedVersion}_${OS}/ -} - -# Check to see if the fleetctl binary exists in the script's install directory. -check_installed_version() { - # If the fleetctl binary exists, we'll check the version of it using fleetctl -v. - if [ -x "${FLEETCTL_INSTALL_DIR}/fleetctl" ]; then - installed_version=$("${FLEETCTL_INSTALL_DIR}/fleetctl" -v | awk 'NR==1{print $NF}' | sed 's/^v//') - echo "Installed version: ${installed_version}" - else - return 1 - fi -} - -# Create the install directory if it does not exist. -mkdir -p ${FLEETCTL_INSTALL_DIR} - -# Construct download URL -# ex: https://github.com/fleetdm/fleet/releases/download/fleet-v4.43.3/fleetctl_v4.43.3_macos.zip -DOWNLOAD_URL="https://github.com/fleetdm/fleet/releases/download/fleet-v${latest_strippedVersion}/fleetctl_v${latest_strippedVersion}_${OS}.tar.gz" - - -if check_installed_version; then - if version_gt $latest_strippedVersion $installed_version; then - # Prompt the user for an upgrade - read -p "A newer version of fleetctl ($latest_strippedVersion) is available. Would you like to upgrade? (y/n): " upgrade_choice - - if [[ "$upgrade_choice" =~ ^[Yy](es)?$ ]]; then - # Remove the old binary - rm -f "${FLEETCTL_INSTALL_DIR}/fleetctl" - echo "Removing an older version of fleetctl." - - # Download and install the new version - download_and_extract - echo "fleetctl installed successfully in ${FLEETCTL_INSTALL_DIR}" - echo - echo "To start the local demo:" - echo - echo "1. Start Docker Desktop" - echo "2. Run ~/.fleetctl/fleetctl preview" - else - echo "Upgrade canceled." - fi - else - read -p "You are already using the latest version of fleetctl ($latest_strippedVersion) Would you like to reinstall it? (y/n): " reinstall_choice - - if [[ "$reinstall_choice" =~ ^[Yy](es)?$ ]]; then - # Remove the old binary - rm -f "${FLEETCTL_INSTALL_DIR}/fleetctl" - echo "Removing an older version of fleetctl." - - # Download and install the new version - download_and_extract - echo "fleetctl reinstalled successfully in ${FLEETCTL_INSTALL_DIR}" - echo - echo "To start the local demo:" - echo - echo "1. Start Docker Desktop" - echo "2. Run ~/.fleetctl/fleetctl preview" - else - echo "Install canceled." - fi - fi -else - # If there is no existing fleetctl binary, download the latest version and extract it. - download_and_extract - echo "fleetctl installed successfully in ${FLEETCTL_INSTALL_DIR}" - echo - echo "To start the local demo:" - echo - echo "1. Start Docker Desktop" - echo "2. Run ~/.fleetctl/fleetctl preview" -fi - -# Verify if the binary is executable -if [[ ! -x "${FLEETCTL_INSTALL_DIR}/fleetctl" ]]; then - echo "Failed to install or upgrade fleetctl. Please check your permissions and try running this script again." - exit 1 -fi - diff --git a/website/assets/resources/install-fleetctl.sh b/website/assets/resources/install-fleetctl.sh new file mode 100644 index 0000000000..4c21a19b11 --- /dev/null +++ b/website/assets/resources/install-fleetctl.sh @@ -0,0 +1,57 @@ +#!/bin/bash + +set -e + +FLEETCTL_INSTALL_DIR="${HOME}/.fleetctl/" + + +# Check for necessary commands +for cmd in curl tar grep sed; do + if ! command -v $cmd &> /dev/null; then + echo "Error: $cmd is not installed." >&2 + exit 1 + fi +done + +echo "Fetching the latest version of fleetctl..." + + +# Fetch the latest version number from NPM +latest_strippedVersion=$(curl -s "https://registry.npmjs.org/fleetctl/latest" | grep -o '"version": *"[^"]*"' | cut -d'"' -f4) +echo "Latest version available on NPM: $latest_strippedVersion" + +version_gt() { + test "$(printf '%s\n' "$@" | sort -V | head -n 1)" != "$1"; +} + +# Determine operating system (Linux or MacOS) +OS="$(uname -s)" + +case "${OS}" in + Linux*) OS='linux';; + Darwin*) OS='macos';; + *) echo "Unsupported operating system: ${OS}"; exit 1;; +esac + +# Create the install directory if it does not exist. +mkdir -p "${FLEETCTL_INSTALL_DIR}" + +# Construct download URL +# ex: https://github.com/fleetdm/fleet/releases/download/fleet-v4.43.3/fleetctl_v4.43.3_macos.zip +DOWNLOAD_URL="https://github.com/fleetdm/fleet/releases/download/fleet-v${latest_strippedVersion}/fleetctl_v${latest_strippedVersion}_${OS}.tar.gz" + +# Download the latest version of fleetctl and extract it. +echo "Downloading fleetctl ${latest_strippedVersion} for ${OS}..." +curl -sSL "$DOWNLOAD_URL" | tar -xz -C "$FLEETCTL_INSTALL_DIR" --strip-components=1 fleetctl_v"${latest_strippedVersion}"_${OS}/ +echo "fleetctl installed successfully in ${FLEETCTL_INSTALL_DIR}" +echo +echo "To start the local demo:" +echo +echo "1. Start Docker Desktop" +echo "2. Run ~/.fleetctl/fleetctl preview" + +# Verify if the binary is executable +if [[ ! -x "${FLEETCTL_INSTALL_DIR}/fleetctl" ]]; then + echo "Failed to install or upgrade fleetctl. Please check your permissions and try running this script again." + exit 1 +fi diff --git a/website/assets/styles/pages/endpoint-ops.less b/website/assets/styles/pages/endpoint-ops.less index da33ce37b2..cdd28ba44b 100644 --- a/website/assets/styles/pages/endpoint-ops.less +++ b/website/assets/styles/pages/endpoint-ops.less @@ -1,14 +1,17 @@ #endpoint-ops { + @heading-line-height: 120%; + @text-line-height: 150%; + background: linear-gradient(180deg, #E8F1F6 0%, #FFF 8.76%); h1 { - font-size: 56px; + font-size: 48px; font-weight: 800; - line-height: 54px; + line-height: @heading-line-height; } h2 { font-size: 64px; font-weight: 800; - line-height: 76.8px; + line-height: @heading-line-height; } h3 { font-size: 32px; @@ -17,21 +20,25 @@ margin-bottom: 24px; } h4 { - font-family: 'Roboto Mono'; - font-style: normal; - font-weight: 500; + font-family: 'Roboto Mono', monospace; + font-weight: 400; font-size: 18px; line-height: 24px; color: @core-fleet-black-75; - margin-bottom: 4px; + margin-bottom: 8px; } p { font-size: 16px; - line-height: 24px; + line-height: @text-line-height; color: @core-fleet-black-75; + a[href] { + text-decoration: underline; + color: @core-fleet-black-75; + text-underline-offset: 3px; + } } strong { - colore: @core-fleet-black; + color: @core-fleet-black; font-size: 16px; font-weight: 800; line-height: 19.2px; @@ -71,10 +78,10 @@ display: block; } p { - font-size: 14px; + font-size: 16px; font-style: normal; font-weight: 400; - line-height: 21px; + line-height: @text-line-height; margin-bottom: 40px; } } @@ -88,12 +95,11 @@ } [purpose='cta-button'] { cursor: pointer; - margin-right: 32px; + margin-right: 24px; background: @core-vibrant-red; border-radius: 8px; - padding-left: 32px; - padding-right: 32px; - height: 48px; + padding: 16px 32px; + // height: 48px; display: flex; justify-content: center; align-items: center; @@ -109,7 +115,7 @@ position: absolute; top: 0; left: -5px; - width: 70%; + width: 50%; height: 100%; transform: skew(-10deg); transition: left 0.5s ease-in, opacity 0.50s ease-in, width 0.5s ease-in; @@ -334,7 +340,7 @@ } [purpose='feature'] { - margin-bottom: 180px; + margin-bottom: 160px; h3 { margin-bottom: 24px; } diff --git a/website/config/custom.js b/website/config/custom.js index 2937ef2590..ac3d165342 100644 --- a/website/config/custom.js +++ b/website/config/custom.js @@ -221,9 +221,10 @@ module.exports.custom = { // Handbook 'handbook/README.md': 'mikermcneil', // See https://github.com/fleetdm/fleet/pull/13195 'handbook/company': 'mikermcneil', + 'handbook/company/product-groups.md': ['lukeheath', 'sampfluger88','mikermcneil'], 'handbook/digital-experience': ['sampfluger88','mikermcneil'], 'handbook/business-operations': ['sampfluger88','mikermcneil'], - 'handbook/engineering': ['sampfluger88','mikermcneil'], + 'handbook/engineering': ['sampfluger88','mikermcneil', 'lukeheath'], 'handbook/product-design': ['sampfluger88','mikermcneil'], 'handbook/sales': ['sampfluger88','mikermcneil'], 'handbook/demand': ['sampfluger88','mikermcneil'], diff --git a/website/config/env/production.js b/website/config/env/production.js index be9e2a88bb..5f22dd83d1 100644 --- a/website/config/env/production.js +++ b/website/config/env/production.js @@ -222,7 +222,7 @@ module.exports = { ***************************************************************************/ cookie: { secure: true, - maxAge: 24 * 60 * 60 * 1000, // 24 hours + maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days }, }, diff --git a/website/config/routes.js b/website/config/routes.js index 30ef74d23e..8a56ffe00f 100644 --- a/website/config/routes.js +++ b/website/config/routes.js @@ -17,8 +17,6 @@ module.exports.routes = { action: 'view-homepage-or-redirect', locals: { isHomepage: true, - pageTitleForMeta: 'Fleet | Open-source device management', - pageDescriptionForMeta: 'Open-source device management and security for teams with thousands of laptops and servers. (macOS, Windows, Linux, ChromeOS)' } }, @@ -520,6 +518,7 @@ module.exports.routes = { 'GET /sign-in-to/microsoft-automatic-enrollment-tool': 'https://portal.azure.com', 'GET /learn-more-about/custom-os-settings': '/docs/using-fleet/mdm-custom-os-settings', 'GET /learn-more-about/enrolling-hosts': '/docs/using-fleet/adding-hosts', + 'GET /learn-more-about/setup-assistant': '/docs/using-fleet/mdm-macos-setup-experience#macos-setup-assistant', // Sitemap // ============================================================================================================= diff --git a/website/scripts/get-bug-and-pr-report.js b/website/scripts/get-bug-and-pr-report.js index 3585124ea6..5894be6ecb 100644 --- a/website/scripts/get-bug-and-pr-report.js +++ b/website/scripts/get-bug-and-pr-report.js @@ -350,7 +350,6 @@ module.exports = { let averageNumberOfDaysBugsAreOpenFor = Math.round(_.sum(daysSinceBugsWereOpened) / daysSinceBugsWereOpened.length); let averageNumberOfDaysUnreleasedBugsAreOpenFor = Math.round(_.sum(daysSinceUnreleasedBugsWereOpened) / daysSinceUnreleasedBugsWereOpened.length); let averageNumberOfDaysReleasedBugsAreOpenFor = Math.round(_.sum(daysSinceReleasedBugsWereOpened)/daysSinceReleasedBugsWereOpened.length); - let averageNumberOfDaysFromCommitToMerge = Math.round(_.sum(commitToMergeTimesInDays)/commitToMergeTimesInDays.length); let averageDaysPullRequestsAreOpenFor = Math.round(_.sum(daysSincePullRequestsWereOpened)/daysSincePullRequestsWereOpened.length); let averageDaysContributorPullRequestsAreOpenFor = Math.round(_.sum(daysSinceContributorPullRequestsWereOpened)/daysSinceContributorPullRequestsWereOpened.length); @@ -376,57 +375,87 @@ module.exports = { }, 0); // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + const kpiResults = []; + + // NOTE: If order of the KPI sheets columns changes, the order values are pushed into this array needs to change, as well. + kpiResults.push( + averageDaysContributorPullRequestsAreOpenFor, + daysSinceContributorPullRequestsWereOpened.length, + averageDaysPullRequestsAreOpenFor, + daysSincePullRequestsWereOpened.length, + averageNumberOfDaysReleasedBugsAreOpenFor, + averageNumberOfDaysUnreleasedBugsAreOpenFor, + allBugsClosedInPastWeek.length, + averageNumberOfDaysBugsAreOpenFor, + allBugsCreatedInPastWeek.length, + allBugsCreatedInPastWeekEndpointOps.length, + allBugsCreatedInPastWeekEndpointOpsCustomerImpacting.length, + allBugsCreatedInPastWeekEndpointOpsReleased.length, + allBugsCreatedInPastWeekEndpointOpsUnreleased.length, + allBugsCreatedInPastWeekMobileDeviceManagement.length, + allBugsCreatedInPastWeekMobileDeviceManagementCustomerImpacting.length, + allBugsCreatedInPastWeekMobileDeviceManagementReleased.length, + allBugsCreatedInPastWeekMobileDeviceManagementUnreleased.length, + daysSinceBugsWereOpened.length, + allBugsWithReleasedLabel.length, + allBugsWithUnreleasedLabel.length); + // Log the results sails.log(` - Bugs: + + CSV for copy-pasting into KPI spreadsheet: --------------------------- - Number of open issues with the "bug" label in fleetdm/fleet: ${daysSinceBugsWereOpened.length} - Average open time: ${averageNumberOfDaysBugsAreOpenFor} days. + ${kpiResults.join(',')} - Number of open issues with the "~unreleased bug" label in fleetdm/fleet: ${allBugsWithUnreleasedLabel.length} - Average open time: ${averageNumberOfDaysUnreleasedBugsAreOpenFor} days. + Note: Copy the values above, then in Google sheets paste them into a cell and select "Split text to columns" to paste the values into separate cells. - Number of open issues with the "~released bug" label in fleetdm/fleet: ${allBugsWithReleasedLabel.length} - Average open time: ${averageNumberOfDaysReleasedBugsAreOpenFor} days. + Pull requests: + --------------------------- + Average open time (no bots, no handbook, no ceo): ${averageDaysContributorPullRequestsAreOpenFor} days. + Number of open pull requests in the fleetdm/fleet Github repo (no bots, no handbook, no ceo): ${daysSinceContributorPullRequestsWereOpened.length} - Number of issues with the "bug" label opened in the past week: ${allBugsCreatedInPastWeek.length} + Average open time (all PRs): ${averageDaysPullRequestsAreOpenFor} days. + Number of open pull requests in the fleetdm/fleet Github repo: ${daysSincePullRequestsWereOpened.length} + + Bugs (part 1): + --------------------------- + Average open time (released bugs): ${averageNumberOfDaysReleasedBugsAreOpenFor} days. + + Average open time (unreleased bugs): ${averageNumberOfDaysUnreleasedBugsAreOpenFor} days. Number of issues with the "bug" label closed in the past week: ${allBugsClosedInPastWeek.length} + Average open time (all bugs): ${averageNumberOfDaysBugsAreOpenFor} days. + + Number of issues with the "bug" label opened in the past week: ${allBugsCreatedInPastWeek.length} + Endpoint Operations: --------------------------- Number of issues with the "#g-endpoint-ops" and "bug" labels opened in the past week: ${allBugsCreatedInPastWeekEndpointOps.length} - Number of issues with the "#g-endpoint-ops", "bug", and "~unreleased bug" labels opened in the past week: ${allBugsCreatedInPastWeekEndpointOpsUnreleased.length} + Number of issues with the "#g-endpoint-ops", "bug", and "customer-" labels opened in the past week: ${allBugsCreatedInPastWeekEndpointOpsCustomerImpacting.length} Number of issues with the "#g-endpoint-ops", "bug", and "~released bug" labels opened in the past week: ${allBugsCreatedInPastWeekEndpointOpsReleased.length} - Number of issues with the "#g-endpoint-ops", "bug", and "customer-" labels opened in the past week: ${allBugsCreatedInPastWeekEndpointOpsCustomerImpacting.length} + Number of issues with the "#g-endpoint-ops", "bug", and "~unreleased bug" labels opened in the past week: ${allBugsCreatedInPastWeekEndpointOpsUnreleased.length} MDM: --------------------------- Number of issues with the "#g-mdm" and "bug" labels opened in the past week: ${allBugsCreatedInPastWeekMobileDeviceManagement.length} - Number of issues with the "#g-mdm", "bug", and "~unreleased bug" labels opened in the past week: ${allBugsCreatedInPastWeekMobileDeviceManagementUnreleased.length} + Number of issues with the "#g-mdm", "bug", and "customer-" labels opened in the past week: ${allBugsCreatedInPastWeekMobileDeviceManagementCustomerImpacting.length} Number of issues with the "#g-emdm", "bug", and "~released bug" labels opened in the past week: ${allBugsCreatedInPastWeekMobileDeviceManagementReleased.length} - Number of issues with the "#g-mdm", "bug", and "customer-" labels opened in the past week: ${allBugsCreatedInPastWeekMobileDeviceManagementCustomerImpacting.length} + Number of issues with the "#g-mdm", "bug", and "~unreleased bug" labels opened in the past week: ${allBugsCreatedInPastWeekMobileDeviceManagementUnreleased.length} - Closed pull requests: + Bugs (part 2): --------------------------- - Number of pull requests merged in the past three weeks in fleetdm/fleet: ${commitToMergeTimesInDays.length} - Average time from first commit to merge: ${averageNumberOfDaysFromCommitToMerge} days. + Number of open issues with the "bug" label in fleetdm/fleet: ${daysSinceBugsWereOpened.length} + Number of open issues with the "~released bug" label in fleetdm/fleet: ${allBugsWithReleasedLabel.length} - Open pull requests - --------------------------- - Number of open pull requests in the fleetdm/fleet Github repo: ${daysSincePullRequestsWereOpened.length} - Average open time: ${averageDaysPullRequestsAreOpenFor} days. - - Number of open pull requests in the fleetdm/fleet Github repo (no bots, no handbook, no ceo): ${daysSinceContributorPullRequestsWereOpened.length} - Average open time (no bots, no handbook, no ceo): ${averageDaysContributorPullRequestsAreOpenFor} days. - + Number of open issues with the "~unreleased bug" label in fleetdm/fleet: ${allBugsWithUnreleasedLabel.length} Pull requests requiring CEO review --------------------------------------- diff --git a/website/scripts/send-aggregated-metrics-to-datadog.js b/website/scripts/send-aggregated-metrics-to-datadog.js index 415ef0e75a..959c3625b5 100644 --- a/website/scripts/send-aggregated-metrics-to-datadog.js +++ b/website/scripts/send-aggregated-metrics-to-datadog.js @@ -18,10 +18,8 @@ module.exports = { // Get all the usage snapshots for the past week. let usageStatisticsReportedInTheLastWeek = await HistoricalUsageSnapshot.find({ createdAt: { '>=': oneWeekAgoAt},// Search for records created in the past week. - fleetVersion: {startsWith: '4.'},// Only track metrics for instances reporting 4.x.x versions of Fleet. }) .sort('createdAt DESC');// Sort the results by the createdAt timestamp - // Filter out development premium licenses and loadtests. let filteredStatistics = _.filter(usageStatisticsReportedInTheLastWeek, (report)=>{ return !_.contains(['Fleet Sandbox', 'fleet-loadtest', 'development-only', 'Dev license (expired)', ''], report.organization); @@ -36,8 +34,6 @@ module.exports = { let latestReportFromThisInstance = _.find(statisticsReportedByFleetInstance[id], {id: lastReportIdForThisInstance}); latestStatisticsForEachInstance.push(latestReportFromThisInstance); } - let numberOfInstancesToReport = latestStatisticsForEachInstance.length; - // Get a filtered array of metrics reported by Fleet Premium instances let latestPremiumUsageStatistics = _.filter(latestStatisticsForEachInstance, {licenseTier: 'premium'}); // Group reports by organization name. @@ -47,14 +43,17 @@ module.exports = { let reportsForThisOrg = _.sortByOrder(reportsByOrgName[org], 'createdAt', 'desc'); let lastReportForThisOrg = reportsForThisOrg[0]; // Get the metrics we'll report for each org. - let lastNumberOfHostsReported = lastReportForThisOrg.numHostsEnrolled; + // Combine the numHostsEnrolled values from the last report for each unique Fleet instance that reports this organization. + let totalNumberOfHostsReportedByThisOrg = _.sum(reportsForThisOrg, (report)=>{ + return report.numHostsEnrolled; + }); let lastReportedFleetVersion = lastReportForThisOrg.fleetVersion; let hostCountMetricForThisOrg = { metric: 'usage_statistics.num_hosts_enrolled_by_org', type: 3, points: [{ timestamp: timestampForTheseMetrics, - value: lastNumberOfHostsReported + value: totalNumberOfHostsReportedByThisOrg }], resources: [{ name: reportsByOrgName[org][0].anonymousIdentifier, @@ -67,11 +66,18 @@ module.exports = { }; metricsToReport.push(hostCountMetricForThisOrg); } + // Filter the statistics to be only for released versions of Fleet. + // Note: we're doing this after we've reported the metrics for Fleet Premium instances to make sure + // that we are reporting metrics sent by customers who may be using a non-4.x.x version of Fleet. + let latestStatisticsReportedByReleasedFleetVersions = _.filter(latestStatisticsForEachInstance, (statistics)=>{ + return _.startsWith(statistics.fleetVersion, '4.'); + }); + let numberOfInstancesToReport = latestStatisticsReportedByReleasedFleetVersions.length; // Build aggregated metrics for JSON attrributes // Create an empty object to store combined host counts. let combinedHostsEnrolledByOperatingSystem = {}; // Get an array of the last reported hostsEnrolledByOperatingSystem values. - let allHostsEnrolledByOsValues = _.pluck(latestStatisticsForEachInstance, 'hostsEnrolledByOperatingSystem'); + let allHostsEnrolledByOsValues = _.pluck(latestStatisticsReportedByReleasedFleetVersions, 'hostsEnrolledByOperatingSystem'); // Iterate through each reported value, and combine them. for(let reportedHostCounts of allHostsEnrolledByOsValues) { _.merge(combinedHostsEnrolledByOperatingSystem, reportedHostCounts, (combinedCountsForThisOperatingSystemType, countsForThisOperatingSystemType) => { @@ -116,7 +122,7 @@ module.exports = { }//āˆž - let allHostsEnrolledByOsqueryVersion = _.pluck(latestStatisticsForEachInstance, 'hostsEnrolledByOsqueryVersion'); + let allHostsEnrolledByOsqueryVersion = _.pluck(latestStatisticsReportedByReleasedFleetVersions, 'hostsEnrolledByOsqueryVersion'); let combinedHostsEnrolledByOsqueryVersion = []; let flattenedHostsEnrolledByOsqueryVersions = _.flatten(allHostsEnrolledByOsqueryVersion); let groupedHostsEnrolledValuesByOsqueryVersion = _.groupBy(flattenedHostsEnrolledByOsqueryVersions, 'osqueryVersion'); @@ -142,7 +148,7 @@ module.exports = { let combinedHostsEnrolledByOrbitVersion = []; - let allHostsEnrolledByOrbitVersion = _.pluck(latestStatisticsForEachInstance, 'hostsEnrolledByOrbitVersion'); + let allHostsEnrolledByOrbitVersion = _.pluck(latestStatisticsReportedByReleasedFleetVersions, 'hostsEnrolledByOrbitVersion'); let flattenedHostsEnrolledByOrbitVersions = _.flatten(allHostsEnrolledByOrbitVersion); let groupedHostsEnrolledValuesByOrbitVersion = _.groupBy(flattenedHostsEnrolledByOrbitVersions, 'orbitVersion'); for(let orbitVersion in groupedHostsEnrolledValuesByOrbitVersion) { @@ -165,7 +171,7 @@ module.exports = { }//āˆž // Merge the arrays of JSON storedErrors - let allStoredErrors = _.pluck(latestStatisticsForEachInstance, 'storedErrors'); + let allStoredErrors = _.pluck(latestStatisticsReportedByReleasedFleetVersions, 'storedErrors'); let flattenedStoredErrors = _.flatten(allStoredErrors); let groupedStoredErrorsByLocation = _.groupBy(flattenedStoredErrors, 'loc'); let combinedStoredErrors = []; @@ -204,7 +210,7 @@ module.exports = { // Build a metric for each Fleet version reported. - let statisticsByReportedFleetVersion = _.groupBy(latestStatisticsForEachInstance, 'fleetVersion'); + let statisticsByReportedFleetVersion = _.groupBy(latestStatisticsReportedByReleasedFleetVersions, 'fleetVersion'); for(let version in statisticsByReportedFleetVersion){ let numberOfInstancesReportingThisVersion = statisticsByReportedFleetVersion[version].length; metricsToReport.push({ @@ -218,7 +224,7 @@ module.exports = { }); } // Build a metric for each license tier reported. - let statisticsByReportedFleetLicenseTier = _.groupBy(latestStatisticsForEachInstance, 'licenseTier'); + let statisticsByReportedFleetLicenseTier = _.groupBy(latestStatisticsReportedByReleasedFleetVersions, 'licenseTier'); for(let tier in statisticsByReportedFleetLicenseTier){ let numberOfInstancesReportingThisLicenseTier = statisticsByReportedFleetLicenseTier[tier].length; metricsToReport.push({ @@ -233,7 +239,7 @@ module.exports = { } // Build aggregated metrics for boolean variables: // Software Inventory - let numberOfInstancesWithSoftwareInventoryEnabled = _.where(latestStatisticsForEachInstance, {softwareInventoryEnabled: true}).length; + let numberOfInstancesWithSoftwareInventoryEnabled = _.where(latestStatisticsReportedByReleasedFleetVersions, {softwareInventoryEnabled: true}).length; let numberOfInstancesWithSoftwareInventoryDisabled = numberOfInstancesToReport - numberOfInstancesWithSoftwareInventoryEnabled; metricsToReport.push({ metric: 'usage_statistics.software_inventory', @@ -254,7 +260,7 @@ module.exports = { tags: [`enabled:false`], }); // vulnDetectionEnabled - let numberOfInstancesWithVulnDetectionEnabled = _.where(latestStatisticsForEachInstance, {vulnDetectionEnabled: true}).length; + let numberOfInstancesWithVulnDetectionEnabled = _.where(latestStatisticsReportedByReleasedFleetVersions, {vulnDetectionEnabled: true}).length; let numberOfInstancesWithVulnDetectionDisabled = numberOfInstancesToReport - numberOfInstancesWithVulnDetectionEnabled; metricsToReport.push({ metric: 'usage_statistics.vuln_detection', @@ -275,7 +281,7 @@ module.exports = { tags: [`enabled:false`], }); // SystemUsersEnabled - let numberOfInstancesWithSystemUsersEnabled = _.where(latestStatisticsForEachInstance, {systemUsersEnabled: true}).length; + let numberOfInstancesWithSystemUsersEnabled = _.where(latestStatisticsReportedByReleasedFleetVersions, {systemUsersEnabled: true}).length; let numberOfInstancesWithSystemUsersDisabled = numberOfInstancesToReport - numberOfInstancesWithSystemUsersEnabled; metricsToReport.push({ metric: 'usage_statistics.system_users', @@ -296,7 +302,7 @@ module.exports = { tags: [`enabled:false`], }); // hostsStatusWebHookEnabled - let numberOfInstancesWithHostsStatusWebHookEnabled = _.where(latestStatisticsForEachInstance, {hostsStatusWebHookEnabled: true}).length; + let numberOfInstancesWithHostsStatusWebHookEnabled = _.where(latestStatisticsReportedByReleasedFleetVersions, {hostsStatusWebHookEnabled: true}).length; let numberOfInstancesWithHostsStatusWebHookDisabled = numberOfInstancesToReport - numberOfInstancesWithHostsStatusWebHookEnabled; metricsToReport.push({ metric: 'usage_statistics.host_status_webhook', @@ -317,7 +323,7 @@ module.exports = { tags: [`enabled:false`], }); // mdmMacOsEnabled - let numberOfInstancesWithMdmMacOsEnabled = _.where(latestStatisticsForEachInstance, {mdmMacOsEnabled: true}).length; + let numberOfInstancesWithMdmMacOsEnabled = _.where(latestStatisticsReportedByReleasedFleetVersions, {mdmMacOsEnabled: true}).length; let numberOfInstancesWithMdmMacOsDisabled = numberOfInstancesToReport - numberOfInstancesWithMdmMacOsEnabled; metricsToReport.push({ metric: 'usage_statistics.macos_mdm', @@ -338,7 +344,7 @@ module.exports = { tags: [`enabled:false`], }); // mdmWindowsEnabled - let numberOfInstancesWithMdmWindowsEnabled = _.where(latestStatisticsForEachInstance, {mdmWindowsEnabled: true}).length; + let numberOfInstancesWithMdmWindowsEnabled = _.where(latestStatisticsReportedByReleasedFleetVersions, {mdmWindowsEnabled: true}).length; let numberOfInstancesWithMdmWindowsDisabled = numberOfInstancesToReport - numberOfInstancesWithMdmWindowsEnabled; metricsToReport.push({ metric: 'usage_statistics.windows_mdm', @@ -359,7 +365,7 @@ module.exports = { tags: [`enabled:false`], }); // liveQueryDisabled - let numberOfInstancesWithLiveQueryDisabled = _.where(latestStatisticsForEachInstance, {liveQueryDisabled: true}).length; + let numberOfInstancesWithLiveQueryDisabled = _.where(latestStatisticsReportedByReleasedFleetVersions, {liveQueryDisabled: true}).length; let numberOfInstancesWithLiveQueryEnabled = numberOfInstancesToReport - numberOfInstancesWithLiveQueryDisabled; metricsToReport.push({ metric: 'usage_statistics.live_query', @@ -380,7 +386,7 @@ module.exports = { tags: [`enabled:true`], }); // hostExpiryEnabled - let numberOfInstancesWithHostExpiryEnabled = _.where(latestStatisticsForEachInstance, {hostExpiryEnabled: true}).length; + let numberOfInstancesWithHostExpiryEnabled = _.where(latestStatisticsReportedByReleasedFleetVersions, {hostExpiryEnabled: true}).length; let numberOfInstancesWithHostExpiryDisabled = numberOfInstancesToReport - numberOfInstancesWithHostExpiryEnabled; metricsToReport.push({ metric: 'usage_statistics.host_expiry', @@ -402,7 +408,7 @@ module.exports = { }); // Create two metrics to track total number of hosts reported in the last week. - let totalNumberOfHostsReportedByPremiumInstancesInTheLastWeek = _.sum(_.pluck(_.filter(latestStatisticsForEachInstance, {licenseTier: 'premium'}), 'numHostsEnrolled')); + let totalNumberOfHostsReportedByPremiumInstancesInTheLastWeek = _.sum(_.pluck(_.filter(latestStatisticsReportedByReleasedFleetVersions, {licenseTier: 'premium'}), 'numHostsEnrolled')); metricsToReport.push({ metric: 'usage_statistics.total_num_hosts_enrolled', type: 3, @@ -413,7 +419,7 @@ module.exports = { tags: [`license_tier:premium`], }); - let totalNumberOfHostsReportedByFreeInstancesInTheLastWeek = _.sum(_.pluck(_.filter(latestStatisticsForEachInstance, {licenseTier: 'free'}), 'numHostsEnrolled')); + let totalNumberOfHostsReportedByFreeInstancesInTheLastWeek = _.sum(_.pluck(_.filter(latestStatisticsReportedByReleasedFleetVersions, {licenseTier: 'free'}), 'numHostsEnrolled')); metricsToReport.push({ metric: 'usage_statistics.total_num_hosts_enrolled', type: 3, diff --git a/website/views/layouts/layout.ejs b/website/views/layouts/layout.ejs index 6c0f355862..050c974600 100644 --- a/website/views/layouts/layout.ejs +++ b/website/views/layouts/layout.ejs @@ -8,24 +8,30 @@ var hideHeaderLinks;// Hides the header navigation links. var hideFooterLinks;// Hides footer links, reduces the height of the footer to 60px; var showAdminLinks;// Shows links to admin pages to admin users. + + // Applies personalization for who people come from ads, so that the website makes more sense for them: + var primaryBuyingSituation; + var defaultMetaTitle = 'Fleet | Open-source '+(primaryBuyingSituation === 'mdm' ? 'device management' : primaryBuyingSituation === 'vm' ? 'vulnerability management' : 'endpoint ops'); + var defaultMetaDescription = 'Open-source '+(['mdm'].includes(primaryBuyingSituation) ? 'IT' : ['eo-security','vm'].includes(primaryBuyingSituation) ? 'security' : 'IT and security')+' for teams with lots of '+(primaryBuyingSituation === 'mdm' ? 'computers. (macOS, Windows, Linux, ChromeOS)' : 'workstations and servers. (Linux, macOS, Windows, cloud, data center, OT/ICS, Chrome)'); + var corporationDisplayName = 'Fleet' + (primaryBuyingSituation === 'mdm' ? ' Device Management' : '') + ' Inc.'; // Ā« Fleet has a DBA as "Fleet", with an official Delaware corporation name of "Fleet Device Management Inc". -mikermcneil, 2024-03-01 %> - <%= typeof pageTitleForMeta !== 'undefined' ? pageTitleForMeta : 'Fleet | Open-source device management' %> - + <%= typeof pageTitleForMeta !== 'undefined' ? pageTitleForMeta : defaultMetaTitle %> + <% /* Viewport tag for sensible mobile support */ %> <%// Twitter meta tags%> - - + + <%// Meta tags for other social previews %> - - + + <% /* Script tags should normally be included further down the page- but any scripts that load fonts (e.g. Fontawesome ≄v5) are special exceptions to the rule. (Include them up here along with any hard-coded Ā«linkĀ» tags for Typekit, @@ -174,31 +180,29 @@

@@ -225,33 +229,29 @@
Pricing @@ -322,8 +322,8 @@
- Ā© <%= (new Date()).getFullYear() %> Fleet Device Management Inc. + Ā© <%= (new Date()).getFullYear() %> <%= corporationDisplayName %> Privacy @@ -378,7 +378,7 @@ a small checkmarkSOC2 Type 2 certified
- Ā© <%= (new Date()).getFullYear() %> Fleet Device Management Inc. + Ā© <%= (new Date()).getFullYear() %> <%= corporationDisplayName %> Privacy
diff --git a/website/views/pages/device-management.ejs b/website/views/pages/device-management.ejs index e0d39961f6..87774b8d5c 100644 --- a/website/views/pages/device-management.ejs +++ b/website/views/pages/device-management.ejs @@ -157,11 +157,12 @@
-

Absolute certainty

-

Reduce time wasted hunting down whether a change happened. Actually verify that settings are applied using real data pulled from your users' devices.

+

Shorten the feedback loop

+

Spend less time debugging whether changes actually happened. Auto-verify using real data pulled from your users' devices.

Use a git repo as the source of truth to reduce errors (submitting the wrong patch, configuration setting etc)

Every change to a policy or security control is tracked and auditable in Fleet’s history, or via the repo commit log

+

Instantly reveal failed patches and broken settings with osquery to shorten the feedback loop and uncover problems sooner.

diff --git a/website/views/pages/endpoint-ops.ejs b/website/views/pages/endpoint-ops.ejs index 6d94ef7d9b..033fbf225b 100644 --- a/website/views/pages/endpoint-ops.ejs +++ b/website/views/pages/endpoint-ops.ejs @@ -4,19 +4,19 @@

Endpoint operations

-

A consistent interface

+

A consistent interface

- A device verifying compliance for every endpoint + A device verifying compliance for every endpoint
- Simplify security tooling -

Consolidate your security tooling on top of open data standards like YAML, SQL, and JSON.

- Ship data to any platform -

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

Pulse check anything -

Simplify security audits, build definitive reports, and discover and verify ongoing compliance for every endpoint, from workstations to data centers.

+

Use a live connection to every endpoint to simplify audit, compliance, and reporting from workstations to data centers.

+ Ship data to any platform +

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

+ Osquery on easy mode +

You don’t need to be an osquery expert to get the answers you need from your devices, Fleet does some of that for you.

Show me See real data @@ -49,7 +49,87 @@
-

Simplify security tooling

+

Pulse check anything

+

Use a live connection to every endpoint to simplify audit, compliance, and reporting from workstations to data centers.

+
+ +
+ +
+
+ Software and asset inventory +
Live connection
+

Talk to online devices in real time with Fleet’s live query API. Implement custom workflows like conditional access based on device posture, Identity, and more.

+
+ +
+ Software and asset inventory +
Software and asset inventory
+

Get visibility into all endpoints across any operating system*, including support for servers and containers in every cloud infrastructure.

+
+ +
+ See logins for every endpoint +
See logins for every endpoint
+

Identify who logs in to any system, including login history and current sessions. Look up any computer by the email address of the person using it.

+
+ + +
+
+ +
+ CIS benchmarks +
CIS benchmarks
+

Keep all your endpoints* compliant with customizable baselines, or use common benchmarks like CIS.

+
+ +
+ Get in front of the IdP +
Get in front of the IdP
+

Gate access with common device trust policies from industry peers, or roll out your own device health checks using system data and events.

+
+ +
+ Verify updates and settings +
Verify updates and settings
+

Track progress towards deadlines for security posture remediation projects, and enforce due dates through automations.

+
+
+

*Currently limited to: macOS, Linux, Windows, Chromebooks, OT, data centers, Amazon Web Services (AWS), Google Cloud (GCP), and the Microsoft Cloud (Azure).

+
+ +
+
+

Ship data to any platform

+

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

+
+

Extract data and correlate it with your log aggregator, SIEM, or data lake.

+

Ease your logging burden, pull the data you need.

+
+
+
+ Ship data to any platform +
+
+ +
+
+ Ship data to any platform +
+
+

Osquery on easy mode

+

Accelerate deployment and get more out of osquery. You don’t need to be an osquery expert to get the answers you need from your devices, Fleet takes care of some of that for you.

+
+

Remotely disable/enable agent features, choose plugins, and keep osquery up to date.

+

Import community queries from other security teams at top brands like Palantir and Fastly.

+

Implement the Center for Internet Security (CIS) benchmarks (one click, 400+ queries, supported by Fleet). Or customize exactly the queries you need.

+
+
+
+ +
+

Open security tooling

Consolidate your security tooling on top of open data standards like YAML, SQL, and JSON.

@@ -96,77 +176,6 @@

*Companies like Fastly and Gusto use Fleet in production with hundreds of thousands of endpoints, including containers, OT, and laptops.

-
-
-

Ship data to any platform

-

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

-
-

Extract data and correlate it with your log aggregator, SIEM, or data lake.

-

Ease your logging burden, pull the data you need.

-
-
-
- Ship data to any platform -
-
- -
-

Pulse check anything

-

Simplify security audits, build definitive reports, and discover and verify ongoing compliance for every endpoint, from workstations to data centers.

-
- -
- -
-
- Software and asset inventory -
Software and asset inventory
-

Get visibility into all endpoints across any operating system*, including support for servers and containers in every cloud infrastructure.

-
- -
- EDR health checks -
EDR health checks
-

Verify that your EDR tools are installed and working so you can identify and address configuration issues quickly.

-
- -
- Automatic posture assessment -
Automatic posture assessment
-

Keep all your endpoints* compliant with customizable baselines, or use common benchmarks like CIS.

-
-
-
-
- See logins for every endpoint -
See logins for every endpoint
-

Identify who logs in to any system, including login history and current sessions.Ā  Look up any computer by the email address of the person using it.

-
- -
- Verify updates and settings -
Verify updates and settings
-

Enforce secure configurations and safe, up-to-date software through automations.

-
-
-

*Currently limited to: macOS, Linux, Windows, Chromebooks, OT, data centers, Amazon Web Services (AWS), Google Cloud (GCP), and the Microsoft Cloud (Azure).

-
- - -
-
- Ship data to any platform -
-
-

Osquery on easy mode

-

Accelerate deployment and get more out of osquery. You don’t need to be an osquery expert to get the answers you need from your devices, Fleet takes care of some of that for you.

-
-

Remotely disable/enable agent features, choose plugins, and keep osquery up to date.

-

Import community queries from other security teams at top brands like Palantir and Fastly.

-

Implement the Center for Internet Security (CIS) benchmarks (one click, 400+ queries, supported by Fleet). Or customize exactly the queries you need.

-
-
-
<%/* End of page gradient */%> @@ -174,16 +183,16 @@

Who else uses Fleet?

-

Empowering security and IT teams, globally

+

Empowering IT teams

-
+
-

Open-source endpoint ops

-

Focus on data, not vendors

+

Endpoint operations

+

A consistent interface

Show me See real data diff --git a/website/views/pages/fleetctl-preview.ejs b/website/views/pages/fleetctl-preview.ejs index b389c76ea5..80825c5129 100644 --- a/website/views/pages/fleetctl-preview.ejs +++ b/website/views/pages/fleetctl-preview.ejs @@ -27,7 +27,7 @@

Install the fleetctl command line tool:

-

curl -SsLO https://fleetdm.com/resources/install-fleet.sh -o install_fleet.sh && shasum -a 256 install_fleet.sh

+

curl -sSL https://fleetdm.com/resources/install-fleetctl.sh | bash

Run a local demo of the Fleet server:

@@ -45,7 +45,7 @@

Install the fleetctl command line tool:

-

curl -SsLO https://fleetdm.com/resources/install-fleet.sh -o install_fleet.sh && shasum -a 256 install_fleet.sh

+

curl -sSL https://fleetdm.com/resources/install-fleetctl.sh | bash

Run a local demo of the Fleet server:

diff --git a/website/views/pages/homepage.ejs b/website/views/pages/homepage.ejs index 695cc744cc..611cfd952d 100644 --- a/website/views/pages/homepage.ejs +++ b/website/views/pages/homepage.ejs @@ -7,7 +7,7 @@ <%/* Hero text */%>

For teams with lots of different endpoints

-

Focus on data, not vendors

+

<%- partial('../partials/primary-tagline.partial.ejs') %>

Replace the sprawl with open-source code that works the way you want.

Show me @@ -97,7 +97,7 @@
- Endpoint ops + Endpoint ops
@@ -312,7 +312,7 @@

For teams with lots of different endpoints

-

Focus on data, not vendors

+

<%- partial('../partials/primary-tagline.partial.ejs') %>

Show me Try it out diff --git a/website/views/pages/vulnerability-management.ejs b/website/views/pages/vulnerability-management.ejs index 50b542445f..2d525f3cc2 100644 --- a/website/views/pages/vulnerability-management.ejs +++ b/website/views/pages/vulnerability-management.ejs @@ -81,11 +81,11 @@
- Consolidate your security stack + Untangle your security stack
-

Consolidate your security stack

-

Consolidate your point vulnerability solution with your cybersecurity asset management and log capture tools.

+

Untangle your security stack

+

Use open data and APIs to connect your point vulnerability solution with your cybersecurity asset management and log capture tools.

Prevent duplicated, inaccurate CMDBs to reduce tool sprawl and wasted budget

Normalize asset management data and software inventories from multiple tools and operating systems

diff --git a/website/views/partials/primary-tagline.partial.ejs b/website/views/partials/primary-tagline.partial.ejs new file mode 100644 index 0000000000..debe33a902 --- /dev/null +++ b/website/views/partials/primary-tagline.partial.ejs @@ -0,0 +1 @@ +<%= primaryBuyingSituation === 'mdm' ? 'Your last MDM migration' : 'Focus on data, not vendors' %>